mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30: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,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
115
server/src/__tests__/documents-service.test.ts
Normal file
115
server/src/__tests__/documents-service.test.ts
Normal 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",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
86
server/src/__tests__/issue-continuation-summary.test.ts
Normal file
86
server/src/__tests__/issue-continuation-summary.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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." }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
153
server/src/__tests__/run-continuations.test.ts
Normal file
153
server/src/__tests__/run-continuations.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
132
server/src/__tests__/run-liveness.test.ts
Normal file
132
server/src/__tests__/run-liveness.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue