Merge pull request #3538 from paperclipai/PAP-1355-right-now-when-agents-boot-they-re-instructed-to-call-the-api-to-checkout-the-issue-so-that-they-have-exclusive

Improve scoped wake checkout and linked worktree reuse
This commit is contained in:
Dotta
2026-04-12 21:08:20 -05:00
committed by GitHub
11 changed files with 443 additions and 86 deletions
@@ -253,6 +253,7 @@ type PaperclipWakeComment = {
type PaperclipWakePayload = {
reason: string | null;
issue: PaperclipWakeIssue | null;
checkedOutByHarness: boolean;
executionStage: PaperclipWakeExecutionStage | null;
commentIds: string[];
latestCommentId: string | null;
@@ -363,6 +364,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
return {
reason: asString(payload.reason, "").trim() || null,
issue: normalizePaperclipWakeIssue(payload.issue),
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
executionStage,
commentIds,
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
@@ -432,6 +434,9 @@ export function renderPaperclipWakePrompt(
if (normalized.issue?.priority) {
lines.push(`- issue priority: ${normalized.issue.priority}`);
}
if (normalized.checkedOutByHarness) {
lines.push("- checkout: already claimed by the harness for this run");
}
if (normalized.missingCount > 0) {
lines.push(`- omitted comments: ${normalized.missingCount}`);
}
@@ -465,6 +470,15 @@ export function renderPaperclipWakePrompt(
}
}
if (normalized.checkedOutByHarness) {
lines.push(
"",
"The harness already checked out this issue for the current run.",
"Do not call `/api/issues/{id}/checkout` again unless you intentionally switch to a different task.",
"",
);
}
if (normalized.comments.length > 0) {
lines.push("New comments in order:");
}
@@ -568,9 +568,10 @@ describe("codex execute", () => {
id: "issue-1",
identifier: "PAP-1201",
title: "Fix gallery opening for inline images",
status: "todo",
status: "in_progress",
priority: "medium",
},
checkedOutByHarness: true,
commentIds: [],
latestCommentId: null,
comments: [],
@@ -598,16 +599,19 @@ describe("codex execute", () => {
issue: {
identifier: "PAP-1201",
title: "Fix gallery opening for inline images",
status: "todo",
status: "in_progress",
priority: "medium",
},
checkedOutByHarness: true,
commentIds: [],
});
expect(capture.prompt).toContain("## Paperclip Wake Payload");
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images");
expect(capture.prompt).toContain("- pending comments: 0/0");
expect(capture.prompt).toContain("- issue status: todo");
expect(capture.prompt).toContain("- issue status: in_progress");
expect(capture.prompt).toContain("- checkout: already claimed by the harness for this run");
expect(capture.prompt).toContain("The harness already checked out this issue for the current run.");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
@@ -496,15 +496,34 @@ describe("heartbeat comment wake batching", () => {
id: issueId,
identifier: `${issuePrefix}-1`,
title: "Require a comment",
status: "todo",
status: "in_progress",
priority: "medium",
},
checkedOutByHarness: true,
commentIds: [],
},
});
expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload");
expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake.");
expect(String(firstPayload.message ?? "")).toContain("- checkout: already claimed by the harness for this run");
expect(String(firstPayload.message ?? "")).toContain(
"The harness already checked out this issue for the current run.",
);
expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`);
const checkedOutIssue = await db
.select({
status: issues.status,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(checkedOutIssue).toMatchObject({
status: "in_progress",
checkoutRunId: firstRun?.id,
executionRunId: firstRun?.id,
});
gateway.releaseFirstWait();
await waitFor(async () => {
const runs = await db
@@ -555,4 +574,5 @@ describe("heartbeat comment wake batching", () => {
await gateway.close();
}
}, 20_000);
});
@@ -367,6 +367,99 @@ describe("realizeExecutionWorkspace", () => {
expect(second.branchName).toBe(first.branchName);
});
it("reuses the current linked worktree instead of nesting another worktree inside it", async () => {
const repoRoot = await createTempRepo();
const branchName = "PAP-1355-worktree-reuse";
const currentWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName);
await fs.mkdir(path.dirname(currentWorktree), { recursive: true });
await execFileAsync("git", ["worktree", "add", "-b", branchName, currentWorktree, "HEAD"], { cwd: repoRoot });
const realized = await realizeExecutionWorkspace({
base: {
baseCwd: currentWorktree,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-1",
identifier: "PAP-1355",
title: "worktree reuse",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
const expectedWorktreePath = await fs.realpath(currentWorktree);
expect(realized.created).toBe(false);
await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath);
await expect(fs.realpath(realized.worktreePath ?? "")).resolves.toBe(expectedWorktreePath);
});
it("reuses an already checked out branch from git worktree metadata even when the target path differs", async () => {
const repoRoot = await createTempRepo();
const branchName = "PAP-1355-worktree-reuse";
const existingWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName);
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
await fs.mkdir(path.dirname(existingWorktree), { recursive: true });
await execFileAsync("git", ["worktree", "add", "-b", branchName, existingWorktree, "HEAD"], { cwd: repoRoot });
const realized = await realizeExecutionWorkspace({
base: {
baseCwd: existingWorktree,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
worktreeParentDir: ".paperclip/other-worktrees",
},
},
issue: {
id: "issue-1",
identifier: "PAP-1355",
title: "worktree reuse",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
recorder,
});
const expectedWorktreePath = await fs.realpath(existingWorktree);
expect(realized.created).toBe(false);
await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath);
expect(operations).toHaveLength(1);
expect(operations[0]?.phase).toBe("worktree_prepare");
expect(operations[0]?.command).toBeNull();
expect(operations[0]?.metadata).toMatchObject({
branchName,
created: false,
reused: true,
worktreePath: expectedWorktreePath,
});
});
it("slugifies unsafe issue titles for branch names and worktree folders", async () => {
const repoRoot = await createTempRepo();
@@ -31,7 +31,8 @@ If `PAPERCLIP_APPROVAL_ID` is set:
## 5. Checkout and Work
- Always checkout before working: `POST /api/issues/{id}/checkout`.
- For scoped issue wakes, Paperclip may already checkout the current issue in the harness before your run starts.
- Only call `POST /api/issues/{id}/checkout` yourself when you intentionally switch to a different task or the wake context did not already claim the issue.
- Never retry a 409 -- that task belongs to someone else.
- Do the work. Update status and comment when done.
+74 -21
View File
@@ -17,7 +17,7 @@ import {
projects,
projectWorkspaces,
} from "@paperclipai/db";
import { conflict, notFound } from "../errors.js";
import { conflict, HttpError, notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { publishLiveEvent } from "./live-events.js";
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
@@ -74,6 +74,7 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const WAKE_COMMENT_IDS_KEY = "wakeCommentIds";
const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake";
const PAPERCLIP_HARNESS_CHECKOUT_KEY = "paperclipHarnessCheckedOut";
const DETACHED_PROCESS_ERROR_CODE = "process_detached";
const startLocksByAgent = new Map<string, Promise<void>>();
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
@@ -760,6 +761,36 @@ function describeSessionResetReason(
return null;
}
function shouldAutoCheckoutIssueForWake(input: {
contextSnapshot: Record<string, unknown> | null | undefined;
issueStatus: string | null;
issueAssigneeAgentId: string | null;
agentId: string;
}) {
if (input.issueAssigneeAgentId !== input.agentId) return false;
const issueStatus = readNonEmptyString(input.issueStatus);
if (
issueStatus !== "todo" &&
issueStatus !== "backlog" &&
issueStatus !== "blocked" &&
issueStatus !== "in_progress"
) {
return false;
}
const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason);
if (!wakeReason) return false;
if (wakeReason === "issue_comment_mentioned") return false;
if (wakeReason.startsWith("execution_")) return false;
return true;
}
function isCheckoutConflictError(error: unknown): boolean {
return error instanceof HttpError && error.status === 409 && error.message === "Issue checkout conflict";
}
function deriveCommentId(
contextSnapshot: Record<string, unknown> | null | undefined,
payload: Record<string, unknown> | null | undefined,
@@ -1005,6 +1036,7 @@ async function buildPaperclipWakePayload(input: {
priority: issueSummary.priority,
}
: null,
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
commentIds,
latestCommentId: commentIds[commentIds.length - 1] ?? null,
@@ -1216,6 +1248,27 @@ export function heartbeatService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function getIssueExecutionContext(companyId: string, issueId: string) {
return db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
assigneeAgentId: issues.assigneeAgentId,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
}
async function getRuntimeState(agentId: string) {
return db
.select()
@@ -2644,26 +2697,26 @@ export function heartbeatService(db: Db) {
const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null);
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
const issueId = readNonEmptyString(context.issueId);
const issueContext = issueId
? await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
priority: issues.priority,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
assigneeAgentId: issues.assigneeAgentId,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null)
: null;
let issueContext = issueId ? await getIssueExecutionContext(agent.companyId, issueId) : null;
if (
issueId &&
issueContext &&
shouldAutoCheckoutIssueForWake({
contextSnapshot: context,
issueStatus: issueContext.status,
issueAssigneeAgentId: issueContext.assigneeAgentId,
agentId: agent.id,
})
) {
try {
await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id);
context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true;
} catch (error) {
if (!isCheckoutConflictError(error)) throw error;
context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = false;
}
issueContext = await getIssueExecutionContext(agent.companyId, issueId);
}
const issueAssigneeOverrides =
issueContext && issueContext.assigneeAgentId === agent.id
? parseIssueAssigneeAdapterOverrides(
+139 -58
View File
@@ -513,6 +513,67 @@ function gitErrorIncludes(error: unknown, needle: string) {
return message.toLowerCase().includes(needle.toLowerCase());
}
type GitWorktreeListEntry = {
worktree: string;
branch: string | null;
};
function parseGitWorktreeListPorcelain(raw: string): GitWorktreeListEntry[] {
const entries: GitWorktreeListEntry[] = [];
let current: Partial<GitWorktreeListEntry> = {};
for (const line of raw.split(/\r?\n/)) {
if (line.startsWith("worktree ")) {
current = { worktree: line.slice("worktree ".length) };
continue;
}
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length);
continue;
}
if (line === "" && current.worktree) {
entries.push({
worktree: current.worktree,
branch: current.branch ?? null,
});
current = {};
}
}
if (current.worktree) {
entries.push({
worktree: current.worktree,
branch: current.branch ?? null,
});
}
return entries;
}
async function resolveGitOwnerRepoRoot(cwd: string): Promise<string> {
const checkoutRoot = path.resolve(await runGit(["rev-parse", "--show-toplevel"], cwd));
const commonDir = await runGit(["rev-parse", "--git-common-dir"], checkoutRoot).catch(() => null);
if (!commonDir) return checkoutRoot;
return path.dirname(path.resolve(checkoutRoot, commonDir));
}
async function findRegisteredGitWorktreeByBranch(repoRoot: string, branchName: string): Promise<string | null> {
const raw = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => null);
if (!raw) return null;
const expectedBranchRef = `refs/heads/${branchName}`;
for (const entry of parseGitWorktreeListPorcelain(raw)) {
if (entry.branch !== expectedBranchRef) continue;
return path.resolve(entry.worktree);
}
return null;
}
async function isGitCheckout(cwd: string): Promise<boolean> {
return Boolean(await runGit(["rev-parse", "--git-dir"], cwd).catch(() => null));
}
async function detectDefaultBranch(repoRoot: string): Promise<string | null> {
// Try the explicit remote HEAD first (set by git clone or git remote set-head)
try {
@@ -878,7 +939,7 @@ export async function realizeExecutionWorkspace(input: {
};
}
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
const repoRoot = await resolveGitOwnerRepoRoot(input.base.baseCwd);
const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}");
const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
issue: input.issue,
@@ -901,50 +962,59 @@ export async function realizeExecutionWorkspace(input: {
await fs.mkdir(worktreeParentDir, { recursive: true });
const existingWorktree = await directoryExists(worktreePath);
if (existingWorktree) {
const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null);
if (existingGitDir) {
if (input.recorder) {
await input.recorder.recordOperation({
phase: "worktree_prepare",
cwd: repoRoot,
metadata: {
repoRoot,
worktreePath,
branchName,
baseRef,
created: false,
reused: true,
},
run: async () => ({
status: "succeeded",
exitCode: 0,
system: `Reused existing git worktree at ${worktreePath}\n`,
}),
});
}
await provisionExecutionWorktree({
strategy: rawStrategy,
base: input.base,
repoRoot,
worktreePath,
branchName,
issue: input.issue,
agent: input.agent,
created: false,
recorder: input.recorder ?? null,
async function reuseExistingWorktree(reusablePath: string) {
if (input.recorder) {
await input.recorder.recordOperation({
phase: "worktree_prepare",
cwd: repoRoot,
metadata: {
repoRoot,
worktreePath: reusablePath,
branchName,
baseRef,
created: false,
reused: true,
},
run: async () => ({
status: "succeeded",
exitCode: 0,
system: `Reused existing git worktree at ${reusablePath}\n`,
}),
});
return {
...input.base,
strategy: "git_worktree",
cwd: worktreePath,
branchName,
worktreePath,
warnings: [],
created: false,
};
}
await provisionExecutionWorktree({
strategy: rawStrategy,
base: input.base,
repoRoot,
worktreePath: reusablePath,
branchName,
issue: input.issue,
agent: input.agent,
created: false,
recorder: input.recorder ?? null,
});
return {
...input.base,
strategy: "git_worktree" as const,
cwd: reusablePath,
branchName,
worktreePath: reusablePath,
warnings: [],
created: false,
};
}
const existingWorktree = await directoryExists(worktreePath);
if (existingWorktree && await isGitCheckout(worktreePath)) {
return await reuseExistingWorktree(worktreePath);
}
const registeredBranchWorktree = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
if (registeredBranchWorktree && await isGitCheckout(registeredBranchWorktree)) {
return await reuseExistingWorktree(registeredBranchWorktree);
}
if (existingWorktree) {
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`);
}
@@ -967,21 +1037,32 @@ export async function realizeExecutionWorkspace(input: {
if (!gitErrorIncludes(error, "already exists")) {
throw error;
}
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
args: ["worktree", "add", worktreePath, branchName],
cwd: repoRoot,
metadata: {
repoRoot,
worktreePath,
branchName,
baseRef,
created: false,
reusedExistingBranch: true,
},
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
failureLabel: `git worktree add ${worktreePath}`,
});
try {
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
args: ["worktree", "add", worktreePath, branchName],
cwd: repoRoot,
metadata: {
repoRoot,
worktreePath,
branchName,
baseRef,
created: false,
reusedExistingBranch: true,
},
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
failureLabel: `git worktree add ${worktreePath}`,
});
} catch (attachError) {
if (!gitErrorIncludes(attachError, "already checked out")) {
throw attachError;
}
const reusablePath = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
if (!reusablePath || !await isGitCheckout(reusablePath)) {
throw attachError;
}
return await reuseExistingWorktree(reusablePath);
}
}
await provisionExecutionWorktree({
strategy: rawStrategy,
+28
View File
@@ -42,6 +42,12 @@ interface TestContext {
issueIds: string[];
}
interface IssueRunLockState {
assigneeAgentId: string | null;
checkoutRunId: string | null;
executionRunId: string | null;
}
/** Create an authenticated APIRequestContext for an agent (token set, no run ID yet). */
async function createAgentRequest(token: string): Promise<APIRequestContext> {
return pwRequest.newContext({
@@ -58,6 +64,17 @@ async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promi
return run.id;
}
async function getIssueRunLockState(board: APIRequestContext, issueId: string): Promise<IssueRunLockState> {
const res = await board.get(`${BASE_URL}/api/issues/${issueId}`);
expect(res.ok()).toBe(true);
const issue = await res.json();
return {
assigneeAgentId: issue.assigneeAgentId ?? null,
checkoutRunId: issue.checkoutRunId ?? null,
executionRunId: issue.executionRunId ?? null,
};
}
/** PATCH an issue as an agent with a fresh heartbeat run ID. */
async function agentPatch(
board: APIRequestContext,
@@ -88,6 +105,17 @@ async function agentCheckoutAndPatch(
data: { agentId: agent.agentId, expectedStatuses },
});
if (!checkoutRes.ok()) {
if (checkoutRes.status() === 409) {
const issueRunLock = await getIssueRunLockState(board, issueId);
const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId;
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
headers: { "X-Paperclip-Run-Id": lockedRunId ?? runId },
data: patchData,
});
if (res.ok() && issueRunLock.assigneeAgentId === agent.agentId) {
return res;
}
}
// If agent checkout fails (e.g. run expired), fall back to board checkout
// then PATCH with the agent's identity
const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import type { ActiveRunForIssue } from "../api/heartbeats";
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "./issueActiveRun";
describe("issueActiveRun", () => {
const makeIssue = (
overrides: Partial<Pick<Issue, "status" | "executionRunId">>,
): Pick<Issue, "status" | "executionRunId"> => ({
status: "todo",
executionRunId: null,
...overrides,
});
it("tracks active runs while an issue is still in progress", () => {
expect(shouldTrackIssueActiveRun(makeIssue({ status: "in_progress" }))).toBe(true);
});
it("tracks active runs while an execution run id is still attached", () => {
expect(shouldTrackIssueActiveRun(makeIssue({ status: "done", executionRunId: "run-123" }))).toBe(true);
});
it("drops stale cached active runs once the issue is closed and unlocked", () => {
const staleActiveRun: ActiveRunForIssue = {
id: "run-123",
status: "running",
invocationSource: "assignment",
triggerDetail: "system",
startedAt: "2026-04-13T01:29:00.000Z",
finishedAt: null,
createdAt: "2026-04-13T01:29:00.000Z",
agentId: "agent-1",
agentName: "Builder",
adapterType: "codex_local",
issueId: "issue-1",
};
expect(
resolveIssueActiveRun(
makeIssue({ status: "done" }),
staleActiveRun,
),
).toBeNull();
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { Issue } from "@paperclipai/shared";
import type { ActiveRunForIssue } from "../api/heartbeats";
export function shouldTrackIssueActiveRun(
issue: Pick<Issue, "status" | "executionRunId"> | null | undefined,
): boolean {
return Boolean(issue && (issue.status === "in_progress" || issue.executionRunId));
}
export function resolveIssueActiveRun(
issue: Pick<Issue, "status" | "executionRunId"> | null | undefined,
activeRun: ActiveRunForIssue | null | undefined,
): ActiveRunForIssue | null {
return shouldTrackIssueActiveRun(issue) ? (activeRun ?? null) : null;
}
+5 -2
View File
@@ -26,6 +26,7 @@ import {
readIssueDetailHeaderSeed,
rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
import {
hasBlockingShortcutDialog,
resolveIssueDetailGoKeyAction,
@@ -471,13 +472,15 @@ export function IssueDetail() {
placeholderData: keepPreviousData,
});
const { data: activeRun, isLoading: activeRunLoading } = useQuery({
const shouldPollActiveRun = shouldTrackIssueActiveRun(issue);
const { data: rawActiveRun, isLoading: activeRunLoading } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId!),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"),
enabled: !!issueId && shouldPollActiveRun,
refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000,
placeholderData: keepPreviousData,
});
const activeRun = resolveIssueActiveRun(issue, rawActiveRun);
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const runningIssueRun = useMemo(