diff --git a/docs/guides/agent-developer/heartbeat-protocol.md b/docs/guides/agent-developer/heartbeat-protocol.md index 10e587a60b6..b846d7ab973 100644 --- a/docs/guides/agent-developer/heartbeat-protocol.md +++ b/docs/guides/agent-developer/heartbeat-protocol.md @@ -110,3 +110,16 @@ Always set `parentId` and `goalId` on subtasks. - **Always set parentId** on subtasks - **Never cancel cross-team tasks** — reassign to your manager - **Escalate when stuck** — use your chain of command + +## Run Liveness + +Paperclip records run liveness as metadata on heartbeat runs. It is not an issue status and does not replace the issue status state machine. + +- Issue status remains authoritative for workflow: `todo`, `in_progress`, `blocked`, `in_review`, `done`, and related states. +- Run liveness describes the latest run outcome: for example `completed`, `advanced`, `plan_only`, `empty_response`, `blocked`, `failed`, or `needs_followup`. +- Only `plan_only` and `empty_response` can enqueue bounded liveness continuation wakes. +- Continuations re-wake the same assigned agent on the same issue when the issue is still active and budget/execution policy allow it. +- `continuationAttempt` counts semantic liveness continuations for a source run chain. It is separate from process recovery, queued wake delivery, adapter session resume, and other operational retries. +- Liveness continuation wake prompts include the attempt, source run, liveness state, liveness reason, and the instruction for the next heartbeat. +- Continuations do not mark the issue `blocked` or `done`. If automatic continuations are exhausted, Paperclip leaves an audit comment so a human or manager can clarify, block, or assign follow-up work. +- Workspace provisioning alone is not treated as concrete task progress. Durable progress should appear as tool/action events, issue comments, document or work-product revisions, activity log entries, commits, or tests. diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 721fd1f1837..4f7761858cf 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -162,6 +162,14 @@ describe("renderPaperclipWakePrompt", () => { body: "# Continuation Summary\n\n## Next Action\n\n- Integrate child outputs.", updatedAt: "2026-04-18T12:00:00.000Z", }, + livenessContinuation: { + attempt: 2, + maxAttempts: 2, + sourceRunId: "run-1", + state: "plan_only", + reason: "Run described future work without concrete action evidence", + instruction: "Take the first concrete action now.", + }, childIssueSummaries: [ { id: "child-1", @@ -178,6 +186,13 @@ describe("renderPaperclipWakePrompt", () => { continuationSummary: { body: expect.stringContaining("Continuation Summary"), }, + livenessContinuation: { + attempt: 2, + maxAttempts: 2, + sourceRunId: "run-1", + state: "plan_only", + instruction: "Take the first concrete action now.", + }, childIssueSummaries: [ { identifier: "PAP-101", @@ -189,6 +204,12 @@ describe("renderPaperclipWakePrompt", () => { const prompt = renderPaperclipWakePrompt(payload); expect(prompt).toContain("Issue continuation summary:"); expect(prompt).toContain("Integrate child outputs."); + expect(prompt).toContain("Run liveness continuation:"); + expect(prompt).toContain("- attempt: 2/2"); + expect(prompt).toContain("- source run: run-1"); + expect(prompt).toContain("- liveness state: plan_only"); + expect(prompt).toContain("- reason: Run described future work without concrete action evidence"); + expect(prompt).toContain("- instruction: Take the first concrete action now."); expect(prompt).toContain("Direct child issue summaries:"); expect(prompt).toContain("PAP-101 Implement helper (done)"); expect(prompt).toContain("Added the helper route and tests."); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 6663f3d6962..9078f47b28b 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -269,6 +269,15 @@ type PaperclipWakeContinuationSummary = { updatedAt: string | null; }; +type PaperclipWakeLivenessContinuation = { + attempt: number | null; + maxAttempts: number | null; + sourceRunId: string | null; + state: string | null; + reason: string | null; + instruction: string | null; +}; + type PaperclipWakeChildIssueSummary = { id: string | null; identifier: string | null; @@ -284,6 +293,7 @@ type PaperclipWakePayload = { checkedOutByHarness: boolean; executionStage: PaperclipWakeExecutionStage | null; continuationSummary: PaperclipWakeContinuationSummary | null; + livenessContinuation: PaperclipWakeLivenessContinuation | null; childIssueSummaries: PaperclipWakeChildIssueSummary[]; childIssueSummaryTruncated: boolean; commentIds: string[]; @@ -342,6 +352,25 @@ function normalizePaperclipWakeContinuationSummary(value: unknown): PaperclipWak }; } +function normalizePaperclipWakeLivenessContinuation(value: unknown): PaperclipWakeLivenessContinuation | null { + const continuation = parseObject(value); + const attempt = asNumber(continuation.attempt, 0); + const maxAttempts = asNumber(continuation.maxAttempts, 0); + const sourceRunId = asString(continuation.sourceRunId, "").trim() || null; + const state = asString(continuation.state, "").trim() || null; + const reason = asString(continuation.reason, "").trim() || null; + const instruction = asString(continuation.instruction, "").trim() || null; + if (!attempt && !maxAttempts && !sourceRunId && !state && !reason && !instruction) return null; + return { + attempt: attempt > 0 ? attempt : null, + maxAttempts: maxAttempts > 0 ? maxAttempts : null, + sourceRunId, + state, + reason, + instruction, + }; +} + function normalizePaperclipWakeChildIssueSummary(value: unknown): PaperclipWakeChildIssueSummary | null { const child = parseObject(value); const id = asString(child.id, "").trim() || null; @@ -413,13 +442,14 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl : []; const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage); const continuationSummary = normalizePaperclipWakeContinuationSummary(payload.continuationSummary); + const livenessContinuation = normalizePaperclipWakeLivenessContinuation(payload.livenessContinuation); const childIssueSummaries = Array.isArray(payload.childIssueSummaries) ? payload.childIssueSummaries .map((entry) => normalizePaperclipWakeChildIssueSummary(entry)) .filter((entry): entry is PaperclipWakeChildIssueSummary => Boolean(entry)) : []; - if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && !executionStage && !continuationSummary && !normalizePaperclipWakeIssue(payload.issue)) { + if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) { return null; } @@ -429,6 +459,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false), executionStage, continuationSummary, + livenessContinuation, childIssueSummaries, childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false), commentIds, @@ -550,6 +581,28 @@ export function renderPaperclipWakePrompt( } } + if (normalized.livenessContinuation) { + const continuation = normalized.livenessContinuation; + lines.push("", "Run liveness continuation:"); + if (continuation.attempt) { + lines.push( + `- attempt: ${continuation.attempt}${continuation.maxAttempts ? `/${continuation.maxAttempts}` : ""}`, + ); + } + if (continuation.sourceRunId) { + lines.push(`- source run: ${continuation.sourceRunId}`); + } + if (continuation.state) { + lines.push(`- liveness state: ${continuation.state}`); + } + if (continuation.reason) { + lines.push(`- reason: ${continuation.reason}`); + } + if (continuation.instruction) { + lines.push(`- instruction: ${continuation.instruction}`); + } + } + if (normalized.childIssueSummaries.length > 0) { lines.push("", "Direct child issue summaries:"); for (const child of normalized.childIssueSummaries) { diff --git a/server/src/__tests__/run-continuations.test.ts b/server/src/__tests__/run-continuations.test.ts index 0d42a1e592e..a9e1e7b0006 100644 --- a/server/src/__tests__/run-continuations.test.ts +++ b/server/src/__tests__/run-continuations.test.ts @@ -72,13 +72,20 @@ describe("run liveness continuations", () => { issueId, sourceRunId: runId, livenessState: "plan_only", + livenessReason: "Planned without acting", continuationAttempt: 1, maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, + instruction: "Take the first concrete action now.", }); expect(decision.contextSnapshot).toMatchObject({ issueId, wakeReason: RUN_LIVENESS_CONTINUATION_REASON, livenessContinuationAttempt: 1, + livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS, + livenessContinuationSourceRunId: runId, + livenessContinuationState: "plan_only", + livenessContinuationReason: "Planned without acting", + livenessContinuationInstruction: "Take the first concrete action now.", }); }); diff --git a/server/src/__tests__/run-liveness.test.ts b/server/src/__tests__/run-liveness.test.ts index 34480402495..08af19b7d26 100644 --- a/server/src/__tests__/run-liveness.test.ts +++ b/server/src/__tests__/run-liveness.test.ts @@ -56,6 +56,22 @@ describe("run liveness classifier", () => { expect(classification.lastUsefulActionAt).toBe(latestEvidenceAt); }); + it("does not treat workspace operations alone as concrete progress", () => { + const classification = classifyRunLiveness({ + ...baseInput, + resultJson: { + summary: "I will inspect the repo next.", + }, + evidence: { + workspaceOperationsCreated: 1, + latestEvidenceAt: new Date("2026-04-18T12:00:00Z"), + }, + }); + + expect(classification.livenessState).toBe("plan_only"); + expect(classification.lastUsefulActionAt).toBeNull(); + }); + it("exempts planning/document tasks from plan-only retry classification", () => { const classification = classifyRunLiveness({ ...baseInput, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index bb9c74e8349..31bf060bc07 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1356,6 +1356,19 @@ async function buildPaperclipWakePayload(input: { ? input.contextSnapshot.childIssueSummaries : [], childIssueSummaryTruncated: input.contextSnapshot.childIssueSummaryTruncated === true, + livenessContinuation: readNonEmptyString(input.contextSnapshot.livenessContinuationState) || + readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction) || + readNonEmptyString(input.contextSnapshot.livenessContinuationSourceRunId) || + typeof input.contextSnapshot.livenessContinuationAttempt === "number" + ? { + attempt: input.contextSnapshot.livenessContinuationAttempt, + maxAttempts: input.contextSnapshot.livenessContinuationMaxAttempts, + sourceRunId: readNonEmptyString(input.contextSnapshot.livenessContinuationSourceRunId), + state: readNonEmptyString(input.contextSnapshot.livenessContinuationState), + reason: readNonEmptyString(input.contextSnapshot.livenessContinuationReason), + instruction: readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction), + } + : null, checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, executionStage: Object.keys(executionStage).length > 0 ? executionStage : null, continuationSummary: continuationSummary diff --git a/server/src/services/run-continuations.ts b/server/src/services/run-continuations.ts index 64eb62027d0..8cdf4eab37f 100644 --- a/server/src/services/run-continuations.ts +++ b/server/src/services/run-continuations.ts @@ -176,8 +176,11 @@ export function decideRunLivenessContinuation(input: { taskKey: issue.id, wakeReason: RUN_LIVENESS_CONTINUATION_REASON, livenessContinuationAttempt: nextAttempt, + livenessContinuationMaxAttempts: maxAttempts, livenessContinuationSourceRunId: run.id, livenessContinuationState: livenessState, + livenessContinuationReason: livenessReason, + livenessContinuationInstruction: payload.instruction, }, }; } diff --git a/server/src/services/run-liveness.ts b/server/src/services/run-liveness.ts index 2a02edad956..75f82909b13 100644 --- a/server/src/services/run-liveness.ts +++ b/server/src/services/run-liveness.ts @@ -139,6 +139,9 @@ function normalizeEvidence(evidence: Partial | null | export function hasConcreteActionEvidence(evidence: Partial | null | undefined) { const normalized = normalizeEvidence(evidence); + // Workspace creation is setup evidence, not task progress by itself. It can + // appear in reasons alongside durable activity, but it must not prevent a + // planning-only or empty run from receiving a bounded continuation. return ( normalized.issueCommentsCreated + normalized.documentRevisionsCreated +