mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] Add run liveness continuations (#4083)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Heartbeat runs are the control-plane record of each agent execution window. > - Long-running local agents can exhaust context or stop while still holding useful next-step state. > - Operators need that stop reason, next action, and continuation path to be durable and visible. > - This pull request adds run liveness metadata, continuation summaries, and UI surfaces for issue run ledgers. > - The benefit is that interrupted or long-running work can resume with clearer context instead of losing the agent's last useful handoff. ## What Changed - Added heartbeat-run liveness fields, continuation attempt tracking, and an idempotent `0058` migration. - Added server services and tests for run liveness, continuation summaries, stop metadata, and activity backfill. - Wired local and HTTP adapters to surface continuation/liveness context through shared adapter utilities. - Added shared constants, validators, and heartbeat types for liveness continuation state. - Added issue-detail UI surfaces for continuation handoffs and the run ledger, with component tests. - Updated agent runtime docs, heartbeat protocol docs, prompt guidance, onboarding assets, and skills instructions to explain continuation behavior. - Addressed Greptile feedback by scoping document evidence by run, excluding system continuation-summary documents from liveness evidence, importing shared liveness types, surfacing hidden ledger run counts, documenting bounded retry behavior, and moving run-ledger liveness backfill off the request path. ## Verification - `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts server/src/__tests__/run-continuations.test.ts server/src/__tests__/run-liveness.test.ts server/src/__tests__/activity-service.test.ts server/src/__tests__/documents-service.test.ts server/src/__tests__/issue-continuation-summary.test.ts server/src/services/heartbeat-stop-metadata.test.ts ui/src/components/IssueRunLedger.test.tsx ui/src/components/IssueContinuationHandoff.test.tsx ui/src/components/IssueDocumentsSection.test.tsx` - `pnpm --filter @paperclipai/db build` - `pnpm exec vitest run server/src/__tests__/activity-service.test.ts ui/src/components/IssueRunLedger.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm exec vitest run server/src/__tests__/activity-service.test.ts server/src/__tests__/run-continuations.test.ts ui/src/components/IssueRunLedger.test.tsx` - `pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a plan document update"` - `pnpm exec vitest run server/src/__tests__/activity-service.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity service|treats a plan document update"` - Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and Snyk all passed. - Confirmed `public-gh/master` is an ancestor of this branch after fetching `public-gh master`. - Confirmed `pnpm-lock.yaml` is not included in the branch diff. - Confirmed migration `0058_wealthy_starbolt.sql` is ordered after `0057` and uses `IF NOT EXISTS` guards for repeat application. - Greptile inline review threads are resolved. ## Risks - Medium risk: this touches heartbeat execution, liveness recovery, activity rendering, issue routes, shared contracts, docs, and UI. - Migration risk is mitigated by additive columns/indexes and idempotent guards. - Run-ledger liveness backfill is now asynchronous, so the first ledger response can briefly show historical missing liveness until the background backfill completes. - UI screenshot coverage is not included in this packaging pass; validation is currently through focused component tests. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4, local tool-use coding agent with terminal, git, GitHub connector, GitHub CLI, and Paperclip API access. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Screenshot note: no before/after screenshots were captured in this PR packaging pass; the UI changes are covered by focused component tests listed above. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b9a80dcf22
commit
236d11d36f
71 changed files with 18254 additions and 85 deletions
|
|
@ -1,6 +1,11 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runChildProcess } from "./server-utils.js";
|
||||
import {
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
renderPaperclipWakePrompt,
|
||||
runChildProcess,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "./server-utils.js";
|
||||
|
||||
function isPidAlive(pid: number) {
|
||||
try {
|
||||
|
|
@ -21,6 +26,25 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) {
|
|||
}
|
||||
|
||||
describe("runChildProcess", () => {
|
||||
it("does not arm a timeout when timeoutSec is 0", async () => {
|
||||
const result = await runChildProcess(
|
||||
randomUUID(),
|
||||
process.execPath,
|
||||
["-e", "setTimeout(() => process.stdout.write('done'), 150);"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
timeoutSec: 0,
|
||||
graceSec: 1,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.stdout).toBe("done");
|
||||
});
|
||||
|
||||
it("waits for onSpawn before sending stdin to the child", async () => {
|
||||
const spawnDelayMs = 150;
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -86,3 +110,108 @@ describe("runChildProcess", () => {
|
|||
expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPaperclipWakePrompt", () => {
|
||||
it("keeps the default local-agent prompt action-oriented", () => {
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
|
||||
"Respect budget, pause/cancel, approval gates, and company boundaries",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the execution contract to scoped wake prompts", () => {
|
||||
const prompt = renderPaperclipWakePrompt({
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1580",
|
||||
title: "Update prompts",
|
||||
status: "in_progress",
|
||||
},
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
comments: [],
|
||||
fallbackFetchNeeded: false,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(prompt).toContain("Execution contract: take concrete action in this heartbeat");
|
||||
expect(prompt).toContain("use child issues instead of polling");
|
||||
expect(prompt).toContain("mark blocked work with the unblock owner/action");
|
||||
});
|
||||
|
||||
it("includes continuation and child issue summaries in structured wake context", () => {
|
||||
const payload = {
|
||||
reason: "issue_children_completed",
|
||||
issue: {
|
||||
id: "parent-1",
|
||||
identifier: "PAP-100",
|
||||
title: "Integrate child work",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
continuationSummary: {
|
||||
key: "continuation-summary",
|
||||
title: "Continuation Summary",
|
||||
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",
|
||||
identifier: "PAP-101",
|
||||
title: "Implement helper",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
summary: "Added the helper route and tests.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({
|
||||
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",
|
||||
summary: "Added the helper route and tests.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,6 +66,17 @@ const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
|
|||
"../../../../../skills",
|
||||
];
|
||||
|
||||
export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
"",
|
||||
"Execution contract:",
|
||||
"- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.",
|
||||
"- Leave durable progress in comments, documents, or work products with a clear next action.",
|
||||
"- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.",
|
||||
"- If blocked, mark the issue blocked and name the unblock owner and action.",
|
||||
"- Respect budget, pause/cancel, approval gates, and company boundaries.",
|
||||
].join("\n");
|
||||
|
||||
export interface PaperclipSkillEntry {
|
||||
key: string;
|
||||
runtimeName: string;
|
||||
|
|
@ -250,11 +261,41 @@ type PaperclipWakeComment = {
|
|||
authorId: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakeContinuationSummary = {
|
||||
key: string | null;
|
||||
title: string | null;
|
||||
body: string;
|
||||
bodyTruncated: boolean;
|
||||
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;
|
||||
title: string | null;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakePayload = {
|
||||
reason: string | null;
|
||||
issue: PaperclipWakeIssue | null;
|
||||
checkedOutByHarness: boolean;
|
||||
executionStage: PaperclipWakeExecutionStage | null;
|
||||
continuationSummary: PaperclipWakeContinuationSummary | null;
|
||||
livenessContinuation: PaperclipWakeLivenessContinuation | null;
|
||||
childIssueSummaries: PaperclipWakeChildIssueSummary[];
|
||||
childIssueSummaryTruncated: boolean;
|
||||
commentIds: string[];
|
||||
latestCommentId: string | null;
|
||||
comments: PaperclipWakeComment[];
|
||||
|
|
@ -298,6 +339,50 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n
|
|||
};
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeContinuationSummary(value: unknown): PaperclipWakeContinuationSummary | null {
|
||||
const summary = parseObject(value);
|
||||
const body = asString(summary.body, "").trim();
|
||||
if (!body) return null;
|
||||
return {
|
||||
key: asString(summary.key, "").trim() || null,
|
||||
title: asString(summary.title, "").trim() || null,
|
||||
body,
|
||||
bodyTruncated: asBoolean(summary.bodyTruncated, false),
|
||||
updatedAt: asString(summary.updatedAt, "").trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
const identifier = asString(child.identifier, "").trim() || null;
|
||||
const title = asString(child.title, "").trim() || null;
|
||||
const status = asString(child.status, "").trim() || null;
|
||||
const priority = asString(child.priority, "").trim() || null;
|
||||
const summary = asString(child.summary, "").trim() || null;
|
||||
if (!id && !identifier && !title && !status && !summary) return null;
|
||||
return { id, identifier, title, status, priority, summary };
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null {
|
||||
const principal = parseObject(value);
|
||||
const typeRaw = asString(principal.type, "").trim().toLowerCase();
|
||||
|
|
@ -356,8 +441,15 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
|||
.map((entry) => entry.trim())
|
||||
: [];
|
||||
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 && !executionStage && !normalizePaperclipWakeIssue(payload.issue)) {
|
||||
if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -366,6 +458,10 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
|||
issue: normalizePaperclipWakeIssue(payload.issue),
|
||||
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
|
||||
executionStage,
|
||||
continuationSummary,
|
||||
livenessContinuation,
|
||||
childIssueSummaries,
|
||||
childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false),
|
||||
commentIds,
|
||||
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
||||
comments,
|
||||
|
|
@ -406,6 +502,8 @@ export function renderPaperclipWakePrompt(
|
|||
"Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.",
|
||||
"Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
||||
"",
|
||||
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
|
||||
"",
|
||||
`- reason: ${normalized.reason ?? "unknown"}`,
|
||||
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
||||
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
|
||||
|
|
@ -421,6 +519,8 @@ export function renderPaperclipWakePrompt(
|
|||
"Use this inline wake data first before refetching the issue thread.",
|
||||
"Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
||||
"",
|
||||
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
|
||||
"",
|
||||
`- reason: ${normalized.reason ?? "unknown"}`,
|
||||
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
||||
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
|
||||
|
|
@ -470,6 +570,55 @@ export function renderPaperclipWakePrompt(
|
|||
}
|
||||
}
|
||||
|
||||
if (normalized.continuationSummary) {
|
||||
lines.push(
|
||||
"",
|
||||
"Issue continuation summary:",
|
||||
normalized.continuationSummary.body,
|
||||
);
|
||||
if (normalized.continuationSummary.bodyTruncated) {
|
||||
lines.push("[continuation summary truncated]");
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const label = child.identifier ?? child.id ?? "unknown";
|
||||
lines.push(
|
||||
`- ${label}${child.title ? ` ${child.title}` : ""}${child.status ? ` (${child.status})` : ""}`,
|
||||
);
|
||||
if (child.summary) {
|
||||
lines.push(` ${child.summary}`);
|
||||
}
|
||||
}
|
||||
if (normalized.childIssueSummaryTruncated) {
|
||||
lines.push("[child issue summaries truncated]");
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.checkedOutByHarness) {
|
||||
lines.push(
|
||||
"",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue