mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02: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:
@@ -154,6 +154,14 @@ Each AGENTS.md body should include not just what the agent does, but how they fi
|
||||
|
||||
This turns a collection of agents into an organization that actually works together. Without workflow context, agents operate in isolation — they do their job but don't know what happens before or after them.
|
||||
|
||||
Add a concise execution contract to every generated working agent:
|
||||
|
||||
- Start actionable work in the same heartbeat and do not stop at a plan unless planning was requested.
|
||||
- Leave durable progress in comments, documents, or work products with the next action.
|
||||
- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.
|
||||
- Mark blocked work with the unblock owner and action.
|
||||
- Respect budget, pause/cancel, approval gates, and company boundaries.
|
||||
|
||||
### Step 5: Confirm Output Location
|
||||
|
||||
Ask the user where to write the package. Common options:
|
||||
|
||||
@@ -105,6 +105,13 @@ Your responsibilities:
|
||||
- Implement features and fix bugs
|
||||
- Write tests and documentation
|
||||
- Participate in code reviews
|
||||
|
||||
Execution contract:
|
||||
|
||||
- Start actionable implementation work in the same heartbeat; do not stop at a plan unless planning was requested.
|
||||
- Leave durable progress with a clear next action.
|
||||
- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.
|
||||
- Mark blocked work with the unblock owner and action.
|
||||
```
|
||||
|
||||
## teams/engineering/TEAM.md
|
||||
|
||||
@@ -548,7 +548,7 @@ Import from `@paperclipai/adapter-utils/server-utils`:
|
||||
### Prompt Templates
|
||||
- Support `promptTemplate` for every run
|
||||
- Use `renderTemplate()` with the standard variable set
|
||||
- Default prompt: `"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work."`
|
||||
- Default prompt should use `DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE` from `@paperclipai/adapter-utils/server-utils` so local adapters share Paperclip's execution contract: act in the same heartbeat, avoid planning-only exits unless requested, leave durable progress and a next action, use child issues instead of polling, mark blockers with owner/action, and respect governance boundaries.
|
||||
|
||||
### Error Handling
|
||||
- Differentiate timeout vs process error vs parse failure
|
||||
|
||||
@@ -114,14 +114,14 @@ If the connection drops, the UI reconnects automatically.
|
||||
|
||||
1. Enable timer wakeups (for example every 300s)
|
||||
2. Keep assignment wakeups on
|
||||
3. Use a focused prompt template
|
||||
3. Use a focused prompt template that tells agents to act in the same heartbeat, leave durable progress, and mark blocked work with an owner/action
|
||||
4. Watch run logs and adjust prompt/config over time
|
||||
|
||||
## 7.2 Event-driven loop (less constant polling)
|
||||
|
||||
1. Disable timer or set a long interval
|
||||
2. Keep wake-on-assignment enabled
|
||||
3. Use on-demand wakeups for manual nudges
|
||||
3. Use child issues, comments, and on-demand wakeups for handoffs instead of loops that poll agents, sessions, or processes
|
||||
|
||||
## 7.3 Safety-first loop
|
||||
|
||||
|
||||
@@ -124,14 +124,14 @@ If the connection drops, the UI reconnects automatically.
|
||||
|
||||
1. Enable timer wakeups (for example every 300s)
|
||||
2. Keep assignment wakeups on
|
||||
3. Use a focused prompt template
|
||||
3. Use a focused prompt template that tells agents to act in the same heartbeat, leave durable progress, and mark blocked work with an owner/action
|
||||
4. Watch run logs and adjust prompt/config over time
|
||||
|
||||
## 7.2 Event-driven loop (less constant polling)
|
||||
|
||||
1. Disable timer or set a long interval
|
||||
2. Keep wake-on-assignment enabled
|
||||
3. Use on-demand wakeups for manual nudges
|
||||
3. Use child issues, comments, and on-demand wakeups for handoffs instead of loops that poll agents, sessions, or processes
|
||||
|
||||
## 7.3 Safety-first loop
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ Read ancestors to understand why this task exists. If woken by a specific commen
|
||||
|
||||
### Step 7: Do the Work
|
||||
|
||||
Use your tools and capabilities to complete the task.
|
||||
Use your tools and capabilities to complete the task. If the issue is actionable, take a concrete action in the same heartbeat. Do not stop at a plan unless the issue asked for planning.
|
||||
|
||||
Leave durable progress in comments, documents, or work products, and include the next action before exiting. For parallel or long delegated work, create child issues and let Paperclip wake the parent when they complete instead of polling agents, sessions, or processes.
|
||||
|
||||
### Step 8: Update Status
|
||||
|
||||
@@ -102,6 +104,22 @@ Always set `parentId` and `goalId` on subtasks.
|
||||
- **Always checkout** before working — never PATCH to `in_progress` manually
|
||||
- **Never retry a 409** — the task belongs to someone else
|
||||
- **Always comment** on in-progress work before exiting a heartbeat
|
||||
- **Start actionable work** in the same heartbeat; planning-only exits are for planning tasks
|
||||
- **Leave a clear next action** in durable issue context
|
||||
- **Use child issues instead of polling** for long or parallel delegated work
|
||||
- **Always set parentId** on subtasks
|
||||
- **Never cancel cross-team tasks** — reassign to your manager
|
||||
- **Escalate when stuck** — use your chain of command
|
||||
|
||||
## Run Liveness
|
||||
|
||||
Paperclip records run liveness as metadata on heartbeat runs. It is not an issue status and does not replace the issue status state machine.
|
||||
|
||||
- Issue status remains authoritative for workflow: `todo`, `in_progress`, `blocked`, `in_review`, `done`, and related states.
|
||||
- Run liveness describes the latest run outcome: for example `completed`, `advanced`, `plan_only`, `empty_response`, `blocked`, `failed`, or `needs_followup`.
|
||||
- Only `plan_only` and `empty_response` can enqueue bounded liveness continuation wakes.
|
||||
- Continuations re-wake the same assigned agent on the same issue when the issue is still active and budget/execution policy allow it.
|
||||
- `continuationAttempt` counts semantic liveness continuations for a source run chain. It is separate from process recovery, queued wake delivery, adapter session resume, and other operational retries.
|
||||
- Liveness continuation wake prompts include the attempt, source run, liveness state, liveness reason, and the instruction for the next heartbeat.
|
||||
- Continuations do not mark the issue `blocked` or `done`. If automatic continuations are exhausted, Paperclip leaves an audit comment so a human or manager can clarify, block, or assign follow-up work.
|
||||
- Workspace provisioning alone is not treated as concrete task progress. Durable progress should appear as tool/action events, issue comments, document or work-product revisions, activity log entries, commits, or tests.
|
||||
|
||||
@@ -20,6 +20,13 @@ The Heartbeat Procedure:
|
||||
8. Update status: PATCH /api/issues/{issueId} with status and comment
|
||||
9. Delegate if needed: POST /api/companies/{companyId}/issues
|
||||
|
||||
Execution Contract:
|
||||
- If the issue is actionable, start concrete work in this heartbeat. Do not stop at a plan unless the issue asks for planning.
|
||||
- Leave durable progress in comments, documents, or work products, with a clear next action.
|
||||
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
|
||||
- If blocked, PATCH the issue to blocked and name the unblock owner and action.
|
||||
- Respect budget, pause/cancel, approval gates, and company boundaries.
|
||||
|
||||
Critical Rules:
|
||||
- Always checkout before working. Never PATCH to in_progress manually.
|
||||
- Never retry a 409. The task belongs to someone else.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runChildProcess } from "./server-utils.js";
|
||||
import {
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
renderPaperclipWakePrompt,
|
||||
runChildProcess,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "./server-utils.js";
|
||||
|
||||
function isPidAlive(pid: number) {
|
||||
try {
|
||||
@@ -21,6 +26,25 @@ async function waitForPidExit(pid: number, timeoutMs = 2_000) {
|
||||
}
|
||||
|
||||
describe("runChildProcess", () => {
|
||||
it("does not arm a timeout when timeoutSec is 0", async () => {
|
||||
const result = await runChildProcess(
|
||||
randomUUID(),
|
||||
process.execPath,
|
||||
["-e", "setTimeout(() => process.stdout.write('done'), 150);"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
timeoutSec: 0,
|
||||
graceSec: 1,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.stdout).toBe("done");
|
||||
});
|
||||
|
||||
it("waits for onSpawn before sending stdin to the child", async () => {
|
||||
const spawnDelayMs = 150;
|
||||
const startedAt = Date.now();
|
||||
@@ -86,3 +110,108 @@ describe("runChildProcess", () => {
|
||||
expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPaperclipWakePrompt", () => {
|
||||
it("keeps the default local-agent prompt action-oriented", () => {
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
|
||||
"Respect budget, pause/cancel, approval gates, and company boundaries",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the execution contract to scoped wake prompts", () => {
|
||||
const prompt = renderPaperclipWakePrompt({
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1580",
|
||||
title: "Update prompts",
|
||||
status: "in_progress",
|
||||
},
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
comments: [],
|
||||
fallbackFetchNeeded: false,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(prompt).toContain("Execution contract: take concrete action in this heartbeat");
|
||||
expect(prompt).toContain("use child issues instead of polling");
|
||||
expect(prompt).toContain("mark blocked work with the unblock owner/action");
|
||||
});
|
||||
|
||||
it("includes continuation and child issue summaries in structured wake context", () => {
|
||||
const payload = {
|
||||
reason: "issue_children_completed",
|
||||
issue: {
|
||||
id: "parent-1",
|
||||
identifier: "PAP-100",
|
||||
title: "Integrate child work",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
continuationSummary: {
|
||||
key: "continuation-summary",
|
||||
title: "Continuation Summary",
|
||||
body: "# Continuation Summary\n\n## Next Action\n\n- Integrate child outputs.",
|
||||
updatedAt: "2026-04-18T12:00:00.000Z",
|
||||
},
|
||||
livenessContinuation: {
|
||||
attempt: 2,
|
||||
maxAttempts: 2,
|
||||
sourceRunId: "run-1",
|
||||
state: "plan_only",
|
||||
reason: "Run described future work without concrete action evidence",
|
||||
instruction: "Take the first concrete action now.",
|
||||
},
|
||||
childIssueSummaries: [
|
||||
{
|
||||
id: "child-1",
|
||||
identifier: "PAP-101",
|
||||
title: "Implement helper",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
summary: "Added the helper route and tests.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({
|
||||
continuationSummary: {
|
||||
body: expect.stringContaining("Continuation Summary"),
|
||||
},
|
||||
livenessContinuation: {
|
||||
attempt: 2,
|
||||
maxAttempts: 2,
|
||||
sourceRunId: "run-1",
|
||||
state: "plan_only",
|
||||
instruction: "Take the first concrete action now.",
|
||||
},
|
||||
childIssueSummaries: [
|
||||
{
|
||||
identifier: "PAP-101",
|
||||
summary: "Added the helper route and tests.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const prompt = renderPaperclipWakePrompt(payload);
|
||||
expect(prompt).toContain("Issue continuation summary:");
|
||||
expect(prompt).toContain("Integrate child outputs.");
|
||||
expect(prompt).toContain("Run liveness continuation:");
|
||||
expect(prompt).toContain("- attempt: 2/2");
|
||||
expect(prompt).toContain("- source run: run-1");
|
||||
expect(prompt).toContain("- liveness state: plan_only");
|
||||
expect(prompt).toContain("- reason: Run described future work without concrete action evidence");
|
||||
expect(prompt).toContain("- instruction: Take the first concrete action now.");
|
||||
expect(prompt).toContain("Direct child issue summaries:");
|
||||
expect(prompt).toContain("PAP-101 Implement helper (done)");
|
||||
expect(prompt).toContain("Added the helper route and tests.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,17 @@ const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
|
||||
"../../../../../skills",
|
||||
];
|
||||
|
||||
export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
"",
|
||||
"Execution contract:",
|
||||
"- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.",
|
||||
"- Leave durable progress in comments, documents, or work products with a clear next action.",
|
||||
"- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.",
|
||||
"- If blocked, mark the issue blocked and name the unblock owner and action.",
|
||||
"- Respect budget, pause/cancel, approval gates, and company boundaries.",
|
||||
].join("\n");
|
||||
|
||||
export interface PaperclipSkillEntry {
|
||||
key: string;
|
||||
runtimeName: string;
|
||||
@@ -250,11 +261,41 @@ type PaperclipWakeComment = {
|
||||
authorId: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakeContinuationSummary = {
|
||||
key: string | null;
|
||||
title: string | null;
|
||||
body: string;
|
||||
bodyTruncated: boolean;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakeLivenessContinuation = {
|
||||
attempt: number | null;
|
||||
maxAttempts: number | null;
|
||||
sourceRunId: string | null;
|
||||
state: string | null;
|
||||
reason: string | null;
|
||||
instruction: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakeChildIssueSummary = {
|
||||
id: string | null;
|
||||
identifier: string | null;
|
||||
title: string | null;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
summary: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakePayload = {
|
||||
reason: string | null;
|
||||
issue: PaperclipWakeIssue | null;
|
||||
checkedOutByHarness: boolean;
|
||||
executionStage: PaperclipWakeExecutionStage | null;
|
||||
continuationSummary: PaperclipWakeContinuationSummary | null;
|
||||
livenessContinuation: PaperclipWakeLivenessContinuation | null;
|
||||
childIssueSummaries: PaperclipWakeChildIssueSummary[];
|
||||
childIssueSummaryTruncated: boolean;
|
||||
commentIds: string[];
|
||||
latestCommentId: string | null;
|
||||
comments: PaperclipWakeComment[];
|
||||
@@ -298,6 +339,50 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeContinuationSummary(value: unknown): PaperclipWakeContinuationSummary | null {
|
||||
const summary = parseObject(value);
|
||||
const body = asString(summary.body, "").trim();
|
||||
if (!body) return null;
|
||||
return {
|
||||
key: asString(summary.key, "").trim() || null,
|
||||
title: asString(summary.title, "").trim() || null,
|
||||
body,
|
||||
bodyTruncated: asBoolean(summary.bodyTruncated, false),
|
||||
updatedAt: asString(summary.updatedAt, "").trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeLivenessContinuation(value: unknown): PaperclipWakeLivenessContinuation | null {
|
||||
const continuation = parseObject(value);
|
||||
const attempt = asNumber(continuation.attempt, 0);
|
||||
const maxAttempts = asNumber(continuation.maxAttempts, 0);
|
||||
const sourceRunId = asString(continuation.sourceRunId, "").trim() || null;
|
||||
const state = asString(continuation.state, "").trim() || null;
|
||||
const reason = asString(continuation.reason, "").trim() || null;
|
||||
const instruction = asString(continuation.instruction, "").trim() || null;
|
||||
if (!attempt && !maxAttempts && !sourceRunId && !state && !reason && !instruction) return null;
|
||||
return {
|
||||
attempt: attempt > 0 ? attempt : null,
|
||||
maxAttempts: maxAttempts > 0 ? maxAttempts : null,
|
||||
sourceRunId,
|
||||
state,
|
||||
reason,
|
||||
instruction,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeChildIssueSummary(value: unknown): PaperclipWakeChildIssueSummary | null {
|
||||
const child = parseObject(value);
|
||||
const id = asString(child.id, "").trim() || null;
|
||||
const identifier = asString(child.identifier, "").trim() || null;
|
||||
const title = asString(child.title, "").trim() || null;
|
||||
const status = asString(child.status, "").trim() || null;
|
||||
const priority = asString(child.priority, "").trim() || null;
|
||||
const summary = asString(child.summary, "").trim() || null;
|
||||
if (!id && !identifier && !title && !status && !summary) return null;
|
||||
return { id, identifier, title, status, priority, summary };
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null {
|
||||
const principal = parseObject(value);
|
||||
const typeRaw = asString(principal.type, "").trim().toLowerCase();
|
||||
@@ -356,8 +441,15 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||
.map((entry) => entry.trim())
|
||||
: [];
|
||||
const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage);
|
||||
const continuationSummary = normalizePaperclipWakeContinuationSummary(payload.continuationSummary);
|
||||
const livenessContinuation = normalizePaperclipWakeLivenessContinuation(payload.livenessContinuation);
|
||||
const childIssueSummaries = Array.isArray(payload.childIssueSummaries)
|
||||
? payload.childIssueSummaries
|
||||
.map((entry) => normalizePaperclipWakeChildIssueSummary(entry))
|
||||
.filter((entry): entry is PaperclipWakeChildIssueSummary => Boolean(entry))
|
||||
: [];
|
||||
|
||||
if (comments.length === 0 && commentIds.length === 0 && !executionStage && !normalizePaperclipWakeIssue(payload.issue)) {
|
||||
if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -366,6 +458,10 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||
issue: normalizePaperclipWakeIssue(payload.issue),
|
||||
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
|
||||
executionStage,
|
||||
continuationSummary,
|
||||
livenessContinuation,
|
||||
childIssueSummaries,
|
||||
childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false),
|
||||
commentIds,
|
||||
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
||||
comments,
|
||||
@@ -406,6 +502,8 @@ export function renderPaperclipWakePrompt(
|
||||
"Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.",
|
||||
"Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
||||
"",
|
||||
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
|
||||
"",
|
||||
`- reason: ${normalized.reason ?? "unknown"}`,
|
||||
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
||||
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
|
||||
@@ -421,6 +519,8 @@ export function renderPaperclipWakePrompt(
|
||||
"Use this inline wake data first before refetching the issue thread.",
|
||||
"Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
||||
"",
|
||||
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
|
||||
"",
|
||||
`- reason: ${normalized.reason ?? "unknown"}`,
|
||||
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
||||
`- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`,
|
||||
@@ -470,6 +570,55 @@ export function renderPaperclipWakePrompt(
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.continuationSummary) {
|
||||
lines.push(
|
||||
"",
|
||||
"Issue continuation summary:",
|
||||
normalized.continuationSummary.body,
|
||||
);
|
||||
if (normalized.continuationSummary.bodyTruncated) {
|
||||
lines.push("[continuation summary truncated]");
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.livenessContinuation) {
|
||||
const continuation = normalized.livenessContinuation;
|
||||
lines.push("", "Run liveness continuation:");
|
||||
if (continuation.attempt) {
|
||||
lines.push(
|
||||
`- attempt: ${continuation.attempt}${continuation.maxAttempts ? `/${continuation.maxAttempts}` : ""}`,
|
||||
);
|
||||
}
|
||||
if (continuation.sourceRunId) {
|
||||
lines.push(`- source run: ${continuation.sourceRunId}`);
|
||||
}
|
||||
if (continuation.state) {
|
||||
lines.push(`- liveness state: ${continuation.state}`);
|
||||
}
|
||||
if (continuation.reason) {
|
||||
lines.push(`- reason: ${continuation.reason}`);
|
||||
}
|
||||
if (continuation.instruction) {
|
||||
lines.push(`- instruction: ${continuation.instruction}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.childIssueSummaries.length > 0) {
|
||||
lines.push("", "Direct child issue summaries:");
|
||||
for (const child of normalized.childIssueSummaries) {
|
||||
const label = child.identifier ?? child.id ?? "unknown";
|
||||
lines.push(
|
||||
`- ${label}${child.title ? ` ${child.title}` : ""}${child.status ? ` (${child.status})` : ""}`,
|
||||
);
|
||||
if (child.summary) {
|
||||
lines.push(` ${child.summary}`);
|
||||
}
|
||||
}
|
||||
if (normalized.childIssueSummaryTruncated) {
|
||||
lines.push("[child issue summaries truncated]");
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.checkedOutByHarness) {
|
||||
lines.push(
|
||||
"",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
@@ -300,7 +301,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const model = asString(config.model, "");
|
||||
const effort = asString(config.effort, "");
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -218,7 +219,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "codex");
|
||||
const model = asString(config.model, "");
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -164,7 +165,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "agent");
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
@@ -140,7 +141,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "gemini");
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
|
||||
@@ -420,7 +420,9 @@ function buildWakeText(
|
||||
" - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\",\"in_review\"]}",
|
||||
" - GET /api/issues/{issueId}",
|
||||
" - GET /api/issues/{issueId}/comments",
|
||||
" - Execute the issue instructions exactly.",
|
||||
" - Execute the issue instructions exactly. If the issue is actionable, take concrete action in this run; do not stop at a plan unless planning was requested.",
|
||||
" - Leave durable progress with a clear next action. Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes.",
|
||||
" - If blocked, PATCH /api/issues/{issueId} with {\"status\":\"blocked\",\"comment\":\"what is blocked, who owns the unblock, and the next action\"}.",
|
||||
" - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.",
|
||||
" - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.",
|
||||
"4) If issueId does not exist:",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
@@ -97,7 +98,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "opencode");
|
||||
const model = asString(config.model, "").trim();
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
|
||||
@@ -113,7 +114,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "pi");
|
||||
const model = asString(config.model, "").trim();
|
||||
@@ -276,7 +277,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
||||
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
|
||||
} catch (err) {
|
||||
instructionsReadFailed = true;
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
|
||||
6
packages/db/src/migrations/0058_wealthy_starbolt.sql
Normal file
6
packages/db/src/migrations/0058_wealthy_starbolt.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "liveness_state" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "liveness_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "continuation_attempt" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "last_useful_action_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "next_action" text;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "heartbeat_runs_company_liveness_idx" ON "heartbeat_runs" USING btree ("company_id","liveness_state","created_at");
|
||||
13490
packages/db/src/migrations/meta/0058_snapshot.json
Normal file
13490
packages/db/src/migrations/meta/0058_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -407,6 +407,13 @@
|
||||
"when": 1776309613598,
|
||||
"tag": "0057_tidy_join_requests",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 58,
|
||||
"version": "7",
|
||||
"when": 1776542245004,
|
||||
"tag": "0058_wealthy_starbolt",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ export const heartbeatRuns = pgTable(
|
||||
issueCommentStatus: text("issue_comment_status").notNull().default("not_applicable"),
|
||||
issueCommentSatisfiedByCommentId: uuid("issue_comment_satisfied_by_comment_id"),
|
||||
issueCommentRetryQueuedAt: timestamp("issue_comment_retry_queued_at", { withTimezone: true }),
|
||||
livenessState: text("liveness_state"),
|
||||
livenessReason: text("liveness_reason"),
|
||||
continuationAttempt: integer("continuation_attempt").notNull().default(0),
|
||||
lastUsefulActionAt: timestamp("last_useful_action_at", { withTimezone: true }),
|
||||
nextAction: text("next_action"),
|
||||
contextSnapshot: jsonb("context_snapshot").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -51,5 +56,10 @@ export const heartbeatRuns = pgTable(
|
||||
table.agentId,
|
||||
table.startedAt,
|
||||
),
|
||||
companyLivenessIdx: index("heartbeat_runs_company_liveness_idx").on(
|
||||
table.companyId,
|
||||
table.livenessState,
|
||||
table.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -141,6 +141,16 @@ export type IssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
||||
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
|
||||
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
||||
|
||||
export const ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY = "continuation-summary" as const;
|
||||
export const SYSTEM_ISSUE_DOCUMENT_KEYS = [ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY] as const;
|
||||
export type SystemIssueDocumentKey = (typeof SYSTEM_ISSUE_DOCUMENT_KEYS)[number];
|
||||
|
||||
const SYSTEM_ISSUE_DOCUMENT_KEY_SET = new Set<string>(SYSTEM_ISSUE_DOCUMENT_KEYS);
|
||||
|
||||
export function isSystemIssueDocumentKey(key: string): key is SystemIssueDocumentKey {
|
||||
return SYSTEM_ISSUE_DOCUMENT_KEY_SET.has(key);
|
||||
}
|
||||
|
||||
export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const;
|
||||
export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number];
|
||||
|
||||
@@ -343,6 +353,17 @@ export const HEARTBEAT_RUN_STATUSES = [
|
||||
] as const;
|
||||
export type HeartbeatRunStatus = (typeof HEARTBEAT_RUN_STATUSES)[number];
|
||||
|
||||
export const RUN_LIVENESS_STATES = [
|
||||
"completed",
|
||||
"advanced",
|
||||
"plan_only",
|
||||
"empty_response",
|
||||
"blocked",
|
||||
"failed",
|
||||
"needs_followup",
|
||||
] as const;
|
||||
export type RunLivenessState = (typeof RUN_LIVENESS_STATES)[number];
|
||||
|
||||
export const LIVE_EVENT_TYPES = [
|
||||
"heartbeat.run.queued",
|
||||
"heartbeat.run.status",
|
||||
|
||||
@@ -16,6 +16,9 @@ export {
|
||||
ISSUE_PRIORITIES,
|
||||
ISSUE_ORIGIN_KINDS,
|
||||
ISSUE_RELATION_TYPES,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
SYSTEM_ISSUE_DOCUMENT_KEYS,
|
||||
isSystemIssueDocumentKey,
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
@@ -49,6 +52,7 @@ export {
|
||||
BUDGET_INCIDENT_RESOLUTION_ACTIONS,
|
||||
HEARTBEAT_INVOCATION_SOURCES,
|
||||
HEARTBEAT_RUN_STATUSES,
|
||||
RUN_LIVENESS_STATES,
|
||||
WAKEUP_TRIGGER_DETAILS,
|
||||
WAKEUP_REQUEST_STATUSES,
|
||||
LIVE_EVENT_TYPES,
|
||||
@@ -93,6 +97,7 @@ export {
|
||||
type IssuePriority,
|
||||
type IssueOriginKind,
|
||||
type IssueRelationType,
|
||||
type SystemIssueDocumentKey,
|
||||
type IssueExecutionPolicyMode,
|
||||
type IssueExecutionStageType,
|
||||
type IssueExecutionStateStatus,
|
||||
@@ -125,6 +130,7 @@ export {
|
||||
type BudgetIncidentResolutionAction,
|
||||
type HeartbeatInvocationSource,
|
||||
type HeartbeatRunStatus,
|
||||
type RunLivenessState,
|
||||
type WakeupTriggerDetail,
|
||||
type WakeupRequestStatus,
|
||||
type LiveEventType,
|
||||
@@ -494,6 +500,7 @@ export {
|
||||
type UpdateProjectWorkspace,
|
||||
projectExecutionWorkspacePolicySchema,
|
||||
createIssueSchema,
|
||||
createChildIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionPolicySchema,
|
||||
@@ -521,6 +528,7 @@ export {
|
||||
upsertIssueDocumentSchema,
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
type CreateIssue,
|
||||
type CreateChildIssue,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
type CheckoutIssue,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AgentStatus,
|
||||
HeartbeatInvocationSource,
|
||||
HeartbeatRunStatus,
|
||||
RunLivenessState,
|
||||
WakeupTriggerDetail,
|
||||
WakeupRequestStatus,
|
||||
} from "../constants.js";
|
||||
@@ -38,6 +39,11 @@ export interface HeartbeatRun {
|
||||
processStartedAt: Date | null;
|
||||
retryOfRunId: string | null;
|
||||
processLossRetryCount: number;
|
||||
livenessState: RunLivenessState | null;
|
||||
livenessReason: string | null;
|
||||
continuationAttempt: number;
|
||||
lastUsefulActionAt: Date | null;
|
||||
nextAction: string | null;
|
||||
contextSnapshot: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -134,6 +134,7 @@ export {
|
||||
|
||||
export {
|
||||
createIssueSchema,
|
||||
createChildIssueSchema,
|
||||
createIssueLabelSchema,
|
||||
updateIssueSchema,
|
||||
issueExecutionPolicySchema,
|
||||
@@ -148,6 +149,7 @@ export {
|
||||
upsertIssueDocumentSchema,
|
||||
restoreIssueDocumentRevisionSchema,
|
||||
type CreateIssue,
|
||||
type CreateChildIssue,
|
||||
type CreateIssueLabel,
|
||||
type UpdateIssue,
|
||||
type IssueExecutionWorkspaceSettings,
|
||||
|
||||
@@ -138,6 +138,18 @@ export const createIssueSchema = z.object({
|
||||
|
||||
export type CreateIssue = z.infer<typeof createIssueSchema>;
|
||||
|
||||
export const createChildIssueSchema = createIssueSchema
|
||||
.omit({
|
||||
parentId: true,
|
||||
inheritExecutionWorkspaceFromIssueId: true,
|
||||
})
|
||||
.extend({
|
||||
acceptanceCriteria: z.array(z.string().trim().min(1).max(500)).max(20).optional(),
|
||||
blockParentUntilDone: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type CreateChildIssue = z.infer<typeof createChildIssueSchema>;
|
||||
|
||||
export const createIssueLabelSchema = z.object({
|
||||
name: z.string().trim().min(1).max(48),
|
||||
color: z.string().regex(/^#(?:[0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value"),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -65,7 +65,7 @@ curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \
|
||||
- adapter and runtime config aligned to this environment
|
||||
- leave timer heartbeats off by default; only set `runtimeConfig.heartbeat.enabled=true` with an `intervalSec` when the role genuinely needs scheduled recurring work or the user explicitly asked for it
|
||||
- capabilities
|
||||
- run prompt in adapter config (`promptTemplate` where applicable)
|
||||
- run prompt in adapter config (`promptTemplate` where applicable). For coding or execution agents, include the Paperclip execution contract: start actionable work in the same heartbeat; do not stop at a plan unless planning was requested; leave durable progress with a clear next action; use child issues for long or parallel delegated work instead of polling; mark blocked work with owner/action; respect budget, pause/cancel, approval gates, and company boundaries.
|
||||
- source issue linkage (`sourceIssueId` or `sourceIssueIds`) when this hire came from an issue
|
||||
|
||||
7. Submit hire request.
|
||||
|
||||
@@ -105,6 +105,14 @@ If `currentParticipant` does **not** match you, do not try to advance the stage.
|
||||
|
||||
**Step 7 — Do the work.** Use your tools and capabilities.
|
||||
|
||||
Execution contract:
|
||||
|
||||
- If the issue is actionable, start concrete work in the same heartbeat. Do not stop at a plan unless the issue specifically asks for planning.
|
||||
- Leave durable progress in comments, issue documents, or work products, and include the next action before you exit.
|
||||
- Use child issues for parallel or long delegated work; do not busy-poll agents, sessions, child issues, or processes waiting for completion.
|
||||
- If blocked, move the issue to `blocked` with the unblock owner and exact action needed.
|
||||
- Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries.
|
||||
|
||||
**Step 8 — Update status and communicate.** Always include the run ID header.
|
||||
If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act.
|
||||
|
||||
@@ -293,6 +301,9 @@ If you are asked to create or manage routines you MUST read:
|
||||
- **Honor "send it back to me" requests from board users.** If a board/user asks for review handoff (e.g. "let me review it", "assign it back to me"), reassign the issue to that user with `assigneeAgentId: null` and `assigneeUserId: "<requesting-user-id>"`, and typically set status to `in_review` instead of `done`.
|
||||
Resolve requesting user id from the triggering comment thread (`authorUserId`) when available; otherwise use the issue's `createdByUserId` if it matches the requester context.
|
||||
- **Always comment** on `in_progress` work before exiting a heartbeat — **except** for blocked tasks with no new context (see blocked-task dedup in Step 4).
|
||||
- **Start actionable work before planning-only closure.** Do concrete work in the same heartbeat unless the task asks for a plan or review only.
|
||||
- **Leave a next action.** Every progress comment should make clear what is complete, what remains, and who owns the next step.
|
||||
- **Prefer child issues over polling.** Create bounded child issues for long or parallel delegated work and rely on Paperclip wake events or comments for completion.
|
||||
- **Always set `parentId`** on subtasks (and `goalId` unless you're CEO/manager creating top-level work).
|
||||
- **Preserve workspace continuity for follow-ups.** Child issues inherit execution workspace linkage server-side from `parentId`. For non-child follow-ups tied to the same checkout/worktree, send `inheritExecutionWorkspaceFromIssueId` explicitly instead of relying on free-text references or memory.
|
||||
- **Never cancel cross-team tasks.** Reassign to your manager with a comment.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import type { ActivityEvent, RunLivenessState } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export type { RunLivenessState } from "@paperclipai/shared";
|
||||
|
||||
export interface RunForIssue {
|
||||
runId: string;
|
||||
status: string;
|
||||
@@ -13,6 +15,11 @@ export interface RunForIssue {
|
||||
usageJson: Record<string, unknown> | null;
|
||||
resultJson: Record<string, unknown> | null;
|
||||
logBytes?: number | null;
|
||||
livenessState?: RunLivenessState | null;
|
||||
livenessReason?: string | null;
|
||||
continuationAttempt?: number;
|
||||
lastUsefulActionAt?: string | null;
|
||||
nextAction?: string | null;
|
||||
}
|
||||
|
||||
export interface IssueForRun {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { HeartbeatRun, HeartbeatRunEvent, InstanceSchedulerHeartbeatAgent, WorkspaceOperation } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface RunLivenessFields {
|
||||
livenessState: HeartbeatRun["livenessState"];
|
||||
livenessReason: string | null;
|
||||
continuationAttempt: number;
|
||||
lastUsefulActionAt: string | Date | null;
|
||||
nextAction: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveRunForIssue {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -13,6 +21,11 @@ export interface ActiveRunForIssue {
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
issueId?: string | null;
|
||||
livenessState?: RunLivenessFields["livenessState"];
|
||||
livenessReason?: string | null;
|
||||
continuationAttempt?: number;
|
||||
lastUsefulActionAt?: string | Date | null;
|
||||
nextAction?: string | null;
|
||||
}
|
||||
|
||||
export interface LiveRunForIssue {
|
||||
@@ -27,6 +40,11 @@ export interface LiveRunForIssue {
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
issueId?: string | null;
|
||||
livenessState?: RunLivenessFields["livenessState"];
|
||||
livenessReason?: string | null;
|
||||
continuationAttempt?: number;
|
||||
lastUsefulActionAt?: string | null;
|
||||
nextAction?: string | null;
|
||||
}
|
||||
|
||||
export const heartbeatsApi = {
|
||||
|
||||
@@ -130,7 +130,10 @@ export const issuesApi = {
|
||||
),
|
||||
cancelComment: (id: string, commentId: string) =>
|
||||
api.delete<IssueComment>(`/issues/${id}/comments/${commentId}`),
|
||||
listDocuments: (id: string) => api.get<IssueDocument[]>(`/issues/${id}/documents`),
|
||||
listDocuments: (id: string, options?: { includeSystem?: boolean }) =>
|
||||
api.get<IssueDocument[]>(
|
||||
`/issues/${id}/documents${options?.includeSystem ? "?includeSystem=true" : ""}`,
|
||||
),
|
||||
getDocument: (id: string, key: string) => api.get<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||
upsertDocument: (id: string, key: string, data: UpsertIssueDocument) =>
|
||||
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
|
||||
|
||||
107
ui/src/components/IssueContinuationHandoff.test.tsx
Normal file
107
ui/src/components/IssueContinuationHandoff.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { IssueDocument } from "@paperclipai/shared";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueContinuationHandoff } from "./IssueContinuationHandoff";
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children, className }: { children: string; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
|
||||
<button type={type} onClick={onClick} {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createHandoffDocument(): IssueDocument {
|
||||
return {
|
||||
id: "document-handoff",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
title: "Continuation Summary",
|
||||
format: "markdown",
|
||||
body: "# Handoff\n\nResume from the activity tab.",
|
||||
latestRevisionId: "revision-1",
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: "agent-1",
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: "agent-1",
|
||||
updatedByUserId: null,
|
||||
createdAt: new Date("2026-04-19T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-19T12:05:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("IssueContinuationHandoff", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn(async () => undefined) },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders compact metadata by default with copy access", async () => {
|
||||
const root = createRoot(container);
|
||||
const handoff = createHandoffDocument();
|
||||
|
||||
await act(async () => {
|
||||
root.render(<IssueContinuationHandoff document={handoff} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Continuation Summary");
|
||||
expect(container.textContent).toContain("handoff");
|
||||
expect(container.textContent).not.toContain("Resume from the activity tab.");
|
||||
|
||||
const copyButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Copy"));
|
||||
expect(copyButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(handoff.body);
|
||||
expect(container.textContent).toContain("Copied");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("expands and anchors the handoff body when focused from a document deep link", async () => {
|
||||
const root = createRoot(container);
|
||||
const scrollIntoView = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrollIntoView;
|
||||
|
||||
await act(async () => {
|
||||
root.render(<IssueContinuationHandoff document={createHandoffDocument()} focusSignal={1} />);
|
||||
});
|
||||
|
||||
expect(container.querySelector(`#document-${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`)).toBeTruthy();
|
||||
expect(container.textContent).toContain("Resume from the activity tab.");
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
101
ui/src/components/IssueContinuationHandoff.tsx
Normal file
101
ui/src/components/IssueContinuationHandoff.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { IssueDocument } from "@paperclipai/shared";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { Check, ChevronDown, ChevronRight, Copy, History } from "lucide-react";
|
||||
|
||||
type IssueContinuationHandoffProps = {
|
||||
document: IssueDocument | null | undefined;
|
||||
focusSignal?: number;
|
||||
};
|
||||
|
||||
export function IssueContinuationHandoff({
|
||||
document,
|
||||
focusSignal = 0,
|
||||
}: IssueContinuationHandoffProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [highlighted, setHighlighted] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copiedTimerRef.current) {
|
||||
clearTimeout(copiedTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!document || focusSignal <= 0) return;
|
||||
setExpanded(true);
|
||||
setHighlighted(true);
|
||||
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const timer = setTimeout(() => setHighlighted(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [document, focusSignal]);
|
||||
|
||||
const copyBody = useCallback(async () => {
|
||||
if (!document) return;
|
||||
await navigator.clipboard?.writeText(document.body);
|
||||
setCopied(true);
|
||||
if (copiedTimerRef.current) {
|
||||
clearTimeout(copiedTimerRef.current);
|
||||
}
|
||||
copiedTimerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
}, [document]);
|
||||
|
||||
if (!document) return null;
|
||||
|
||||
const title = document.title?.trim() || "Continuation handoff";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
id={`document-${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`}
|
||||
className={cn(
|
||||
"mb-3 rounded-lg border border-border bg-accent/20 p-3 transition-colors duration-1000",
|
||||
highlighted && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
aria-label={expanded ? "Collapse continuation handoff" : "Expand continuation handoff"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
<span className="rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase text-muted-foreground">
|
||||
handoff
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Updated {relativeTime(document.updatedAt)}
|
||||
{document.latestRevisionNumber > 0 ? ` - revision ${document.latestRevisionNumber}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={copyBody} className="shrink-0">
|
||||
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" /> : <Copy className="mr-1.5 h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-background/80 p-3">
|
||||
<MarkdownBody className="paperclip-edit-in-place-content text-sm leading-6" softBreaks={false}>
|
||||
{document.body}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { ComponentProps } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueDocumentsSection } from "./IssueDocumentsSection";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
@@ -260,6 +261,50 @@ describe("IssueDocumentsSection", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("keeps system handoff documents out of the normal document surface", async () => {
|
||||
const issue = createIssue();
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([
|
||||
createIssueDocument({ key: "plan", body: "# Plan" }),
|
||||
createIssueDocument({
|
||||
id: "document-handoff",
|
||||
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
title: "Continuation Summary",
|
||||
body: "# Handoff",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("# Plan");
|
||||
expect(container.textContent).not.toContain("# Handoff");
|
||||
expect(container.querySelector(`#document-${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("shows the restored document body immediately after a revision restore", async () => {
|
||||
const blankLatestDocument = createIssueDocument({
|
||||
body: "",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Issue,
|
||||
IssueDocument,
|
||||
} from "@paperclipai/shared";
|
||||
import { isSystemIssueDocumentKey } from "@paperclipai/shared";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
@@ -204,6 +205,7 @@ export function IssueDocumentsSection({
|
||||
}, [issue.id, queryClient]);
|
||||
|
||||
const syncDocumentCaches = useCallback((document: IssueDocument) => {
|
||||
if (isSystemIssueDocumentKey(document.key)) return;
|
||||
queryClient.setQueryData<IssueDocument[] | undefined>(
|
||||
queryKeys.issues.documents(issue.id),
|
||||
(current) => {
|
||||
@@ -273,7 +275,7 @@ export function IssueDocumentsSection({
|
||||
});
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
return [...(documents ?? [])].sort((a, b) => {
|
||||
return (documents ?? []).filter((doc) => !isSystemIssueDocumentKey(doc.key)).sort((a, b) => {
|
||||
if (a.key === "plan" && b.key !== "plan") return -1;
|
||||
if (a.key !== "plan" && b.key === "plan") return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
|
||||
271
ui/src/components/IssueRunLedger.test.tsx
Normal file
271
ui/src/components/IssueRunLedger.test.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { Issue, RunLivenessState } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RunForIssue } from "../api/activity";
|
||||
import { IssueRunLedgerContent } from "./IssueRunLedger";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-18T20:00:00.000Z"));
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function render(ui: ReactNode) {
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function createRun(overrides: Partial<RunForIssue> = {}): RunForIssue {
|
||||
return {
|
||||
runId: "run-00000000",
|
||||
status: "succeeded",
|
||||
agentId: "agent-1",
|
||||
adapterType: "codex_local",
|
||||
startedAt: "2026-04-18T19:58:00.000Z",
|
||||
finishedAt: "2026-04-18T19:59:00.000Z",
|
||||
createdAt: "2026-04-18T19:58:00.000Z",
|
||||
invocationSource: "assignment",
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
livenessState: "advanced",
|
||||
livenessReason: "Run produced concrete action evidence: 2 activity event(s)",
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: "2026-04-18T19:59:00.000Z",
|
||||
nextAction: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Child issue",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: null,
|
||||
identifier: "PAP-1",
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-04-18T19:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-18T19:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderLedger(props: Partial<ComponentProps<typeof IssueRunLedgerContent>> = {}) {
|
||||
render(
|
||||
<IssueRunLedgerContent
|
||||
runs={props.runs ?? []}
|
||||
liveRuns={props.liveRuns}
|
||||
activeRun={props.activeRun}
|
||||
issueStatus={props.issueStatus ?? "in_progress"}
|
||||
childIssues={props.childIssues ?? []}
|
||||
agentMap={props.agentMap ?? new Map([["agent-1", { name: "CodexCoder" }]])}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("IssueRunLedger", () => {
|
||||
it("renders every liveness state with exhausted continuation context", () => {
|
||||
const states: RunLivenessState[] = [
|
||||
"advanced",
|
||||
"plan_only",
|
||||
"empty_response",
|
||||
"blocked",
|
||||
"failed",
|
||||
"completed",
|
||||
"needs_followup",
|
||||
];
|
||||
|
||||
renderLedger({
|
||||
runs: states.map((state, index) =>
|
||||
createRun({
|
||||
runId: `run-${index}0000000`,
|
||||
createdAt: `2026-04-18T19:5${index}:00.000Z`,
|
||||
livenessState: state,
|
||||
livenessReason: state === "needs_followup"
|
||||
? "Run produced useful output but no concrete action evidence; continuation attempts exhausted"
|
||||
: `state ${state}`,
|
||||
continuationAttempt: state === "needs_followup" ? 3 : 0,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Advanced");
|
||||
expect(container.textContent).toContain("Plan only");
|
||||
expect(container.textContent).toContain("Empty response");
|
||||
expect(container.textContent).toContain("Blocked");
|
||||
expect(container.textContent).toContain("Failed");
|
||||
expect(container.textContent).toContain("Completed");
|
||||
expect(container.textContent).toContain("Needs follow-up");
|
||||
expect(container.textContent).toContain("Exhausted");
|
||||
expect(container.textContent).toContain("Continuation attempt 3");
|
||||
});
|
||||
|
||||
it("renders historical runs without liveness metadata as unavailable", () => {
|
||||
renderLedger({
|
||||
runs: [
|
||||
createRun({
|
||||
livenessState: null,
|
||||
livenessReason: null,
|
||||
continuationAttempt: undefined,
|
||||
lastUsefulActionAt: null,
|
||||
nextAction: null,
|
||||
resultJson: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("No liveness data");
|
||||
expect(container.textContent).toContain("Stop Unavailable");
|
||||
expect(container.textContent).toContain("Last useful action Unavailable");
|
||||
});
|
||||
|
||||
it("shows live runs as pending final checks without missing-data language", () => {
|
||||
renderLedger({
|
||||
runs: [
|
||||
createRun({
|
||||
status: "running",
|
||||
finishedAt: null,
|
||||
livenessState: null,
|
||||
livenessReason: null,
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: null,
|
||||
nextAction: null,
|
||||
resultJson: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Running now by CodexCoder");
|
||||
expect(container.textContent).toContain("Checks after finish");
|
||||
expect(container.textContent).toContain("Last useful action No action recorded yet");
|
||||
expect(container.textContent).toContain("Stop Still running");
|
||||
expect(container.textContent).not.toContain("Liveness pending");
|
||||
expect(container.textContent).not.toContain("initial attempt");
|
||||
});
|
||||
|
||||
it("shows timeout, cancel, and budget stop reasons without raw logs", () => {
|
||||
renderLedger({
|
||||
runs: [
|
||||
createRun({
|
||||
runId: "run-timeout",
|
||||
resultJson: { stopReason: "timeout", timeoutFired: true, effectiveTimeoutSec: 30 },
|
||||
}),
|
||||
createRun({
|
||||
runId: "run-cancel",
|
||||
resultJson: { stopReason: "cancelled" },
|
||||
createdAt: "2026-04-18T19:57:00.000Z",
|
||||
}),
|
||||
createRun({
|
||||
runId: "run-budget",
|
||||
resultJson: { stopReason: "budget_paused" },
|
||||
createdAt: "2026-04-18T19:56:00.000Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("timeout (30s timeout)");
|
||||
expect(container.textContent).toContain("cancelled");
|
||||
expect(container.textContent).toContain("budget paused");
|
||||
});
|
||||
|
||||
it("surfaces active and completed child issue summaries", () => {
|
||||
renderLedger({
|
||||
childIssues: [
|
||||
createIssue({ id: "child-1", identifier: "PAP-2", title: "Implement worker handoff", status: "in_progress" }),
|
||||
createIssue({ id: "child-2", identifier: "PAP-3", title: "Verify final report", status: "done" }),
|
||||
createIssue({ id: "child-3", identifier: "PAP-4", title: "Cancelled experiment", status: "cancelled" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Child work");
|
||||
expect(container.textContent).toContain("1 active, 1 done, 1 cancelled");
|
||||
expect(container.textContent).toContain("PAP-2");
|
||||
expect(container.textContent).toContain("Implement worker handoff");
|
||||
|
||||
renderLedger({
|
||||
childIssues: [
|
||||
createIssue({ id: "child-2", identifier: "PAP-3", title: "Verify final report", status: "done" }),
|
||||
createIssue({ id: "child-3", identifier: "PAP-4", title: "Cancelled experiment", status: "cancelled" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("all 2 terminal (1 done, 1 cancelled)");
|
||||
});
|
||||
|
||||
it("uses wrapping-friendly markup for long next action text", () => {
|
||||
renderLedger({
|
||||
runs: [
|
||||
createRun({
|
||||
nextAction: "Continue investigating this intentionally-long-next-action-token-that-needs-to-wrap-cleanly-on-mobile-and-desktop-without-overlapping-controls.",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const nextAction = [...container.querySelectorAll("span")]
|
||||
.find((node) => node.textContent?.includes("intentionally-long-next-action-token"));
|
||||
expect(nextAction?.className).toContain("break-words");
|
||||
expect(container.textContent).toContain("Next action:");
|
||||
});
|
||||
|
||||
it("shows when older runs are clipped from the ledger", () => {
|
||||
renderLedger({
|
||||
runs: Array.from({ length: 10 }, (_, index) =>
|
||||
createRun({
|
||||
runId: `run-${index.toString().padStart(8, "0")}`,
|
||||
createdAt: `2026-04-18T19:${String(index).padStart(2, "0")}:00.000Z`,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("2 older runs not shown");
|
||||
});
|
||||
});
|
||||
440
ui/src/components/IssueRunLedger.tsx
Normal file
440
ui/src/components/IssueRunLedger.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Issue, Agent } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity";
|
||||
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
||||
|
||||
type IssueRunLedgerProps = {
|
||||
issueId: string;
|
||||
issueStatus: Issue["status"];
|
||||
childIssues: Issue[];
|
||||
agentMap: ReadonlyMap<string, Agent>;
|
||||
hasLiveRuns: boolean;
|
||||
};
|
||||
|
||||
type IssueRunLedgerContentProps = {
|
||||
runs: RunForIssue[];
|
||||
liveRuns?: LiveRunForIssue[];
|
||||
activeRun?: ActiveRunForIssue | null;
|
||||
issueStatus: Issue["status"];
|
||||
childIssues: Issue[];
|
||||
agentMap: ReadonlyMap<string, Pick<Agent, "name">>;
|
||||
};
|
||||
|
||||
type LedgerRun = RunForIssue & {
|
||||
isLive?: boolean;
|
||||
agentName?: string;
|
||||
};
|
||||
|
||||
type LivenessCopy = {
|
||||
label: string;
|
||||
tone: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const LIVENESS_COPY: Record<RunLivenessState, LivenessCopy> = {
|
||||
completed: {
|
||||
label: "Completed",
|
||||
tone: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
description: "Issue reached a terminal state.",
|
||||
},
|
||||
advanced: {
|
||||
label: "Advanced",
|
||||
tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300",
|
||||
description: "Run produced concrete evidence of progress.",
|
||||
},
|
||||
plan_only: {
|
||||
label: "Plan only",
|
||||
tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
description: "Run described future work without concrete action evidence.",
|
||||
},
|
||||
empty_response: {
|
||||
label: "Empty response",
|
||||
tone: "border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-300",
|
||||
description: "Run finished without useful output.",
|
||||
},
|
||||
blocked: {
|
||||
label: "Blocked",
|
||||
tone: "border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300",
|
||||
description: "Run or issue declared a blocker.",
|
||||
},
|
||||
failed: {
|
||||
label: "Failed",
|
||||
tone: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
|
||||
description: "Run ended unsuccessfully.",
|
||||
},
|
||||
needs_followup: {
|
||||
label: "Needs follow-up",
|
||||
tone: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
||||
description: "Run produced useful output but did not prove concrete progress.",
|
||||
},
|
||||
};
|
||||
|
||||
const PENDING_LIVENESS_COPY: LivenessCopy = {
|
||||
label: "Checks after finish",
|
||||
tone: "border-border bg-background text-muted-foreground",
|
||||
description: "Liveness is evaluated after the run finishes.",
|
||||
};
|
||||
|
||||
const MISSING_LIVENESS_COPY: LivenessCopy = {
|
||||
label: "No liveness data",
|
||||
tone: "border-border bg-background text-muted-foreground",
|
||||
description: "This run has no persisted liveness classification.",
|
||||
};
|
||||
|
||||
const TERMINAL_CHILD_STATUSES = new Set<Issue["status"]>(["done", "cancelled"]);
|
||||
const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function formatDuration(start: string | Date | null | undefined, end: string | Date | null | undefined) {
|
||||
if (!start) return null;
|
||||
const startMs = new Date(start).getTime();
|
||||
const endMs = end ? new Date(end).getTime() : Date.now();
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
|
||||
const totalSeconds = Math.max(0, Math.round((endMs - startMs) / 1000));
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function toIsoString(value: string | Date | null | undefined) {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function liveRunToLedgerRun(run: LiveRunForIssue | ActiveRunForIssue): LedgerRun {
|
||||
return {
|
||||
runId: run.id,
|
||||
status: run.status,
|
||||
agentId: run.agentId,
|
||||
agentName: run.agentName,
|
||||
adapterType: run.adapterType,
|
||||
startedAt: toIsoString(run.startedAt),
|
||||
finishedAt: toIsoString(run.finishedAt),
|
||||
createdAt: toIsoString(run.createdAt) ?? new Date().toISOString(),
|
||||
invocationSource: run.invocationSource,
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
isLive: run.status === "queued" || run.status === "running",
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRuns(
|
||||
runs: RunForIssue[],
|
||||
liveRuns: LiveRunForIssue[] | undefined,
|
||||
activeRun: ActiveRunForIssue | null | undefined,
|
||||
) {
|
||||
const byId = new Map<string, LedgerRun>();
|
||||
for (const run of runs) byId.set(run.runId, run);
|
||||
for (const run of liveRuns ?? []) {
|
||||
const existing = byId.get(run.id);
|
||||
byId.set(run.id, existing ? { ...existing, isLive: true, agentName: run.agentName } : liveRunToLedgerRun(run));
|
||||
}
|
||||
if (activeRun && !byId.has(activeRun.id)) {
|
||||
byId.set(activeRun.id, liveRunToLedgerRun(activeRun));
|
||||
}
|
||||
|
||||
return [...byId.values()].sort((a, b) => {
|
||||
const aTime = new Date(a.startedAt ?? a.createdAt).getTime();
|
||||
const bTime = new Date(b.startedAt ?? b.createdAt).getTime();
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
return b.runId.localeCompare(a.runId);
|
||||
});
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return status.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function isActiveRun(run: Pick<LedgerRun, "status" | "isLive">) {
|
||||
return run.isLive || ACTIVE_RUN_STATUSES.has(run.status);
|
||||
}
|
||||
|
||||
function runSummary(run: LedgerRun, agentMap: ReadonlyMap<string, Pick<Agent, "name">>) {
|
||||
const agentName = compactAgentName(run, agentMap);
|
||||
if (run.status === "running") return `Running now by ${agentName}`;
|
||||
if (run.status === "queued") return `Queued for ${agentName}`;
|
||||
return `${statusLabel(run.status)} by ${agentName}`;
|
||||
}
|
||||
|
||||
function livenessCopyForRun(run: LedgerRun) {
|
||||
if (run.livenessState) return LIVENESS_COPY[run.livenessState];
|
||||
return isActiveRun(run) ? PENDING_LIVENESS_COPY : MISSING_LIVENESS_COPY;
|
||||
}
|
||||
|
||||
function stopReasonLabel(run: RunForIssue) {
|
||||
const result = asRecord(run.resultJson);
|
||||
const stopReason = readString(result?.stopReason);
|
||||
const timeoutFired = result?.timeoutFired === true;
|
||||
const effectiveTimeoutSec = readNumber(result?.effectiveTimeoutSec);
|
||||
const timeoutText =
|
||||
effectiveTimeoutSec && effectiveTimeoutSec > 0 ? `${effectiveTimeoutSec}s timeout` : null;
|
||||
|
||||
if (timeoutFired || stopReason === "timeout") {
|
||||
return timeoutText ? `timeout (${timeoutText})` : "timeout";
|
||||
}
|
||||
if (stopReason === "budget_paused") return "budget paused";
|
||||
if (stopReason === "cancelled") return "cancelled";
|
||||
if (stopReason === "paused") return "paused";
|
||||
if (stopReason === "process_lost") return "process lost";
|
||||
if (stopReason === "adapter_failed") return "adapter failed";
|
||||
if (stopReason === "completed") return timeoutText ? `completed (${timeoutText})` : "completed";
|
||||
return timeoutText;
|
||||
}
|
||||
|
||||
function stopStatusLabel(run: LedgerRun, stopReason: string | null) {
|
||||
if (stopReason) return stopReason;
|
||||
if (run.status === "queued") return "Waiting to start";
|
||||
if (run.status === "running") return "Still running";
|
||||
if (!run.livenessState) return "Unavailable";
|
||||
return "No stop reason";
|
||||
}
|
||||
|
||||
function lastUsefulActionLabel(run: LedgerRun) {
|
||||
if (run.lastUsefulActionAt) return relativeTime(run.lastUsefulActionAt);
|
||||
if (isActiveRun(run)) return "No action recorded yet";
|
||||
if (run.livenessState === "plan_only" || run.livenessState === "needs_followup") {
|
||||
return "No concrete action";
|
||||
}
|
||||
if (run.livenessState === "empty_response") return "No useful output";
|
||||
if (!run.livenessState) return "Unavailable";
|
||||
return "None recorded";
|
||||
}
|
||||
|
||||
function continuationLabel(run: LedgerRun) {
|
||||
if (!run.continuationAttempt || run.continuationAttempt <= 0) return null;
|
||||
return `Continuation attempt ${run.continuationAttempt}`;
|
||||
}
|
||||
|
||||
function hasExhaustedContinuation(run: RunForIssue) {
|
||||
return /continuation attempts exhausted/i.test(run.livenessReason ?? "");
|
||||
}
|
||||
|
||||
function childIssueSummary(childIssues: Issue[]) {
|
||||
const active = childIssues.filter((issue) => !TERMINAL_CHILD_STATUSES.has(issue.status));
|
||||
const done = childIssues.filter((issue) => issue.status === "done").length;
|
||||
const cancelled = childIssues.filter((issue) => issue.status === "cancelled").length;
|
||||
return { active, done, cancelled, total: childIssues.length };
|
||||
}
|
||||
|
||||
function compactAgentName(run: LedgerRun, agentMap: ReadonlyMap<string, Pick<Agent, "name">>) {
|
||||
return run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||
}
|
||||
|
||||
export function IssueRunLedger({
|
||||
issueId,
|
||||
issueStatus,
|
||||
childIssues,
|
||||
agentMap,
|
||||
hasLiveRuns,
|
||||
}: IssueRunLedgerProps) {
|
||||
const { data: runs } = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueId),
|
||||
queryFn: () => activityApi.runsForIssue(issueId),
|
||||
refetchInterval: hasLiveRuns ? 5000 : false,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
||||
});
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
||||
enabled: hasLiveRuns,
|
||||
refetchInterval: 3000,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
|
||||
});
|
||||
const { data: activeRun = null } = useQuery({
|
||||
queryKey: queryKeys.issues.activeRun(issueId),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
|
||||
enabled: hasLiveRuns || issueStatus === "in_progress",
|
||||
refetchInterval: hasLiveRuns ? false : 3000,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
|
||||
});
|
||||
|
||||
return (
|
||||
<IssueRunLedgerContent
|
||||
runs={runs ?? []}
|
||||
liveRuns={liveRuns}
|
||||
activeRun={activeRun}
|
||||
issueStatus={issueStatus}
|
||||
childIssues={childIssues}
|
||||
agentMap={agentMap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueRunLedgerContent({
|
||||
runs,
|
||||
liveRuns,
|
||||
activeRun,
|
||||
issueStatus,
|
||||
childIssues,
|
||||
agentMap,
|
||||
}: IssueRunLedgerContentProps) {
|
||||
const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]);
|
||||
const latestRun = ledgerRuns[0] ?? null;
|
||||
const children = childIssueSummary(childIssues);
|
||||
|
||||
return (
|
||||
<section className="space-y-3" aria-label="Issue run ledger">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Run ledger</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{latestRun
|
||||
? runSummary(latestRun, agentMap)
|
||||
: issueStatus === "in_progress"
|
||||
? "Waiting for the first run record."
|
||||
: "No runs linked yet."}
|
||||
</p>
|
||||
</div>
|
||||
{latestRun ? (
|
||||
<Link
|
||||
to={`/agents/${latestRun.agentId}/runs/${latestRun.runId}`}
|
||||
className="shrink-0 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Latest run
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{children.total > 0 ? (
|
||||
<div className="rounded-md border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-medium text-foreground">Child work</span>
|
||||
<span className="text-muted-foreground">
|
||||
{children.active.length > 0
|
||||
? `${children.active.length} active, ${children.done} done, ${children.cancelled} cancelled`
|
||||
: `all ${children.total} terminal (${children.done} done, ${children.cancelled} cancelled)`}
|
||||
</span>
|
||||
</div>
|
||||
{children.active.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{children.active.slice(0, 4).map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-background px-2 py-1 text-[11px] hover:bg-accent/40"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-muted-foreground">{child.identifier ?? child.id.slice(0, 8)}</span>
|
||||
<span className="truncate">{child.title}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{statusLabel(child.status)}</span>
|
||||
</Link>
|
||||
))}
|
||||
{children.active.length > 4 ? (
|
||||
<span className="rounded-md border border-border px-2 py-1 text-[11px] text-muted-foreground">
|
||||
+{children.active.length - 4} more
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{ledgerRuns.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border px-3 py-3 text-sm text-muted-foreground">
|
||||
Historical runs without liveness metadata will appear here once linked to this issue.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border rounded-md border border-border/70">
|
||||
{ledgerRuns.slice(0, 8).map((run) => {
|
||||
const liveness = livenessCopyForRun(run);
|
||||
const stopReason = stopReasonLabel(run);
|
||||
const duration = formatDuration(run.startedAt, run.finishedAt);
|
||||
const exhausted = hasExhaustedContinuation(run);
|
||||
const continuation = continuationLabel(run);
|
||||
return (
|
||||
<article key={run.runId} className="space-y-2 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.runId}`}
|
||||
className="min-w-0 max-w-full truncate font-mono text-xs text-foreground hover:underline"
|
||||
>
|
||||
{run.runId.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="rounded-md border border-border px-1.5 py-0.5 text-[11px] capitalize text-muted-foreground">
|
||||
{statusLabel(run.status)}
|
||||
</span>
|
||||
{run.isLive ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-1.5 py-0.5 text-[11px] text-cyan-700 dark:text-cyan-300">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-cyan-400" />
|
||||
live
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border px-1.5 py-0.5 text-[11px] font-medium",
|
||||
liveness.tone,
|
||||
)}
|
||||
title={liveness.description}
|
||||
>
|
||||
{liveness.label}
|
||||
</span>
|
||||
{exhausted ? (
|
||||
<span className="rounded-md border border-red-500/30 bg-red-500/10 px-1.5 py-0.5 text-[11px] font-medium text-red-700 dark:text-red-300">
|
||||
Exhausted
|
||||
</span>
|
||||
) : null}
|
||||
{continuation ? (
|
||||
<span className="text-[11px] text-muted-foreground">{continuation}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-foreground">Elapsed</span>{" "}
|
||||
{duration ?? "unknown"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-foreground">Last useful action</span>{" "}
|
||||
{lastUsefulActionLabel(run)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-foreground">Stop</span>{" "}
|
||||
{stopStatusLabel(run, stopReason)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{run.livenessReason ? (
|
||||
<p className="min-w-0 break-words text-xs leading-5 text-muted-foreground">
|
||||
{run.livenessReason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{run.nextAction ? (
|
||||
<div className="min-w-0 rounded-md bg-accent/40 px-2 py-1.5 text-xs leading-5">
|
||||
<span className="font-medium text-foreground">Next action: </span>
|
||||
<span className="break-words text-muted-foreground">{run.nextAction}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{ledgerRuns.length > 8 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{ledgerRuns.length - 8} older runs not shown
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -154,6 +154,11 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
livenessState: null,
|
||||
livenessReason: null,
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: null,
|
||||
nextAction: null,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
contextSnapshot: null,
|
||||
|
||||
@@ -48,6 +48,7 @@ export const queryKeys = {
|
||||
feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const,
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
documents: (issueId: string) => ["issues", "documents", issueId] as const,
|
||||
document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const,
|
||||
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,
|
||||
activity: (issueId: string) => ["issues", "activity", issueId] as const,
|
||||
runs: (issueId: string) => ["issues", "runs", issueId] as const,
|
||||
|
||||
@@ -148,6 +148,11 @@ describe("FailedRunInboxRow", () => {
|
||||
processStartedAt: null,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
livenessState: null,
|
||||
livenessReason: null,
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: null,
|
||||
nextAction: null,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
contextSnapshot: null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEve
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
|
||||
import { ApiError } from "../api/client";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { activityApi, type RunForIssue } from "../api/activity";
|
||||
@@ -59,9 +60,11 @@ import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { IssueRunLedger } from "../components/IssueRunLedger";
|
||||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
||||
@@ -103,6 +106,7 @@ import {
|
||||
import {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
type ActivityEvent,
|
||||
type Agent,
|
||||
type FeedbackVote,
|
||||
@@ -722,20 +726,28 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
|
||||
type IssueDetailActivityTabProps = {
|
||||
issueId: string;
|
||||
issueStatus: Issue["status"];
|
||||
childIssues: Issue[];
|
||||
agentMap: Map<string, Agent>;
|
||||
hasLiveRuns: boolean;
|
||||
currentUserId: string | null;
|
||||
userProfileMap: Map<string, import("../lib/company-members").CompanyUserProfile>;
|
||||
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
|
||||
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
|
||||
handoffFocusSignal?: number;
|
||||
};
|
||||
|
||||
function IssueDetailActivityTab({
|
||||
issueId,
|
||||
issueStatus,
|
||||
childIssues,
|
||||
agentMap,
|
||||
hasLiveRuns,
|
||||
currentUserId,
|
||||
userProfileMap,
|
||||
pendingApprovalAction,
|
||||
onApprovalAction,
|
||||
handoffFocusSignal = 0,
|
||||
}: IssueDetailActivityTabProps) {
|
||||
const { data: activity, isLoading: activityLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId),
|
||||
@@ -752,6 +764,21 @@ function IssueDetailActivityTab({
|
||||
queryFn: () => issuesApi.listApprovals(issueId),
|
||||
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.listApprovals>>>(issueId),
|
||||
});
|
||||
const { data: continuationHandoff } = useQuery({
|
||||
queryKey: queryKeys.issues.document(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await issuesApi.getDocument(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.getDocument>> | null>(
|
||||
issueId,
|
||||
),
|
||||
});
|
||||
const initialLoading =
|
||||
(activityLoading && activity === undefined)
|
||||
|| (linkedRunsLoading && linkedRuns === undefined);
|
||||
@@ -800,6 +827,16 @@ function IssueDetailActivityTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<IssueRunLedger
|
||||
issueId={issueId}
|
||||
issueStatus={issueStatus}
|
||||
childIssues={childIssues}
|
||||
agentMap={agentMap}
|
||||
hasLiveRuns={hasLiveRuns}
|
||||
/>
|
||||
</div>
|
||||
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
|
||||
{linkedApprovals && linkedApprovals.length > 0 && (
|
||||
<div className="mb-3 space-y-3">
|
||||
{linkedApprovals.map((approval) => (
|
||||
@@ -877,6 +914,7 @@ export function IssueDetail() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("chat");
|
||||
const [handoffFocusSignal, setHandoffFocusSignal] = useState(0);
|
||||
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
|
||||
approvalId: string;
|
||||
action: "approve" | "reject";
|
||||
@@ -1960,6 +1998,15 @@ export function IssueDetail() {
|
||||
};
|
||||
}, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
if (!hash.startsWith("#document-")) return;
|
||||
const documentKey = decodeURIComponent(hash.slice("#document-".length));
|
||||
if (documentKey !== ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY) return;
|
||||
setDetailTab("activity");
|
||||
setHandoffFocusSignal((current) => current + 1);
|
||||
}, [location.hash]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingCommentComposerFocusKey === 0) return;
|
||||
if (detailTab !== "chat") return;
|
||||
@@ -2661,10 +2708,14 @@ export function IssueDetail() {
|
||||
{detailTab === "activity" ? (
|
||||
<IssueDetailActivityTab
|
||||
issueId={issue.id}
|
||||
issueStatus={issue.status}
|
||||
childIssues={childIssues}
|
||||
agentMap={agentMap}
|
||||
hasLiveRuns={hasLiveRuns}
|
||||
currentUserId={currentUserId}
|
||||
userProfileMap={userProfileMap}
|
||||
pendingApprovalAction={pendingApprovalAction}
|
||||
handoffFocusSignal={handoffFocusSignal}
|
||||
onApprovalAction={(approvalId, action) => {
|
||||
approvalDecision.mutate({ approvalId, action });
|
||||
}}
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
"packages/db",
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/opencode-local",
|
||||
"server",
|
||||
|
||||
Reference in New Issue
Block a user