fix: surface liveness continuation context

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-19 07:08:20 -05:00
1 parent 09ad5fc2f4
commit cd2c2995e3
8 files changed
+130 -1

No files matched your search

@@ -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.
@@ -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.");
+54 -1
View File
@@ -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) {
@@ -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.",
});
});
+16
View File
@@ -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,
+13
View File
@@ -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
+3
View File
@@ -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,
},
};
}
+3
View File
@@ -139,6 +139,9 @@ function normalizeEvidence(evidence: Partial<RunLivenessEvidenceInput> | null |
export function hasConcreteActionEvidence(evidence: Partial<RunLivenessEvidenceInput> | 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 +