diff --git a/packages/plugins/plugin-missions/src/mission-runtime.ts b/packages/plugins/plugin-missions/src/mission-runtime.ts index 763949e6723..4619805009b 100644 --- a/packages/plugins/plugin-missions/src/mission-runtime.ts +++ b/packages/plugins/plugin-missions/src/mission-runtime.ts @@ -487,20 +487,33 @@ async function appendDecisionLog( ctx: PluginContext, companyId: string, issueId: string, - existingBody: string, entry: { marker: string; body: string }, ) { - const nextBody = appendDecisionLogEntry(existingBody, entry); - if (nextBody === existingBody.trim()) return nextBody; - await ctx.issues.documents.upsert({ - issueId, - companyId, - key: "decision-log", - title: "Decision Log", - body: nextBody, - changeSummary: "Recorded mission decision", - }); - return nextBody; + for (let attempt = 0; attempt < 2; attempt += 1) { + const existing = await ctx.issues.documents.get(issueId, "decision-log", companyId); + const existingBody = existing?.body ?? ""; + const nextBody = appendDecisionLogEntry(existingBody, entry); + if (nextBody === existingBody.trim()) return nextBody; + + try { + await ctx.issues.documents.upsert({ + issueId, + companyId, + key: "decision-log", + title: "Decision Log", + body: nextBody, + changeSummary: "Recorded mission decision", + baseRevisionId: existing?.latestRevisionId ?? null, + }); + return nextBody; + } catch (error) { + const message = stringifyError(error); + if (attempt === 0 && message.includes("updated by someone else")) continue; + throw error; + } + } + + throw new Error("Unable to append mission decision log entry"); } async function ensureFixIssue( @@ -590,7 +603,7 @@ export async function advanceMission( actorRunId: input.actorRunId ?? null, }; const actorLabel = buildActorLabel(actor); - const { mission, summary, featuresDocument, decisionLogBody } = await buildSummaryInternal( + const { mission, summary, featuresDocument } = await buildSummaryInternal( ctx, input.companyId, input.issueId, @@ -626,7 +639,6 @@ export async function advanceMission( ctx, input.companyId, input.issueId, - decisionLogBody, buildMissionEventEntry({ markerKey: `advance:paused:${stopReason}:${summary.latestValidationRound}`, title: `Advance Paused ${summary.missionIdentifier ?? input.issueId}`, @@ -655,13 +667,11 @@ export async function advanceMission( createdFixIssues.push(fixIssue); } - let decisionLogBodyAfterFixes = decisionLogBody; if (createdFixIssues.length > 0) { - decisionLogBodyAfterFixes = await appendDecisionLog( + await appendDecisionLog( ctx, input.companyId, input.issueId, - decisionLogBodyAfterFixes, buildMissionEventEntry({ markerKey: `advance:fixes:${createdFixIssues.map((issue) => issue.id).join(",")}`, title: `Advance Created Fixes ${summary.missionIdentifier ?? input.issueId}`, @@ -699,7 +709,6 @@ export async function advanceMission( ctx, input.companyId, input.issueId, - decisionLogBodyAfterFixes, buildMissionEventEntry({ markerKey: `advance:wakeup:${wokenIssueIds.join(",")}`, title: `Advance Woke Issues ${summary.missionIdentifier ?? input.issueId}`, @@ -765,7 +774,7 @@ export async function waiveMissionFinding( actorRunId: input.actorRunId ?? null, }; const actorLabel = buildActorLabel(actor); - const { summary, decisionLogBody } = await buildSummaryInternal(ctx, input.companyId, input.issueId); + const { summary } = await buildSummaryInternal(ctx, input.companyId, input.issueId); const finding = summary.findings.find((candidate) => candidate.id === input.findingId); if (!finding) { throw new Error(`Mission finding not found: ${input.findingId}`); @@ -778,7 +787,7 @@ export async function waiveMissionFinding( actorLabel, createdAt, }); - await appendDecisionLog(ctx, input.companyId, input.issueId, decisionLogBody, waiverEntry); + await appendDecisionLog(ctx, input.companyId, input.issueId, waiverEntry); await ctx.state.set( { scopeKind: "issue", scopeId: input.issueId, stateKey: `waiver:${input.findingId}` }, { diff --git a/packages/plugins/plugin-missions/tests/plugin.spec.ts b/packages/plugins/plugin-missions/tests/plugin.spec.ts index 7192dc1396f..184614e6279 100644 --- a/packages/plugins/plugin-missions/tests/plugin.spec.ts +++ b/packages/plugins/plugin-missions/tests/plugin.spec.ts @@ -527,6 +527,19 @@ describe("missions plugin worker", () => { }); } + const originalUpsert = harness.ctx.issues.documents.upsert; + const documentUpdates: Array<{ key: string; baseRevisionId: string | null | undefined }> = []; + harness.ctx.issues.documents.upsert = async (input) => { + const existing = await harness.ctx.issues.documents.get(input.issueId, input.key, input.companyId); + if (existing && input.baseRevisionId !== existing.latestRevisionId) { + throw new Error( + input.baseRevisionId ? "Document was updated by someone else" : "Document update requires baseRevisionId", + ); + } + documentUpdates.push({ key: input.key, baseRevisionId: input.baseRevisionId }); + return originalUpsert(input); + }; + const first = await harness.performAction<{ createdFixIssueIds: string[] }>("advance", { companyId, issueId: rootIssueId, @@ -542,12 +555,35 @@ describe("missions plugin worker", () => { const generated = await harness.ctx.issues.list({ companyId, limit: 50 }); expect(generated.filter((item) => item.originKind === "plugin:paperclip.missions:fix")).toHaveLength(1); - await expect(harness.performAction("waive", { + const summaryResponse = await plugin.definition.onApiRequest?.({ + routeKey: "summary", + method: "GET", + path: `/issues/${rootIssueId}/missions/summary`, + params: { issueId: rootIssueId }, + query: {}, + body: null, + actor: { actorType: "agent", actorId: workerAgentId, agentId: workerAgentId, runId: "run-test" }, companyId, - issueId: rootIssueId, - findingId: "FINDING-MISSION-001", - rationale: "Accepted temporarily for test coverage.", - })).resolves.toMatchObject({ + headers: {}, + }); + expect(summaryResponse?.body).toMatchObject({ + validationSummary: expect.objectContaining({ + findings: [expect.objectContaining({ id: "FINDING-MISSION-001", computedStatus: "fix_created" })], + }), + }); + + const waiverResponse = await plugin.definition.onApiRequest?.({ + routeKey: "waive", + method: "POST", + path: `/issues/${rootIssueId}/missions/findings/FINDING-MISSION-001/waive`, + params: { issueId: rootIssueId, findingKey: "FINDING-MISSION-001" }, + query: {}, + body: { rationale: "Accepted temporarily for test coverage." }, + actor: { actorType: "agent", actorId: workerAgentId, agentId: workerAgentId, runId: "run-test" }, + companyId, + headers: {}, + }); + expect(waiverResponse?.body).toMatchObject({ findingId: "FINDING-MISSION-001", waived: true, }); @@ -555,5 +591,10 @@ describe("missions plugin worker", () => { const decisionLog = await harness.ctx.issues.documents.get(rootIssueId, "decision-log", companyId); expect(decisionLog?.body).toContain("paperclip:mission-finding-waiver:FINDING-MISSION-001"); expect(decisionLog?.body).toContain("Accepted temporarily for test coverage."); + expect(documentUpdates.filter((update) => update.key === "decision-log")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ baseRevisionId: expect.any(String) }), + ]), + ); }); }); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index dc325452076..5b4c13d4566 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -776,6 +776,7 @@ export interface WorkerToHostMethods { title?: string; format?: string; changeSummary?: string; + baseRevisionId?: string | null; }, result: IssueDocument, ]; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 367905a1ded..ca66e580b25 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -878,7 +878,7 @@ export interface PluginIssueDocumentsClient { * * Requires the `issue.documents.write` capability. * - * @param input - Document data including issueId, key, body, and optional title/format/changeSummary + * @param input - Document data including issueId, key, body, and optional title/format/changeSummary/baseRevisionId */ upsert(input: { issueId: string; @@ -888,6 +888,7 @@ export interface PluginIssueDocumentsClient { title?: string; format?: string; changeSummary?: string; + baseRevisionId?: string | null; }): Promise; /** diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 3f1f5a9d057..426028cbf9e 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -775,6 +775,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost title: input.title, format: input.format, changeSummary: input.changeSummary, + baseRevisionId: input.baseRevisionId, }); }, diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 9976e04cfc0..8ea55e83dda 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1558,6 +1558,7 @@ export function buildHostServices( title: params.title ?? null, format: params.format ?? "markdown", changeSummary: params.changeSummary ?? null, + baseRevisionId: params.baseRevisionId ?? null, }); await logPluginActivity({ companyId,