mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
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:
commit
11de5ae9c9
11 changed files with 443 additions and 86 deletions
|
|
@ -253,6 +253,7 @@ type PaperclipWakeComment = {
|
||||||
type PaperclipWakePayload = {
|
type PaperclipWakePayload = {
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
issue: PaperclipWakeIssue | null;
|
issue: PaperclipWakeIssue | null;
|
||||||
|
checkedOutByHarness: boolean;
|
||||||
executionStage: PaperclipWakeExecutionStage | null;
|
executionStage: PaperclipWakeExecutionStage | null;
|
||||||
commentIds: string[];
|
commentIds: string[];
|
||||||
latestCommentId: string | null;
|
latestCommentId: string | null;
|
||||||
|
|
@ -363,6 +364,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||||
return {
|
return {
|
||||||
reason: asString(payload.reason, "").trim() || null,
|
reason: asString(payload.reason, "").trim() || null,
|
||||||
issue: normalizePaperclipWakeIssue(payload.issue),
|
issue: normalizePaperclipWakeIssue(payload.issue),
|
||||||
|
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
|
||||||
executionStage,
|
executionStage,
|
||||||
commentIds,
|
commentIds,
|
||||||
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
||||||
|
|
@ -432,6 +434,9 @@ export function renderPaperclipWakePrompt(
|
||||||
if (normalized.issue?.priority) {
|
if (normalized.issue?.priority) {
|
||||||
lines.push(`- issue priority: ${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) {
|
if (normalized.missingCount > 0) {
|
||||||
lines.push(`- omitted comments: ${normalized.missingCount}`);
|
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) {
|
if (normalized.comments.length > 0) {
|
||||||
lines.push("New comments in order:");
|
lines.push("New comments in order:");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -568,9 +568,10 @@ describe("codex execute", () => {
|
||||||
id: "issue-1",
|
id: "issue-1",
|
||||||
identifier: "PAP-1201",
|
identifier: "PAP-1201",
|
||||||
title: "Fix gallery opening for inline images",
|
title: "Fix gallery opening for inline images",
|
||||||
status: "todo",
|
status: "in_progress",
|
||||||
priority: "medium",
|
priority: "medium",
|
||||||
},
|
},
|
||||||
|
checkedOutByHarness: true,
|
||||||
commentIds: [],
|
commentIds: [],
|
||||||
latestCommentId: null,
|
latestCommentId: null,
|
||||||
comments: [],
|
comments: [],
|
||||||
|
|
@ -598,16 +599,19 @@ describe("codex execute", () => {
|
||||||
issue: {
|
issue: {
|
||||||
identifier: "PAP-1201",
|
identifier: "PAP-1201",
|
||||||
title: "Fix gallery opening for inline images",
|
title: "Fix gallery opening for inline images",
|
||||||
status: "todo",
|
status: "in_progress",
|
||||||
priority: "medium",
|
priority: "medium",
|
||||||
},
|
},
|
||||||
|
checkedOutByHarness: true,
|
||||||
commentIds: [],
|
commentIds: [],
|
||||||
});
|
});
|
||||||
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
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("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("- issue: PAP-1201 Fix gallery opening for inline images");
|
||||||
expect(capture.prompt).toContain("- pending comments: 0/0");
|
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 {
|
} finally {
|
||||||
if (previousHome === undefined) delete process.env.HOME;
|
if (previousHome === undefined) delete process.env.HOME;
|
||||||
else process.env.HOME = previousHome;
|
else process.env.HOME = previousHome;
|
||||||
|
|
|
||||||
|
|
@ -496,15 +496,34 @@ describe("heartbeat comment wake batching", () => {
|
||||||
id: issueId,
|
id: issueId,
|
||||||
identifier: `${issuePrefix}-1`,
|
identifier: `${issuePrefix}-1`,
|
||||||
title: "Require a comment",
|
title: "Require a comment",
|
||||||
status: "todo",
|
status: "in_progress",
|
||||||
priority: "medium",
|
priority: "medium",
|
||||||
},
|
},
|
||||||
|
checkedOutByHarness: true,
|
||||||
commentIds: [],
|
commentIds: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload");
|
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("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`);
|
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();
|
gateway.releaseFirstWait();
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const runs = await db
|
const runs = await db
|
||||||
|
|
@ -555,4 +574,5 @@ describe("heartbeat comment wake batching", () => {
|
||||||
await gateway.close();
|
await gateway.close();
|
||||||
}
|
}
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,99 @@ describe("realizeExecutionWorkspace", () => {
|
||||||
expect(second.branchName).toBe(first.branchName);
|
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 () => {
|
it("slugifies unsafe issue titles for branch names and worktree folders", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ If `PAPERCLIP_APPROVAL_ID` is set:
|
||||||
|
|
||||||
## 5. Checkout and Work
|
## 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.
|
- Never retry a 409 -- that task belongs to someone else.
|
||||||
- Do the work. Update status and comment when done.
|
- Do the work. Update status and comment when done.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
projects,
|
projects,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import { conflict, notFound } from "../errors.js";
|
import { conflict, HttpError, notFound } from "../errors.js";
|
||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
import { publishLiveEvent } from "./live-events.js";
|
import { publishLiveEvent } from "./live-events.js";
|
||||||
import { getRunLogStore, type RunLogHandle } from "./run-log-store.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 DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||||
const WAKE_COMMENT_IDS_KEY = "wakeCommentIds";
|
const WAKE_COMMENT_IDS_KEY = "wakeCommentIds";
|
||||||
const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake";
|
const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake";
|
||||||
|
const PAPERCLIP_HARNESS_CHECKOUT_KEY = "paperclipHarnessCheckedOut";
|
||||||
const DETACHED_PROCESS_ERROR_CODE = "process_detached";
|
const DETACHED_PROCESS_ERROR_CODE = "process_detached";
|
||||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||||
|
|
@ -760,6 +761,36 @@ function describeSessionResetReason(
|
||||||
return null;
|
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(
|
function deriveCommentId(
|
||||||
contextSnapshot: Record<string, unknown> | null | undefined,
|
contextSnapshot: Record<string, unknown> | null | undefined,
|
||||||
payload: Record<string, unknown> | null | undefined,
|
payload: Record<string, unknown> | null | undefined,
|
||||||
|
|
@ -1005,6 +1036,7 @@ async function buildPaperclipWakePayload(input: {
|
||||||
priority: issueSummary.priority,
|
priority: issueSummary.priority,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
|
||||||
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
|
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
|
||||||
commentIds,
|
commentIds,
|
||||||
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
||||||
|
|
@ -1216,6 +1248,27 @@ export function heartbeatService(db: Db) {
|
||||||
.then((rows) => rows[0] ?? null);
|
.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) {
|
async function getRuntimeState(agentId: string) {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -2644,26 +2697,26 @@ export function heartbeatService(db: Db) {
|
||||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null);
|
const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null);
|
||||||
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
|
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
|
||||||
const issueId = readNonEmptyString(context.issueId);
|
const issueId = readNonEmptyString(context.issueId);
|
||||||
const issueContext = issueId
|
let issueContext = issueId ? await getIssueExecutionContext(agent.companyId, issueId) : null;
|
||||||
? await db
|
if (
|
||||||
.select({
|
issueId &&
|
||||||
id: issues.id,
|
issueContext &&
|
||||||
identifier: issues.identifier,
|
shouldAutoCheckoutIssueForWake({
|
||||||
title: issues.title,
|
contextSnapshot: context,
|
||||||
status: issues.status,
|
issueStatus: issueContext.status,
|
||||||
priority: issues.priority,
|
issueAssigneeAgentId: issueContext.assigneeAgentId,
|
||||||
projectId: issues.projectId,
|
agentId: agent.id,
|
||||||
projectWorkspaceId: issues.projectWorkspaceId,
|
})
|
||||||
executionWorkspaceId: issues.executionWorkspaceId,
|
) {
|
||||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
try {
|
||||||
assigneeAgentId: issues.assigneeAgentId,
|
await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id);
|
||||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true;
|
||||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
} catch (error) {
|
||||||
})
|
if (!isCheckoutConflictError(error)) throw error;
|
||||||
.from(issues)
|
context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = false;
|
||||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
}
|
||||||
.then((rows) => rows[0] ?? null)
|
issueContext = await getIssueExecutionContext(agent.companyId, issueId);
|
||||||
: null;
|
}
|
||||||
const issueAssigneeOverrides =
|
const issueAssigneeOverrides =
|
||||||
issueContext && issueContext.assigneeAgentId === agent.id
|
issueContext && issueContext.assigneeAgentId === agent.id
|
||||||
? parseIssueAssigneeAdapterOverrides(
|
? parseIssueAssigneeAdapterOverrides(
|
||||||
|
|
|
||||||
|
|
@ -513,6 +513,67 @@ function gitErrorIncludes(error: unknown, needle: string) {
|
||||||
return message.toLowerCase().includes(needle.toLowerCase());
|
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> {
|
async function detectDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||||
// Try the explicit remote HEAD first (set by git clone or git remote set-head)
|
// Try the explicit remote HEAD first (set by git clone or git remote set-head)
|
||||||
try {
|
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 branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}");
|
||||||
const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
|
const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
|
||||||
issue: input.issue,
|
issue: input.issue,
|
||||||
|
|
@ -901,50 +962,59 @@ export async function realizeExecutionWorkspace(input: {
|
||||||
|
|
||||||
await fs.mkdir(worktreeParentDir, { recursive: true });
|
await fs.mkdir(worktreeParentDir, { recursive: true });
|
||||||
|
|
||||||
const existingWorktree = await directoryExists(worktreePath);
|
async function reuseExistingWorktree(reusablePath: string) {
|
||||||
if (existingWorktree) {
|
if (input.recorder) {
|
||||||
const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null);
|
await input.recorder.recordOperation({
|
||||||
if (existingGitDir) {
|
phase: "worktree_prepare",
|
||||||
if (input.recorder) {
|
cwd: repoRoot,
|
||||||
await input.recorder.recordOperation({
|
metadata: {
|
||||||
phase: "worktree_prepare",
|
repoRoot,
|
||||||
cwd: repoRoot,
|
worktreePath: reusablePath,
|
||||||
metadata: {
|
branchName,
|
||||||
repoRoot,
|
baseRef,
|
||||||
worktreePath,
|
created: false,
|
||||||
branchName,
|
reused: true,
|
||||||
baseRef,
|
},
|
||||||
created: false,
|
run: async () => ({
|
||||||
reused: true,
|
status: "succeeded",
|
||||||
},
|
exitCode: 0,
|
||||||
run: async () => ({
|
system: `Reused existing git worktree at ${reusablePath}\n`,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
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.`);
|
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")) {
|
if (!gitErrorIncludes(error, "already exists")) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await recordGitOperation(input.recorder, {
|
try {
|
||||||
phase: "worktree_prepare",
|
await recordGitOperation(input.recorder, {
|
||||||
args: ["worktree", "add", worktreePath, branchName],
|
phase: "worktree_prepare",
|
||||||
cwd: repoRoot,
|
args: ["worktree", "add", worktreePath, branchName],
|
||||||
metadata: {
|
cwd: repoRoot,
|
||||||
repoRoot,
|
metadata: {
|
||||||
worktreePath,
|
repoRoot,
|
||||||
branchName,
|
worktreePath,
|
||||||
baseRef,
|
branchName,
|
||||||
created: false,
|
baseRef,
|
||||||
reusedExistingBranch: true,
|
created: false,
|
||||||
},
|
reusedExistingBranch: true,
|
||||||
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
|
},
|
||||||
failureLabel: `git worktree add ${worktreePath}`,
|
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({
|
await provisionExecutionWorktree({
|
||||||
strategy: rawStrategy,
|
strategy: rawStrategy,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ interface TestContext {
|
||||||
issueIds: string[];
|
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). */
|
/** Create an authenticated APIRequestContext for an agent (token set, no run ID yet). */
|
||||||
async function createAgentRequest(token: string): Promise<APIRequestContext> {
|
async function createAgentRequest(token: string): Promise<APIRequestContext> {
|
||||||
return pwRequest.newContext({
|
return pwRequest.newContext({
|
||||||
|
|
@ -58,6 +64,17 @@ async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promi
|
||||||
return run.id;
|
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. */
|
/** PATCH an issue as an agent with a fresh heartbeat run ID. */
|
||||||
async function agentPatch(
|
async function agentPatch(
|
||||||
board: APIRequestContext,
|
board: APIRequestContext,
|
||||||
|
|
@ -88,6 +105,17 @@ async function agentCheckoutAndPatch(
|
||||||
data: { agentId: agent.agentId, expectedStatuses },
|
data: { agentId: agent.agentId, expectedStatuses },
|
||||||
});
|
});
|
||||||
if (!checkoutRes.ok()) {
|
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
|
// If agent checkout fails (e.g. run expired), fall back to board checkout
|
||||||
// then PATCH with the agent's identity
|
// then PATCH with the agent's identity
|
||||||
const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
|
const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
|
||||||
|
|
|
||||||
45
ui/src/lib/issueActiveRun.test.ts
Normal file
45
ui/src/lib/issueActiveRun.test.ts
Normal 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
ui/src/lib/issueActiveRun.ts
Normal file
15
ui/src/lib/issueActiveRun.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
readIssueDetailHeaderSeed,
|
readIssueDetailHeaderSeed,
|
||||||
rememberIssueDetailLocationState,
|
rememberIssueDetailLocationState,
|
||||||
} from "../lib/issueDetailBreadcrumb";
|
} from "../lib/issueDetailBreadcrumb";
|
||||||
|
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
|
||||||
import {
|
import {
|
||||||
hasBlockingShortcutDialog,
|
hasBlockingShortcutDialog,
|
||||||
resolveIssueDetailGoKeyAction,
|
resolveIssueDetailGoKeyAction,
|
||||||
|
|
@ -471,13 +472,15 @@ export function IssueDetail() {
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: activeRun, isLoading: activeRunLoading } = useQuery({
|
const shouldPollActiveRun = shouldTrackIssueActiveRun(issue);
|
||||||
|
const { data: rawActiveRun, isLoading: activeRunLoading } = useQuery({
|
||||||
queryKey: queryKeys.issues.activeRun(issueId!),
|
queryKey: queryKeys.issues.activeRun(issueId!),
|
||||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
|
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
|
||||||
enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"),
|
enabled: !!issueId && shouldPollActiveRun,
|
||||||
refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000,
|
refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
const activeRun = resolveIssueActiveRun(issue, rawActiveRun);
|
||||||
|
|
||||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||||
const runningIssueRun = useMemo(
|
const runningIssueRun = useMemo(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue