Auto-checkout scoped issue wakes in the harness

This commit is contained in:
Dotta 2026-04-11 10:53:28 -05:00
parent b649bd454f
commit c1bb938519
5 changed files with 107 additions and 25 deletions

View file

@ -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:");
} }

View file

@ -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;

View file

@ -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

View file

@ -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.

View file

@ -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,32 @@ 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 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 +1032,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 +1244,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 +2693,21 @@ 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, await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id);
assigneeAgentId: issues.assigneeAgentId, context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true;
assigneeAdapterOverrides: issues.assigneeAdapterOverrides, issueContext = await getIssueExecutionContext(agent.companyId, issueId);
executionWorkspaceSettings: issues.executionWorkspaceSettings, }
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null)
: null;
const issueAssigneeOverrides = const issueAssigneeOverrides =
issueContext && issueContext.assigneeAgentId === agent.id issueContext && issueContext.assigneeAgentId === agent.id
? parseIssueAssigneeAdapterOverrides( ? parseIssueAssigneeAdapterOverrides(