From 73105e3f7b0be7eb43761b0043a26531cc0d29cc Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 12 Sep 2026 17:32:50 -0500 Subject: [PATCH] fix: deliver current task descriptions on resumed runs Keep updated task objectives across later resumes while preserving new human direction and explicit interactions. Co-Authored-By: Paperclip --- doc/SPEC-implementation.md | 8 ++ .../adapter-utils/src/server-utils.test.ts | 34 +++++-- packages/adapter-utils/src/server-utils.ts | 44 ++------- .../services/execution-continuation.test.ts | 93 +++++++++++++++++++ server/src/services/execution-continuation.ts | 54 ++++++++--- 5 files changed, 176 insertions(+), 57 deletions(-) diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 8502540d7a..0bb1837e1e 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1247,6 +1247,14 @@ Behavior: - `thin`: send IDs and pointers only; agent fetches context via API - `fat`: include current assignments, goal summary, budget snapshot, and recent comments +When an adapter includes the task brief, it includes the current description on +resumed sessions as well as fresh sessions. A saved provider conversation does +not prove that it has received later description edits. Comment deltas may +remain compact; they do not replace the current brief. An edited brief must not +be replaced by an already delivered historical comment, including on subsequent +resumes. New human direction and explicit interaction outcomes remain available +alongside the brief. These context updates do not reset the provider session. + ## 11.5 Recovery Work Classes Status-only recovery coordination must include guard context that prevents deliverable work and document or plan updates (`allowDeliverableWork: false`, `allowDocumentUpdates: false`, `resumeRequiresNormalModel: true`). Recovery work classes do not select or change the agent model. diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index c2b6e44887..6b980daabf 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -1332,7 +1332,28 @@ describe("renderPaperclipWakePrompt", () => { }); }); - it("omits the issue description from non-assignment resume deltas and leaves a fetch breadcrumb", () => { + it("delivers an edited task brief on an ordinary resumed status wake", () => { + const description = "Run sh /tmp/current-verification.sh once; do not run task/acceptance-step.sh."; + const wake = { + reason: "issue_status_changed", + issue: { id: "issue-1", title: "Existing task", status: "in_progress", description }, + commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 }, + comments: [], + fallbackFetchNeeded: false, + }; + const prompt = renderPaperclipWakePrompt(wake, { resumedSession: true }); + expect(prompt).toContain(description); + expect(prompt).toContain("Paperclip Resume Delta"); + expect(prompt).not.toContain("omitted from this resume delta"); + const selected = selectPaperclipTaskMarkdown({ + paperclipWake: wake, + paperclipTaskMarkdown: `Current task: ${description}`, + paperclipTaskMarkdownCompact: "Existing task", + }, { resumedSession: true }); + expect(selected).toContain(description); + }); + + it("keeps the current issue description on unchanged and assignment-shaped resumes", () => { const basePayload = { issue: { id: "issue-1", @@ -1352,10 +1373,9 @@ describe("renderPaperclipWakePrompt", () => { { ...basePayload, reason: "issue_commented" }, { resumedSession: true }, ); - expect(commentResume).not.toContain("Issue description:"); - expect(commentResume).toContain( - "- issue description: omitted from this resume delta; fetch the issue if you need the latest brief", - ); + expect(commentResume).toContain("Issue description:"); + expect(commentResume).toContain(basePayload.issue.description); + expect(commentResume).not.toContain("omitted from this resume delta"); // Assignment-shaped resumes still deliver the brief: the resuming session // may be picking this issue up for the first time. @@ -3040,7 +3060,7 @@ describe("selectPaperclipTaskMarkdown", () => { ).toBe(fullMarkdown); }); - it("returns the compact markdown for non-assignment resume deltas", () => { + it("retains the full current brief on non-assignment resume deltas", () => { expect( selectPaperclipTaskMarkdown( { @@ -3050,7 +3070,7 @@ describe("selectPaperclipTaskMarkdown", () => { }, { resumedSession: true }, ), - ).toBe(compactMarkdown); + ).toBe(fullMarkdown); }); it("falls back to the full markdown when no compact variant exists", () => { diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index c44e65eea7..ecf6c20aed 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -2150,28 +2150,14 @@ export function isAssignmentShapedPaperclipWakeReason( ); } -// Picks the task-context markdown variant for adapters that inject it into the -// prompt. Fresh sessions, assignment-shaped wakes, and recovery wakes get the -// full brief; other resume deltas get the compact variant (description -// stripped) because the session already received the brief when it picked the -// issue up. Falls back to the full variant when no compact one was provided. +// A resumed provider session has no certified revision of the task description. +// Always deliver the current brief: an ordinary status/comment wake can follow +// an edit, even when its comment delta is empty. This does not reset the session. export function selectPaperclipTaskMarkdown( context: Record | null | undefined, - options: { resumedSession?: boolean } = {}, + _options: { resumedSession?: boolean } = {}, ): string { - const full = asString(context?.paperclipTaskMarkdown, "").trim(); - if (!full) return ""; - if (options.resumedSession !== true) return full; - const wake = normalizePaperclipWakePayload(context?.paperclipWake); - if (!wake) return full; - if ( - isAssignmentShapedPaperclipWakeReason(wake.reason) || - isPaperclipRecoveryWakePayload(context?.paperclipWake) - ) { - return full; - } - const compact = asString(context?.paperclipTaskMarkdownCompact, "").trim(); - return compact || full; + return asString(context?.paperclipTaskMarkdown, "").trim(); } // Runtime-only connector skills are supplied by the server after assignment resolution. @@ -2465,18 +2451,10 @@ function renderPaperclipWakePromptBody( lines.push(`- issue priority: ${normalized.issue.priority}`); } const issueDescription = normalized.issue?.description ?? null; - // Resume deltas skip the description: the session already received the brief - // when it picked up the issue. Assignment-shaped and recovery wakes are the - // exceptions — there the resuming session may be seeing this issue fresh. - const resumeOmitsIssueDescription = - resumedSession && - !recoveryScoped && - !isAssignmentShapedPaperclipWakeReason(normalized.reason); - if ( - issueDescription !== null && - options.suppressIssueDescription !== true && - !resumeOmitsIssueDescription - ) { + // Comment coverage does not establish which description revision the provider + // saw. Keep the current brief on resumes; adapters carrying the full task + // markdown can still suppress this duplicate copy explicitly. + if (issueDescription !== null && options.suppressIssueDescription !== true) { lines.push( "", "Issue description:", @@ -2488,10 +2466,6 @@ function renderPaperclipWakePromptBody( "[issue description truncated; fetch the issue for the full brief]", ); } - } else if (issueDescription !== null && resumeOmitsIssueDescription) { - lines.push( - "- issue description: omitted from this resume delta; fetch the issue if you need the latest brief", - ); } if (normalized.checkboxSelection) { if (normalized.checkboxSelection.prompt) { diff --git a/server/src/services/execution-continuation.test.ts b/server/src/services/execution-continuation.test.ts index ca3f03b8e8..18e384c562 100644 --- a/server/src/services/execution-continuation.test.ts +++ b/server/src/services/execution-continuation.test.ts @@ -133,6 +133,99 @@ const support = await getEmbeddedPostgresTestSupport(); summary: "Notion read completed.", exposeLowTrustRaw: false, }); + it("retains an edited brief alongside historical direction on the same provider-session resume", async () => { + const taskId = randomUUID(), priorRunId = randomUUID(), commentId = randomUUID(); + const oldBrief = 'Run bash "$HOME/task/acceptance-step.sh".'; + const newBrief = "Run sh /tmp/current-verification.sh exactly once; do not run task/acceptance-step.sh."; + await db.insert(issues).values({ id: taskId, companyId, title: "Existing task", description: oldBrief, + status: "in_progress", assigneeAgentId: agentId }); + await db.insert(issueComments).values({ id: commentId, companyId, issueId: taskId, + authorType: "user", authorUserId: "local-board", body: oldBrief }); + const input = { db, companyId, issueId: taskId, agentId, + context: { wakeReason: "issue_status_changed" }, summary: null, exposeLowTrustRaw: false }; + const before = await buildExecutionContinuation(input); + await db.insert(heartbeatRuns).values({ id: priorRunId, companyId, agentId, status: "succeeded", + sessionIdAfter: "original-provider-conversation", contextSnapshot: { + issueId: taskId, paperclipIssue: { description: oldBrief }, executionContinuation: before, + } }); + await db.update(issues).set({ description: newBrief }).where(eq(issues.id, taskId)); + const after = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId }); + expect(after.objective).toBe(newBrief); + const stalePointer = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId, + context: { wakeReason: "issue_status_changed", latestCommentId: commentId } }); + expect(stalePointer.objective).toBe(newBrief); + + expect(after.messages).toEqual(before.messages); + expect(after.resumeDelta).toMatchObject({ baseRunId: priorRunId, messages: [] }); + const prompt = renderPaperclipWakePrompt({ reason: "issue_status_changed", + issue: { id: taskId, title: "Existing task", description: newBrief }, + executionContinuation: after, fallbackFetchNeeded: false }, { resumedSession: true }); + expect(prompt).toContain(newBrief); + expect(prompt).toContain("Paperclip Resume Delta"); + const [retained] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, priorRunId)); + expect(retained.sessionIdAfter).toBe("original-provider-conversation"); + // A second ordinary resume must not promote the older comment again now + // that the newly delivered description is equal to the stored description. + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: taskId, + paperclipIssue: { description: newBrief }, executionContinuation: after, + } }).where(eq(heartbeatRuns.id, priorRunId)); + const secondResume = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId }); + expect(secondResume.objective).toBe(newBrief); + expect(secondResume.resumeDelta?.messages).toEqual([]); + + + // A later comment may refine the brief or authorize the next planning step. + const direction = "The plan is approved; implement only its first step."; + const directionId = randomUUID(); + await db.insert(issueComments).values({ id: directionId, companyId, issueId: taskId, authorType: "user", + authorUserId: "local-board", body: direction, createdAt: new Date(Date.now() + 1000) }); + const coalesced = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId }); + expect(coalesced.objective).toBe(direction); + const later = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId, + context: { wakeReason: "issue_commented", commentId: directionId } }); + expect(later.objective).toBe(direction); + expect(later.resumeDelta?.messages.map(message => message.body)).toEqual([direction]); + + // Unchanged task resumes keep the comment's refinement, with no new comment required. + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: taskId, + paperclipIssue: { description: newBrief }, executionContinuation: later, + } }).where(eq(heartbeatRuns.id, priorRunId)); + const unchanged = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId }); + expect(unchanged.objective).toBe(direction); + expect(unchanged.resumeDelta?.messages).toEqual([]); + + // An accepted plan interaction retains the originating request and decision. + const approvalId = randomUUID(); + await db.insert(issueThreadInteractions).values({ id: approvalId, companyId, issueId: taskId, + kind: "request_confirmation", status: "accepted", sourceRunId: priorRunId, + sourceCommentId: directionId, originCommentIds: [directionId], + payload: { version: 1, prompt: "Approve the plan?", target: { + type: "issue_document", key: "plan", revisionId: randomUUID(), revisionNumber: 1, + } }, result: { version: 1, outcome: "accepted" } }); + const approved = await buildExecutionContinuation({ ...input, + context: { interactionId: approvalId, wakeReason: "issue_interaction_resolved" } }); + expect(approved.objective).toBe(direction); + expect(approved.originCommentIds).toContain(directionId); + expect(approved.interactionOutcomes).toContainEqual(expect.objectContaining({ + id: approvalId, kind: "request_confirmation", status: "accepted", + })); + + // Conversations continue their current human message, not their persistent brief. + await db.update(issues).set({ conversationAgentId: agentId, conversationUserId: "local-board", + conversationState: "active" }).where(eq(issues.id, taskId)); + const conversation = await buildExecutionContinuation(input); + expect(conversation.objective).toBe(direction); + // Removing the comment that supported the objective cannot replay its old body. + await db.update(issues).set({ conversationAgentId: null, conversationUserId: null, + conversationState: null }).where(eq(issues.id, taskId)); + await db.update(issueComments).set({ deletedAt: new Date() }).where(eq(issueComments.id, directionId)); + const deletedDirection = await buildExecutionContinuation({ ...input, previousContextRunId: priorRunId }); + expect(deletedDirection.objective).toBe(newBrief); + expect(deletedDirection.messages.find(message => message.id === directionId)?.body).toBe(""); + + + }); + it("cancelled admission must not hide the interrupted execution", async () => { const rejectedId = randomUUID(); await db.update(heartbeatRuns).set({ status: "interrupted", errorCode: "server_shutdown_interrupted", createdAt: new Date("2026-09-08T10:00:00Z") }).where(eq(heartbeatRuns.id, runId)); diff --git a/server/src/services/execution-continuation.ts b/server/src/services/execution-continuation.ts index 2b4b694c2e..16bfddafb5 100644 --- a/server/src/services/execution-continuation.ts +++ b/server/src/services/execution-continuation.ts @@ -186,24 +186,20 @@ export async function buildExecutionContinuation(input: { const deliveredMessages = Array.isArray(priorEnvelope.messages) ? priorEnvelope.messages.map(object) : null; + const wasDelivered = (message: (typeof messages)[number]) => + deliveredMessages?.some((prior) => + prior.id === message.id && prior.updatedAt === message.updatedAt && + prior.body === message.body && prior.deleted === message.deleted && + prior.authorId === message.authorId && + (prior.createdByRunId ?? null) === message.createdByRunId && + JSON.stringify(prior.sourceTrust) === JSON.stringify(message.sourceTrust), + ) ?? false; const resumeDelta = deliveredMessages && input.previousContextRunId ? { baseRunId: input.previousContextRunId, - messages: messages.filter( - (message) => - originCommentIds.includes(message.id) || - !deliveredMessages.some( - (prior) => - prior.id === message.id && - prior.updatedAt === message.updatedAt && - prior.body === message.body && - prior.deleted === message.deleted && - prior.authorId === message.authorId && - (prior.createdByRunId ?? null) === message.createdByRunId && - JSON.stringify(prior.sourceTrust) === - JSON.stringify(message.sourceTrust), - ), + messages: messages.filter((message) => + originCommentIds.includes(message.id) || !wasDelivered(message), ), } : undefined; @@ -211,6 +207,34 @@ export async function buildExecutionContinuation(input: { (row) => row.authorType === "user" && !row.createdByRunId && !row.deleted && row.body.trim().length > 0, ); + // Description edits create no comment. Do not promote an already delivered + // historical comment over the current brief, including on a second resume. + const wakeCommentIds = continuationOriginCommentIds({ + commentId: input.context.commentId, + latestCommentId: input.context.latestCommentId, + commentIds: input.context.commentIds, + wakeCommentIds: input.context.wakeCommentIds, + }); + const hasUserWakeComment = messages.some((message) => + wakeCommentIds.includes(message.id) && message.authorType === "user" && + !message.createdByRunId && !message.deleted && message.body.trim().length > 0 && + !wasDelivered(message), + ); + const previousIssue = object(previousRun?.context?.paperclipIssue); + const briefUnchanged = Object.hasOwn(previousIssue, "description") && + previousIssue.description === issue.description; + const newUserDirection = deliveredMessages && latestRequest && !wasDelivered(latestRequest); + const previousObjective = string(priorEnvelope.objective); + // Never revive deleted, edited, or newly quarantined text from a prior run. + const priorObjectiveStillCurrent = previousObjective === issue.description || + messages.some((message) => message.authorType === "user" && !message.createdByRunId && + !message.deleted && message.body === previousObjective); + const objective = (hasUserWakeComment || triggerInteraction || issue.conversationAgentId || + (briefUnchanged && newUserDirection)) + ? latestRequest?.body ?? issue.description ?? issue.title + : briefUnchanged && previousObjective && priorObjectiveStillCurrent + ? previousObjective + : issue.description ?? latestRequest?.body ?? issue.title; const priorRuns = await db .select({ id: heartbeatRuns.id, result: heartbeatRuns.resultJson, status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode, runtimeMode: heartbeatRuns.runtimeMode, retryOfRunId: heartbeatRuns.retryOfRunId }) .from(heartbeatRuns) @@ -305,7 +329,7 @@ export async function buildExecutionContinuation(input: { sourceRunId, }, originCommentIds, - objective: latestRequest?.body ?? issue.description ?? issue.title, + objective, messages, interactionOutcomes: interactions .filter((row) => row.status !== "pending")