[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:
Dotta 2026-04-20 06:01:49 -05:00 committed by GitHub
parent b9a80dcf22
commit 236d11d36f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 18254 additions and 85 deletions

View file

@ -1,6 +1,17 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import {
agents,
companies,
createDb,
documentRevisions,
documents,
heartbeatRuns,
issueComments,
issueDocuments,
issues,
} from "@paperclipai/db";
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@ -9,6 +20,8 @@ import { activityService } from "../services/activity.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
type ActivityService = ReturnType<typeof activityService>;
type IssueRun = Awaited<ReturnType<ActivityService["runsForIssue"]>>[number];
if (!embeddedPostgresSupport.supported) {
console.warn(
@ -16,6 +29,23 @@ if (!embeddedPostgresSupport.supported) {
);
}
async function waitForIssueRun(
service: ActivityService,
companyId: string,
issueId: string,
predicate: (run: IssueRun) => boolean,
) {
const deadline = Date.now() + 2_000;
let latestRuns: IssueRun[] = [];
while (Date.now() < deadline) {
latestRuns = await service.runsForIssue(companyId, issueId);
const run = latestRuns.find(predicate);
if (run) return { run, runs: latestRuns };
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`Timed out waiting for issue run. Latest run count: ${latestRuns.length}`);
}
describeEmbeddedPostgres("activity service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@ -26,6 +56,11 @@ describeEmbeddedPostgres("activity service", () => {
}, 20_000);
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
@ -78,9 +113,17 @@ describeEmbeddedPostgres("activity service", () => {
resultJson: {
billing_type: "metered",
total_cost_usd: 0.42,
stopReason: "timeout",
effectiveTimeoutSec: 30,
timeoutFired: true,
summary: "done",
nestedHuge: { payload: "y".repeat(256_000) },
},
livenessState: "advanced",
livenessReason: "Run produced concrete action evidence: 1 issue comment(s)",
continuationAttempt: 2,
lastUsefulActionAt: new Date("2026-04-18T19:59:00.000Z"),
nextAction: "Review the completed output.",
});
const runs = await activityService(db).runsForIssue(companyId, issueId);
@ -111,6 +154,337 @@ describeEmbeddedPostgres("activity service", () => {
costUsd: 0.42,
cost_usd: 0.42,
total_cost_usd: 0.42,
stopReason: "timeout",
effectiveTimeoutSec: 30,
timeoutFired: true,
});
expect(runs[0]).toMatchObject({
livenessState: "advanced",
livenessReason: "Run produced concrete action evidence: 1 issue comment(s)",
continuationAttempt: 2,
lastUsefulActionAt: new Date("2026-04-18T19:59:00.000Z"),
nextAction: "Review the completed output.",
});
});
it("backfills missing liveness for completed issue runs before returning the ledger", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
const completedAt = new Date("2026-04-18T20:04:00.000Z");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Fix run ledger",
description: "Make the run ledger answer whether a run advanced.",
status: "done",
priority: "medium",
assigneeAgentId: agentId,
completedAt,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
startedAt: new Date("2026-04-18T20:00:00.000Z"),
finishedAt: completedAt,
contextSnapshot: { issueId },
resultJson: {
summary: "Finished the implementation.",
},
livenessState: null,
livenessReason: null,
lastUsefulActionAt: null,
nextAction: null,
});
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: runId,
body: "Done",
createdAt: completedAt,
});
const service = activityService(db);
const { run, runs } = await waitForIssueRun(
service,
companyId,
issueId,
(entry) => entry.runId === runId && entry.livenessState === "completed",
);
expect(runs).toHaveLength(1);
expect(run).toMatchObject({
runId,
livenessState: "completed",
livenessReason: "Issue is done",
continuationAttempt: 0,
lastUsefulActionAt: completedAt,
});
const [persisted] = await db.select().from(heartbeatRuns);
expect(persisted).toMatchObject({
id: runId,
livenessState: "completed",
livenessReason: "Issue is done",
continuationAttempt: 0,
lastUsefulActionAt: completedAt,
});
});
it("does not backfill document evidence from a different run", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
const otherRunId = randomUUID();
const documentId = randomUUID();
const revisionId = randomUUID();
const createdAt = new Date("2026-04-18T20:08:00.000Z");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Fix run ledger",
description: "Make the run ledger answer whether a run advanced.",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values([
{
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
startedAt: new Date("2026-04-18T20:00:00.000Z"),
finishedAt: new Date("2026-04-18T20:02:00.000Z"),
contextSnapshot: { issueId },
resultJson: {
summary: "Next steps:\n- inspect files",
},
livenessState: null,
livenessReason: null,
},
{
id: otherRunId,
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
startedAt: new Date("2026-04-18T20:05:00.000Z"),
finishedAt: createdAt,
contextSnapshot: { issueId },
resultJson: {
summary: "Updated the plan document.",
},
livenessState: "advanced",
livenessReason: "Run produced concrete action evidence: 1 document revision(s)",
},
]);
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "# Plan\n\n- Inspect files",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: agentId,
updatedByAgentId: agentId,
createdAt,
updatedAt: createdAt,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "# Plan\n\n- Inspect files",
createdByAgentId: agentId,
createdByRunId: otherRunId,
createdAt,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
createdAt,
updatedAt: createdAt,
});
const service = activityService(db);
const { run: backfilledRun } = await waitForIssueRun(
service,
companyId,
issueId,
(entry) => entry.runId === runId && entry.livenessState === "plan_only",
);
expect(backfilledRun).toMatchObject({
runId,
livenessState: "plan_only",
livenessReason: "Run described future work without concrete action evidence",
lastUsefulActionAt: null,
});
});
it("does not treat continuation summary revisions as concrete backfill evidence", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
const documentId = randomUUID();
const revisionId = randomUUID();
const createdAt = new Date("2026-04-18T20:12:00.000Z");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Fix run ledger",
description: "Make the run ledger answer whether a run advanced.",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
startedAt: new Date("2026-04-18T20:10:00.000Z"),
finishedAt: createdAt,
contextSnapshot: { issueId },
resultJson: {
summary: "Next steps:\n- inspect files",
},
livenessState: null,
livenessReason: null,
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Continuation Summary",
format: "markdown",
latestBody: "# Continuation Summary",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: agentId,
updatedByAgentId: agentId,
createdAt,
updatedAt: createdAt,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Continuation Summary",
format: "markdown",
body: "# Continuation Summary",
createdByAgentId: agentId,
createdByRunId: runId,
createdAt,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
createdAt,
updatedAt: createdAt,
});
const service = activityService(db);
const { run: backfilledRun } = await waitForIssueRun(
service,
companyId,
issueId,
(entry) => entry.runId === runId && entry.livenessState === "plan_only",
);
expect(backfilledRun).toMatchObject({
runId,
livenessState: "plan_only",
livenessReason: "Run described future work without concrete action evidence",
lastUsefulActionAt: null,
});
});
});

View file

@ -458,7 +458,7 @@ describe("agent skill routes", () => {
adapterType: "claude_local",
}),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("Keep the work moving until it's done."),
"AGENTS.md": expect.stringMatching(/Start actionable work in the same heartbeat\.[\s\S]*Keep the work moving until it is done\./),
}),
{ entryFile: "AGENTS.md", replaceExisting: false },
);

View file

@ -0,0 +1,115 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
companies,
createDb,
documentRevisions,
documents,
issueDocuments,
issues,
} from "@paperclipai/db";
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { documentService } from "../services/documents.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres document service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("documentService system issue documents", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof documentService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-documents-service-");
db = createDb(tempDb.connectionString);
svc = documentService(db);
}, 20_000);
afterEach(async () => {
await db.delete(documentRevisions);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issues);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createIssueWithDocuments() {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "PAP-1600",
title: "System document filtering",
description: "Validate document filtering",
status: "in_progress",
priority: "medium",
});
await svc.upsertIssueDocument({
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "# Plan",
});
await svc.upsertIssueDocument({
issueId,
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
title: "Continuation Summary",
format: "markdown",
body: "# Handoff",
});
return { issueId };
}
it("filters continuation summaries from default document lists and issue payload summaries", async () => {
const { issueId } = await createIssueWithDocuments();
const defaultDocuments = await svc.listIssueDocuments(issueId);
expect(defaultDocuments.map((doc) => doc.key)).toEqual(["plan"]);
const payload = await svc.getIssueDocumentPayload({ id: issueId, description: null });
expect(payload.planDocument?.key).toBe("plan");
expect(payload.documentSummaries.map((doc) => doc.key)).toEqual(["plan"]);
});
it("keeps system documents available for includeSystem and direct fetch callers", async () => {
const { issueId } = await createIssueWithDocuments();
const debugDocuments = await svc.listIssueDocuments(issueId, { includeSystem: true });
expect(debugDocuments.map((doc) => doc.key)).toEqual([
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
"plan",
]);
const directHandoff = await svc.getIssueDocumentByKey(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY);
expect(directHandoff).toEqual(expect.objectContaining({
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
body: "# Handoff",
}));
});
});

