mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
Auto-checkout scoped issue wakes in the harness
This commit is contained in:
parent
b649bd454f
commit
c1bb938519
5 changed files with 107 additions and 25 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue