Add recovery handoff system notices (#5289)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Agent runs can end productively while the source issue still lacks a
durable final disposition.
> - That leaves the control plane unsure whether to resume, escalate, or
close the work.
> - Issue comments also need a presentation contract so system-authored
recovery notices can render as first-class thread messages without
overloading normal comments.
> - This pull request adds successful-run handoff recovery, comment
presentation metadata, and system notice rendering.
> - The benefit is stricter task liveness with clearer operator-facing
recovery state.

## What Changed

- Added successful-run handoff decisions, wake payloads, escalation
behavior, and recovery tests.
- Added issue comment presentation metadata with migration
`0078_white_darwin.sql` and shared/server/company portability support.
- Rendered recovery/system notices in issue chat with dedicated UI
components, fixtures, tests, and storybook/lab coverage.
- Included the current recovery model-profile hint patch so automatic
recovery follow-ups use the cheap profile.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/services/recovery/successful-run-handoff.test.ts
ui/src/components/SystemNotice.test.tsx
ui/src/lib/system-notice-comment.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx`

## Risks

- Migration-bearing PR: merge this before any other branch that might
later add a migration.
- The branch touches both recovery services and issue-thread rendering,
so review should pay attention to recovery wake idempotency and comment
metadata compatibility.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-06 06:05:58 -05:00 committed by GitHub
parent 50db8c01d2
commit 454edfe81e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 21919 additions and 125 deletions

View file

@ -35,9 +35,11 @@ const projectSvc = {
const issueSvc = {
list: vi.fn(),
listComments: vi.fn(),
getById: vi.fn(),
getByIdentifier: vi.fn(),
create: vi.fn(),
addComment: vi.fn(),
};
const routineSvc = {
@ -131,6 +133,14 @@ describe("company portability", () => {
config,
secretKeys: new Set<string>(),
}));
issueSvc.listComments.mockResolvedValue([]);
issueSvc.addComment.mockResolvedValue({
id: "comment-imported",
body: "Imported comment",
authorType: "system",
presentation: null,
metadata: null,
});
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@ -2333,6 +2343,103 @@ describe("company portability", () => {
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("does not silently add local adapter permission bypasses on import", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-created",
name: String(input.name),
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "claude_local",
adapterConfig: expect.not.objectContaining({
dangerouslySkipPermissions: expect.anything(),
}),
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
extraArgs: [],
args: ["--legacy-arg"],
},
},
},
}, "user-1");
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: expect.objectContaining({
extraArgs: ["--skip-git-repo-check"],
args: ["--legacy-arg"],
}),
}));
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.not.objectContaining({
dangerouslyBypassApprovalsAndSandbox: expect.anything(),
dangerouslyBypassSandbox: expect.anything(),
}),
}));
});
it("preserves issue labelIds through export and import round-trip", async () => {
const portability = companyPortabilityService({} as any);
@ -2399,6 +2506,85 @@ describe("company portability", () => {
);
});
it("preserves issue comment presentation fields through export and import", async () => {
const portability = companyPortabilityService({} as any);
const presentation = { kind: "system_notice", tone: "warning", detailsDefaultOpen: false };
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Needs disposition",
description: "System notice source",
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "high",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("comments:");
expect(extension).toContain("system_notice");
expect(extension).toContain("successful_run_missing_state");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Needs disposition" });
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(issueSvc.addComment).toHaveBeenCalledWith(
"issue-imported",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: undefined },
{
authorType: "system",
presentation,
metadata,
createdAt: "2026-05-04T12:00:00.000Z",
},
);
});
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
const portability = companyPortabilityService({} as any);

View file

@ -208,6 +208,7 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
expect(evaluations[0]).toMatchObject({
priority: "medium",
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
originId: runId,
originFingerprint: `stale_active_run:${companyId}:${runId}`,
});

View file

@ -13,6 +13,7 @@ import {
issues,
} from "@paperclipai/db";
import { heartbeatService } from "../services/heartbeat.ts";
import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 50) {
@ -543,8 +544,24 @@ describe("heartbeat comment wake batching", () => {
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "user-1",
body: "Queued follow-up",
presentation: {
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sections: [
{
rows: [
{ type: "key_value", label: "Cause", value: "successful_run_missing_state" },
],
},
],
},
})
.returning()
.then((rows) => rows[0]);
@ -577,7 +594,15 @@ describe("heartbeat comment wake batching", () => {
comments: [
expect.objectContaining({
id: queuedComment.id,
authorType: "user",
body: "Queued follow-up",
presentation: expect.objectContaining({
kind: "system_notice",
tone: "warning",
}),
metadata: expect.objectContaining({
version: 1,
}),
}),
],
commentWindow: {
@ -1130,6 +1155,7 @@ describe("heartbeat comment wake batching", () => {
expect(payloads).toHaveLength(2);
expect(runs[1]?.contextSnapshot).toMatchObject({
retryReason: "missing_issue_comment",
modelProfile: "cheap",
});
} finally {
gateway.releaseFirstWait();
@ -1329,8 +1355,9 @@ describe("heartbeat comment wake batching", () => {
eq(agentWakeupRequests.agentId, primaryAgentId),
eq(agentWakeupRequests.reason, "missing_issue_comment"),
),
);
);
expect(missingCommentRetries).toHaveLength(1);
expect(missingCommentRetries[0]?.payload).toMatchObject({ modelProfile: "cheap" });
} finally {
gateway.releaseFirstWait();
await gateway.close();
@ -1566,7 +1593,8 @@ describe("heartbeat comment wake batching", () => {
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
return runs.length === 1 && runs[0]?.status === "succeeded" && runs[0]?.issueCommentStatus === "satisfied";
const sourceRun = runs.find((run) => run.id === firstRun?.id);
return sourceRun?.status === "succeeded" && sourceRun.issueCommentStatus === "satisfied";
});
const runs = await db
@ -1574,9 +1602,26 @@ describe("heartbeat comment wake batching", () => {
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(1);
expect(runs[0]?.issueCommentStatus).toBe("satisfied");
expect(runs[0]?.issueCommentSatisfiedByCommentId).not.toBeNull();
const sourceRun = runs.find((run) => run.id === firstRun?.id);
expect(sourceRun?.issueCommentStatus).toBe("satisfied");
expect(sourceRun?.issueCommentSatisfiedByCommentId).not.toBeNull();
await waitFor(async () => {
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId));
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
const hasHandoffComment = comments.some((comment) =>
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
);
const hasHandoffWake = wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return hasHandoffComment && hasHandoffWake;
});
const comments = await db
.select()
@ -1584,16 +1629,19 @@ describe("heartbeat comment wake batching", () => {
.where(eq(issueComments.issueId, issueId))
.orderBy(asc(issueComments.createdAt));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toBe("Manual completion comment from the run.");
expect(comments[0]?.createdByRunId).toBe(firstRun?.id);
expect(comments.some((comment) => comment.body === "Manual completion comment from the run.")).toBe(true);
expect(comments.some((comment) =>
comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY
)).toBe(true);
expect(comments.every((comment) => !comment.body.startsWith("## Run summary"))).toBe(true);
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)));
expect(wakeups).toHaveLength(1);
expect(wakeups.some((wakeup) => wakeup.reason === "missing_issue_comment")).toBe(false);
expect(wakeups.some((wakeup) => wakeup.reason === "finish_successful_run_handoff")).toBe(true);
} finally {
gateway.releaseFirstWait();
await gateway.close();

View file

@ -280,6 +280,23 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
unresolvedBlockerIssueIds: [blockerId],
});
let finishReadyRun!: () => void;
const readyRunCanFinish = new Promise<void>((resolve) => {
finishReadyRun = resolve;
});
mockAdapterExecute.mockImplementationOnce(async () => {
await readyRunCanFinish;
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Ready dependency scheduling run complete.",
provider: "test",
model: "test-model",
};
});
const readyWake = await heartbeat.wakeup(agentId, {
source: "assignment",
triggerDetail: "system",
@ -288,6 +305,15 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: readyIssueId, wakeReason: "issue_assigned" },
});
expect(readyWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: readyIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: readyWake!.id,
body: "Ready dependency scheduling run complete.",
});
finishReadyRun();
await waitForCondition(async () => {
const run = await db
@ -354,6 +380,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
expect(promotedBlockedRun?.status).toBe("succeeded");
expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2);
const noActiveRuns = await waitForCondition(async () => {
const rows = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
return rows.every((run) => run.status !== "queued" && run.status !== "running");
}, 10_000);
expect(noActiveRuns).toBe(true);
});
it("honors maxConcurrentRuns 1 by leaving a second assignment wake queued", async () => {
@ -429,6 +463,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: firstIssueId, wakeReason: "issue_assigned" },
});
expect(firstWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: firstIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: firstWake!.id,
body: "First assignment run completed.",
});
const firstRunStarted = await waitForCondition(async () => {
const run = await db
@ -439,7 +481,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
return run?.status === "running";
});
expect(firstRunStarted).toBe(true);
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1);
const firstAdapterStarted = await waitForCondition(async () => mockAdapterExecute.mock.calls.length === 1, 30_000);
expect(firstAdapterStarted).toBe(true);
const secondWake = await heartbeat.wakeup(agentId, {
@ -450,6 +492,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
contextSnapshot: { issueId: secondIssueId, wakeReason: "issue_assigned" },
});
expect(secondWake).not.toBeNull();
await db.insert(issueComments).values({
companyId,
issueId: secondIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: secondWake!.id,
body: "Second assignment run completed.",
});
const secondRunWhileFirstRunning = await db
.select({ status: heartbeatRuns.status })
@ -470,11 +520,11 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
return run?.status === "succeeded";
});
expect(secondRunSucceeded).toBe(true);
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
expect(mockAdapterExecute.mock.calls.length).toBeGreaterThanOrEqual(2);
} finally {
finishFirstRun();
}
});
}, 40_000);
it("cancels stale queued runs when issue blockers are still unresolved", async () => {
const companyId = randomUUID();
@ -598,6 +648,14 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
.update(agentWakeupRequests)
.set({ runId: readyRunId })
.where(eq(agentWakeupRequests.id, readyWakeupRequestId));
await db.insert(issueComments).values({
companyId,
issueId: readyIssueId,
authorAgentId: agentId,
authorType: "agent",
createdByRunId: readyRunId,
body: "Ready queued run completed.",
});
await db
.update(issues)
.set({
@ -665,7 +723,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
executionLockedAt: null,
});
expect(readyRun?.status).toBe("succeeded");
expect(mockAdapterExecute).toHaveBeenCalledTimes(1);
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
});
it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => {

View file

@ -320,6 +320,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(escalations[0]).toMatchObject({
parentId: blockerIssueId,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
status: expect.stringMatching(/^(todo|in_progress|done)$/),
originFingerprint: [
"harness_liveness_leaf",
@ -568,6 +569,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
executionWorkspaceId: null,
executionWorkspacePreference: null,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
});

View file

@ -23,6 +23,7 @@ import {
issueRelations,
issueTreeHoldMembers,
issueTreeHolds,
issueWorkProducts,
issues,
} from "@paperclipai/db";
import {
@ -69,7 +70,15 @@ vi.mock("../adapters/index.ts", async () => {
};
});
import { heartbeatService } from "../services/heartbeat.ts";
import {
heartbeatService,
redactDetectedSuccessfulRunProgressSummaryForBoard,
} from "../services/heartbeat.ts";
import {
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
} from "../services/recovery/index.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -313,6 +322,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(costEvents);
await db.delete(environmentLeases);
await db.delete(environments);
await db.delete(issueWorkProducts);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
@ -709,6 +719,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
originId: input.issueId,
originRunId: input.runId,
priority: "medium",
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
expect(recovery.title).toContain("Recover stalled issue");
expect(recovery.description).toContain(`Previous source status: \`${input.previousStatus}\``);
@ -743,6 +754,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
companyId: input.companyId,
reason: "issue_assigned",
source: "assignment",
payload: expect.objectContaining({ modelProfile: "cheap" }),
});
const recoveryRun = recoveryWakeup?.runId
@ -758,6 +770,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
source: "stranded_issue_recovery",
sourceIssueId: input.issueId,
strandedRunId: input.runId,
modelProfile: "cheap",
});
return recovery;
@ -915,6 +928,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(retryRun?.status).toBe("queued");
expect(retryRun?.retryOfRunId).toBe(runId);
expect(retryRun?.processLossRetryCount).toBe(1);
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
const issue = await db
.select()
@ -1227,7 +1241,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect((failedRun?.resultJson as Record<string, unknown> | null)?.errorFamily).toBe("transient_upstream");
expect(retryRun?.status).toBe("scheduled_retry");
expect(retryRun?.scheduledRetryReason).toBe("transient_failure");
expect((retryRun?.contextSnapshot as Record<string, unknown> | null)?.codexTransientFallbackMode).toBe("same_session");
expect(retryRun?.contextSnapshot).toMatchObject({
codexTransientFallbackMode: "same_session",
modelProfile: "cheap",
});
const issue = await db
.select()
@ -1241,6 +1258,448 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments).toHaveLength(0);
});
it("queues one finish-handoff wake when a successful run leaves in-progress work without a next action", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Implemented the backend detector, but did not choose a final issue state.",
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented the backend detector, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(1);
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
expect(handoffWakeups[0]?.payload).toMatchObject({
issueId,
sourceRunId: runId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
handoffAttempt: 1,
maxHandoffAttempts: 1,
resumeIntent: true,
resumeFromRunId: runId,
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(handoffComment).toBeTruthy();
expect(handoffComment?.authorType).toBe("system");
expect(handoffComment?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
detailsDefaultOpen: false,
});
expect(handoffComment?.metadata).toMatchObject({
version: 1,
sections: expect.arrayContaining([
expect.objectContaining({
title: "Required action",
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "run_link", runId }),
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
]),
}),
]),
});
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
expect(activity.some((event) => event.action === "issue.successful_run_handoff_required")).toBe(true);
});
it("requeues a missing-disposition handoff when the previous corrective wake was cancelled", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const idempotencyKey = `finish_successful_run_handoff:${issueId}:${runId}:1`;
await db.insert(agentWakeupRequests).values({
id: randomUUID(),
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "finish_successful_run_handoff",
payload: {
issueId,
sourceRunId: runId,
handoffRequired: true,
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
},
status: "cancelled",
idempotencyKey,
requestedAt: new Date("2026-03-19T00:00:01.000Z"),
finishedAt: new Date("2026-03-19T00:00:02.000Z"),
updatedAt: new Date("2026-03-19T00:00:02.000Z"),
});
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Implemented recovery handling, but did not choose a final issue state.",
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Implemented recovery handling, but did not choose a final issue state.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.idempotencyKey, idempotencyKey));
const requeued = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return requeued.length > 1 ? requeued : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(2);
expect(handoffWakeups.filter((wakeup) => wakeup.status === "cancelled")).toHaveLength(1);
expect(handoffWakeups.some((wakeup) => wakeup.status !== "cancelled")).toBe(true);
});
it("queues one missing-disposition handoff for artifact-producing successful runs left in progress", async () => {
const { companyId, agentId, runId, issueId } = await seedQueuedIssueRunFixture();
mockAdapterExecute.mockImplementationOnce(async (ctx: { runId: string }) => {
const documentId = randomUUID();
const revisionId = randomUUID();
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: agentId,
createdByRunId: ctx.runId,
body: "Drafted the Phase 3 test plan but did not choose a final issue disposition.",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Regression test plan",
format: "markdown",
latestBody: "# Regression test plan\n\n- Cover artifact-producing successful runs",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: agentId,
updatedByAgentId: agentId,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Regression test plan",
format: "markdown",
body: "# Regression test plan\n\n- Cover artifact-producing successful runs",
createdByAgentId: agentId,
createdByRunId: ctx.runId,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(issueWorkProducts).values({
companyId,
issueId,
type: "report",
provider: "test",
externalId: "phase-3-report",
title: "Phase 3 regression notes",
status: "ready",
summary: "Successful run produced a visible artifact.",
createdByRunId: ctx.runId,
});
return {
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Created comments, a plan document, and a work product without choosing a disposition.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const settledRun = await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
const classifiedRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
expect(classifiedRun?.status ?? settledRun?.status).toBe("succeeded");
expect(classifiedRun?.livenessState).toBe("advanced");
expect(handoffWakeups).toHaveLength(1);
expect(handoffWakeups[0]?.idempotencyKey).toBe(`finish_successful_run_handoff:${issueId}:${runId}:1`);
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("in_progress");
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments.filter((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toHaveLength(1);
expect(comments.some((comment) => comment.body.startsWith("Drafted the Phase 3 test plan"))).toBe(true);
const workProducts = await db.select().from(issueWorkProducts).where(eq(issueWorkProducts.issueId, issueId));
expect(workProducts).toHaveLength(1);
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(0);
});
it("redacts secret-bearing successful-run detected progress before handoff disclosure", async () => {
const { agentId, runId, issueId } = await seedQueuedIssueRunFixture();
const bearerSecret = "live-bearer-token-value";
const apiKeySecret = "sk-testsuccessfulhandoffsecret";
const redactedDetectedSummary = redactDetectedSuccessfulRunProgressSummaryForBoard(
`Next action noted: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
{ enabled: false },
);
expect(redactedDetectedSummary).toContain("***REDACTED***");
expect(redactedDetectedSummary).not.toContain(bearerSecret);
expect(redactedDetectedSummary).not.toContain(apiKeySecret);
mockAdapterExecute.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Made progress but left the issue open.",
resultJson: {
message: `Next action: Authorization: Bearer ${bearerSecret} OPENAI_API_KEY=${apiKeySecret}`,
},
provider: "test",
model: "test-model",
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await waitForRunToSettle(heartbeat, runId, 5_000);
const handoffWakeups = await waitForValue(async () => {
const rows = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const matches = rows.filter((wakeup) => wakeup.reason === "finish_successful_run_handoff");
return matches.length > 0 ? matches : null;
}, 5_000);
await waitForHeartbeatIdle(db, 5_000);
expect(handoffWakeups).toHaveLength(1);
const wakeupPayloadText = JSON.stringify(handoffWakeups[0]?.payload ?? {});
expect(wakeupPayloadText).not.toContain(bearerSecret);
expect(wakeupPayloadText).not.toContain(apiKeySecret);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const handoffComment = comments.find((comment) => comment.body === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
expect(handoffComment).toBeTruthy();
expect(handoffComment?.body).not.toContain(bearerSecret);
expect(handoffComment?.body).not.toContain(apiKeySecret);
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(bearerSecret);
expect(JSON.stringify(handoffComment?.metadata ?? {})).not.toContain(apiKeySecret);
const activity = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, issueId));
const handoffActivity = activity.find((event) => event.action === "issue.successful_run_handoff_required");
expect(handoffActivity).toBeTruthy();
const activityDetailsText = JSON.stringify(handoffActivity?.details ?? {});
expect(activityDetailsText).not.toContain(bearerSecret);
expect(activityDetailsText).not.toContain(apiKeySecret);
});
it("escalates an exhausted failed successful-run handoff without using generic continuation recovery first", async () => {
const { companyId, agentId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "failed",
runErrorCode: "adapter_failed",
runError: "Authorization: Bearer sk-test-successful-handoff-secret",
});
const sourceRunId = randomUUID();
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "finish_successful_run_handoff",
sourceRunId,
resumeFromRunId: sourceRunId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
},
})
.where(eq(heartbeatRuns.id, runId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.escalated).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const recovery = await waitForValue(async () =>
db.select().from(issues).where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "stranded_issue_recovery"),
eq(issues.originId, issueId),
),
).then((rows) => rows[0] ?? null),
);
expect(recovery?.assigneeAgentId).toBe(agentId);
expect(recovery?.title).toContain("Recover missing next step");
expect(recovery?.description).toContain("Normalized cause: `successful_run_missing_state`");
expect(recovery?.description).toContain("not a runtime/adapter crash report");
expect(recovery?.description).toContain(`Source run: [\`${sourceRunId}\`]`);
expect(recovery?.description).toContain("Missing disposition: `clear_next_step`");
expect(recovery?.description).toContain("Source assignee: [CodexCoder]");
expect(recovery?.description).not.toContain("sk-test-successful-handoff-secret");
const sourceIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(sourceIssue?.status).toBe("blocked");
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recovery?.id]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments[0]?.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
expect(comments[0]?.authorType).toBe("system");
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "danger",
detailsDefaultOpen: false,
});
expect(comments[0]?.metadata).toMatchObject({
version: 1,
sections: expect.arrayContaining([
expect.objectContaining({
title: "Recovery owner",
rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: recovery?.identifier }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CodexCoder" }),
]),
}),
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
}),
]),
});
expect(comments[0]?.body).not.toContain("sk-test-successful-handoff-secret");
expect(JSON.stringify(comments[0]?.metadata ?? {})).not.toContain("sk-test-successful-handoff-secret");
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
expect(activity.some((event) => event.action === "issue.successful_run_handoff_escalated")).toBe(true);
});
it("escalates an exhausted successful handoff run that still leaves no disposition", async () => {
const { companyId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
livenessState: "advanced",
});
const sourceRunId = randomUUID();
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "finish_successful_run_handoff",
sourceRunId,
resumeFromRunId: sourceRunId,
handoffRequired: true,
handoffReason: "successful_run_missing_state",
missingDisposition: "clear_next_step",
handoffAttempt: 1,
maxHandoffAttempts: 1,
},
})
.where(eq(heartbeatRuns.id, runId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.successfulContinuationObserved).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1);
const recovery = await waitForValue(async () =>
db.select().from(issues).where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "stranded_issue_recovery"),
eq(issues.originId, issueId),
),
).then((rows) => rows[0] ?? null),
);
expect(recovery?.description).toContain("Latest handoff run status: `succeeded`");
expect(recovery?.description).toContain("Suggested");
});
it("clears the detached warning when the run reports activity again", async () => {
const { runId } = await seedRunFixture({
includeIssue: false,
@ -1315,6 +1774,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
@ -1326,6 +1786,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
taskId: issueId,
wakeReason: "issue_assigned",
source: "issue.assigned_todo_liveness_dispatch",
modelProfile: "cheap",
});
expect((runs[0]?.contextSnapshot as Record<string, unknown>)?.retryReason).toBeUndefined();
@ -1433,6 +1894,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
payload: expect.objectContaining({
issueId: unblocked.issueId,
mutation: "assigned_todo_liveness_dispatch",
modelProfile: "cheap",
}),
});
const unblockedRuns = await db
@ -1486,6 +1948,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@ -1524,6 +1987,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.continuation_recovery",
modelProfile: "cheap",
});
const recoveries = await db
@ -1575,6 +2039,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@ -1738,6 +2203,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const retryRun = runs.find((row) => row.id !== runId);
expect(retryRun?.id).toBeTruthy();
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("issue_continuation_needed");
expect(retryRun?.contextSnapshot).toMatchObject({ modelProfile: "cheap" });
if (retryRun) {
await waitForRunToSettle(heartbeat, retryRun.id);
}
@ -2215,6 +2681,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
@ -2281,6 +2748,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
});
@ -2336,6 +2804,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed",
retryOfRunId: runId,
source: "issue.productive_terminal_continuation_recovery",
modelProfile: "cheap",
});
});

View file

@ -286,6 +286,7 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
retryOfRunId: sourceRunId,
scheduledRetryAttempt: 1,
scheduledRetryReason: "transient_failure",
contextSnapshot: expect.objectContaining({ modelProfile: "cheap" }),
});
expect(retryRun?.scheduledRetryAt?.toISOString()).toBe(expectedDueAt.toISOString());

View file

@ -38,7 +38,12 @@ const mockTxInsert = vi.hoisted(() => vi.fn(() => ({ values: mockTxInsertValues
const mockTx = vi.hoisted(() => ({
insert: mockTxInsert,
}));
const mockDbSelectOrderBy = vi.hoisted(() => vi.fn(async () => []));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ orderBy: mockDbSelectOrderBy })));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
}));
const mockFeedbackService = vi.hoisted(() => ({
@ -236,9 +241,17 @@ describe.sequential("issue comment reopen routes", () => {
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDbSelect.mockReset();
mockDbSelectFrom.mockReset();
mockDbSelectWhere.mockReset();
mockDbSelectOrderBy.mockReset();
mockDb.transaction.mockReset();
mockTxInsertValues.mockResolvedValue(undefined);
mockTxInsert.mockImplementation(() => ({ values: mockTxInsertValues }));
mockDbSelectOrderBy.mockResolvedValue([]);
mockDbSelectWhere.mockImplementation(() => ({ orderBy: mockDbSelectOrderBy }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx));
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
@ -545,6 +558,68 @@ describe.sequential("issue comment reopen routes", () => {
));
});
it("passes validated comment presentation fields to trusted board comment writes", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
issueId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
body: "Paperclip needs a disposition before this issue can continue.",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata: {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
},
createdAt: new Date(),
updatedAt: new Date(),
});
mockIssueService.findMentionedAgents.mockResolvedValue([]);
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
const presentation = { kind: "system_notice", tone: "warning" };
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
});
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: "local-board", runId: null },
{
authorType: "user",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata,
},
);
});
it("rejects invalid comment metadata before writing a comment", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
const res = await request(app)
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({
body: "Invalid metadata",
metadata: { version: 1, arbitrary: true },
});
expect(res.status).toBe(400);
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("does not move dependency-blocked issues to todo via POST comments", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("blocked"));
mockIssueService.getDependencyReadiness.mockResolvedValue({

View file

@ -372,6 +372,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
issueId,
clearReason: "max_attempts_exhausted",
maxAttempts: 1,
modelProfile: "cheap",
});
const activity = await db
@ -414,6 +415,7 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
expect(recoveryIssue).toMatchObject({
parentId: issueId,
priority: "high",
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
expect(["todo", "in_progress"]).toContain(recoveryIssue?.status);
});

View file

@ -91,6 +91,11 @@ const mockWorkProductService = vi.hoisted(() => ({
const mockEnvironmentService = vi.hoisted(() => ({}));
const mockDb = vi.hoisted(() => ({
select: vi.fn(),
execute: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
@ -130,7 +135,7 @@ function createApp() {
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
@ -186,6 +191,14 @@ describe.sequential("issue goal context routes", () => {
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockDb.select.mockReturnValue({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(async () => []),
})),
})),
});
mockDb.execute.mockResolvedValue([]);
mockProjectService.getById.mockResolvedValue({
id: legacyProjectLinkedIssue.projectId,
companyId: "company-1",

View file

@ -201,6 +201,7 @@ describeEmbeddedPostgres("productivity review service", () => {
expect(reviews).toHaveLength(1);
expect(reviews[0]?.parentId).toBe(seeded.issueId);
expect(reviews[0]?.assigneeAgentId).toBe(seeded.managerId);
expect(reviews[0]?.assigneeAdapterOverrides).toEqual({ modelProfile: "cheap" });
expect(reviews[0]?.originId).toBe(seeded.issueId);
expect(reviews[0]?.originFingerprint).toBe(`productivity-review:${seeded.issueId}`);
expect(reviews[0]?.description).toContain("Primary trigger: `no_comment_streak`");

View file

@ -76,10 +76,12 @@ describe("run liveness continuations", () => {
continuationAttempt: 1,
maxContinuationAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
instruction: "Take the first concrete action now.",
modelProfile: "cheap",
});
expect(decision.contextSnapshot).toMatchObject({
issueId,
wakeReason: RUN_LIVENESS_CONTINUATION_REASON,
modelProfile: "cheap",
livenessContinuationAttempt: 1,
livenessContinuationMaxAttempts: DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
livenessContinuationSourceRunId: runId,

View file

@ -146,6 +146,7 @@ vi.mock("../services/index.js", () => ({
reconcileStrandedAssignedIssues: vi.fn(async () => ({
dispatchRequeued: 0,
continuationRequeued: 0,
successfulRunHandoffEscalated: 0,
escalated: 0,
skipped: 0,
issueIds: [],