View file

@ -65,6 +65,11 @@ describeEmbeddedPostgres("heartbeat list", () => {
agentId,
invocationSource: "assignment",
status: "running",
livenessState: "advanced",
livenessReason: "run produced action evidence",
continuationAttempt: 1,
lastUsefulActionAt: new Date("2026-04-18T12:00:00Z"),
nextAction: "continue implementation",
contextSnapshot: { issueId: randomUUID() },
});
@ -80,6 +85,13 @@ describeEmbeddedPostgres("heartbeat list", () => {
expect(runs).toHaveLength(1);
expect(runs[0]?.id).toBe(runId);
expect(runs[0]?.processGroupId ?? null).toBeNull();
expect(runs[0]).toMatchObject({
livenessState: "advanced",
livenessReason: "run produced action evidence",
continuationAttempt: 1,
nextAction: "continue implementation",
});
expect(runs[0]?.lastUsefulActionAt).toEqual(new Date("2026-04-18T12:00:00Z"));
} finally {
if (originalDescriptor) {
Object.defineProperty(heartbeatRuns, "processGroupId", originalDescriptor);

View file

@ -10,9 +10,12 @@ import {
companySkills,
companies,
createDb,
documentRevisions,
documents,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
issues,
} from "@paperclipai/db";
import {
@ -22,6 +25,17 @@ import {
import { runningProcesses } from "../adapters/index.ts";
const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() }));
const mockTrackAgentFirstHeartbeat = vi.hoisted(() => vi.fn());
const mockAdapterExecute = vi.hoisted(() =>
vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Recovered stranded heartbeat work.",
provider: "test",
model: "test-model",
})),
);
vi.mock("../telemetry.ts", () => ({
getTelemetryClient: () => mockTelemetryClient,
@ -43,14 +57,7 @@ vi.mock("../adapters/index.ts", async () => {
...actual,
getServerAdapter: vi.fn(() => ({
supportsLocalAgentJwt: false,
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
provider: "test",
model: "test-model",
})),
execute: mockAdapterExecute,
})),
};
});
@ -104,6 +111,20 @@ async function waitForRunToSettle(
return heartbeat.getRun(runId);
}
async function waitForValue<T>(
read: () => Promise<T | null | undefined>,
timeoutMs = 3_000,
) {
const deadline = Date.now() + timeoutMs;
let latest: T | null | undefined = null;
while (Date.now() < deadline) {
latest = await read();
if (latest) return latest;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return latest ?? null;
}
async function spawnOrphanedProcessGroup() {
const leader = spawn(
process.execPath,
@ -157,6 +178,15 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
afterEach(async () => {
vi.clearAllMocks();
mockAdapterExecute.mockImplementation(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Recovered stranded heartbeat work.",
provider: "test",
model: "test-model",
}));
runningProcesses.clear();
for (const child of childProcesses) {
child.kill("SIGKILL");
@ -170,10 +200,26 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
}
cleanupPids.clear();
for (let attempt = 0; attempt < 10; attempt += 1) {
const runs = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns);
if (runs.every((run) => run.status !== "queued" && run.status !== "running")) {
break;
let idlePolls = 0;
for (let attempt = 0; attempt < 100; attempt += 1) {
const runs = await db
.select({
status: heartbeatRuns.status,
processPid: heartbeatRuns.processPid,
processGroupId: heartbeatRuns.processGroupId,
})
.from(heartbeatRuns);
const managedExecutionStillActive = runs.some(
(run) =>
(run.status === "queued" || run.status === "running") &&
!run.processPid &&
!run.processGroupId,
);
if (!managedExecutionStillActive) {
idlePolls += 1;
if (idlePolls >= 3) break;
} else {
idlePolls = 0;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
@ -182,6 +228,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(agentRuntimeState);
await db.delete(companySkills);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issues);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
@ -439,6 +488,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(failedRun?.status).toBe("failed");
expect(failedRun?.errorCode).toBe("process_lost");
expect(failedRun?.livenessState).toBe("failed");
expect(failedRun?.livenessReason).toContain("process_lost");
expect(failedRun?.resultJson).toMatchObject({
stopReason: "process_lost",
timeoutConfigured: false,
timeoutFired: false,
});
expect(retryRun?.status).toBe("queued");
expect(retryRun?.retryOfRunId).toBe(runId);
expect(retryRun?.processLossRetryCount).toBe(1);
@ -553,6 +609,23 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
);
});
it("records manual cancellation stop metadata", async () => {
const { runId } = await seedRunFixture({
agentStatus: "running",
includeIssue: false,
});
const heartbeat = heartbeatService(db);
const cancelled = await heartbeat.cancelRun(runId);
expect(cancelled?.status).toBe("cancelled");
expect(cancelled?.resultJson).toMatchObject({
stopReason: "cancelled",
effectiveTimeoutSec: 0,
timeoutConfigured: false,
timeoutFired: false,
});
});
it("re-enqueues assigned todo work when the last issue run died and no wake remains", async () => {
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "todo",
@ -629,6 +702,106 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
});
it("classifies actionable plan-only recovery and enqueues one liveness continuation", async () => {
mockAdapterExecute.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "I will inspect the repo next and then implement the fix.",
provider: "test",
model: "test-model",
});
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
});
const heartbeat = heartbeatService(db);
await heartbeat.reconcileStrandedAssignedIssues();
const livenessWake = await waitForValue(async () => {
const rows = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
return rows.find((row) => row.reason === "run_liveness_continuation") ?? null;
});
expect(livenessWake).toBeTruthy();
expect(livenessWake?.payload).toMatchObject({
issueId,
livenessState: "plan_only",
continuationAttempt: 1,
});
const sourceRunId = (livenessWake?.payload as Record<string, unknown> | null)?.sourceRunId;
expect(sourceRunId).toBeTruthy();
const sourceRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, String(sourceRunId)))
.then((rows) => rows[0] ?? null);
expect(sourceRun?.id).not.toBe(runId);
expect(sourceRun?.livenessState).toBe("plan_only");
});
it("treats a plan document update as progress and does not enqueue liveness continuation", async () => {
const { agentId, companyId, issueId, runId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
});
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
const documentId = randomUUID();
const revisionId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "# Plan\n\n- Inspect files\n- Implement fix",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: agentId,
updatedByAgentId: agentId,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "# Plan\n\n- Inspect files\n- Implement fix",
createdByAgentId: agentId,
createdByRunId: ctx.runId,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Plan:\n- Inspect files\n- Implement fix",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.reconcileStrandedAssignedIssues();
const retryRun = await waitForValue(async () => {
const rows = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
return rows.find((row) => row.id !== runId && row.livenessState === "advanced") ?? null;
});
expect(retryRun?.livenessState).toBe("advanced");
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
expect(wakes.some((row) => row.reason === "run_liveness_continuation")).toBe(false);
});
it("blocks stranded in-progress work after the continuation retry was already used", async () => {
const { issueId } = await seedStrandedIssueFixture({
status: "in_progress",

View file

@ -15,6 +15,10 @@ describe("summarizeHeartbeatRunResultJson", () => {
total_cost_usd: 1.23,
cost_usd: 0.45,
costUsd: 0.67,
stopReason: "timeout",
effectiveTimeoutSec: 30,
timeoutConfigured: true,
timeoutFired: true,
nested: { ignored: true },
});
@ -26,6 +30,10 @@ describe("summarizeHeartbeatRunResultJson", () => {
total_cost_usd: 1.23,
cost_usd: 0.45,
costUsd: 0.67,
stopReason: "timeout",
effectiveTimeoutSec: 30,
timeoutConfigured: true,
timeoutFired: true,
});
});

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS,
buildContinuationSummaryMarkdown,
} from "../services/issue-continuation-summary.js";
describe("issue continuation summaries", () => {
it("builds bounded issue-local handoff context with required sections", () => {
const body = buildContinuationSummaryMarkdown({
issue: {
id: "issue-1",
identifier: "PAP-1579",
title: "Add continuation summaries",
description: [
"## Objective",
"",
"Keep work resumable after adapter session reset.",
"",
"## Acceptance Criteria",
"",
"- Summary is issue-local",
"- Wake context includes the summary",
].join("\n"),
status: "in_progress",
priority: "medium",
},
run: {
id: "run-1",
status: "succeeded",
error: null,
resultJson: {
summary: "Updated server/src/services/heartbeat.ts and packages/adapter-utils/src/server-utils.ts.",
},
stdoutExcerpt: null,
stderrExcerpt: null,
finishedAt: new Date("2026-04-18T12:00:00.000Z"),
},
agent: {
id: "agent-1",
name: "CodexCoder",
adapterType: "codex_local",
},
});
expect(body).toContain("# Continuation Summary");
expect(body).toContain("## Objective");
expect(body).toContain("Keep work resumable after adapter session reset.");
expect(body).toContain("## Acceptance Criteria");
expect(body).toContain("- Summary is issue-local");
expect(body).toContain("## Recent Concrete Actions");
expect(body).toContain("Run `run-1` finished with status `succeeded`");
expect(body).toContain("`server/src/services/heartbeat.ts`");
expect(body).toContain("## Commands Run");
expect(body).toContain("## Blockers / Decisions");
expect(body).toContain("## Next Action");
expect(body.length).toBeLessThanOrEqual(ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS);
});
it("uses failure state to point the next run at the error", () => {
const body = buildContinuationSummaryMarkdown({
issue: {
id: "issue-1",
identifier: "PAP-1579",
title: "Add continuation summaries",
description: null,
status: "in_progress",
priority: "medium",
},
run: {
id: "run-2",
status: "failed",
error: "adapter failed",
errorCode: "adapter_failed",
resultJson: null,
},
agent: {
id: "agent-1",
name: "CodexCoder",
adapterType: "codex_local",
},
});
expect(body).toContain("Latest run error (adapter_failed): adapter failed");
expect(body).toContain("Inspect the failed run, fix the cause");
});
});

View file

@ -39,6 +39,7 @@ vi.mock("../services/index.js", () => ({
wakeup: mockWakeup,
reportRunActivity: vi.fn(async () => undefined),
}),
getIssueContinuationSummaryDocument: vi.fn(async () => null),
instanceSettingsService: () => ({
get: vi.fn(),
listCompanyIds: vi.fn(),
@ -197,6 +198,31 @@ describe("issue dependency wakeups in issue routes", () => {
id: "parent-1",
assigneeAgentId: "agent-9",
childIssueIds: ["child-0", "child-1"],
childIssueSummaries: [
{
id: "child-0",
identifier: "PAP-100",
title: "First child",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
updatedAt: new Date("2026-04-18T12:00:00.000Z"),
summary: "First child finished.",
},
{
id: "child-1",
identifier: "PAP-101",
title: "Last child",
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
updatedAt: new Date("2026-04-18T12:05:00.000Z"),
summary: "Last child finished.",
},
],
childIssueSummaryTruncated: false,
});
const res = await request(await createApp()).patch("/api/issues/child-1").send({ status: "done" });
@ -209,6 +235,14 @@ describe("issue dependency wakeups in issue routes", () => {
payload: expect.objectContaining({
issueId: "parent-1",
completedChildIssueId: "child-1",
childIssueSummaries: expect.arrayContaining([
expect.objectContaining({ identifier: "PAP-101", summary: "Last child finished." }),
]),
}),
contextSnapshot: expect.objectContaining({
childIssueSummaries: expect.arrayContaining([
expect.objectContaining({ identifier: "PAP-100", summary: "First child finished." }),
]),
}),
}),
);

View file

@ -10,6 +10,7 @@ const mockIssueService = vi.hoisted(() => ({
}));
const mockDocumentsService = vi.hoisted(() => ({
listIssueDocuments: vi.fn(),
listIssueDocumentRevisions: vi.fn(),
restoreIssueDocumentRevision: vi.fn(),
}));
@ -114,6 +115,25 @@ describe("issue document revision routes", () => {
title: "Document revisions",
status: "in_progress",
});
mockDocumentsService.listIssueDocuments.mockResolvedValue([
{
id: "document-1",
companyId,
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "# Plan",
latestRevisionId: "revision-2",
latestRevisionNumber: 2,
createdByAgentId: null,
createdByUserId: "board-user",
updatedByAgentId: null,
updatedByUserId: "board-user",
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:10:00.000Z"),
},
]);
mockDocumentsService.listIssueDocumentRevisions.mockResolvedValue([
{
id: "revision-2",
@ -169,6 +189,20 @@ describe("issue document revision routes", () => {
]);
});
it("filters system documents by default on the document list route", async () => {
const res = await request(await createApp()).get(`/api/issues/${issueId}/documents`);
expect(res.status).toBe(200);
expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: false });
expect(res.body).toEqual([expect.objectContaining({ key: "plan" })]);
});
it("passes includeSystem=true through for debug document listing", async () => {
await request(await createApp()).get(`/api/issues/${issueId}/documents?includeSystem=true`);
expect(mockDocumentsService.listIssueDocuments).toHaveBeenCalledWith(issueId, { includeSystem: true });
});
it("restores a revision through the append-only route and logs the action", async () => {
const res = await request(await createApp())
.post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`)

View file

@ -22,6 +22,11 @@ const mockGoalService = vi.hoisted(() => ({
getDefaultCompanyGoal: vi.fn(),
}));
const mockDocumentsService = vi.hoisted(() => ({
getIssueDocumentPayload: vi.fn(),
getIssueDocumentByKey: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
@ -30,9 +35,7 @@ vi.mock("../services/index.js", () => ({
agentService: () => ({
getById: vi.fn(),
}),
documentService: () => ({
getIssueDocumentPayload: vi.fn(async () => ({})),
}),
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({
getById: vi.fn(),
}),
@ -139,6 +142,8 @@ describe("issue goal context routes", () => {
});
mockIssueService.getComment.mockResolvedValue(null);
mockIssueService.listAttachments.mockResolvedValue([]);
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
mockProjectService.getById.mockResolvedValue({
id: legacyProjectLinkedIssue.projectId,
companyId: "company-1",
@ -214,6 +219,31 @@ describe("issue goal context routes", () => {
expect(res.body.attachments).toEqual([]);
});
it("preserves direct continuation summary lookup in GET /issues/:id/heartbeat-context", async () => {
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue({
key: "continuation-summary",
title: "Continuation Summary",
body: "# Handoff",
latestRevisionId: "revision-1",
latestRevisionNumber: 1,
updatedAt: new Date("2026-04-19T12:00:00.000Z"),
});
const res = await request(await createApp()).get(
"/api/issues/11111111-1111-4111-8111-111111111111/heartbeat-context",
);
expect(res.status).toBe(200);
expect(mockDocumentsService.getIssueDocumentByKey).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"continuation-summary",
);
expect(res.body.continuationSummary).toEqual(expect.objectContaining({
key: "continuation-summary",
body: "# Handoff",
}));
});
it("surfaces blocker summaries on GET /issues/:id/heartbeat-context", async () => {
mockIssueService.getRelationSummaries.mockResolvedValue({
blockedBy: [

View file

@ -8,6 +8,7 @@ import {
companies,
createDb,
executionWorkspaces,
goals,
instanceSettings,
issueComments,
issueInboxArchives,
@ -70,6 +71,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
@ -753,6 +755,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(goals);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
@ -1007,6 +1010,104 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
mode: "operator_branch",
});
});
it("createChild applies parent defaults, acceptance criteria, workspace inheritance, and optional parent blocker chaining", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const goalId = randomUUID();
const parentIssueId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Ship child helpers",
level: "task",
status: "active",
});
await db.insert(projects).values({
id: projectId,
companyId,
goalId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
isPrimary: true,
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
providerRef: `/tmp/${executionWorkspaceId}`,
});
await db.insert(issues).values({
id: parentIssueId,
companyId,
projectId,
projectWorkspaceId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
requestDepth: 1,
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
});
const { issue: child, parentBlockerAdded } = await svc.createChild(parentIssueId, {
title: "Child helper",
status: "todo",
description: "Implement the helper.",
acceptanceCriteria: ["Uses the parent issue as parentId", "Reuses the parent execution workspace"],
blockParentUntilDone: true,
});
expect(parentBlockerAdded).toBe(true);
expect(child.parentId).toBe(parentIssueId);
expect(child.projectId).toBe(projectId);
expect(child.goalId).toBe(goalId);
expect(child.requestDepth).toBe(2);
expect(child.description).toContain("## Acceptance Criteria");
expect(child.description).toContain("- Uses the parent issue as parentId");
expect(child.projectWorkspaceId).toBe(projectWorkspaceId);
expect(child.executionWorkspaceId).toBe(executionWorkspaceId);
expect(child.executionWorkspacePreference).toBe("reuse_existing");
const parentRelations = await svc.getRelationSummaries(parentIssueId);
expect(parentRelations.blockedBy).toEqual([
expect.objectContaining({
id: child.id,
title: "Child helper",
}),
]);
});
});
describeEmbeddedPostgres("issueService blockers and dependency wake readiness", () => {
@ -1208,10 +1309,15 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await svc.update(childB, { status: "cancelled" });
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toEqual({
expect(await svc.getWakeableParentAfterChildCompletion(parentId)).toMatchObject({
id: parentId,
assigneeAgentId,
childIssueIds: [childA, childB],
childIssueSummaries: [
expect.objectContaining({ id: childA, title: "Child A", status: "done" }),
expect.objectContaining({ id: childB, title: "Child B", status: "cancelled" }),
],
childIssueSummaryTruncated: false,
});
});
});

View file

@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
RUN_LIVENESS_CONTINUATION_REASON,
buildRunLivenessContinuationIdempotencyKey,
decideRunLivenessContinuation,
} from "../services/run-continuations.ts";
const companyId = "company-1";
const agentId = "agent-1";
const issueId = "issue-1";
const runId = "run-1";
function run(overrides: Record<string, unknown> = {}) {
return {
id: runId,
companyId,
agentId,
continuationAttempt: 0,
...overrides,
} as never;
}
function issue(overrides: Record<string, unknown> = {}) {
return {
id: issueId,
companyId,
identifier: "PAP-1577",
title: "Add bounded liveness continuation wakes",
status: "in_progress",
assigneeAgentId: agentId,
executionState: null,
projectId: null,
...overrides,
} as never;
}
function agent(overrides: Record<string, unknown> = {}) {
return {
id: agentId,
companyId,
status: "idle",
...overrides,
} as never;
}
describe("run liveness continuations", () => {
it("enqueues the first plan_only continuation for the same issue and assignee", () => {
const decision = decideRunLivenessContinuation({
run: run(),
issue: issue(),
agent: agent(),
livenessState: "plan_only",
livenessReason: "Planned without acting",
nextAction: "Take the first concrete action now.",
budgetBlocked: false,
idempotentWakeExists: false,
});
expect(decision.kind).toBe("enqueue");
if (decision.kind !== "enqueue") return;
expect(decision.nextAttempt).toBe(1);
expect(decision.idempotencyKey).toBe(
buildRunLivenessContinuationIdempotencyKey({
issueId,
sourceRunId: runId,
livenessState: "plan_only",
nextAttempt: 1,
}),
);
expect(decision.payload).toMatchObject({
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.",
});
});
it("enqueues the second empty_response continuation", () => {
const decision = decideRunLivenessContinuation({
run: run({ continuationAttempt: 1 }),
issue: issue(),
agent: agent(),
livenessState: "empty_response",
livenessReason: "No useful output",
nextAction: null,
budgetBlocked: false,
idempotentWakeExists: false,
});
expect(decision.kind).toBe("enqueue");
if (decision.kind !== "enqueue") return;
expect(decision.nextAttempt).toBe(2);
});
it("does not enqueue a third continuation and returns an exhaustion comment", () => {
const decision = decideRunLivenessContinuation({
run: run({ continuationAttempt: 2 }),
issue: issue(),
agent: agent(),
livenessState: "plan_only",
livenessReason: "Still planning",
nextAction: null,
budgetBlocked: false,
idempotentWakeExists: false,
});
expect(decision.kind).toBe("exhausted");
if (decision.kind !== "exhausted") return;
expect(decision.comment).toContain("Bounded liveness continuation exhausted");
expect(decision.comment).toContain("Attempts used: 2/2");
});
it("skips non-actionable and guarded issues", () => {
const guardedCases = [
{ livenessState: "advanced" as const },
{ issue: issue({ status: "done" }) },
{ issue: issue({ assigneeAgentId: "other-agent" }) },
{ issue: issue({ executionState: { status: "pending" } }) },
{ agent: agent({ status: "paused" }) },
{ budgetBlocked: true },
{ idempotentWakeExists: true },
];
for (const guarded of guardedCases) {
const decision = decideRunLivenessContinuation({
run: run(),
issue: guarded.issue ?? issue(),
agent: guarded.agent ?? agent(),
livenessState: guarded.livenessState ?? "plan_only",
livenessReason: "No progress",
nextAction: null,
budgetBlocked: guarded.budgetBlocked ?? false,
idempotentWakeExists: guarded.idempotentWakeExists ?? false,
});
expect(decision.kind).toBe("skip");
}
});
});

View file

@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { classifyRunLiveness } from "../services/run-liveness.ts";
const baseInput = {
runStatus: "succeeded",
issue: {
status: "in_progress",
title: "Implement feature",
description: "Add the requested behavior.",
},
resultJson: null,
stdoutExcerpt: null,
stderrExcerpt: null,
error: null,
errorCode: null,
continuationAttempt: 0,
evidence: null,
};
describe("run liveness classifier", () => {
it("classifies text-only future work as plan_only", () => {
const classification = classifyRunLiveness({
...baseInput,
resultJson: {
summary: "I will inspect the repo next and then implement the fix.",
},
});
expect(classification.livenessState).toBe("plan_only");
expect(classification.nextAction).toContain("inspect the repo");
});
it("classifies empty successful output as empty_response", () => {
const classification = classifyRunLiveness(baseInput);
expect(classification.livenessState).toBe("empty_response");
});
it("treats issue comments, documents, products, and actions as progress", () => {
const latestEvidenceAt = new Date("2026-04-18T12:00:00Z");
const classification = classifyRunLiveness({
...baseInput,
resultJson: {
summary: "Updated implementation.",
},
evidence: {
issueCommentsCreated: 1,
documentRevisionsCreated: 1,
workProductsCreated: 1,
toolOrActionEventsCreated: 1,
latestEvidenceAt,
},
});
expect(classification.livenessState).toBe("advanced");
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,
issue: {
status: "in_progress",
title: "Draft implementation plan",
description: "Create a plan for the work.",
},
resultJson: {
summary: "Plan:\n- Inspect files\n- Implement after approval",
},
});
expect(classification.livenessState).toBe("advanced");
});
it("exempts runs that update the plan document from plan-only classification", () => {
const classification = classifyRunLiveness({
...baseInput,
resultJson: {
summary: "Next steps:\n- inspect files\n- implement the service",
},
evidence: {
documentRevisionsCreated: 1,
planDocumentRevisionsCreated: 1,
latestEvidenceAt: new Date("2026-04-18T12:00:00Z"),
},
});
expect(classification.livenessState).toBe("advanced");
});
it("classifies done issues as completed", () => {
const classification = classifyRunLiveness({
...baseInput,
issue: {
...baseInput.issue,
status: "done",
},
resultJson: {
summary: "Finished the implementation.",
},
});
expect(classification.livenessState).toBe("completed");
});
it("classifies declared blockers as blocked", () => {
const classification = classifyRunLiveness({
...baseInput,
resultJson: {
summary: "I cannot proceed because I need access credentials.",
},
});
expect(classification.livenessState).toBe("blocked");
});
});