mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] Add run liveness continuations (#4083)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Heartbeat runs are the control-plane record of each agent execution window. > - Long-running local agents can exhaust context or stop while still holding useful next-step state. > - Operators need that stop reason, next action, and continuation path to be durable and visible. > - This pull request adds run liveness metadata, continuation summaries, and UI surfaces for issue run ledgers. > - The benefit is that interrupted or long-running work can resume with clearer context instead of losing the agent's last useful handoff. ## What Changed - Added heartbeat-run liveness fields, continuation attempt tracking, and an idempotent `0058` migration. - Added server services and tests for run liveness, continuation summaries, stop metadata, and activity backfill. - Wired local and HTTP adapters to surface continuation/liveness context through shared adapter utilities. - Added shared constants, validators, and heartbeat types for liveness continuation state. - Added issue-detail UI surfaces for continuation handoffs and the run ledger, with component tests. - Updated agent runtime docs, heartbeat protocol docs, prompt guidance, onboarding assets, and skills instructions to explain continuation behavior. - Addressed Greptile feedback by scoping document evidence by run, excluding system continuation-summary documents from liveness evidence, importing shared liveness types, surfacing hidden ledger run counts, documenting bounded retry behavior, and moving run-ledger liveness backfill off the request path. ## Verification - `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts server/src/__tests__/run-continuations.test.ts server/src/__tests__/run-liveness.test.ts server/src/__tests__/activity-service.test.ts server/src/__tests__/documents-service.test.ts server/src/__tests__/issue-continuation-summary.test.ts server/src/services/heartbeat-stop-metadata.test.ts ui/src/components/IssueRunLedger.test.tsx ui/src/components/IssueContinuationHandoff.test.tsx ui/src/components/IssueDocumentsSection.test.tsx` - `pnpm --filter @paperclipai/db build` - `pnpm exec vitest run server/src/__tests__/activity-service.test.ts ui/src/components/IssueRunLedger.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm exec vitest run server/src/__tests__/activity-service.test.ts server/src/__tests__/run-continuations.test.ts ui/src/components/IssueRunLedger.test.tsx` - `pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts -t "treats a plan document update"` - `pnpm exec vitest run server/src/__tests__/activity-service.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts -t "activity service|treats a plan document update"` - Remote PR checks on head `e53b1a1d`: `verify`, `e2e`, `policy`, and Snyk all passed. - Confirmed `public-gh/master` is an ancestor of this branch after fetching `public-gh master`. - Confirmed `pnpm-lock.yaml` is not included in the branch diff. - Confirmed migration `0058_wealthy_starbolt.sql` is ordered after `0057` and uses `IF NOT EXISTS` guards for repeat application. - Greptile inline review threads are resolved. ## Risks - Medium risk: this touches heartbeat execution, liveness recovery, activity rendering, issue routes, shared contracts, docs, and UI. - Migration risk is mitigated by additive columns/indexes and idempotent guards. - Run-ledger liveness backfill is now asynchronous, so the first ledger response can briefly show historical missing liveness until the background backfill completes. - UI screenshot coverage is not included in this packaging pass; validation is currently through focused component tests. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4, local tool-use coding agent with terminal, git, GitHub connector, GitHub CLI, and Paperclip API access. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Screenshot note: no before/after screenshots were captured in this PR packaging pass; the UI changes are covered by focused component tests listed above. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b9a80dcf22
commit
236d11d36f
71 changed files with 18254 additions and 85 deletions
|
|
@ -1,6 +1,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");
|
||||
});
|
||||
});
|
||||
46
server/src/adapters/http/execute.test.ts
Normal file
46
server/src/adapters/http/execute.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("http adapter execute", () => {
|
||||
it("reports configured request timeout as timed_out", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
})),
|
||||
);
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Agent",
|
||||
adapterType: "http",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
url: "https://example.test/webhook",
|
||||
timeoutMs: 1,
|
||||
},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.errorCode).toBe("timeout");
|
||||
expect(result.errorMessage).toContain("timed out after 1ms");
|
||||
});
|
||||
});
|
||||
|
|
@ -36,6 +36,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
timedOut: false,
|
||||
summary: `HTTP ${method} ${url}`,
|
||||
};
|
||||
} catch (err) {
|
||||
if (timer && err instanceof Error && err.name === "AbortError") {
|
||||
return {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: true,
|
||||
errorMessage: `HTTP ${method} ${url} timed out after ${timeoutMs}ms`,
|
||||
errorCode: "timeout",
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ You MUST delegate work rather than doing it yourself. When a task is assigned to
|
|||
- Don't let tasks sit idle. If you delegate something, check that it's progressing.
|
||||
- If a report is blocked, help unblock them -- escalate to the board if needed.
|
||||
- If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work.
|
||||
- Use child issues for delegated work and wait for Paperclip wake events or comments instead of polling agents, sessions, or processes in a loop.
|
||||
- Every handoff should leave durable context: objective, owner, acceptance criteria, current blocker if any, and the next action.
|
||||
- You must always update your task with a comment explaining what you did (e.g., who you delegated to and why).
|
||||
|
||||
## Memory and Planning
|
||||
|
|
|
|||
|
|
@ -1,3 +1,12 @@
|
|||
You are an agent at Paperclip company.
|
||||
|
||||
Keep the work moving until it's done. If you need QA to review it, ask them. If you need your boss to review it, ask them. If someone needs to unblock you, assign them the ticket with a comment asking for what you need. Don't let work just sit here. You must always update your task with a comment.
|
||||
## Execution Contract
|
||||
|
||||
- Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning.
|
||||
- Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them.
|
||||
- Leave durable progress in task comments, documents, or work products, and make the next action clear before you exit.
|
||||
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
|
||||
- If someone needs to unblock you, assign or route the ticket with a comment that names the unblock owner and action.
|
||||
- Respect budget, pause/cancel, approval gates, and company boundaries.
|
||||
|
||||
Do not let work sit here. You must always update your task with a comment.
|
||||
|
|
|
|||
|
|
@ -2382,6 +2382,11 @@ export function agentRoutes(db: Db) {
|
|||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agentsTable.name,
|
||||
adapterType: agentsTable.adapterType,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
livenessReason: heartbeatRuns.livenessReason,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
|
||||
nextAction: heartbeatRuns.nextAction,
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
|
||||
};
|
||||
|
||||
|
|
@ -2555,6 +2560,11 @@ export function agentRoutes(db: Db) {
|
|||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agentsTable.name,
|
||||
adapterType: agentsTable.adapterType,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
livenessReason: heartbeatRuns.livenessReason,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
|
||||
nextAction: heartbeatRuns.nextAction,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
createIssueWorkProductSchema,
|
||||
createIssueLabelSchema,
|
||||
checkoutIssueSchema,
|
||||
createChildIssueSchema,
|
||||
createIssueSchema,
|
||||
feedbackTargetTypeSchema,
|
||||
feedbackTraceStatusSchema,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
upsertIssueFeedbackVoteSchema,
|
||||
linkIssueApprovalSchema,
|
||||
issueDocumentKeySchema,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
updateIssueWorkProductSchema,
|
||||
upsertIssueDocumentSchema,
|
||||
|
|
@ -769,7 +771,7 @@ export function issueRoutes(
|
|||
? req.query.wakeCommentId.trim()
|
||||
: null;
|
||||
|
||||
const [{ project, goal }, ancestors, commentCursor, wakeComment, relations, attachments] =
|
||||
const [{ project, goal }, ancestors, commentCursor, wakeComment, relations, attachments, continuationSummary] =
|
||||
await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
|
|
@ -777,6 +779,7 @@ export function issueRoutes(
|
|||
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
||||
svc.getRelationSummaries(issue.id),
|
||||
svc.listAttachments(issue.id),
|
||||
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
|
|
@ -833,6 +836,16 @@ export function issueRoutes(
|
|||
contentPath: withContentPath(a).contentPath,
|
||||
createdAt: a.createdAt,
|
||||
})),
|
||||
continuationSummary: continuationSummary
|
||||
? {
|
||||
key: continuationSummary.key,
|
||||
title: continuationSummary.title,
|
||||
body: continuationSummary.body,
|
||||
latestRevisionId: continuationSummary.latestRevisionId,
|
||||
latestRevisionNumber: continuationSummary.latestRevisionNumber,
|
||||
updatedAt: continuationSummary.updatedAt,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -856,7 +869,9 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const docs = await documentsSvc.listIssueDocuments(issue.id);
|
||||
const docs = await documentsSvc.listIssueDocuments(issue.id, {
|
||||
includeSystem: req.query.includeSystem === "true",
|
||||
});
|
||||
res.json(docs);
|
||||
});
|
||||
|
||||
|
|
@ -1372,6 +1387,62 @@ export function issueRoutes(
|
|||
res.status(201).json(issue);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/children", validate(createChildIssueSchema), async (req, res) => {
|
||||
const parentId = req.params.id as string;
|
||||
const parent = await svc.getById(parentId);
|
||||
if (!parent) {
|
||||
res.status(404).json({ error: "Parent issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, parent.companyId);
|
||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
||||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||
await assertCanAssignTasks(req, parent.companyId);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
|
||||
...req.body,
|
||||
executionPolicy,
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
actorAgentId: actor.agentId,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: parent.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.child_created",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
parentId: parent.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
inheritedExecutionWorkspaceFromIssueId: parent.id,
|
||||
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
|
||||
...(parentBlockerAdded ? { parentBlockerAdded: true } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
void queueIssueAssignmentWakeup({
|
||||
heartbeat,
|
||||
issue,
|
||||
reason: "issue_assigned",
|
||||
mutation: "create",
|
||||
contextSource: "issue.child_create",
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
});
|
||||
|
||||
res.status(201).json(issue);
|
||||
});
|
||||
|
||||
router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
|
|
@ -1940,6 +2011,8 @@ export function issueRoutes(
|
|||
issueId: parent.id,
|
||||
completedChildIssueId: issue.id,
|
||||
childIssueIds: parent.childIssueIds,
|
||||
childIssueSummaries: parent.childIssueSummaries,
|
||||
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
|
||||
},
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
|
|
@ -1950,6 +2023,8 @@ export function issueRoutes(
|
|||
source: "issue.children_completed",
|
||||
completedChildIssueId: issue.id,
|
||||
childIssueIds: parent.childIssueIds,
|
||||
childIssueSummaries: parent.childIssueSummaries,
|
||||
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
documentRevisions,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
issueWorkProducts,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { classifyRunLiveness } from "./run-liveness.js";
|
||||
|
||||
export interface ActivityFilters {
|
||||
companyId: string;
|
||||
|
|
@ -10,6 +24,7 @@ export interface ActivityFilters {
|
|||
}
|
||||
|
||||
export function activityService(db: Db) {
|
||||
const scheduledLivenessBackfills = new Set<string>();
|
||||
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||
const summarizedUsageJson = sql<Record<string, unknown> | null>`
|
||||
case
|
||||
|
|
@ -74,11 +89,230 @@ export function activityService(db: Db) {
|
|||
${heartbeatRuns.resultJson} -> 'total_cost_usd',
|
||||
${heartbeatRuns.resultJson} -> 'cost_usd',
|
||||
${heartbeatRuns.resultJson} -> 'costUsd'
|
||||
)
|
||||
),
|
||||
'stopReason', ${heartbeatRuns.resultJson} -> 'stopReason',
|
||||
'effectiveTimeoutSec', ${heartbeatRuns.resultJson} -> 'effectiveTimeoutSec',
|
||||
'effectiveTimeoutMs', ${heartbeatRuns.resultJson} -> 'effectiveTimeoutMs',
|
||||
'timeoutConfigured', ${heartbeatRuns.resultJson} -> 'timeoutConfigured',
|
||||
'timeoutSource', ${heartbeatRuns.resultJson} -> 'timeoutSource',
|
||||
'timeoutFired', ${heartbeatRuns.resultJson} -> 'timeoutFired'
|
||||
))
|
||||
end
|
||||
`.as("resultJson");
|
||||
|
||||
function countValue(value: unknown) {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0;
|
||||
}
|
||||
|
||||
function dateValue(value: unknown) {
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function latestDate(...values: unknown[]) {
|
||||
let latest: Date | null = null;
|
||||
for (const value of values) {
|
||||
const parsed = dateValue(value);
|
||||
if (!parsed) continue;
|
||||
if (!latest || parsed.getTime() > latest.getTime()) latest = parsed;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
async function backfillMissingRunLivenessForIssue(companyId: string, issueId: string) {
|
||||
const runs = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
status: heartbeatRuns.status,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
resultJson: heartbeatRuns.resultJson,
|
||||
stdoutExcerpt: heartbeatRuns.stdoutExcerpt,
|
||||
stderrExcerpt: heartbeatRuns.stderrExcerpt,
|
||||
error: heartbeatRuns.error,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
isNull(heartbeatRuns.livenessState),
|
||||
sql`${heartbeatRuns.status} not in ('queued', 'running')`,
|
||||
or(
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
|
||||
sql`exists (
|
||||
select 1
|
||||
from ${activityLog}
|
||||
where ${activityLog.companyId} = ${companyId}
|
||||
and ${activityLog.entityType} = 'issue'
|
||||
and ${activityLog.entityId} = ${issueId}
|
||||
and ${activityLog.runId} = ${heartbeatRuns.id}
|
||||
)`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(20);
|
||||
|
||||
if (runs.length === 0) return;
|
||||
|
||||
const issue = await db
|
||||
.select({
|
||||
status: issues.status,
|
||||
title: issues.title,
|
||||
description: issues.description,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
for (const run of runs) {
|
||||
const context = asRecord(run.contextSnapshot);
|
||||
const continuationAttempt =
|
||||
readNumber(context?.continuationAttempt) ??
|
||||
readNumber(context?.livenessContinuationAttempt) ??
|
||||
run.continuationAttempt ??
|
||||
0;
|
||||
|
||||
const [commentStats] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestAt: sql<Date | null>`max(${issueComments.createdAt})`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, companyId),
|
||||
eq(issueComments.issueId, issueId),
|
||||
eq(issueComments.createdByRunId, run.id),
|
||||
),
|
||||
);
|
||||
|
||||
const [documentStats] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
planCount: sql<number>`count(*) filter (where ${issueDocuments.key} = 'plan')::int`,
|
||||
latestAt: sql<Date | null>`max(${documentRevisions.createdAt})`,
|
||||
})
|
||||
.from(documentRevisions)
|
||||
.innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId))
|
||||
.where(
|
||||
and(
|
||||
eq(documentRevisions.companyId, companyId),
|
||||
eq(documentRevisions.createdByRunId, run.id),
|
||||
eq(issueDocuments.companyId, companyId),
|
||||
eq(issueDocuments.issueId, issueId),
|
||||
sql`${issueDocuments.key} != ${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`,
|
||||
),
|
||||
);
|
||||
|
||||
const [workProductStats] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestAt: sql<Date | null>`max(${issueWorkProducts.createdAt})`,
|
||||
})
|
||||
.from(issueWorkProducts)
|
||||
.where(
|
||||
and(
|
||||
eq(issueWorkProducts.companyId, companyId),
|
||||
eq(issueWorkProducts.issueId, issueId),
|
||||
eq(issueWorkProducts.createdByRunId, run.id),
|
||||
),
|
||||
);
|
||||
|
||||
const [workspaceOperationStats] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestAt: sql<Date | null>`max(${workspaceOperations.startedAt})`,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(and(eq(workspaceOperations.companyId, companyId), eq(workspaceOperations.heartbeatRunId, run.id)));
|
||||
|
||||
const [activityStats] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestAt: sql<Date | null>`max(${activityLog.createdAt})`,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.runId, run.id)));
|
||||
|
||||
const [eventStats] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))::int`,
|
||||
latestAt: sql<Date | null>`max(${heartbeatRunEvents.createdAt}) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))`,
|
||||
})
|
||||
.from(heartbeatRunEvents)
|
||||
.where(and(eq(heartbeatRunEvents.companyId, companyId), eq(heartbeatRunEvents.runId, run.id)));
|
||||
|
||||
const classification = classifyRunLiveness({
|
||||
runStatus: run.status,
|
||||
issue,
|
||||
resultJson: asRecord(run.resultJson),
|
||||
stdoutExcerpt: run.stdoutExcerpt,
|
||||
stderrExcerpt: run.stderrExcerpt,
|
||||
error: run.error,
|
||||
errorCode: run.errorCode,
|
||||
continuationAttempt,
|
||||
evidence: {
|
||||
issueCommentsCreated: countValue(commentStats?.count),
|
||||
documentRevisionsCreated: countValue(documentStats?.count),
|
||||
planDocumentRevisionsCreated: countValue(documentStats?.planCount),
|
||||
workProductsCreated: countValue(workProductStats?.count),
|
||||
workspaceOperationsCreated: countValue(workspaceOperationStats?.count),
|
||||
activityEventsCreated: countValue(activityStats?.count),
|
||||
toolOrActionEventsCreated: countValue(eventStats?.count),
|
||||
latestEvidenceAt: latestDate(
|
||||
commentStats?.latestAt,
|
||||
documentStats?.latestAt,
|
||||
workProductStats?.latestAt,
|
||||
workspaceOperationStats?.latestAt,
|
||||
activityStats?.latestAt,
|
||||
eventStats?.latestAt,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
livenessState: classification.livenessState,
|
||||
livenessReason: classification.livenessReason,
|
||||
continuationAttempt: classification.continuationAttempt,
|
||||
lastUsefulActionAt: classification.lastUsefulActionAt,
|
||||
nextAction: classification.nextAction,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.livenessState)));
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRunLivenessBackfill(companyId: string, issueId: string) {
|
||||
const key = `${companyId}:${issueId}`;
|
||||
if (scheduledLivenessBackfills.has(key)) return;
|
||||
scheduledLivenessBackfills.add(key);
|
||||
void backfillMissingRunLivenessForIssue(companyId, issueId)
|
||||
.catch((err: unknown) => {
|
||||
logger.warn({ err, companyId, issueId }, "run liveness backfill failed");
|
||||
})
|
||||
.finally(() => {
|
||||
scheduledLivenessBackfills.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
list: (filters: ActivityFilters) => {
|
||||
const conditions = [eq(activityLog.companyId, filters.companyId)];
|
||||
|
|
@ -128,8 +362,9 @@ export function activityService(db: Db) {
|
|||
)
|
||||
.orderBy(desc(activityLog.createdAt)),
|
||||
|
||||
runsForIssue: (companyId: string, issueId: string) =>
|
||||
db
|
||||
runsForIssue: async (companyId: string, issueId: string) => {
|
||||
scheduleRunLivenessBackfill(companyId, issueId);
|
||||
return db
|
||||
.select({
|
||||
runId: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
|
|
@ -142,6 +377,11 @@ export function activityService(db: Db) {
|
|||
usageJson: summarizedUsageJson,
|
||||
resultJson: summarizedResultJson,
|
||||
logBytes: heartbeatRuns.logBytes,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
livenessReason: heartbeatRuns.livenessReason,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
|
||||
nextAction: heartbeatRuns.nextAction,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(
|
||||
|
|
@ -167,7 +407,8 @@ export function activityService(db: Db) {
|
|||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt)),
|
||||
.orderBy(desc(heartbeatRuns.createdAt));
|
||||
},
|
||||
|
||||
issuesForRun: async (runId: string) => {
|
||||
const run = await db
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db";
|
||||
import { issueDocumentKeySchema } from "@paperclipai/shared";
|
||||
import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
function normalizeDocumentKey(key: string) {
|
||||
|
|
@ -83,8 +83,14 @@ const issueDocumentSelect = {
|
|||
};
|
||||
|
||||
export function documentService(db: Db) {
|
||||
const filterSystemDocuments = <T extends { key: string }>(rows: T[], includeSystem: boolean) =>
|
||||
includeSystem ? rows : rows.filter((row) => !isSystemIssueDocumentKey(row.key));
|
||||
|
||||
return {
|
||||
getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => {
|
||||
getIssueDocumentPayload: async (
|
||||
issue: { id: string; description: string | null },
|
||||
options: { includeSystem?: boolean } = {},
|
||||
) => {
|
||||
const [planDocument, documentSummaries] = await Promise.all([
|
||||
db
|
||||
.select(issueDocumentSelect)
|
||||
|
|
@ -104,7 +110,8 @@ export function documentService(db: Db) {
|
|||
|
||||
return {
|
||||
planDocument: planDocument ? mapIssueDocumentRow(planDocument, true) : null,
|
||||
documentSummaries: documentSummaries.map((row) => mapIssueDocumentRow(row, false)),
|
||||
documentSummaries: filterSystemDocuments(documentSummaries, options.includeSystem ?? false)
|
||||
.map((row) => mapIssueDocumentRow(row, false)),
|
||||
legacyPlanDocument: legacyPlanBody
|
||||
? {
|
||||
key: "plan" as const,
|
||||
|
|
@ -115,14 +122,14 @@ export function documentService(db: Db) {
|
|||
};
|
||||
},
|
||||
|
||||
listIssueDocuments: async (issueId: string) => {
|
||||
listIssueDocuments: async (issueId: string, options: { includeSystem?: boolean } = {}) => {
|
||||
const rows = await db
|
||||
.select(issueDocumentSelect)
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(eq(issueDocuments.issueId, issueId))
|
||||
.orderBy(asc(issueDocuments.key), desc(documents.updatedAt));
|
||||
return rows.map((row) => mapIssueDocumentRow(row, true));
|
||||
return filterSystemDocuments(rows, options.includeSystem ?? false).map((row) => mapIssueDocumentRow(row, true));
|
||||
},
|
||||
|
||||
getIssueDocumentByKey: async (issueId: string, rawKey: string) => {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,26 @@ export function summarizeHeartbeatRunResultJson(
|
|||
}
|
||||
}
|
||||
|
||||
for (const key of ["stopReason", "timeoutSource"] as const) {
|
||||
const value = readCommentText(resultJson[key]);
|
||||
if (value !== null) {
|
||||
summary[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ["effectiveTimeoutSec", "effectiveTimeoutMs"] as const) {
|
||||
const value = readNumericField(resultJson, key);
|
||||
if (value !== undefined && value !== null) {
|
||||
summary[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ["timeoutConfigured", "timeoutFired"] as const) {
|
||||
if (typeof resultJson[key] === "boolean") {
|
||||
summary[key] = resultJson[key];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(summary).length > 0 ? summary : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
86
server/src/services/heartbeat-stop-metadata.test.ts
Normal file
86
server/src/services/heartbeat-stop-metadata.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHeartbeatRunStopMetadata,
|
||||
mergeHeartbeatRunStopMetadata,
|
||||
resolveHeartbeatRunTimeoutPolicy,
|
||||
} from "./heartbeat-stop-metadata.js";
|
||||
|
||||
describe("heartbeat stop metadata", () => {
|
||||
it("keeps local coding adapters at no timeout by default", () => {
|
||||
for (const adapterType of [
|
||||
"codex_local",
|
||||
"claude_local",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"process",
|
||||
]) {
|
||||
expect(resolveHeartbeatRunTimeoutPolicy(adapterType, {})).toEqual({
|
||||
effectiveTimeoutSec: 0,
|
||||
timeoutConfigured: false,
|
||||
timeoutSource: "default",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("records configured timeout policy and timeout stop reason", () => {
|
||||
const metadata = buildHeartbeatRunStopMetadata({
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { timeoutSec: 45 },
|
||||
outcome: "timed_out",
|
||||
errorCode: "timeout",
|
||||
errorMessage: "Timed out after 45s",
|
||||
});
|
||||
|
||||
expect(metadata).toEqual({
|
||||
effectiveTimeoutSec: 45,
|
||||
timeoutConfigured: true,
|
||||
timeoutSource: "config",
|
||||
stopReason: "timeout",
|
||||
timeoutFired: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes budget cancellation from manual cancellation", () => {
|
||||
expect(
|
||||
buildHeartbeatRunStopMetadata({
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
outcome: "cancelled",
|
||||
errorCode: "cancelled",
|
||||
errorMessage: "Cancelled due to budget pause",
|
||||
}).stopReason,
|
||||
).toBe("budget_paused");
|
||||
|
||||
expect(
|
||||
buildHeartbeatRunStopMetadata({
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
outcome: "cancelled",
|
||||
errorCode: "cancelled",
|
||||
errorMessage: "Cancelled by control plane",
|
||||
}).stopReason,
|
||||
).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("preserves existing result fields when merging stop metadata", () => {
|
||||
const result = mergeHeartbeatRunStopMetadata(
|
||||
{ summary: "done" },
|
||||
buildHeartbeatRunStopMetadata({
|
||||
adapterType: "openclaw_gateway",
|
||||
adapterConfig: {},
|
||||
outcome: "succeeded",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
summary: "done",
|
||||
stopReason: "completed",
|
||||
effectiveTimeoutSec: 120,
|
||||
timeoutConfigured: true,
|
||||
timeoutSource: "default",
|
||||
timeoutFired: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
119
server/src/services/heartbeat-stop-metadata.ts
Normal file
119
server/src/services/heartbeat-stop-metadata.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
export type HeartbeatRunOutcome = "succeeded" | "failed" | "cancelled" | "timed_out";
|
||||
|
||||
export type HeartbeatRunStopReason =
|
||||
| "completed"
|
||||
| "timeout"
|
||||
| "cancelled"
|
||||
| "budget_paused"
|
||||
| "paused"
|
||||
| "process_lost"
|
||||
| "adapter_failed";
|
||||
|
||||
export interface HeartbeatRunTimeoutPolicy {
|
||||
effectiveTimeoutSec: number | null;
|
||||
effectiveTimeoutMs?: number | null;
|
||||
timeoutConfigured: boolean;
|
||||
timeoutSource: "config" | "default" | "unknown";
|
||||
}
|
||||
|
||||
export interface HeartbeatRunStopMetadata extends HeartbeatRunTimeoutPolicy {
|
||||
stopReason: HeartbeatRunStopReason;
|
||||
timeoutFired: boolean;
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasOwn(record: Record<string, unknown>, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(record, key);
|
||||
}
|
||||
|
||||
function defaultTimeoutSecForAdapter(adapterType: string) {
|
||||
return adapterType === "openclaw_gateway" ? 120 : 0;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatRunTimeoutPolicy(
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown> | null | undefined,
|
||||
): HeartbeatRunTimeoutPolicy {
|
||||
const config = adapterConfig ?? {};
|
||||
|
||||
if (adapterType === "http") {
|
||||
const hasTimeoutMs = hasOwn(config, "timeoutMs");
|
||||
const rawTimeoutMs = hasTimeoutMs ? readFiniteNumber(config.timeoutMs) : 0;
|
||||
const timeoutMs = Math.max(0, Math.floor(rawTimeoutMs ?? 0));
|
||||
return {
|
||||
effectiveTimeoutSec: timeoutMs / 1000,
|
||||
effectiveTimeoutMs: timeoutMs,
|
||||
timeoutConfigured: timeoutMs > 0,
|
||||
timeoutSource: hasTimeoutMs ? "config" : "default",
|
||||
};
|
||||
}
|
||||
|
||||
const hasTimeoutSec = hasOwn(config, "timeoutSec");
|
||||
const defaultTimeoutSec = defaultTimeoutSecForAdapter(adapterType);
|
||||
const rawTimeoutSec = hasTimeoutSec ? readFiniteNumber(config.timeoutSec) : defaultTimeoutSec;
|
||||
const timeoutSec = Math.max(0, Math.floor(rawTimeoutSec ?? defaultTimeoutSec));
|
||||
|
||||
return {
|
||||
effectiveTimeoutSec: timeoutSec,
|
||||
timeoutConfigured: timeoutSec > 0,
|
||||
timeoutSource: hasTimeoutSec ? "config" : "default",
|
||||
};
|
||||
}
|
||||
|
||||
export function inferHeartbeatRunStopReason(input: {
|
||||
outcome: HeartbeatRunOutcome;
|
||||
errorCode?: string | null;
|
||||
errorMessage?: string | null;
|
||||
}): HeartbeatRunStopReason {
|
||||
if (input.outcome === "succeeded") return "completed";
|
||||
if (input.outcome === "timed_out") return "timeout";
|
||||
if (input.outcome === "failed" && input.errorCode === "process_lost") return "process_lost";
|
||||
if (input.outcome === "cancelled") {
|
||||
const message = (input.errorMessage ?? "").toLowerCase();
|
||||
if (message.includes("budget")) return "budget_paused";
|
||||
if (message.includes("pause") || message.includes("paused")) return "paused";
|
||||
return "cancelled";
|
||||
}
|
||||
return "adapter_failed";
|
||||
}
|
||||
|
||||
export function buildHeartbeatRunStopMetadata(input: {
|
||||
adapterType: string;
|
||||
adapterConfig: Record<string, unknown> | null | undefined;
|
||||
outcome: HeartbeatRunOutcome;
|
||||
errorCode?: string | null;
|
||||
errorMessage?: string | null;
|
||||
}): HeartbeatRunStopMetadata {
|
||||
const timeoutPolicy = resolveHeartbeatRunTimeoutPolicy(input.adapterType, input.adapterConfig);
|
||||
const stopReason = inferHeartbeatRunStopReason(input);
|
||||
return {
|
||||
...timeoutPolicy,
|
||||
stopReason,
|
||||
timeoutFired: stopReason === "timeout",
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeHeartbeatRunStopMetadata(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
metadata: HeartbeatRunStopMetadata,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...(resultJson ?? {}),
|
||||
stopReason: metadata.stopReason,
|
||||
effectiveTimeoutSec: metadata.effectiveTimeoutSec,
|
||||
timeoutConfigured: metadata.timeoutConfigured,
|
||||
timeoutSource: metadata.timeoutSource,
|
||||
timeoutFired: metadata.timeoutFired,
|
||||
...(metadata.effectiveTimeoutMs != null ? { effectiveTimeoutMs: metadata.effectiveTimeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -4,19 +4,25 @@ import { execFile as execFileCallback } from "node:child_process";
|
|||
import { promisify } from "node:util";
|
||||
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig, RunLivenessState } from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
agentTaskSessions,
|
||||
agentWakeupRequests,
|
||||
activityLog,
|
||||
companySkills as companySkillsTable,
|
||||
documentRevisions,
|
||||
issueDocuments,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
issueWorkProducts,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import { conflict, HttpError, notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
|
@ -40,6 +46,14 @@ import {
|
|||
HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES,
|
||||
mergeHeartbeatRunResultJson,
|
||||
} from "./heartbeat-run-summary.js";
|
||||
import {
|
||||
buildHeartbeatRunStopMetadata,
|
||||
mergeHeartbeatRunStopMetadata,
|
||||
} from "./heartbeat-stop-metadata.js";
|
||||
import {
|
||||
classifyRunLiveness,
|
||||
type RunLivenessClassificationInput,
|
||||
} from "./run-liveness.js";
|
||||
import { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
import {
|
||||
buildWorkspaceReadyComment,
|
||||
|
|
@ -53,6 +67,10 @@ import {
|
|||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import {
|
||||
getIssueContinuationSummaryDocument,
|
||||
refreshIssueContinuationSummary,
|
||||
} from "./issue-continuation-summary.js";
|
||||
import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { workspaceOperationService } from "./workspace-operations.js";
|
||||
import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js";
|
||||
|
|
@ -65,6 +83,13 @@ import {
|
|||
resolveExecutionWorkspaceMode,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import {
|
||||
RUN_LIVENESS_CONTINUATION_REASON,
|
||||
buildRunLivenessContinuationIdempotencyKey,
|
||||
decideRunLivenessContinuation,
|
||||
findExistingRunLivenessContinuationWake,
|
||||
readContinuationAttempt,
|
||||
} from "./run-continuations.js";
|
||||
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
||||
import {
|
||||
hasSessionCompactionThresholds,
|
||||
|
|
@ -397,6 +422,11 @@ const heartbeatRunListColumns = {
|
|||
processStartedAt: heartbeatRuns.processStartedAt,
|
||||
retryOfRunId: heartbeatRuns.retryOfRunId,
|
||||
processLossRetryCount: heartbeatRuns.processLossRetryCount,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
livenessReason: heartbeatRuns.livenessReason,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
|
||||
nextAction: heartbeatRuns.nextAction,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
updatedAt: heartbeatRuns.updatedAt,
|
||||
} as const;
|
||||
|
|
@ -490,6 +520,11 @@ const heartbeatRunIssueSummaryColumns = {
|
|||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
livenessReason: heartbeatRuns.livenessReason,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
|
||||
nextAction: heartbeatRuns.nextAction,
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
|
||||
} as const;
|
||||
|
||||
|
|
@ -1204,6 +1239,14 @@ async function buildPaperclipWakePayload(input: {
|
|||
db: Db;
|
||||
companyId: string;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
continuationSummary?:
|
||||
| {
|
||||
key: string;
|
||||
title: string | null;
|
||||
body: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
| null;
|
||||
issueSummary?:
|
||||
| {
|
||||
id: string;
|
||||
|
|
@ -1217,6 +1260,7 @@ async function buildPaperclipWakePayload(input: {
|
|||
const executionStage = parseObject(input.contextSnapshot.executionStage);
|
||||
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
||||
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
||||
const continuationSummary = input.continuationSummary ?? null;
|
||||
const issueSummary =
|
||||
input.issueSummary ??
|
||||
(issueId
|
||||
|
|
@ -1309,8 +1353,37 @@ async function buildPaperclipWakePayload(input: {
|
|||
priority: issueSummary.priority,
|
||||
}
|
||||
: null,
|
||||
childIssueSummaries: Array.isArray(input.contextSnapshot.childIssueSummaries)
|
||||
? input.contextSnapshot.childIssueSummaries
|
||||
: [],
|
||||
childIssueSummaryTruncated: input.contextSnapshot.childIssueSummaryTruncated === true,
|
||||
livenessContinuation: readNonEmptyString(input.contextSnapshot.livenessContinuationState) ||
|
||||
readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction) ||
|
||||
readNonEmptyString(input.contextSnapshot.livenessContinuationSourceRunId) ||
|
||||
typeof input.contextSnapshot.livenessContinuationAttempt === "number"
|
||||
? {
|
||||
attempt: input.contextSnapshot.livenessContinuationAttempt,
|
||||
maxAttempts: input.contextSnapshot.livenessContinuationMaxAttempts,
|
||||
sourceRunId: readNonEmptyString(input.contextSnapshot.livenessContinuationSourceRunId),
|
||||
state: readNonEmptyString(input.contextSnapshot.livenessContinuationState),
|
||||
reason: readNonEmptyString(input.contextSnapshot.livenessContinuationReason),
|
||||
instruction: readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction),
|
||||
}
|
||||
: null,
|
||||
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
|
||||
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
|
||||
continuationSummary: continuationSummary
|
||||
? {
|
||||
key: continuationSummary.key,
|
||||
title: continuationSummary.title,
|
||||
body:
|
||||
continuationSummary.body.length > 4_000
|
||||
? continuationSummary.body.slice(0, 4_000)
|
||||
: continuationSummary.body,
|
||||
bodyTruncated: continuationSummary.body.length > 4_000,
|
||||
updatedAt: continuationSummary.updatedAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
commentIds,
|
||||
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
||||
comments,
|
||||
|
|
@ -1643,6 +1716,7 @@ export function heartbeatService(db: Db) {
|
|||
agent: typeof agents.$inferSelect;
|
||||
sessionId: string | null;
|
||||
issueId: string | null;
|
||||
continuationSummaryBody?: string | null;
|
||||
}): Promise<SessionCompactionDecision> {
|
||||
const { agent, sessionId, issueId } = input;
|
||||
if (!sessionId) {
|
||||
|
|
@ -1746,6 +1820,9 @@ export function heartbeatService(db: Db) {
|
|||
issueId ? `- Issue: ${issueId}` : "",
|
||||
`- Rotation reason: ${reason}`,
|
||||
latestTextSummary ? `- Last run summary: ${latestTextSummary}` : "",
|
||||
input.continuationSummaryBody
|
||||
? `- Issue continuation summary: ${input.continuationSummaryBody.slice(0, 1_500)}`
|
||||
: "",
|
||||
"Continue from the current task state. Rebuild only the minimum context you need.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
|
@ -2170,6 +2247,136 @@ export function heartbeatService(db: Db) {
|
|||
.where(eq(agentWakeupRequests.id, wakeupRequestId));
|
||||
}
|
||||
|
||||
async function addContinuationExhaustedCommentOnce(input: {
|
||||
run: typeof heartbeatRuns.$inferSelect;
|
||||
issueId: string;
|
||||
comment: string;
|
||||
}) {
|
||||
const existing = await db
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.run.companyId),
|
||||
eq(issueComments.issueId, input.issueId),
|
||||
eq(issueComments.createdByRunId, input.run.id),
|
||||
sql`${issueComments.body} like 'Bounded liveness continuation exhausted%'`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return;
|
||||
await issuesSvc.addComment(input.issueId, input.comment, {
|
||||
agentId: input.run.agentId,
|
||||
runId: input.run.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRunLivenessContinuation(run: typeof heartbeatRuns.$inferSelect) {
|
||||
const livenessState = run.livenessState as RunLivenessState | null;
|
||||
if (livenessState !== "plan_only" && livenessState !== "empty_response") return;
|
||||
|
||||
const context = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(context.issueId);
|
||||
if (!issueId) return;
|
||||
|
||||
const [issue, agent] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
executionState: issues.executionState,
|
||||
projectId: issues.projectId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, run.agentId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
|
||||
const budgetBlock =
|
||||
issue && agent
|
||||
? await budgets.getInvocationBlock(issue.companyId, agent.id, {
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const nextAttempt = readContinuationAttempt(run.continuationAttempt) + 1;
|
||||
const idempotencyKey = issue
|
||||
? buildRunLivenessContinuationIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
livenessState,
|
||||
nextAttempt,
|
||||
})
|
||||
: null;
|
||||
const existingWake = idempotencyKey
|
||||
? await findExistingRunLivenessContinuationWake(db, {
|
||||
companyId: run.companyId,
|
||||
idempotencyKey,
|
||||
})
|
||||
: null;
|
||||
|
||||
const decision = decideRunLivenessContinuation({
|
||||
run,
|
||||
issue,
|
||||
agent,
|
||||
livenessState,
|
||||
livenessReason: run.livenessReason,
|
||||
nextAction: run.nextAction,
|
||||
budgetBlocked: Boolean(budgetBlock),
|
||||
idempotentWakeExists: Boolean(existingWake),
|
||||
});
|
||||
|
||||
if (decision.kind === "exhausted") {
|
||||
await setRunStatus(run.id, run.status, {
|
||||
livenessReason: `${run.livenessReason ?? "Run ended without concrete progress"}; continuation attempts exhausted`,
|
||||
});
|
||||
await addContinuationExhaustedCommentOnce({
|
||||
run,
|
||||
issueId,
|
||||
comment: decision.comment,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.kind !== "enqueue") return;
|
||||
|
||||
const continuationRun = await enqueueWakeup(run.agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: RUN_LIVENESS_CONTINUATION_REASON,
|
||||
payload: decision.payload,
|
||||
contextSnapshot: decision.contextSnapshot,
|
||||
idempotencyKey: decision.idempotencyKey,
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "heartbeat",
|
||||
});
|
||||
|
||||
if (continuationRun) {
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
continuationAttempt: decision.nextAttempt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, continuationRun.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function appendRunEvent(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
seq: number,
|
||||
|
|
@ -2298,6 +2505,47 @@ export function heartbeatService(db: Db) {
|
|||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function refreshContinuationSummaryForRun(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
) {
|
||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
||||
if (!issueId) return null;
|
||||
try {
|
||||
return await refreshIssueContinuationSummary({
|
||||
db,
|
||||
issueId,
|
||||
run: {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
error: run.error,
|
||||
errorCode: run.errorCode,
|
||||
resultJson: run.resultJson as Record<string, unknown> | null,
|
||||
stdoutExcerpt: run.stdoutExcerpt,
|
||||
stderrExcerpt: run.stderrExcerpt,
|
||||
finishedAt: run.finishedAt,
|
||||
},
|
||||
agent: {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
adapterType: agent.adapterType,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
runId: run.id,
|
||||
issueId,
|
||||
agentId: agent.id,
|
||||
},
|
||||
"failed to refresh issue continuation summary",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueMissingIssueCommentRetry(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
agent: typeof agents.$inferSelect,
|
||||
|
|
@ -2737,6 +2985,194 @@ export function heartbeatService(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
function mergeRunStopMetadataForAgent(
|
||||
agent: Pick<typeof agents.$inferSelect, "adapterType" | "adapterConfig">,
|
||||
outcome: "succeeded" | "failed" | "cancelled" | "timed_out",
|
||||
options?: {
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
errorCode?: string | null;
|
||||
errorMessage?: string | null;
|
||||
},
|
||||
) {
|
||||
const stopMetadata = buildHeartbeatRunStopMetadata({
|
||||
adapterType: agent.adapterType,
|
||||
adapterConfig: parseObject(agent.adapterConfig),
|
||||
outcome,
|
||||
errorCode: options?.errorCode ?? null,
|
||||
errorMessage: options?.errorMessage ?? null,
|
||||
});
|
||||
return mergeHeartbeatRunStopMetadata(options?.resultJson ?? null, stopMetadata);
|
||||
}
|
||||
|
||||
function countValue(value: unknown) {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0;
|
||||
}
|
||||
|
||||
function dateValue(value: unknown) {
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function latestDate(...values: unknown[]) {
|
||||
let latest: Date | null = null;
|
||||
for (const value of values) {
|
||||
const parsed = dateValue(value);
|
||||
if (!parsed) continue;
|
||||
if (!latest || parsed.getTime() > latest.getTime()) latest = parsed;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function buildRunLivenessInput(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): Promise<RunLivenessClassificationInput> {
|
||||
const context = parseObject(run.contextSnapshot);
|
||||
const contextIssueId = readNonEmptyString(context.issueId);
|
||||
const continuationAttempt = asNumber(context.continuationAttempt, run.continuationAttempt ?? 0);
|
||||
|
||||
const issue = contextIssueId
|
||||
? await db
|
||||
.select({
|
||||
status: issues.status,
|
||||
title: issues.title,
|
||||
description: issues.description,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, run.companyId), eq(issues.id, contextIssueId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
|
||||
const [commentStats] = contextIssueId
|
||||
? await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestAt: sql<Date | null>`max(${issueComments.createdAt})`,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, run.companyId),
|
||||
eq(issueComments.issueId, contextIssueId),
|
||||
eq(issueComments.createdByRunId, run.id),
|
||||
),
|
||||
)
|
||||
: [{ count: 0, latestAt: null }];
|
||||
|
||||
const [documentStats] = contextIssueId
|
||||
? await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
planCount: sql<number>`count(*) filter (where ${issueDocuments.key} = 'plan')::int`,
|
||||
latestAt: sql<Date | null>`max(${documentRevisions.createdAt})`,
|
||||
})
|
||||
.from(documentRevisions)
|
||||
.innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId))
|
||||
.where(
|
||||
and(
|
||||
eq(documentRevisions.companyId, run.companyId),
|
||||
eq(documentRevisions.createdByRunId, run.id),
|
||||
eq(issueDocuments.companyId, run.companyId),
|
||||
eq(issueDocuments.issueId, contextIssueId),
|
||||
sql`${issueDocuments.key} != ${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`,
|
||||
),
|
||||
)
|
||||
: [{ count: 0, planCount: 0, latestAt: null }];
|
||||
|
||||
const [workProductStats] = contextIssueId
|
||||
? await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestAt: sql<Date | null>`max(${issueWorkProducts.createdAt})`,
|
||||
})
|
||||
.from(issueWorkProducts)
|
||||
.where(
|
||||
and(
|
||||
eq(issueWorkProducts.companyId, run.companyId),
|
||||
eq(issueWorkProducts.issueId, contextIssueId),
|
||||
eq(issueWorkProducts.createdByRunId, run.id),
|
||||
),
|
||||
)
|
||||
: [{ count: 0, latestAt: null }];
|
||||
|
||||
const [workspaceOperationStats] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestAt: sql<Date | null>`max(${workspaceOperations.startedAt})`,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(and(eq(workspaceOperations.companyId, run.companyId), eq(workspaceOperations.heartbeatRunId, run.id)));
|
||||
|
||||
const [activityStats] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
latestAt: sql<Date | null>`max(${activityLog.createdAt})`,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(and(eq(activityLog.companyId, run.companyId), eq(activityLog.runId, run.id)));
|
||||
|
||||
const [eventStats] = await db
|
||||
.select({
|
||||
count: sql<number>`count(*) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))::int`,
|
||||
latestAt: sql<Date | null>`max(${heartbeatRunEvents.createdAt}) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))`,
|
||||
})
|
||||
.from(heartbeatRunEvents)
|
||||
.where(and(eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id)));
|
||||
|
||||
return {
|
||||
runStatus: run.status,
|
||||
issue,
|
||||
resultJson: resultJson ?? run.resultJson ?? null,
|
||||
stdoutExcerpt: run.stdoutExcerpt ?? null,
|
||||
stderrExcerpt: run.stderrExcerpt ?? null,
|
||||
error: run.error ?? null,
|
||||
errorCode: run.errorCode ?? null,
|
||||
continuationAttempt,
|
||||
evidence: {
|
||||
issueCommentsCreated: countValue(commentStats?.count),
|
||||
documentRevisionsCreated: countValue(documentStats?.count),
|
||||
planDocumentRevisionsCreated: countValue(documentStats?.planCount),
|
||||
workProductsCreated: countValue(workProductStats?.count),
|
||||
workspaceOperationsCreated: countValue(workspaceOperationStats?.count),
|
||||
activityEventsCreated: countValue(activityStats?.count),
|
||||
toolOrActionEventsCreated: countValue(eventStats?.count),
|
||||
latestEvidenceAt: latestDate(
|
||||
commentStats?.latestAt,
|
||||
documentStats?.latestAt,
|
||||
workProductStats?.latestAt,
|
||||
workspaceOperationStats?.latestAt,
|
||||
activityStats?.latestAt,
|
||||
eventStats?.latestAt,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function classifyAndPersistRunLiveness(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
resultJson?: Record<string, unknown> | null,
|
||||
) {
|
||||
const classification = classifyRunLiveness(await buildRunLivenessInput(run, resultJson));
|
||||
return db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
livenessState: classification.livenessState,
|
||||
livenessReason: classification.livenessReason,
|
||||
continuationAttempt: classification.continuationAttempt,
|
||||
lastUsefulActionAt: classification.lastUsefulActionAt,
|
||||
nextAction: classification.nextAction,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function reapOrphanedRuns(opts?: { staleThresholdMs?: number }) {
|
||||
const staleThresholdMs = opts?.staleThresholdMs ?? 0;
|
||||
const now = new Date();
|
||||
|
|
@ -2746,6 +3182,7 @@ export function heartbeatService(db: Db) {
|
|||
.select({
|
||||
run: heartbeatRuns,
|
||||
adapterType: agents.adapterType,
|
||||
adapterConfig: agents.adapterConfig,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
||||
|
|
@ -2753,7 +3190,7 @@ export function heartbeatService(db: Db) {
|
|||
|
||||
const reaped: string[] = [];
|
||||
|
||||
for (const { run, adapterType } of activeRuns) {
|
||||
for (const { run, adapterType, adapterConfig } of activeRuns) {
|
||||
if (runningProcesses.has(run.id) || activeRunExecutions.has(run.id)) continue;
|
||||
|
||||
// Apply staleness threshold to avoid false positives
|
||||
|
|
@ -2803,6 +3240,15 @@ export function heartbeatService(db: Db) {
|
|||
error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage,
|
||||
errorCode: "process_lost",
|
||||
finishedAt: now,
|
||||
resultJson: mergeRunStopMetadataForAgent(
|
||||
{ adapterType, adapterConfig },
|
||||
"failed",
|
||||
{
|
||||
resultJson: parseObject(run.resultJson),
|
||||
errorCode: "process_lost",
|
||||
errorMessage: shouldRetry ? `${baseMessage}; retrying once` : baseMessage,
|
||||
},
|
||||
),
|
||||
});
|
||||
await setWakeupStatus(run.wakeupRequestId, "failed", {
|
||||
finishedAt: now,
|
||||
|
|
@ -2810,6 +3256,7 @@ export function heartbeatService(db: Db) {
|
|||
});
|
||||
if (!finalizedRun) finalizedRun = await getRun(run.id);
|
||||
if (!finalizedRun) continue;
|
||||
finalizedRun = await classifyAndPersistRunLiveness(finalizedRun, parseObject(finalizedRun.resultJson)) ?? finalizedRun;
|
||||
|
||||
let retriedRun: typeof heartbeatRuns.$inferSelect | null = null;
|
||||
if (shouldRetry) {
|
||||
|
|
@ -3340,10 +3787,24 @@ export function heartbeatService(db: Db) {
|
|||
executionWorkspacePreference: issueContext.executionWorkspacePreference,
|
||||
}
|
||||
: null;
|
||||
const continuationSummary = issueRef
|
||||
? await getIssueContinuationSummaryDocument(db, issueRef.id)
|
||||
: null;
|
||||
if (continuationSummary) {
|
||||
context.paperclipContinuationSummary = {
|
||||
key: continuationSummary.key,
|
||||
title: continuationSummary.title,
|
||||
body: continuationSummary.body,
|
||||
updatedAt: continuationSummary.updatedAt.toISOString(),
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipContinuationSummary;
|
||||
}
|
||||
const paperclipWakePayload = await buildPaperclipWakePayload({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
contextSnapshot: context,
|
||||
continuationSummary,
|
||||
issueSummary: issueRef
|
||||
? {
|
||||
id: issueRef.id,
|
||||
|
|
@ -3656,6 +4117,7 @@ export function heartbeatService(db: Db) {
|
|||
agent,
|
||||
sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter,
|
||||
issueId,
|
||||
continuationSummaryBody: continuationSummary?.body ?? null,
|
||||
});
|
||||
if (sessionCompaction.rotate) {
|
||||
context.paperclipSessionHandoffMarkdown = sessionCompaction.handoffMarkdown;
|
||||
|
|
@ -3962,6 +4424,23 @@ export function heartbeatService(db: Db) {
|
|||
} else {
|
||||
outcome = "failed";
|
||||
}
|
||||
const runErrorMessage =
|
||||
outcome === "cancelled"
|
||||
? (latestRun?.error ?? adapterResult.errorMessage ?? "Cancelled")
|
||||
: outcome === "succeeded"
|
||||
? null
|
||||
: redactCurrentUserText(
|
||||
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
|
||||
currentUserRedactionOptions,
|
||||
);
|
||||
const runErrorCode =
|
||||
outcome === "timed_out"
|
||||
? "timeout"
|
||||
: outcome === "cancelled"
|
||||
? (latestRun?.errorCode ?? "cancelled")
|
||||
: outcome === "failed"
|
||||
? (adapterResult.errorCode ?? "adapter_failed")
|
||||
: null;
|
||||
|
||||
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
|
||||
if (handle) {
|
||||
|
|
@ -4004,27 +4483,18 @@ export function heartbeatService(db: Db) {
|
|||
: null;
|
||||
|
||||
const persistedResultJson = mergeHeartbeatRunResultJson(
|
||||
adapterResult.resultJson ?? null,
|
||||
mergeRunStopMetadataForAgent(agent, outcome, {
|
||||
resultJson: adapterResult.resultJson ?? null,
|
||||
errorCode: runErrorCode,
|
||||
errorMessage: runErrorMessage,
|
||||
}),
|
||||
adapterResult.summary ?? null,
|
||||
);
|
||||
|
||||
await setRunStatus(run.id, status, {
|
||||
let persistedRun = await setRunStatus(run.id, status, {
|
||||
finishedAt: new Date(),
|
||||
error:
|
||||
outcome === "succeeded"
|
||||
? null
|
||||
: redactCurrentUserText(
|
||||
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
|
||||
currentUserRedactionOptions,
|
||||
),
|
||||
errorCode:
|
||||
outcome === "timed_out"
|
||||
? "timeout"
|
||||
: outcome === "cancelled"
|
||||
? "cancelled"
|
||||
: outcome === "failed"
|
||||
? (adapterResult.errorCode ?? "adapter_failed")
|
||||
: null,
|
||||
error: runErrorMessage,
|
||||
errorCode: runErrorCode,
|
||||
exitCode: adapterResult.exitCode,
|
||||
signal: adapterResult.signal,
|
||||
usageJson,
|
||||
|
|
@ -4036,13 +4506,16 @@ export function heartbeatService(db: Db) {
|
|||
logSha256: logSummary?.sha256,
|
||||
logCompressed: logSummary?.compressed ?? false,
|
||||
});
|
||||
if (persistedRun) {
|
||||
persistedRun = await classifyAndPersistRunLiveness(persistedRun, persistedResultJson) ?? persistedRun;
|
||||
}
|
||||
|
||||
await setWakeupStatus(run.wakeupRequestId, outcome === "succeeded" ? "completed" : status, {
|
||||
finishedAt: new Date(),
|
||||
error: adapterResult.errorMessage ?? null,
|
||||
error: runErrorMessage,
|
||||
});
|
||||
|
||||
const finalizedRun = await getRun(run.id);
|
||||
const finalizedRun = persistedRun ?? (await getRun(run.id));
|
||||
if (finalizedRun) {
|
||||
await appendRunEvent(finalizedRun, seq++, {
|
||||
eventType: "lifecycle",
|
||||
|
|
@ -4054,13 +4527,15 @@ export function heartbeatService(db: Db) {
|
|||
exitCode: adapterResult.exitCode,
|
||||
},
|
||||
});
|
||||
const livenessRun = finalizedRun;
|
||||
await refreshContinuationSummaryForRun(livenessRun, agent);
|
||||
if (issueId && outcome === "succeeded") {
|
||||
try {
|
||||
const existingRunComment = await findRunIssueComment(finalizedRun.id, finalizedRun.companyId, issueId);
|
||||
const existingRunComment = await findRunIssueComment(livenessRun.id, livenessRun.companyId, issueId);
|
||||
if (!existingRunComment) {
|
||||
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
|
||||
if (issueComment) {
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
|
||||
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: livenessRun.id });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -4070,8 +4545,9 @@ export function heartbeatService(db: Db) {
|
|||
);
|
||||
}
|
||||
}
|
||||
await finalizeIssueCommentPolicy(finalizedRun, agent);
|
||||
await releaseIssueExecutionAndPromote(finalizedRun);
|
||||
await finalizeIssueCommentPolicy(livenessRun, agent);
|
||||
await releaseIssueExecutionAndPromote(livenessRun);
|
||||
await handleRunLivenessContinuation(livenessRun);
|
||||
}
|
||||
|
||||
if (finalizedRun) {
|
||||
|
|
@ -4119,6 +4595,10 @@ export function heartbeatService(db: Db) {
|
|||
error: message,
|
||||
errorCode: "adapter_failed",
|
||||
finishedAt: new Date(),
|
||||
resultJson: mergeRunStopMetadataForAgent(agent, "failed", {
|
||||
errorCode: "adapter_failed",
|
||||
errorMessage: message,
|
||||
}),
|
||||
stdoutExcerpt,
|
||||
stderrExcerpt,
|
||||
logBytes: logSummary?.bytes,
|
||||
|
|
@ -4137,10 +4617,12 @@ export function heartbeatService(db: Db) {
|
|||
level: "error",
|
||||
message,
|
||||
});
|
||||
await finalizeIssueCommentPolicy(failedRun, agent);
|
||||
await releaseIssueExecutionAndPromote(failedRun);
|
||||
const livenessRun = await classifyAndPersistRunLiveness(failedRun) ?? failedRun;
|
||||
await refreshContinuationSummaryForRun(livenessRun, agent);
|
||||
await finalizeIssueCommentPolicy(livenessRun, agent);
|
||||
await releaseIssueExecutionAndPromote(livenessRun);
|
||||
|
||||
await updateRuntimeState(agent, failedRun, {
|
||||
await updateRuntimeState(agent, livenessRun, {
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
|
|
@ -4170,10 +4652,17 @@ export function heartbeatService(db: Db) {
|
|||
// The inner catch did not fire, so we must record the failure here.
|
||||
const message = outerErr instanceof Error ? outerErr.message : "Unknown setup failure";
|
||||
logger.error({ err: outerErr, runId }, "heartbeat execution setup failed");
|
||||
const setupFailureAgent = await getAgent(run.agentId).catch(() => null);
|
||||
await setRunStatus(runId, "failed", {
|
||||
error: message,
|
||||
errorCode: "adapter_failed",
|
||||
finishedAt: new Date(),
|
||||
...(setupFailureAgent ? {
|
||||
resultJson: mergeRunStopMetadataForAgent(setupFailureAgent, "failed", {
|
||||
errorCode: "adapter_failed",
|
||||
errorMessage: message,
|
||||
}),
|
||||
} : {}),
|
||||
}).catch(() => undefined);
|
||||
await setWakeupStatus(run.wakeupRequestId, "failed", {
|
||||
finishedAt: new Date(),
|
||||
|
|
@ -4189,11 +4678,13 @@ export function heartbeatService(db: Db) {
|
|||
level: "error",
|
||||
message,
|
||||
}).catch(() => undefined);
|
||||
const failedAgent = await getAgent(run.agentId).catch(() => null);
|
||||
const livenessRun = await classifyAndPersistRunLiveness(failedRun).catch(() => failedRun);
|
||||
const failedAgent = setupFailureAgent ?? await getAgent(run.agentId).catch(() => null);
|
||||
if (failedAgent) {
|
||||
await finalizeIssueCommentPolicy(failedRun, failedAgent).catch(() => undefined);
|
||||
await refreshContinuationSummaryForRun(livenessRun, failedAgent).catch(() => undefined);
|
||||
await finalizeIssueCommentPolicy(livenessRun, failedAgent).catch(() => undefined);
|
||||
}
|
||||
await releaseIssueExecutionAndPromote(failedRun).catch(() => undefined);
|
||||
await releaseIssueExecutionAndPromote(livenessRun).catch(() => undefined);
|
||||
}
|
||||
// Ensure the agent is not left stuck in "running" if the inner catch handler's
|
||||
// DB calls threw (e.g. a transient DB error in finalizeAgentStatus).
|
||||
|
|
@ -4363,6 +4854,9 @@ export function heartbeatService(db: Db) {
|
|||
const sessionBefore =
|
||||
readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ??
|
||||
await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey);
|
||||
const promotedContinuationAttempt = readContinuationAttempt(
|
||||
promotedContextSnapshot.livenessContinuationAttempt,
|
||||
);
|
||||
const now = new Date();
|
||||
const newRun = await tx
|
||||
.insert(heartbeatRuns)
|
||||
|
|
@ -4375,6 +4869,7 @@ export function heartbeatService(db: Db) {
|
|||
wakeupRequestId: deferred.id,
|
||||
contextSnapshot: promotedContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
continuationAttempt: promotedContinuationAttempt,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
|
@ -4473,6 +4968,7 @@ export function heartbeatService(db: Db) {
|
|||
const sessionBefore =
|
||||
explicitResumeSession?.sessionDisplayId ??
|
||||
await resolveSessionBeforeForWakeup(agent, effectiveTaskKey);
|
||||
const continuationAttempt = readContinuationAttempt(enrichedContextSnapshot.livenessContinuationAttempt);
|
||||
|
||||
const writeSkippedRequest = async (skipReason: string) => {
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
|
|
@ -4771,6 +5267,7 @@ export function heartbeatService(db: Db) {
|
|||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: enrichedContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
continuationAttempt,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
|
@ -4890,6 +5387,7 @@ export function heartbeatService(db: Db) {
|
|||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: enrichedContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
continuationAttempt,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
|
@ -5022,6 +5520,7 @@ export function heartbeatService(db: Db) {
|
|||
const run = await getRun(runId);
|
||||
if (!run) throw notFound("Heartbeat run not found");
|
||||
if (run.status !== "running" && run.status !== "queued") return run;
|
||||
const agent = await getAgent(run.agentId);
|
||||
|
||||
const running = runningProcesses.get(run.id);
|
||||
if (running) {
|
||||
|
|
@ -5041,6 +5540,13 @@ export function heartbeatService(db: Db) {
|
|||
finishedAt: new Date(),
|
||||
error: reason,
|
||||
errorCode: "cancelled",
|
||||
...(agent ? {
|
||||
resultJson: mergeRunStopMetadataForAgent(agent, "cancelled", {
|
||||
resultJson: parseObject(run.resultJson),
|
||||
errorCode: "cancelled",
|
||||
errorMessage: reason,
|
||||
}),
|
||||
} : {}),
|
||||
});
|
||||
|
||||
await setWakeupStatus(run.wakeupRequestId, "cancelled", {
|
||||
|
|
@ -5065,6 +5571,7 @@ export function heartbeatService(db: Db) {
|
|||
}
|
||||
|
||||
async function cancelActiveForAgentInternal(agentId: string, reason = "Cancelled due to agent pause") {
|
||||
const agent = await getAgent(agentId);
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
|
|
@ -5075,6 +5582,13 @@ export function heartbeatService(db: Db) {
|
|||
finishedAt: new Date(),
|
||||
error: reason,
|
||||
errorCode: "cancelled",
|
||||
...(agent ? {
|
||||
resultJson: mergeRunStopMetadataForAgent(agent, "cancelled", {
|
||||
resultJson: parseObject(run.resultJson),
|
||||
errorCode: "cancelled",
|
||||
errorMessage: reason,
|
||||
}),
|
||||
} : {}),
|
||||
});
|
||||
|
||||
await setWakeupStatus(run.wakeupRequestId, "cancelled", {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ export { agentService, deduplicateAgentName } from "./agents.js";
|
|||
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
|
||||
export { assetService } from "./assets.js";
|
||||
export { documentService, extractLegacyPlanBody } from "./documents.js";
|
||||
export {
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
buildContinuationSummaryMarkdown,
|
||||
getIssueContinuationSummaryDocument,
|
||||
refreshIssueContinuationSummary,
|
||||
} from "./issue-continuation-summary.js";
|
||||
export { projectService } from "./projects.js";
|
||||
export { issueService, type IssueFilters } from "./issues.js";
|
||||
export { issueApprovalService } from "./issue-approvals.js";
|
||||
|
|
|
|||
269
server/src/services/issue-continuation-summary.ts
Normal file
269
server/src/services/issue-continuation-summary.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { documents, issueDocuments, issues } from "@paperclipai/db";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import { documentService } from "./documents.js";
|
||||
|
||||
export { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY };
|
||||
export const ISSUE_CONTINUATION_SUMMARY_TITLE = "Continuation Summary";
|
||||
export const ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS = 8_000;
|
||||
const SUMMARY_SECTION_MAX_CHARS = 1_200;
|
||||
const PATH_CANDIDATE_RE = /(?:^|[\s`"'(])((?:server|ui|packages|doc|scripts|\.github)\/[A-Za-z0-9._/-]+)/g;
|
||||
|
||||
type IssueSummaryInput = {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
};
|
||||
|
||||
type RunSummaryInput = {
|
||||
id: string;
|
||||
status: string;
|
||||
error: string | null;
|
||||
errorCode?: string | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
stdoutExcerpt?: string | null;
|
||||
stderrExcerpt?: string | null;
|
||||
finishedAt?: Date | null;
|
||||
};
|
||||
|
||||
type AgentSummaryInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
adapterType: string | null;
|
||||
};
|
||||
|
||||
export type IssueContinuationSummaryDocument = {
|
||||
key: typeof ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY;
|
||||
title: string | null;
|
||||
body: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function truncateText(value: string, maxChars: number) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length <= maxChars) return trimmed;
|
||||
return `${trimmed.slice(0, Math.max(0, maxChars - 20)).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readResultSummary(resultJson: Record<string, unknown> | null | undefined) {
|
||||
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) return null;
|
||||
return (
|
||||
asNonEmptyString(resultJson.summary) ??
|
||||
asNonEmptyString(resultJson.result) ??
|
||||
asNonEmptyString(resultJson.message) ??
|
||||
asNonEmptyString(resultJson.error) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function extractMarkdownSection(markdown: string | null | undefined, heading: string) {
|
||||
if (!markdown) return null;
|
||||
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`^##\\s+${escaped}\\s*$([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im");
|
||||
const match = re.exec(markdown);
|
||||
const section = match?.[1]?.trim();
|
||||
return section ? truncateText(section, SUMMARY_SECTION_MAX_CHARS) : null;
|
||||
}
|
||||
|
||||
function extractPathCandidates(...texts: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
for (const text of texts) {
|
||||
if (!text) continue;
|
||||
for (const match of text.matchAll(PATH_CANDIDATE_RE)) {
|
||||
const path = match[1]?.replace(/[),.;:]+$/, "");
|
||||
if (path) seen.add(path);
|
||||
if (seen.size >= 12) break;
|
||||
}
|
||||
if (seen.size >= 12) break;
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function inferMode(issue: IssueSummaryInput, run: RunSummaryInput) {
|
||||
if (issue.status === "done" || issue.status === "in_review") return "review";
|
||||
if (run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") return "implementation";
|
||||
if (issue.status === "backlog" || issue.status === "todo") return "plan";
|
||||
return "implementation";
|
||||
}
|
||||
|
||||
function inferNextAction(issue: IssueSummaryInput, run: RunSummaryInput, previousNextAction: string | null) {
|
||||
if (issue.status === "done") return "Review the completed issue output and close any remaining follow-up comments.";
|
||||
if (issue.status === "in_review") return "Wait for reviewer feedback or approval before continuing executor work.";
|
||||
if (run.status === "failed" || run.status === "timed_out") {
|
||||
return "Inspect the failed run, fix the cause, and resume from the most recent concrete action above.";
|
||||
}
|
||||
if (run.status === "cancelled") return "Confirm the cancellation reason before starting another run.";
|
||||
return previousNextAction ?? "Resume implementation from the acceptance criteria, latest comments, and this summary.";
|
||||
}
|
||||
|
||||
function bulletList(items: string[], empty: string) {
|
||||
if (items.length === 0) return `- ${empty}`;
|
||||
return items.map((item) => `- ${item}`).join("\n");
|
||||
}
|
||||
|
||||
function extractPreviousNextAction(previousBody: string | null | undefined) {
|
||||
const section = extractMarkdownSection(previousBody, "Next Action");
|
||||
if (!section) return null;
|
||||
return section
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/^[-*]\s+/, "").trim())
|
||||
.find(Boolean) ?? null;
|
||||
}
|
||||
|
||||
export function buildContinuationSummaryMarkdown(input: {
|
||||
issue: IssueSummaryInput;
|
||||
run: RunSummaryInput;
|
||||
agent: AgentSummaryInput;
|
||||
previousSummaryBody?: string | null;
|
||||
}) {
|
||||
const { issue, run, agent } = input;
|
||||
const resultSummary = readResultSummary(run.resultJson);
|
||||
const recentActions = [
|
||||
`Run \`${run.id}\` finished with status \`${run.status}\`${run.finishedAt ? ` at ${run.finishedAt.toISOString()}` : ""}.`,
|
||||
resultSummary ? truncateText(resultSummary, SUMMARY_SECTION_MAX_CHARS) : "No adapter-provided result summary was captured for this run.",
|
||||
];
|
||||
if (run.error) {
|
||||
recentActions.push(`Latest run error${run.errorCode ? ` (${run.errorCode})` : ""}: ${truncateText(run.error, 500)}`);
|
||||
}
|
||||
|
||||
const paths = extractPathCandidates(resultSummary, run.stdoutExcerpt, run.stderrExcerpt, input.previousSummaryBody);
|
||||
const objective = extractMarkdownSection(issue.description, "Objective") ?? issue.description?.trim() ?? "No objective captured.";
|
||||
const acceptanceCriteria = extractMarkdownSection(issue.description, "Acceptance Criteria") ?? "No explicit acceptance criteria captured.";
|
||||
const mode = inferMode(issue, run);
|
||||
const nextAction = inferNextAction(issue, run, extractPreviousNextAction(input.previousSummaryBody));
|
||||
|
||||
const body = [
|
||||
"# Continuation Summary",
|
||||
"",
|
||||
`- Issue: ${issue.identifier ?? issue.id} — ${issue.title}`,
|
||||
`- Status: ${issue.status}`,
|
||||
`- Priority: ${issue.priority}`,
|
||||
`- Current mode: ${mode}`,
|
||||
`- Last updated by run: ${run.id}`,
|
||||
`- Agent: ${agent.name} (${agent.adapterType ?? "unknown"})`,
|
||||
"",
|
||||
"## Objective",
|
||||
"",
|
||||
truncateText(objective, SUMMARY_SECTION_MAX_CHARS),
|
||||
"",
|
||||
"## Acceptance Criteria",
|
||||
"",
|
||||
acceptanceCriteria,
|
||||
"",
|
||||
"## Recent Concrete Actions",
|
||||
"",
|
||||
bulletList(recentActions, "No recent actions captured."),
|
||||
"",
|
||||
"## Files / Routes Touched",
|
||||
"",
|
||||
bulletList(paths.map((path) => `\`${path}\``), "No file or route paths were detected in the captured run summary."),
|
||||
"",
|
||||
"## Commands Run",
|
||||
"",
|
||||
bulletList(
|
||||
[
|
||||
`Heartbeat run \`${run.id}\` invoked adapter \`${agent.adapterType ?? "unknown"}\`.`,
|
||||
"Detailed shell/tool commands remain in the run log and transcript.",
|
||||
],
|
||||
"No command metadata captured.",
|
||||
),
|
||||
"",
|
||||
"## Blockers / Decisions",
|
||||
"",
|
||||
bulletList(
|
||||
run.error
|
||||
? [`Latest run ended with \`${run.status}\`; inspect the error before continuing.`]
|
||||
: ["No new blocker was recorded by the latest run."],
|
||||
"No blockers or decisions captured.",
|
||||
),
|
||||
"",
|
||||
"## Next Action",
|
||||
"",
|
||||
`- ${nextAction}`,
|
||||
].join("\n");
|
||||
|
||||
return truncateText(body, ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS);
|
||||
}
|
||||
|
||||
export async function getIssueContinuationSummaryDocument(
|
||||
db: Db,
|
||||
issueId: string,
|
||||
): Promise<IssueContinuationSummaryDocument | null> {
|
||||
const row = await db
|
||||
.select({
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
body: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
latestRevisionId: row.latestRevisionId,
|
||||
latestRevisionNumber: row.latestRevisionNumber,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshIssueContinuationSummary(input: {
|
||||
db: Db;
|
||||
issueId: string;
|
||||
run: RunSummaryInput;
|
||||
agent: AgentSummaryInput;
|
||||
}) {
|
||||
const { db, issueId, run, agent } = input;
|
||||
const [issue, existing] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
description: issues.description,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
getIssueContinuationSummaryDocument(db, issueId),
|
||||
]);
|
||||
|
||||
if (!issue) return null;
|
||||
const body = buildContinuationSummaryMarkdown({
|
||||
issue,
|
||||
run,
|
||||
agent,
|
||||
previousSummaryBody: existing?.body ?? null,
|
||||
});
|
||||
const result = await documentService(db).upsertIssueDocument({
|
||||
issueId,
|
||||
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
title: ISSUE_CONTINUATION_SUMMARY_TITLE,
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: existing?.latestRevisionId ?? null,
|
||||
changeSummary: `Refresh continuation summary after run ${run.id}`,
|
||||
createdByAgentId: agent.id,
|
||||
createdByRunId: run.id,
|
||||
});
|
||||
return result.document;
|
||||
}
|
||||
|
|
@ -38,6 +38,9 @@ import { getDefaultCompanyGoal } from "./goals.js";
|
|||
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
|
||||
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
|
||||
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
|
||||
const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500;
|
||||
|
||||
function assertTransition(from: string, to: string) {
|
||||
if (from === to) return;
|
||||
|
|
@ -121,10 +124,27 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
|
|||
blockedByIssueIds?: string[];
|
||||
inheritExecutionWorkspaceFromIssueId?: string | null;
|
||||
};
|
||||
type IssueChildCreateInput = IssueCreateInput & {
|
||||
acceptanceCriteria?: string[];
|
||||
blockParentUntilDone?: boolean;
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
};
|
||||
type IssueRelationSummaryMap = {
|
||||
blockedBy: IssueRelationIssueSummary[];
|
||||
blocks: IssueRelationIssueSummary[];
|
||||
};
|
||||
export type ChildIssueCompletionSummary = {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
updatedAt: Date;
|
||||
summary: string | null;
|
||||
};
|
||||
|
||||
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||
if (actorRunId) return checkoutRunId === actorRunId;
|
||||
|
|
@ -138,6 +158,20 @@ function escapeLikePattern(value: string): string {
|
|||
return value.replace(/[\\%_]/g, "\\$&");
|
||||
}
|
||||
|
||||
function truncateInlineSummary(value: string | null | undefined, maxChars = CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) return null;
|
||||
return normalized.length > maxChars ? `${normalized.slice(0, Math.max(0, maxChars - 15)).trimEnd()} [truncated]` : normalized;
|
||||
}
|
||||
|
||||
function appendAcceptanceCriteriaToDescription(description: string | null | undefined, acceptanceCriteria: string[] | undefined) {
|
||||
const criteria = (acceptanceCriteria ?? []).map((item) => item.trim()).filter(Boolean);
|
||||
if (criteria.length === 0) return description ?? null;
|
||||
const base = description?.trim() ?? "";
|
||||
const criteriaMarkdown = ["## Acceptance Criteria", "", ...criteria.map((item) => `- ${item}`)].join("\n");
|
||||
return base ? `${base}\n\n${criteriaMarkdown}` : criteriaMarkdown;
|
||||
}
|
||||
|
||||
async function getProjectDefaultGoalId(
|
||||
db: ProjectGoalReader,
|
||||
companyId: string,
|
||||
|
|
@ -1406,18 +1440,110 @@ export function issueService(db: Db) {
|
|||
}
|
||||
|
||||
const children = await db
|
||||
.select({ id: issues.id, status: issues.status })
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parentIssueId)));
|
||||
.where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parentIssueId)))
|
||||
.orderBy(asc(issues.issueNumber), asc(issues.createdAt));
|
||||
if (children.length === 0) return null;
|
||||
if (!children.every((child) => child.status === "done" || child.status === "cancelled")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childIdsForSummaries = children.slice(0, MAX_CHILD_COMPLETION_SUMMARIES).map((child) => child.id);
|
||||
const commentRows = childIdsForSummaries.length > 0
|
||||
? await db
|
||||
.select({
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(and(eq(issueComments.companyId, parent.companyId), inArray(issueComments.issueId, childIdsForSummaries)))
|
||||
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
|
||||
: [];
|
||||
const latestCommentByIssueId = new Map<string, string>();
|
||||
for (const comment of commentRows) {
|
||||
if (!latestCommentByIssueId.has(comment.issueId)) {
|
||||
latestCommentByIssueId.set(comment.issueId, comment.body);
|
||||
}
|
||||
}
|
||||
const childIssueSummaries: ChildIssueCompletionSummary[] = children
|
||||
.slice(0, MAX_CHILD_COMPLETION_SUMMARIES)
|
||||
.map((child) => ({
|
||||
...child,
|
||||
summary: truncateInlineSummary(latestCommentByIssueId.get(child.id)),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: parent.id,
|
||||
assigneeAgentId: parent.assigneeAgentId,
|
||||
childIssueIds: children.map((child) => child.id),
|
||||
childIssueSummaries,
|
||||
childIssueSummaryTruncated: children.length > childIssueSummaries.length,
|
||||
};
|
||||
},
|
||||
|
||||
createChild: async (
|
||||
parentIssueId: string,
|
||||
data: IssueChildCreateInput,
|
||||
) => {
|
||||
const parent = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, parentIssueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!parent) throw notFound("Parent issue not found");
|
||||
|
||||
const [{ childCount }] = await db
|
||||
.select({ childCount: sql<number>`count(*)::int` })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, parent.companyId), eq(issues.parentId, parent.id)));
|
||||
if (childCount >= MAX_CHILD_ISSUES_CREATED_BY_HELPER) {
|
||||
throw unprocessable(`Parent issue already has the maximum ${MAX_CHILD_ISSUES_CREATED_BY_HELPER} child issues for this helper`);
|
||||
}
|
||||
|
||||
const {
|
||||
acceptanceCriteria,
|
||||
blockParentUntilDone,
|
||||
actorAgentId,
|
||||
actorUserId,
|
||||
...issueData
|
||||
} = data;
|
||||
const child = await issueService(db).create(parent.companyId, {
|
||||
...issueData,
|
||||
parentId: parent.id,
|
||||
projectId: issueData.projectId ?? parent.projectId,
|
||||
goalId: issueData.goalId ?? parent.goalId,
|
||||
requestDepth: Math.max(parent.requestDepth + 1, issueData.requestDepth ?? 0),
|
||||
description: appendAcceptanceCriteriaToDescription(issueData.description, acceptanceCriteria),
|
||||
inheritExecutionWorkspaceFromIssueId: parent.id,
|
||||
});
|
||||
|
||||
if (blockParentUntilDone) {
|
||||
const existingBlockers = await db
|
||||
.select({ blockerIssueId: issueRelations.issueId })
|
||||
.from(issueRelations)
|
||||
.where(and(eq(issueRelations.companyId, parent.companyId), eq(issueRelations.relatedIssueId, parent.id), eq(issueRelations.type, "blocks")));
|
||||
await syncBlockedByIssueIds(
|
||||
parent.id,
|
||||
parent.companyId,
|
||||
[...new Set([...existingBlockers.map((row) => row.blockerIssueId), child.id])],
|
||||
{ agentId: actorAgentId ?? null, userId: actorUserId ?? null },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
issue: child,
|
||||
parentBlockerAdded: Boolean(blockParentUntilDone),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
|||
188
server/src/services/run-continuations.ts
Normal file
188
server/src/services/run-continuations.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import type { RunLivenessState } from "@paperclipai/shared";
|
||||
|
||||
export const RUN_LIVENESS_CONTINUATION_REASON = "run_liveness_continuation";
|
||||
export const DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS = 2;
|
||||
|
||||
const ACTIONABLE_LIVENESS_STATES = new Set<RunLivenessState>(["plan_only", "empty_response"]);
|
||||
const CONTINUATION_ACTIVE_ISSUE_STATUSES = new Set(["todo", "in_progress"]);
|
||||
// A prior adapter error should not permanently suppress bounded liveness
|
||||
// continuations; the max-attempt/idempotency guards prevent unbounded retries.
|
||||
const CONTINUATION_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
const IDEMPOTENT_WAKE_STATUSES = ["queued", "deferred_issue_execution", "completed"];
|
||||
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
type IssueRow = Pick<
|
||||
typeof issues.$inferSelect,
|
||||
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "executionState" | "projectId"
|
||||
>;
|
||||
type AgentRow = Pick<typeof agents.$inferSelect, "id" | "companyId" | "status">;
|
||||
|
||||
export type RunContinuationDecision =
|
||||
| {
|
||||
kind: "enqueue";
|
||||
nextAttempt: number;
|
||||
idempotencyKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
kind: "exhausted";
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
comment: string;
|
||||
}
|
||||
| {
|
||||
kind: "skip";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function readContinuationAttempt(value: unknown): number {
|
||||
const numeric = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0;
|
||||
}
|
||||
|
||||
export function buildRunLivenessContinuationIdempotencyKey(input: {
|
||||
issueId: string;
|
||||
sourceRunId: string;
|
||||
livenessState: RunLivenessState;
|
||||
nextAttempt: number;
|
||||
}) {
|
||||
return [
|
||||
"run_liveness_continuation",
|
||||
input.issueId,
|
||||
input.sourceRunId,
|
||||
input.livenessState,
|
||||
String(input.nextAttempt),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export async function findExistingRunLivenessContinuationWake(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
idempotencyKey: string;
|
||||
},
|
||||
) {
|
||||
return db
|
||||
.select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId),
|
||||
eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey),
|
||||
inArray(agentWakeupRequests.status, IDEMPOTENT_WAKE_STATUSES),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
export function decideRunLivenessContinuation(input: {
|
||||
run: HeartbeatRunRow;
|
||||
issue: IssueRow | null;
|
||||
agent: AgentRow | null;
|
||||
livenessState: RunLivenessState | null;
|
||||
livenessReason: string | null;
|
||||
nextAction: string | null;
|
||||
budgetBlocked: boolean;
|
||||
idempotentWakeExists: boolean;
|
||||
maxAttempts?: number;
|
||||
}): RunContinuationDecision {
|
||||
const {
|
||||
run,
|
||||
issue,
|
||||
agent,
|
||||
livenessState,
|
||||
livenessReason,
|
||||
nextAction,
|
||||
budgetBlocked,
|
||||
idempotentWakeExists,
|
||||
} = input;
|
||||
const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS;
|
||||
|
||||
if (!livenessState || !ACTIONABLE_LIVENESS_STATES.has(livenessState)) {
|
||||
return { kind: "skip", reason: "liveness state is not actionable for continuation" };
|
||||
}
|
||||
if (!issue) return { kind: "skip", reason: "issue not found" };
|
||||
if (!agent) return { kind: "skip", reason: "agent not found" };
|
||||
if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) {
|
||||
return { kind: "skip", reason: "company scope mismatch" };
|
||||
}
|
||||
if (issue.assigneeAgentId !== run.agentId) {
|
||||
return { kind: "skip", reason: "issue is no longer assigned to the source run agent" };
|
||||
}
|
||||
if (!CONTINUATION_ACTIVE_ISSUE_STATUSES.has(issue.status)) {
|
||||
return { kind: "skip", reason: `issue status ${issue.status} is not continuable` };
|
||||
}
|
||||
if (issue.executionState) {
|
||||
return { kind: "skip", reason: "issue is blocked by execution policy state" };
|
||||
}
|
||||
if (!CONTINUATION_AGENT_STATUSES.has(agent.status)) {
|
||||
return { kind: "skip", reason: `agent status ${agent.status} is not invokable` };
|
||||
}
|
||||
if (budgetBlocked) {
|
||||
return { kind: "skip", reason: "budget hard stop blocks continuation" };
|
||||
}
|
||||
|
||||
const currentAttempt = readContinuationAttempt(run.continuationAttempt);
|
||||
if (currentAttempt >= maxAttempts) {
|
||||
return {
|
||||
kind: "exhausted",
|
||||
attempt: currentAttempt,
|
||||
maxAttempts,
|
||||
comment: [
|
||||
"Bounded liveness continuation exhausted",
|
||||
"",
|
||||
`- Last liveness state: \`${livenessState}\``,
|
||||
`- Attempts used: ${currentAttempt}/${maxAttempts}`,
|
||||
`- Reason: ${livenessReason ?? "Run ended without concrete progress"}`,
|
||||
"- Next action: a human or manager should inspect the run and either clarify the task, mark it blocked, or assign a concrete follow-up.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
const nextAttempt = currentAttempt + 1;
|
||||
const idempotencyKey = buildRunLivenessContinuationIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
livenessState,
|
||||
nextAttempt,
|
||||
});
|
||||
if (idempotentWakeExists) {
|
||||
return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
livenessState,
|
||||
livenessReason,
|
||||
continuationAttempt: nextAttempt,
|
||||
maxContinuationAttempts: maxAttempts,
|
||||
instruction:
|
||||
nextAction ??
|
||||
"The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.",
|
||||
};
|
||||
|
||||
return {
|
||||
kind: "enqueue",
|
||||
nextAttempt,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
contextSnapshot: {
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
taskKey: issue.id,
|
||||
wakeReason: RUN_LIVENESS_CONTINUATION_REASON,
|
||||
livenessContinuationAttempt: nextAttempt,
|
||||
livenessContinuationMaxAttempts: maxAttempts,
|
||||
livenessContinuationSourceRunId: run.id,
|
||||
livenessContinuationState: livenessState,
|
||||
livenessContinuationReason: livenessReason,
|
||||
livenessContinuationInstruction: payload.instruction,
|
||||
},
|
||||
};
|
||||
}
|
||||
227
server/src/services/run-liveness.ts
Normal file
227
server/src/services/run-liveness.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import type { HeartbeatRunStatus, IssueStatus, RunLivenessState } from "@paperclipai/shared";
|
||||
|
||||
export interface RunLivenessIssueInput {
|
||||
status: IssueStatus | string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface RunLivenessEvidenceInput {
|
||||
issueCommentsCreated: number;
|
||||
documentRevisionsCreated: number;
|
||||
planDocumentRevisionsCreated: number;
|
||||
workProductsCreated: number;
|
||||
workspaceOperationsCreated: number;
|
||||
activityEventsCreated: number;
|
||||
toolOrActionEventsCreated: number;
|
||||
latestEvidenceAt: Date | null;
|
||||
}
|
||||
|
||||
export interface RunLivenessClassificationInput {
|
||||
runStatus: HeartbeatRunStatus | string;
|
||||
issue: RunLivenessIssueInput | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
stdoutExcerpt?: string | null;
|
||||
stderrExcerpt?: string | null;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
continuationAttempt?: number | null;
|
||||
evidence?: Partial<RunLivenessEvidenceInput> | null;
|
||||
}
|
||||
|
||||
export interface RunLivenessClassification {
|
||||
livenessState: RunLivenessState;
|
||||
livenessReason: string;
|
||||
continuationAttempt: number;
|
||||
lastUsefulActionAt: Date | null;
|
||||
nextAction: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_EVIDENCE: RunLivenessEvidenceInput = {
|
||||
issueCommentsCreated: 0,
|
||||
documentRevisionsCreated: 0,
|
||||
planDocumentRevisionsCreated: 0,
|
||||
workProductsCreated: 0,
|
||||
workspaceOperationsCreated: 0,
|
||||
activityEventsCreated: 0,
|
||||
toolOrActionEventsCreated: 0,
|
||||
latestEvidenceAt: null,
|
||||
};
|
||||
|
||||
const PLANNING_ONLY_RE =
|
||||
/\b(?:i(?:'ll| will| am going to|'m going to)|let me|i need to|next(?:,| i will| i'll)?|my next step is|the next step is)\s+(?:first\s+)?(?:inspect|check|review|look|investigate|analy[sz]e|open|read|start|begin|work on|implement|fix|test|update|create|add)\b/i;
|
||||
const NEXT_STEPS_RE = /^\s*(?:next steps?|plan)\s*:/im;
|
||||
const BLOCKER_RE =
|
||||
/\b(?:blocked|can't proceed|cannot proceed|unable to proceed|waiting on|need(?:s|ed)? .{0,80}\b(?:approval|access|credential|credentials|secret|api key|token|input|clarification)|requires? .{0,80}\b(?:approval|access|credential|credentials|secret|api key|token|input|clarification))\b/i;
|
||||
const NEGATED_BLOCKER_RE = /\b(?:not blocked|no blocker|no blockers|unblocked)\b/i;
|
||||
const PLAN_TASK_TITLE_RE = /\b(?:plan|planning|analysis|investigation|research|report|proposal|design doc|write-?up)\b/i;
|
||||
const PLAN_TASK_DESCRIPTION_RE =
|
||||
/\b(?:create|write|produce|draft|update|revise|prepare)\s+(?:a\s+|the\s+)?(?:plan|analysis|investigation|research report|report|proposal|design doc|write-?up)\b/i;
|
||||
|
||||
function compactReason(reason: string) {
|
||||
return reason.length <= 500 ? reason : `${reason.slice(0, 497)}...`;
|
||||
}
|
||||
|
||||
function normalizeCount(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||||
}
|
||||
|
||||
function normalizeContinuationAttempt(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||||
}
|
||||
|
||||
function readText(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function resultText(resultJson: Record<string, unknown> | null | undefined) {
|
||||
if (!resultJson) return "";
|
||||
return [
|
||||
readText(resultJson.summary),
|
||||
readText(resultJson.result),
|
||||
readText(resultJson.message),
|
||||
readText(resultJson.stdout),
|
||||
readText(resultJson.stderr),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function combinedOutput(input: RunLivenessClassificationInput) {
|
||||
return [
|
||||
resultText(input.resultJson),
|
||||
readText(input.stdoutExcerpt),
|
||||
readText(input.stderrExcerpt),
|
||||
readText(input.error),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function hasUsefulOutput(input: RunLivenessClassificationInput) {
|
||||
return combinedOutput(input).length > 0;
|
||||
}
|
||||
|
||||
export function declaredBlocker(input: RunLivenessClassificationInput) {
|
||||
if (input.issue?.status === "blocked") return true;
|
||||
const text = combinedOutput(input);
|
||||
if (!text || NEGATED_BLOCKER_RE.test(text)) return false;
|
||||
return BLOCKER_RE.test(text);
|
||||
}
|
||||
|
||||
export function looksLikePlanningOnly(input: RunLivenessClassificationInput) {
|
||||
const text = combinedOutput(input);
|
||||
if (!text) return false;
|
||||
return PLANNING_ONLY_RE.test(text) || NEXT_STEPS_RE.test(text);
|
||||
}
|
||||
|
||||
export function isPlanningOrDocumentTask(issue: RunLivenessIssueInput | null | undefined) {
|
||||
if (!issue) return false;
|
||||
if (PLAN_TASK_TITLE_RE.test(issue.title)) return true;
|
||||
return PLAN_TASK_DESCRIPTION_RE.test(issue.description ?? "");
|
||||
}
|
||||
|
||||
function normalizeEvidence(evidence: Partial<RunLivenessEvidenceInput> | null | undefined): RunLivenessEvidenceInput {
|
||||
return {
|
||||
issueCommentsCreated: normalizeCount(evidence?.issueCommentsCreated),
|
||||
documentRevisionsCreated: normalizeCount(evidence?.documentRevisionsCreated),
|
||||
planDocumentRevisionsCreated: normalizeCount(evidence?.planDocumentRevisionsCreated),
|
||||
workProductsCreated: normalizeCount(evidence?.workProductsCreated),
|
||||
workspaceOperationsCreated: normalizeCount(evidence?.workspaceOperationsCreated),
|
||||
activityEventsCreated: normalizeCount(evidence?.activityEventsCreated),
|
||||
toolOrActionEventsCreated: normalizeCount(evidence?.toolOrActionEventsCreated),
|
||||
latestEvidenceAt: evidence?.latestEvidenceAt instanceof Date ? evidence.latestEvidenceAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasConcreteActionEvidence(evidence: Partial<RunLivenessEvidenceInput> | null | undefined) {
|
||||
const normalized = normalizeEvidence(evidence);
|
||||
// Workspace creation is setup evidence, not task progress by itself. It can
|
||||
// appear in reasons alongside durable activity, but it must not prevent a
|
||||
// planning-only or empty run from receiving a bounded continuation.
|
||||
return (
|
||||
normalized.issueCommentsCreated +
|
||||
normalized.documentRevisionsCreated +
|
||||
normalized.workProductsCreated +
|
||||
normalized.activityEventsCreated +
|
||||
normalized.toolOrActionEventsCreated >
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function evidenceReason(evidence: RunLivenessEvidenceInput) {
|
||||
const parts: string[] = [];
|
||||
if (evidence.issueCommentsCreated > 0) parts.push(`${evidence.issueCommentsCreated} issue comment(s)`);
|
||||
if (evidence.documentRevisionsCreated > 0) parts.push(`${evidence.documentRevisionsCreated} document revision(s)`);
|
||||
if (evidence.workProductsCreated > 0) parts.push(`${evidence.workProductsCreated} work product(s)`);
|
||||
if (evidence.workspaceOperationsCreated > 0) parts.push(`${evidence.workspaceOperationsCreated} workspace operation(s)`);
|
||||
if (evidence.activityEventsCreated > 0) parts.push(`${evidence.activityEventsCreated} activity event(s)`);
|
||||
if (evidence.toolOrActionEventsCreated > 0) parts.push(`${evidence.toolOrActionEventsCreated} tool/action event(s)`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function extractNextAction(input: RunLivenessClassificationInput) {
|
||||
const text = combinedOutput(input);
|
||||
if (!text) return null;
|
||||
const line = text
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => PLANNING_ONLY_RE.test(entry) || /^next(?: steps?| action)?\s*:/i.test(entry));
|
||||
if (!line) return null;
|
||||
return line.length <= 500 ? line : `${line.slice(0, 497)}...`;
|
||||
}
|
||||
|
||||
export function classifyRunLiveness(input: RunLivenessClassificationInput): RunLivenessClassification {
|
||||
const evidence = normalizeEvidence(input.evidence);
|
||||
const continuationAttempt = normalizeContinuationAttempt(input.continuationAttempt);
|
||||
const issueStatus = input.issue?.status ?? null;
|
||||
const usefulOutput = hasUsefulOutput(input);
|
||||
const concreteEvidence = hasConcreteActionEvidence(evidence);
|
||||
const planExempt = isPlanningOrDocumentTask(input.issue) || evidence.planDocumentRevisionsCreated > 0;
|
||||
const lastUsefulActionAt = concreteEvidence ? evidence.latestEvidenceAt : null;
|
||||
|
||||
const output = (state: RunLivenessState, reason: string, nextAction: string | null = null): RunLivenessClassification => ({
|
||||
livenessState: state,
|
||||
livenessReason: compactReason(reason),
|
||||
continuationAttempt,
|
||||
lastUsefulActionAt: state === "advanced" || state === "completed" || state === "blocked" ? lastUsefulActionAt : null,
|
||||
nextAction,
|
||||
});
|
||||
|
||||
if (input.runStatus !== "succeeded") {
|
||||
return output("failed", input.errorCode ? `Run ended with ${input.runStatus} (${input.errorCode})` : `Run ended with ${input.runStatus}`);
|
||||
}
|
||||
|
||||
if (issueStatus === "done" || issueStatus === "cancelled") {
|
||||
return output("completed", `Issue is ${issueStatus}`);
|
||||
}
|
||||
|
||||
if (declaredBlocker(input)) {
|
||||
return output("blocked", issueStatus === "blocked" ? "Issue status is blocked" : "Run output declared a concrete blocker", extractNextAction(input));
|
||||
}
|
||||
|
||||
if (!usefulOutput && !concreteEvidence) {
|
||||
return output("empty_response", "Run succeeded without useful output or concrete action evidence");
|
||||
}
|
||||
|
||||
if (concreteEvidence) {
|
||||
return output("advanced", `Run produced concrete action evidence: ${evidenceReason(evidence)}`);
|
||||
}
|
||||
|
||||
if (planExempt && usefulOutput) {
|
||||
return output("advanced", "Planning/document task produced useful output and is exempt from plan-only classification");
|
||||
}
|
||||
|
||||
if (looksLikePlanningOnly(input)) {
|
||||
return output("plan_only", "Run described future work without concrete action evidence", extractNextAction(input));
|
||||
}
|
||||
|
||||
if (usefulOutput) {
|
||||
return output("needs_followup", "Run produced useful output but no concrete action evidence", extractNextAction(input));
|
||||
}
|
||||
|
||||
return output("empty_response", "Run succeeded without useful output");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue