mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
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:
parent
50db8c01d2
commit
454edfe81e
70 changed files with 21919 additions and 125 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ vi.mock("../services/index.js", () => ({
|
|||
reconcileStrandedAssignedIssues: vi.fn(async () => ({
|
||||
dispatchRequeued: 0,
|
||||
continuationRequeued: 0,
|
||||
successfulRunHandoffEscalated: 0,
|
||||
escalated: 0,
|
||||
skipped: 0,
|
||||
issueIds: [],
|
||||
|
|
|
|||
|
|
@ -686,6 +686,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
reconciled.assignmentDispatched > 0 ||
|
||||
reconciled.dispatchRequeued > 0 ||
|
||||
reconciled.continuationRequeued > 0 ||
|
||||
reconciled.successfulRunHandoffEscalated > 0 ||
|
||||
reconciled.escalated > 0
|
||||
) {
|
||||
logger.warn(
|
||||
|
|
@ -751,6 +752,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
reconciled.assignmentDispatched > 0 ||
|
||||
reconciled.dispatchRequeued > 0 ||
|
||||
reconciled.continuationRequeued > 0 ||
|
||||
reconciled.successfulRunHandoffEscalated > 0 ||
|
||||
reconciled.escalated > 0
|
||||
) {
|
||||
logger.warn(
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
|
|||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { issueExecutionDecisions } from "@paperclipai/db";
|
||||
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
acceptIssueThreadInteractionSchema,
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
isClosedIsolatedExecutionWorkspace,
|
||||
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
||||
type ExecutionWorkspace,
|
||||
type SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
|
@ -78,6 +80,7 @@ import { executionWorkspaceService as executionWorkspaceServiceDirect } from "..
|
|||
import { feedbackService } from "../services/feedback.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { redactSensitiveText } from "../redaction.js";
|
||||
import {
|
||||
applyIssueExecutionPolicyTransition,
|
||||
normalizeIssueExecutionPolicy,
|
||||
|
|
@ -113,6 +116,103 @@ type ExecutionStageWakeContext = {
|
|||
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
|
||||
allowedActions: string[];
|
||||
};
|
||||
type SuccessfulRunHandoffActivityRow = {
|
||||
entityId: string;
|
||||
action: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
|
||||
"issue.successful_run_handoff_required",
|
||||
"issue.successful_run_handoff_resolved",
|
||||
"issue.successful_run_handoff_escalated",
|
||||
] as const;
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function successfulRunHandoffStateFromActivity(row: {
|
||||
action: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
}): SuccessfulRunHandoffState | null {
|
||||
const details = row.details ?? {};
|
||||
const state =
|
||||
row.action === "issue.successful_run_handoff_required"
|
||||
? "required"
|
||||
: row.action === "issue.successful_run_handoff_resolved"
|
||||
? "resolved"
|
||||
: row.action === "issue.successful_run_handoff_escalated"
|
||||
? "escalated"
|
||||
: null;
|
||||
if (!state) return null;
|
||||
|
||||
const detectedProgressSummary =
|
||||
readNonEmptyString(details.detectedProgressSummary)
|
||||
?? readNonEmptyString(details.detected_progress_summary)
|
||||
?? null;
|
||||
|
||||
return {
|
||||
state,
|
||||
required: state === "required",
|
||||
sourceRunId:
|
||||
readNonEmptyString(details.sourceRunId)
|
||||
?? readNonEmptyString(details.source_run_id)
|
||||
?? readNonEmptyString(details.resumeFromRunId)
|
||||
?? row.runId
|
||||
?? null,
|
||||
correctiveRunId:
|
||||
readNonEmptyString(details.correctiveRunId)
|
||||
?? readNonEmptyString(details.corrective_run_id)
|
||||
?? (state !== "required" ? row.runId : null),
|
||||
assigneeAgentId:
|
||||
readNonEmptyString(details.assigneeAgentId)
|
||||
?? readNonEmptyString(details.agentId)
|
||||
?? row.agentId
|
||||
?? null,
|
||||
detectedProgressSummary: detectedProgressSummary
|
||||
? redactSensitiveText(detectedProgressSummary)
|
||||
: null,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSuccessfulRunHandoffStates(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
issueIds: string[],
|
||||
): Promise<Map<string, SuccessfulRunHandoffState>> {
|
||||
if (issueIds.length === 0) return new Map();
|
||||
const result = await db.execute(sql`
|
||||
SELECT DISTINCT ON (${activityLog.entityId})
|
||||
${activityLog.entityId} AS "entityId",
|
||||
${activityLog.action} AS "action",
|
||||
${activityLog.agentId} AS "agentId",
|
||||
${activityLog.runId} AS "runId",
|
||||
${activityLog.details} AS "details",
|
||||
${activityLog.createdAt} AS "createdAt"
|
||||
FROM ${activityLog}
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.entityId} IN (${sql.join(issueIds.map((id) => sql`${id}`), sql`, `)})
|
||||
AND ${activityLog.action} IN (${sql.join(SUCCESSFUL_RUN_HANDOFF_ACTIONS.map((action) => sql`${action}`), sql`, `)})
|
||||
ORDER BY ${activityLog.entityId}, ${activityLog.createdAt} DESC, ${activityLog.id} DESC
|
||||
`);
|
||||
const rows = Array.from(result as Iterable<SuccessfulRunHandoffActivityRow>);
|
||||
|
||||
const states = new Map<string, SuccessfulRunHandoffState>();
|
||||
for (const row of rows) {
|
||||
const state = successfulRunHandoffStateFromActivity(row);
|
||||
if (state) states.set(row.entityId, state);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
function executionPrincipalsEqual(
|
||||
left: ParsedExecutionState["currentParticipant"] | null,
|
||||
|
|
@ -1033,7 +1133,15 @@ export function issueRoutes(
|
|||
limit,
|
||||
offset,
|
||||
});
|
||||
res.json(result);
|
||||
const handoffStates = await listSuccessfulRunHandoffStates(
|
||||
db,
|
||||
companyId,
|
||||
result.map((issue) => issue.id),
|
||||
);
|
||||
res.json(result.map((issue) => ({
|
||||
...issue,
|
||||
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
|
||||
})));
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/labels", async (req, res) => {
|
||||
|
|
@ -1221,6 +1329,7 @@ export function issueRoutes(
|
|||
blockerAttention,
|
||||
productivityReview,
|
||||
referenceSummary,
|
||||
successfulRunHandoffStates,
|
||||
] = await Promise.all([
|
||||
resolveIssueProjectAndGoal(issue),
|
||||
svc.getAncestors(issue.id),
|
||||
|
|
@ -1230,6 +1339,7 @@ export function issueRoutes(
|
|||
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
|
||||
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
|
||||
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
||||
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
|
||||
]);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
|
|
@ -1244,6 +1354,7 @@ export function issueRoutes(
|
|||
ancestors,
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
productivityReview,
|
||||
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
|
||||
blockedBy: relations.blockedBy,
|
||||
blocks: relations.blocks,
|
||||
relatedWork: referenceSummary,
|
||||
|
|
@ -3688,6 +3799,10 @@ export function issueRoutes(
|
|||
agentId: actor.agentId ?? undefined,
|
||||
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
||||
runId: actor.runId,
|
||||
}, {
|
||||
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
|
||||
presentation: req.body.presentation ?? null,
|
||||
metadata: req.body.metadata ?? null,
|
||||
});
|
||||
await issueReferencesSvc.syncComment(comment.id);
|
||||
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityInclude,
|
||||
CompanyPortabilityManifest,
|
||||
CompanyPortabilityIssueCommentManifestEntry,
|
||||
CompanyPortabilityPreview,
|
||||
CompanyPortabilityPreviewAgentPlan,
|
||||
CompanyPortabilityPreviewResult,
|
||||
|
|
@ -42,6 +43,9 @@ import {
|
|||
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||
deriveProjectUrlKey,
|
||||
envConfigSchema,
|
||||
issueCommentAuthorTypeSchema,
|
||||
issueCommentMetadataSchema,
|
||||
issueCommentPresentationSchema,
|
||||
normalizeAgentUrlKey,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
|
|
@ -644,6 +648,96 @@ function asInteger(value: unknown): number | null {
|
|||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function hasOwn(record: Record<string, unknown>, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(record, key);
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const entries = value.filter((entry): entry is string => typeof entry === "string");
|
||||
return entries.length === value.length ? entries : null;
|
||||
}
|
||||
|
||||
function derivePortableCommentAuthorType(value: Record<string, unknown>) {
|
||||
const explicit = issueCommentAuthorTypeSchema.safeParse(value.authorType);
|
||||
if (explicit.success) return explicit.data;
|
||||
return asString(value.authorAgentSlug) ? "agent" : asString(value.authorUserId) ? "user" : "system";
|
||||
}
|
||||
|
||||
function readPortableIssueComments(
|
||||
value: unknown,
|
||||
warnings: string[],
|
||||
sourceLabel: string,
|
||||
): CompanyPortabilityIssueCommentManifestEntry[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) {
|
||||
warnings.push(`${sourceLabel} comments were ignored because they are not an array.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const comments: CompanyPortabilityIssueCommentManifestEntry[] = [];
|
||||
for (const [index, entry] of value.entries()) {
|
||||
if (!isPlainRecord(entry)) {
|
||||
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it is not an object.`);
|
||||
continue;
|
||||
}
|
||||
const body = asString(entry.body);
|
||||
if (!body) {
|
||||
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it has no body.`);
|
||||
continue;
|
||||
}
|
||||
const presentation = entry.presentation == null ? null : issueCommentPresentationSchema.safeParse(entry.presentation);
|
||||
if (presentation && !presentation.success) {
|
||||
warnings.push(`${sourceLabel} comment ${index + 1} has invalid presentation metadata and was ignored.`);
|
||||
continue;
|
||||
}
|
||||
const metadata = entry.metadata == null ? null : issueCommentMetadataSchema.safeParse(entry.metadata);
|
||||
if (metadata && !metadata.success) {
|
||||
warnings.push(`${sourceLabel} comment ${index + 1} has invalid hidden metadata and was ignored.`);
|
||||
continue;
|
||||
}
|
||||
const createdAt = asString(entry.createdAt);
|
||||
comments.push({
|
||||
body,
|
||||
authorType: derivePortableCommentAuthorType(entry),
|
||||
authorAgentSlug: asString(entry.authorAgentSlug),
|
||||
authorUserId: asString(entry.authorUserId),
|
||||
presentation: presentation ? presentation.data : null,
|
||||
metadata: metadata ? metadata.data : null,
|
||||
createdAt: createdAt && Number.isNaN(Date.parse(createdAt)) ? null : createdAt,
|
||||
});
|
||||
}
|
||||
return comments;
|
||||
}
|
||||
|
||||
function appendCodexImportArg(adapterConfig: Record<string, unknown>, arg: string) {
|
||||
const extraArgs = readStringArray(adapterConfig.extraArgs);
|
||||
if (extraArgs) {
|
||||
if (!extraArgs.includes(arg)) adapterConfig.extraArgs = [...extraArgs, arg];
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyArgs = readStringArray(adapterConfig.args);
|
||||
if (legacyArgs && legacyArgs.length > 0) {
|
||||
if (!legacyArgs.includes(arg)) adapterConfig.args = [...legacyArgs, arg];
|
||||
return;
|
||||
}
|
||||
|
||||
if (legacyArgs?.includes(arg)) return;
|
||||
adapterConfig.extraArgs = [arg];
|
||||
}
|
||||
|
||||
function applyImportAdapterRunDefaults(
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
const next = { ...adapterConfig };
|
||||
if (adapterType === "codex_local") {
|
||||
appendCodexImportArg(next, "--skip-git-repo-check");
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIssueRoutineTriggerManifestEntry | null {
|
||||
if (!isPlainRecord(value)) return null;
|
||||
const kind = asString(value.kind);
|
||||
|
|
@ -2685,6 +2779,7 @@ function buildManifestFromPackageFiles(
|
|||
assigneeAdapterOverrides: isPlainRecord(extension.assigneeAdapterOverrides)
|
||||
? extension.assigneeAdapterOverrides
|
||||
: null,
|
||||
comments: readPortableIssueComments(extension.comments, warnings, `Task ${slug}`),
|
||||
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
||||
});
|
||||
if (frontmatter.kind && frontmatter.kind !== "task") {
|
||||
|
|
@ -2804,7 +2899,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
|
||||
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
|
||||
}
|
||||
const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);
|
||||
const nextAdapterConfig = writePaperclipSkillSyncPreference(
|
||||
applyImportAdapterRunDefaults(effectiveAdapterType, adapterConfig),
|
||||
desiredSkills,
|
||||
);
|
||||
delete nextAdapterConfig.promptTemplate;
|
||||
delete nextAdapterConfig.bootstrapPromptTemplate;
|
||||
delete nextAdapterConfig.instructionsFilePath;
|
||||
|
|
@ -3380,6 +3478,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
});
|
||||
}
|
||||
}
|
||||
const comments = await issuesSvc.listComments(issue.id, { order: "asc" });
|
||||
files[taskPath] = buildMarkdown(
|
||||
{
|
||||
name: issue.title,
|
||||
|
|
@ -3397,6 +3496,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
projectWorkspaceKey: projectWorkspaceKey ?? undefined,
|
||||
executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined,
|
||||
assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined,
|
||||
comments: comments.length > 0
|
||||
? comments.map((comment) => ({
|
||||
body: comment.body,
|
||||
authorType: comment.authorType,
|
||||
authorAgentSlug: comment.authorAgentId ? (idToSlug.get(comment.authorAgentId) ?? null) : null,
|
||||
// Portable bundles preserve author kind, but not raw board user ids.
|
||||
authorUserId: null,
|
||||
presentation: comment.presentation,
|
||||
metadata: comment.metadata,
|
||||
createdAt: comment.createdAt instanceof Date
|
||||
? comment.createdAt.toISOString()
|
||||
: new Date(comment.createdAt).toISOString(),
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {};
|
||||
}
|
||||
|
|
@ -4496,7 +4609,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
|
||||
issueStatus = "todo";
|
||||
}
|
||||
await issues.create(targetCompany.id, {
|
||||
const createdIssue = await issues.create(targetCompany.id, {
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
title: manifestIssue.title,
|
||||
|
|
@ -4511,6 +4624,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings,
|
||||
labelIds: manifestIssue.labelIds ?? [],
|
||||
});
|
||||
for (const comment of manifestIssue.comments ?? []) {
|
||||
const authorAgentId = comment.authorType === "agent" && comment.authorAgentSlug
|
||||
? importedSlugToAgentId.get(comment.authorAgentSlug)
|
||||
?? existingSlugToAgentId.get(comment.authorAgentSlug)
|
||||
?? null
|
||||
: null;
|
||||
if (comment.authorType === "agent" && comment.authorAgentSlug && !authorAgentId) {
|
||||
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because author agent ${comment.authorAgentSlug} was not imported.`);
|
||||
}
|
||||
const authorType = authorAgentId
|
||||
? "agent"
|
||||
: comment.authorType === "user"
|
||||
? "user"
|
||||
: "system";
|
||||
await issues.addComment(createdIssue.id, comment.body, {
|
||||
agentId: authorAgentId ?? undefined,
|
||||
userId: authorType === "user" ? actorUserId ?? undefined : undefined,
|
||||
}, {
|
||||
authorType,
|
||||
presentation: comment.presentation,
|
||||
metadata: comment.metadata,
|
||||
createdAt: comment.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,9 @@ type FeedbackTargetRecord = {
|
|||
createdAt: Date;
|
||||
authorAgentId: string | null;
|
||||
authorUserId: string | null;
|
||||
authorType?: string | null;
|
||||
presentation?: unknown;
|
||||
metadata?: unknown;
|
||||
createdByRunId: string | null;
|
||||
documentId: string | null;
|
||||
documentKey: string | null;
|
||||
|
|
@ -797,6 +800,9 @@ async function resolveFeedbackTarget(
|
|||
companyId: issueComments.companyId,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
authorType: issueComments.authorType,
|
||||
presentation: issueComments.presentation,
|
||||
metadata: issueComments.metadata,
|
||||
createdByRunId: issueComments.createdByRunId,
|
||||
body: issueComments.body,
|
||||
createdAt: issueComments.createdAt,
|
||||
|
|
@ -820,6 +826,9 @@ async function resolveFeedbackTarget(
|
|||
createdAt: targetComment.createdAt,
|
||||
authorAgentId: targetComment.authorAgentId,
|
||||
authorUserId: targetComment.authorUserId,
|
||||
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
|
||||
presentation: targetComment.presentation ?? null,
|
||||
metadata: targetComment.metadata ?? null,
|
||||
createdByRunId: targetComment.createdByRunId ?? null,
|
||||
documentId: null,
|
||||
documentKey: null,
|
||||
|
|
@ -833,6 +842,9 @@ async function resolveFeedbackTarget(
|
|||
createdAt: targetComment.createdAt.toISOString(),
|
||||
authorAgentId: targetComment.authorAgentId,
|
||||
authorUserId: targetComment.authorUserId,
|
||||
authorType: targetComment.authorType ?? (targetComment.authorAgentId ? "agent" : targetComment.authorUserId ? "user" : "system"),
|
||||
presentation: targetComment.presentation ?? null,
|
||||
metadata: targetComment.metadata ?? null,
|
||||
createdByRunId: targetComment.createdByRunId ?? null,
|
||||
issuePath,
|
||||
targetPath: issuePath ? `${issuePath}#comment-${targetComment.id}` : null,
|
||||
|
|
@ -918,6 +930,9 @@ async function listIssueContextItems(
|
|||
createdAt: issueComments.createdAt,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
authorType: issueComments.authorType,
|
||||
presentation: issueComments.presentation,
|
||||
metadata: issueComments.metadata,
|
||||
createdByRunId: issueComments.createdByRunId,
|
||||
})
|
||||
.from(issueComments)
|
||||
|
|
@ -952,6 +967,9 @@ async function listIssueContextItems(
|
|||
createdAt: row.createdAt,
|
||||
authorAgentId: row.authorAgentId,
|
||||
authorUserId: row.authorUserId,
|
||||
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
|
||||
presentation: row.presentation ?? null,
|
||||
metadata: row.metadata ?? null,
|
||||
createdByRunId: row.createdByRunId ?? null,
|
||||
documentId: null,
|
||||
documentKey: null,
|
||||
|
|
@ -1023,6 +1041,9 @@ async function buildIssueContext(
|
|||
createdAt: item.createdAt.toISOString(),
|
||||
authorAgentId: item.authorAgentId,
|
||||
authorUserId: item.authorUserId,
|
||||
authorType: item.authorType ?? null,
|
||||
presentation: item.presentation ?? null,
|
||||
metadata: item.metadata ?? null,
|
||||
createdByRunId: item.createdByRunId,
|
||||
documentKey: item.documentKey,
|
||||
documentTitle: item.documentTitle,
|
||||
|
|
|
|||
|
|
@ -26,13 +26,16 @@ import {
|
|||
agentTaskSessions,
|
||||
agentWakeupRequests,
|
||||
activityLog,
|
||||
approvals,
|
||||
companySkills as companySkillsTable,
|
||||
documentRevisions,
|
||||
issueDocuments,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issueComments,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
issueWorkProducts,
|
||||
projects,
|
||||
|
|
@ -119,18 +122,33 @@ import {
|
|||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import {
|
||||
RECOVERY_ORIGIN_KINDS,
|
||||
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
RUN_LIVENESS_CONTINUATION_REASON,
|
||||
buildRunLivenessContinuationIdempotencyKey,
|
||||
buildFinishSuccessfulRunHandoffIdempotencyKey,
|
||||
buildSuccessfulRunHandoffRequiredNotice,
|
||||
decideRunLivenessContinuation,
|
||||
decideSuccessfulRunHandoff,
|
||||
findExistingFinishSuccessfulRunHandoffWake,
|
||||
findExistingRunLivenessContinuationWake,
|
||||
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
|
||||
readContinuationAttempt,
|
||||
} from "./recovery/index.js";
|
||||
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
|
||||
import {
|
||||
recoveryAssigneeAdapterOverrides,
|
||||
withRecoveryModelProfileHint,
|
||||
} from "./recovery/model-profile-hint.js";
|
||||
import { recoveryService } from "./recovery/service.js";
|
||||
import { productivityReviewService } from "./productivity-review.js";
|
||||
import { withAgentStartLock } from "./agent-start-lock.js";
|
||||
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import {
|
||||
redactCurrentUserText,
|
||||
redactCurrentUserValue,
|
||||
type CurrentUserRedactionOptions,
|
||||
} from "../log-redaction.js";
|
||||
import { redactEventPayload, redactSensitiveText } from "../redaction.js";
|
||||
import {
|
||||
hasSessionCompactionThresholds,
|
||||
resolveSessionCompactionPolicy,
|
||||
|
|
@ -150,6 +168,16 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
|||
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
|
||||
const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024;
|
||||
const MAX_RUN_EVENT_PAYLOAD_ARRAY_ITEMS = 50;
|
||||
|
||||
export function redactDetectedSuccessfulRunProgressSummaryForBoard(
|
||||
summary: string,
|
||||
currentUserRedactionOptions?: CurrentUserRedactionOptions,
|
||||
) {
|
||||
const normalized = summary.replace(/\s+/g, " ").trim();
|
||||
const redacted = redactSensitiveText(redactCurrentUserText(normalized, currentUserRedactionOptions));
|
||||
return redacted.length <= 280 ? redacted : `${redacted.slice(0, 277)}...`;
|
||||
}
|
||||
|
||||
const MAX_RUN_EVENT_PAYLOAD_OBJECT_KEYS = 100;
|
||||
const MAX_RUN_EVENT_PAYLOAD_DEPTH = 6;
|
||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = AGENT_DEFAULT_MAX_CONCURRENT_RUNS;
|
||||
|
|
@ -1837,8 +1865,11 @@ async function buildPaperclipWakePayload(input: {
|
|||
id: issueComments.id,
|
||||
issueId: issueComments.issueId,
|
||||
body: issueComments.body,
|
||||
authorType: issueComments.authorType,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
presentation: issueComments.presentation,
|
||||
metadata: issueComments.metadata,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
|
|
@ -1882,8 +1913,11 @@ async function buildPaperclipWakePayload(input: {
|
|||
comments.push({
|
||||
id: row.id,
|
||||
issueId: row.issueId,
|
||||
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
|
||||
body,
|
||||
bodyTruncated,
|
||||
presentation: row.presentation ?? null,
|
||||
metadata: row.metadata ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
author: row.authorAgentId
|
||||
? { type: "agent", id: row.authorAgentId }
|
||||
|
|
@ -2541,6 +2575,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
projectId: input.claimed.projectId,
|
||||
goalId: input.claimed.goalId,
|
||||
assigneeAgentId: input.claimed.assigneeAgentId,
|
||||
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
|
||||
originKind: RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
|
||||
originId: input.claimed.id,
|
||||
originFingerprint: `issue_monitor:${input.clearReason}`,
|
||||
|
|
@ -2554,15 +2589,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
triggerDetail: "system",
|
||||
reason: "issue_monitor_recovery_issue",
|
||||
idempotencyKey: `issue-monitor-recovery-issue:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
|
||||
payload: { issueId: recoveryIssue.id, sourceIssueId: input.claimed.id },
|
||||
payload: withRecoveryModelProfileHint({ issueId: recoveryIssue.id, sourceIssueId: input.claimed.id }),
|
||||
requestedByActorType: input.actorType,
|
||||
requestedByActorId: input.actorId,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: recoveryIssue.id,
|
||||
sourceIssueId: input.claimed.id,
|
||||
source: "issue.monitor.recovery_issue",
|
||||
wakeReason: "issue_monitor_recovery_issue",
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2615,7 +2650,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
triggerDetail: "system",
|
||||
reason: "issue_monitor_recovery",
|
||||
idempotencyKey: `issue-monitor-recovery:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: input.claimed.id,
|
||||
monitorAttemptCount: input.nextAttemptCount,
|
||||
monitorNotes: input.claimed.monitorNotes ?? null,
|
||||
|
|
@ -2623,10 +2658,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
serviceName: input.monitor?.serviceName ?? null,
|
||||
timeoutAt: input.monitor?.timeoutAt ?? null,
|
||||
maxAttempts: input.monitor?.maxAttempts ?? null,
|
||||
},
|
||||
}),
|
||||
requestedByActorType: input.actorType,
|
||||
requestedByActorId: input.actorId,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: input.claimed.id,
|
||||
source: "issue.monitor.recovery",
|
||||
wakeReason: "issue_monitor_recovery",
|
||||
|
|
@ -2636,7 +2671,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
serviceName: input.monitor?.serviceName ?? null,
|
||||
timeoutAt: input.monitor?.timeoutAt ?? null,
|
||||
maxAttempts: input.monitor?.maxAttempts ?? null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
|
|
@ -3817,6 +3852,287 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
}
|
||||
|
||||
function issueUiLink(issue: Pick<typeof issues.$inferSelect, "id" | "identifier">) {
|
||||
const label = issue.identifier ?? issue.id;
|
||||
const prefix = issue.identifier?.split("-")[0] || "PAP";
|
||||
return `[${label}](/${prefix}/issues/${label})`;
|
||||
}
|
||||
|
||||
async function buildDetectedSuccessfulRunProgressSummary(run: typeof heartbeatRuns.$inferSelect) {
|
||||
const resultJson = parseObject(run.resultJson);
|
||||
const candidates = [
|
||||
readNonEmptyString(run.nextAction) ? `Next action noted: ${readNonEmptyString(run.nextAction)}` : null,
|
||||
readNonEmptyString(run.livenessReason),
|
||||
readNonEmptyString(resultJson.summary),
|
||||
readNonEmptyString(resultJson.result),
|
||||
readNonEmptyString(resultJson.message),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
const summary = candidates[0];
|
||||
if (!summary) return null;
|
||||
return redactDetectedSuccessfulRunProgressSummaryForBoard(
|
||||
summary,
|
||||
await getCurrentUserRedactionOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
async function addSuccessfulRunHandoffCommentOnce(input: {
|
||||
issue: Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
|
||||
run: typeof heartbeatRuns.$inferSelect;
|
||||
agent: Pick<typeof agents.$inferSelect, "id" | "name">;
|
||||
detectedProgressSummary: string;
|
||||
}) {
|
||||
const existing = await db
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.run.companyId),
|
||||
eq(issueComments.issueId, input.issue.id),
|
||||
eq(issueComments.createdByRunId, input.run.id),
|
||||
sql`(${issueComments.body} = ${SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY} or ${issueComments.body} like '## This issue still needs a next step%' or ${issueComments.body} like '## Successful run missing issue disposition%')`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return null;
|
||||
const notice = buildSuccessfulRunHandoffRequiredNotice(input);
|
||||
return issuesSvc.addComment(
|
||||
input.issue.id,
|
||||
notice.body,
|
||||
{ runId: input.run.id },
|
||||
{
|
||||
authorType: "system",
|
||||
presentation: notice.presentation,
|
||||
metadata: notice.metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSuccessfulRunHandoff(run: typeof heartbeatRuns.$inferSelect, agent: typeof agents.$inferSelect) {
|
||||
if (run.status !== "succeeded") return;
|
||||
const context = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
|
||||
if (!issueId) return;
|
||||
|
||||
const issue = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
executionState: issues.executionState,
|
||||
projectId: issues.projectId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const idempotencyKey = issue
|
||||
? buildFinishSuccessfulRunHandoffIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
})
|
||||
: null;
|
||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null);
|
||||
const detectedProgressSummary = await buildDetectedSuccessfulRunProgressSummary(run);
|
||||
|
||||
const [
|
||||
activeExecutionPath,
|
||||
queuedWake,
|
||||
pendingInteraction,
|
||||
pendingApproval,
|
||||
explicitBlocker,
|
||||
openRecoveryIssue,
|
||||
existingWake,
|
||||
budgetBlock,
|
||||
pauseHold,
|
||||
] = await Promise.all([
|
||||
issue
|
||||
? db
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, issue.companyId),
|
||||
eq(heartbeatRuns.agentId, run.agentId),
|
||||
inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]),
|
||||
sql`(
|
||||
${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}
|
||||
or ${heartbeatRuns.contextSnapshot} ->> 'taskId' = ${issue.id}
|
||||
)`,
|
||||
sql`${heartbeatRuns.id} <> ${run.id}`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
issue
|
||||
? db
|
||||
.select({ id: agentWakeupRequests.id })
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, issue.companyId),
|
||||
eq(agentWakeupRequests.agentId, run.agentId),
|
||||
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution", "claimed"]),
|
||||
sql`(
|
||||
${agentWakeupRequests.payload} ->> 'issueId' = ${issue.id}
|
||||
or ${agentWakeupRequests.payload} ->> 'taskId' = ${issue.id}
|
||||
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'issueId' = ${issue.id}
|
||||
or ${agentWakeupRequests.payload} -> '_paperclipWakeContext' ->> 'taskId' = ${issue.id}
|
||||
)`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
issue
|
||||
? db
|
||||
.select({ id: issueThreadInteractions.id })
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
issue
|
||||
? db
|
||||
.select({ id: issueApprovals.approvalId })
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueApprovals.companyId, issue.companyId),
|
||||
eq(issueApprovals.issueId, issue.id),
|
||||
inArray(approvals.status, ["pending", "revision_requested"]),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
issue
|
||||
? db
|
||||
.select({ id: issueRelations.issueId })
|
||||
.from(issueRelations)
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, issue.companyId),
|
||||
eq(issueRelations.relatedIssueId, issue.id),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
sql`exists (
|
||||
select 1
|
||||
from issues blocker
|
||||
where blocker.id = ${issueRelations.issueId}
|
||||
and blocker.company_id = ${issue.companyId}
|
||||
and blocker.status not in ('done', 'cancelled')
|
||||
and blocker.hidden_at is null
|
||||
)`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
issue
|
||||
? db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, issue.companyId),
|
||||
inArray(issues.originKind, [
|
||||
RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
|
||||
RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
|
||||
]),
|
||||
eq(issues.originId, issue.id),
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
idempotencyKey
|
||||
? findExistingFinishSuccessfulRunHandoffWake(db, {
|
||||
companyId: run.companyId,
|
||||
idempotencyKey,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
issue
|
||||
? budgets.getInvocationBlock(issue.companyId, run.agentId, {
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
issue
|
||||
? treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const decision = decideSuccessfulRunHandoff({
|
||||
run,
|
||||
issue,
|
||||
agent,
|
||||
livenessState: run.livenessState as RunLivenessState | null,
|
||||
detectedProgressSummary,
|
||||
taskKey,
|
||||
hasActiveExecutionPath: Boolean(activeExecutionPath),
|
||||
hasQueuedWake: Boolean(queuedWake),
|
||||
hasPendingInteractionOrApproval: Boolean(pendingInteraction || pendingApproval),
|
||||
hasExplicitBlockerPath: Boolean(explicitBlocker),
|
||||
hasOpenRecoveryIssue: Boolean(openRecoveryIssue),
|
||||
hasPauseHold: Boolean(pauseHold),
|
||||
budgetBlocked: Boolean(budgetBlock),
|
||||
idempotentWakeExists: Boolean(existingWake),
|
||||
});
|
||||
|
||||
if (decision.kind !== "enqueue" || !issue) return;
|
||||
|
||||
const handoffRun = await enqueueWakeup(run.agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
payload: decision.payload,
|
||||
contextSnapshot: decision.contextSnapshot,
|
||||
idempotencyKey: decision.idempotencyKey,
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "heartbeat",
|
||||
});
|
||||
if (!handoffRun) return;
|
||||
|
||||
await addSuccessfulRunHandoffCommentOnce({
|
||||
issue,
|
||||
run,
|
||||
agent,
|
||||
detectedProgressSummary: detectedProgressSummary ?? "The run reported progress, but did not choose a next step.",
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: "system",
|
||||
actorId: "heartbeat",
|
||||
agentId: run.agentId,
|
||||
runId: run.id,
|
||||
action: "issue.successful_run_handoff_required",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
label: "Successful run missing issue disposition",
|
||||
sourceRunId: run.id,
|
||||
correctiveRunId: handoffRun.id,
|
||||
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
missingDisposition: "clear_next_step",
|
||||
detectedProgressSummary,
|
||||
issue: issueUiLink(issue),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function appendRunEvent(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
seq: number,
|
||||
|
|
@ -3998,13 +4314,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||
const retryContextSnapshot = {
|
||||
const retryContextSnapshot = withRecoveryModelProfileHint({
|
||||
...contextSnapshot,
|
||||
retryOfRunId: run.id,
|
||||
wakeReason: "missing_issue_comment",
|
||||
retryReason: "missing_issue_comment",
|
||||
missingIssueCommentForRunId: run.id,
|
||||
};
|
||||
});
|
||||
const now = new Date();
|
||||
|
||||
const retryRun = await db.transaction(async (tx) => {
|
||||
|
|
@ -4027,11 +4343,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "missing_issue_comment",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId,
|
||||
retryOfRunId: run.id,
|
||||
retryReason: "missing_issue_comment",
|
||||
},
|
||||
}),
|
||||
status: "queued",
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
|
|
@ -4219,12 +4535,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||
const retryContextSnapshot = {
|
||||
const retryContextSnapshot = withRecoveryModelProfileHint({
|
||||
...contextSnapshot,
|
||||
retryOfRunId: run.id,
|
||||
wakeReason: "process_lost_retry",
|
||||
retryReason: "process_lost",
|
||||
};
|
||||
});
|
||||
|
||||
const queued = await db.transaction(async (tx) => {
|
||||
const wakeupRequest = await tx
|
||||
|
|
@ -4235,10 +4551,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "process_lost_retry",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
...(issueId ? { issueId } : {}),
|
||||
retryOfRunId: run.id,
|
||||
},
|
||||
}),
|
||||
status: "queued",
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
|
|
@ -4675,7 +4991,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||
const retryContextSnapshot: Record<string, unknown> = {
|
||||
const retryContextSnapshot: Record<string, unknown> = withRecoveryModelProfileHint({
|
||||
...contextSnapshot,
|
||||
retryOfRunId: run.id,
|
||||
wakeReason,
|
||||
|
|
@ -4685,7 +5001,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
scheduledRetryAt: schedule.dueAt.toISOString(),
|
||||
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
|
||||
...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}),
|
||||
};
|
||||
});
|
||||
const maxTurnContinuationIdempotencyKey = retryReason === MAX_TURN_CONTINUATION_RETRY_REASON
|
||||
? `max-turn-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}`
|
||||
: null;
|
||||
|
|
@ -4846,7 +5162,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: wakeReason,
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
...(issueId ? { issueId } : {}),
|
||||
retryOfRunId: run.id,
|
||||
retryReason,
|
||||
|
|
@ -4855,7 +5171,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
scheduledRetryAt: schedule.dueAt.toISOString(),
|
||||
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
|
||||
...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}),
|
||||
},
|
||||
}),
|
||||
status: "queued",
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
|
|
@ -6171,6 +6487,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
.select({
|
||||
id: issueComments.id,
|
||||
body: issueComments.body,
|
||||
authorType: issueComments.authorType,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
presentation: issueComments.presentation,
|
||||
metadata: issueComments.metadata,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
|
|
@ -7235,9 +7556,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
} else if (outcome === "failed" && readTransientRecoveryContractFromRun(livenessRun)) {
|
||||
await scheduleBoundedRetryForRun(livenessRun, agent);
|
||||
}
|
||||
await finalizeIssueCommentPolicy(livenessRun, agent);
|
||||
const issueCommentPolicyResult = await finalizeIssueCommentPolicy(livenessRun, agent);
|
||||
await releaseIssueExecutionAndPromote(livenessRun);
|
||||
await handleRunLivenessContinuation(livenessRun);
|
||||
await handleSuccessfulRunHandoff(
|
||||
issueCommentPolicyResult.outcome === "retry_queued" || issueCommentPolicyResult.outcome === "retry_exhausted"
|
||||
? {
|
||||
...livenessRun,
|
||||
issueCommentStatus: issueCommentPolicyResult.outcome,
|
||||
}
|
||||
: livenessRun,
|
||||
agent,
|
||||
);
|
||||
}
|
||||
|
||||
if (finalizedRun) {
|
||||
|
|
@ -7739,10 +8069,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: recoveryReason,
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
retryOfRunId: run.id,
|
||||
},
|
||||
}),
|
||||
status: "queued",
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
|
|
@ -7760,14 +8090,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
wakeReason: recoveryReason,
|
||||
retryReason,
|
||||
source: recoverySource,
|
||||
retryOfRunId: run.id,
|
||||
},
|
||||
}),
|
||||
sessionIdBefore: recoverySessionBefore,
|
||||
retryOfRunId: run.id,
|
||||
updatedAt: now,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ import {
|
|||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
IssueCommentAuthorType,
|
||||
IssueCommentMetadata,
|
||||
IssueCommentPresentation,
|
||||
IssueBlockerAttention,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
|
|
@ -37,6 +40,9 @@ import {
|
|||
clampIssueRequestDepth,
|
||||
extractAgentMentionIds,
|
||||
extractProjectMentionIds,
|
||||
issueCommentAuthorTypeSchema,
|
||||
issueCommentMetadataSchema,
|
||||
issueCommentPresentationSchema,
|
||||
isUuidLike,
|
||||
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
||||
} from "@paperclipai/shared";
|
||||
|
|
@ -1679,10 +1685,47 @@ export function issueService(db: Db) {
|
|||
return enriched;
|
||||
}
|
||||
|
||||
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
|
||||
function deriveIssueCommentAuthorType(comment: {
|
||||
authorType?: string | null;
|
||||
authorAgentId?: string | null;
|
||||
authorUserId?: string | null;
|
||||
}): IssueCommentAuthorType {
|
||||
const explicit = issueCommentAuthorTypeSchema.safeParse(comment.authorType);
|
||||
if (explicit.success) return explicit.data;
|
||||
if (comment.authorAgentId) return "agent";
|
||||
if (comment.authorUserId) return "user";
|
||||
return "system";
|
||||
}
|
||||
|
||||
function assertIssueCommentAuthorTypeAllowed(
|
||||
actor: { agentId?: string | null; userId?: string | null },
|
||||
authorType: IssueCommentAuthorType,
|
||||
) {
|
||||
if (actor.agentId && authorType !== "agent") {
|
||||
throw unprocessable("Comment authorType must match authenticated actor");
|
||||
}
|
||||
if (actor.userId && authorType !== "user") {
|
||||
throw unprocessable("Comment authorType must match authenticated actor");
|
||||
}
|
||||
if (!actor.agentId && !actor.userId && authorType !== "system") {
|
||||
throw unprocessable("System comments cannot use user or agent authorType without an author id");
|
||||
}
|
||||
}
|
||||
|
||||
function redactIssueComment<T extends { body: string; authorType?: string | null; authorAgentId?: string | null; authorUserId?: string | null; presentation?: unknown; metadata?: unknown }>(
|
||||
comment: T,
|
||||
censorUsernameInLogs: boolean,
|
||||
): T & {
|
||||
authorType: IssueCommentAuthorType;
|
||||
presentation: IssueCommentPresentation | null;
|
||||
metadata: IssueCommentMetadata | null;
|
||||
} {
|
||||
return {
|
||||
...comment,
|
||||
authorType: deriveIssueCommentAuthorType(comment),
|
||||
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
|
||||
presentation: issueCommentPresentationSchema.nullable().catch(null).parse(comment.presentation ?? null),
|
||||
metadata: issueCommentMetadataSchema.nullable().catch(null).parse(comment.metadata ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -3743,6 +3786,12 @@ export function issueService(db: Db) {
|
|||
issueId: string,
|
||||
body: string,
|
||||
actor: { agentId?: string; userId?: string; runId?: string | null },
|
||||
options?: {
|
||||
authorType?: IssueCommentAuthorType | null;
|
||||
presentation?: IssueCommentPresentation | null;
|
||||
metadata?: IssueCommentMetadata | null;
|
||||
createdAt?: Date | string | null;
|
||||
},
|
||||
) => {
|
||||
const issue = await db
|
||||
.select({ companyId: issues.companyId })
|
||||
|
|
@ -3756,6 +3805,13 @@ export function issueService(db: Db) {
|
|||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
};
|
||||
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
|
||||
const authorType = issueCommentAuthorTypeSchema.parse(
|
||||
options?.authorType ?? (actor.agentId ? "agent" : actor.userId ? "user" : "system"),
|
||||
);
|
||||
assertIssueCommentAuthorTypeAllowed(actor, authorType);
|
||||
const presentation = issueCommentPresentationSchema.nullable().parse(options?.presentation ?? null);
|
||||
const metadata = issueCommentMetadataSchema.nullable().parse(options?.metadata ?? null);
|
||||
const createdAt = options?.createdAt ? new Date(options.createdAt) : null;
|
||||
const [comment] = await db
|
||||
.insert(issueComments)
|
||||
.values({
|
||||
|
|
@ -3763,8 +3819,12 @@ export function issueService(db: Db) {
|
|||
issueId,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
authorType,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
body: redactedBody,
|
||||
presentation,
|
||||
metadata,
|
||||
...(createdAt && !Number.isNaN(createdAt.getTime()) ? { createdAt } : {}),
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import { logger } from "../middleware/logger.js";
|
|||
import { logActivity } from "./activity-log.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import {
|
||||
recoveryAssigneeAdapterOverrides,
|
||||
withRecoveryModelProfileHint,
|
||||
} from "./recovery/model-profile-hint.js";
|
||||
import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
|
||||
|
||||
export const PRODUCTIVITY_REVIEW_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.issueProductivityReview;
|
||||
|
|
@ -687,6 +691,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
|
|||
goalId: evidence.sourceIssue.goalId,
|
||||
billingCode: evidence.sourceIssue.billingCode,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: evidence.sourceIssue.id,
|
||||
originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
|
||||
|
|
@ -732,21 +737,21 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
|
|||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: review.id,
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
trigger: evidence.trigger,
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "productivity_review",
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: review.id,
|
||||
taskId: review.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
sourceIssueId: evidence.sourceIssue.id,
|
||||
productivityReviewTrigger: evidence.trigger,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,3 +42,23 @@ export {
|
|||
export type {
|
||||
RunContinuationDecision,
|
||||
} from "./run-liveness-continuations.js";
|
||||
export {
|
||||
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
||||
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES,
|
||||
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_HANDOFF_OPTIONS,
|
||||
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
buildFinishSuccessfulRunHandoffIdempotencyKey,
|
||||
buildSuccessfulRunHandoffExhaustedNotice,
|
||||
buildSuccessfulRunHandoffInstruction,
|
||||
buildSuccessfulRunHandoffRequiredNotice,
|
||||
decideSuccessfulRunHandoff,
|
||||
findExistingFinishSuccessfulRunHandoffWake,
|
||||
isSuccessfulRunHandoffRequiredNoticeBody,
|
||||
} from "./successful-run-handoff.js";
|
||||
export type {
|
||||
SuccessfulRunHandoffNotice,
|
||||
SuccessfulRunHandoffDecision,
|
||||
} from "./successful-run-handoff.js";
|
||||
|
|
|
|||
14
server/src/services/recovery/model-profile-hint.ts
Normal file
14
server/src/services/recovery/model-profile-hint.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export const RECOVERY_MODEL_PROFILE_KEY = "cheap" as const;
|
||||
|
||||
export function withRecoveryModelProfileHint<T extends Record<string, unknown>>(
|
||||
input: T,
|
||||
): T & { modelProfile: typeof RECOVERY_MODEL_PROFILE_KEY } {
|
||||
return {
|
||||
...input,
|
||||
modelProfile: RECOVERY_MODEL_PROFILE_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
export function recoveryAssigneeAdapterOverrides() {
|
||||
return { modelProfile: RECOVERY_MODEL_PROFILE_KEY };
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ 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";
|
||||
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
|
||||
import { RECOVERY_REASON_KINDS } from "./origins.js";
|
||||
|
||||
export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenessContinuation;
|
||||
|
|
@ -155,7 +156,7 @@ export function decideRunLivenessContinuation(input: {
|
|||
return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload = withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
livenessState,
|
||||
|
|
@ -165,14 +166,14 @@ export function decideRunLivenessContinuation(input: {
|
|||
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: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
taskKey: issue.id,
|
||||
|
|
@ -183,6 +184,6 @@ export function decideRunLivenessContinuation(input: {
|
|||
livenessContinuationState: livenessState,
|
||||
livenessContinuationReason: livenessReason,
|
||||
livenessContinuationInstruction: payload.instruction,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ import { instanceSettingsService } from "../instance-settings.js";
|
|||
import { issueTreeControlService } from "../issue-tree-control.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { getRunLogStore } from "../run-log-store.js";
|
||||
import {
|
||||
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
||||
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
buildSuccessfulRunHandoffExhaustedNotice,
|
||||
type SuccessfulRunHandoffNotice,
|
||||
} from "./successful-run-handoff.js";
|
||||
import {
|
||||
RECOVERY_ORIGIN_KINDS,
|
||||
buildIssueGraphLivenessLeafKey,
|
||||
|
|
@ -42,6 +49,10 @@ import {
|
|||
classifyIssueGraphLiveness,
|
||||
type IssueLivenessFinding,
|
||||
} from "./issue-graph-liveness.js";
|
||||
import {
|
||||
recoveryAssigneeAdapterOverrides,
|
||||
withRecoveryModelProfileHint,
|
||||
} from "./model-profile-hint.js";
|
||||
import { isAutomaticRecoverySuppressedByPauseHold } from "./pause-hold-guard.js";
|
||||
|
||||
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
|
||||
|
|
@ -76,6 +87,16 @@ type LatestIssueRun = Pick<
|
|||
> | null;
|
||||
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
|
||||
|
||||
type StrandedRecoveryCause = "stranded_assigned_issue" | typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
|
||||
|
||||
type SuccessfulRunHandoffRecoveryEvidence = {
|
||||
sourceRunId: string | null;
|
||||
correctiveRunId: string;
|
||||
missingDisposition: string;
|
||||
handoffAttempt: number;
|
||||
maxHandoffAttempts: number;
|
||||
};
|
||||
|
||||
type WatchdogDecisionActor =
|
||||
| { type: "board"; userId?: string | null; runId?: string | null }
|
||||
| { type: "agent"; agentId?: string | null; runId?: string | null }
|
||||
|
|
@ -123,6 +144,39 @@ function didAutomaticRecoveryFail(
|
|||
);
|
||||
}
|
||||
|
||||
function successfulRunHandoffRecoveryEvidence(latestRun: LatestIssueRun): SuccessfulRunHandoffRecoveryEvidence | null {
|
||||
if (!latestRun) return null;
|
||||
|
||||
const context = parseObject(latestRun.contextSnapshot);
|
||||
const wakeReason = readNonEmptyString(context.wakeReason);
|
||||
const handoffReason = readNonEmptyString(context.handoffReason);
|
||||
const isSuccessfulRunHandoff =
|
||||
wakeReason === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON ||
|
||||
handoffReason === SUCCESSFUL_RUN_MISSING_STATE_REASON ||
|
||||
asBoolean(context.handoffRequired, false) === true;
|
||||
if (!isSuccessfulRunHandoff) return null;
|
||||
|
||||
const handoffAttempt = asNumber(context.handoffAttempt, 1);
|
||||
const maxHandoffAttempts = asNumber(
|
||||
context.maxHandoffAttempts,
|
||||
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
||||
);
|
||||
return {
|
||||
sourceRunId: readNonEmptyString(context.sourceRunId) ?? readNonEmptyString(context.resumeFromRunId),
|
||||
correctiveRunId: latestRun.id,
|
||||
missingDisposition: readNonEmptyString(context.missingDisposition) ?? "clear_next_step",
|
||||
handoffAttempt,
|
||||
maxHandoffAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
function isExhaustedSuccessfulRunHandoff(latestRun: LatestIssueRun) {
|
||||
const evidence = successfulRunHandoffRecoveryEvidence(latestRun);
|
||||
if (!evidence) return null;
|
||||
if (evidence.handoffAttempt < evidence.maxHandoffAttempts) return { ...evidence, exhausted: false };
|
||||
return { ...evidence, exhausted: true };
|
||||
}
|
||||
|
||||
function issueIdFromRunContext(contextSnapshot: unknown) {
|
||||
const context = parseObject(contextSnapshot);
|
||||
return readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
|
||||
|
|
@ -145,6 +199,11 @@ function runUiLink(run: { id: string; agentId: string }, prefix: string) {
|
|||
return `[${run.id}](/${prefix}/agents/${run.agentId}/runs/${run.id})`;
|
||||
}
|
||||
|
||||
function agentUiLink(agent: { id: string; name: string | null } | null, prefix: string) {
|
||||
if (!agent) return "unknown";
|
||||
return `[${agent.name ?? agent.id}](/${prefix}/agents/${agent.id})`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null) {
|
||||
if (ms === null) return "unknown";
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
|
|
@ -391,20 +450,20 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: input.reason,
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: input.issueId,
|
||||
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: input.issueId,
|
||||
taskId: input.issueId,
|
||||
wakeReason: input.reason,
|
||||
retryReason: input.retryReason,
|
||||
source: input.source,
|
||||
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (queued && input.retryOfRunId) {
|
||||
|
|
@ -427,18 +486,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
mutation: "assigned_todo_liveness_dispatch",
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: "issue.assigned_todo_liveness_dispatch",
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -542,18 +601,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: candidate.id,
|
||||
mutation: "unassigned_blocker_recovery",
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: candidate.id,
|
||||
taskId: candidate.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: "issue.unassigned_blocker_recovery",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (queued) {
|
||||
|
|
@ -995,6 +1054,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
goalId: sourceIssue?.goalId ?? null,
|
||||
billingCode: sourceIssue?.billingCode ?? null,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
|
||||
originKind: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
|
||||
originId: input.run.id,
|
||||
originRunId: input.run.id,
|
||||
|
|
@ -1036,21 +1096,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: evaluation.id,
|
||||
staleRunId: input.run.id,
|
||||
sourceIssueId: sourceIssue?.id ?? null,
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: evaluation.id,
|
||||
taskId: evaluation.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND,
|
||||
staleRunId: input.run.id,
|
||||
sourceIssueId: sourceIssue?.id ?? null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return { kind: "created" as const, evaluationIssueId: evaluation.id };
|
||||
|
|
@ -1294,11 +1354,45 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
latestRun: LatestIssueRun;
|
||||
previousStatus: "todo" | "in_progress";
|
||||
prefix: string;
|
||||
recoveryCause?: StrandedRecoveryCause;
|
||||
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
|
||||
sourceAssignee?: Pick<typeof agents.$inferSelect, "id" | "name"> | null;
|
||||
}) {
|
||||
const sourceIssue = issueUiLink({ identifier: input.issue.identifier, id: input.issue.id }, input.prefix);
|
||||
const runLink = input.latestRun
|
||||
? `[\`${input.latestRun.id}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${input.latestRun.id})`
|
||||
: "none";
|
||||
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON) {
|
||||
const sourceRunId = input.successfulRunHandoffEvidence?.sourceRunId;
|
||||
const sourceRunLink = sourceRunId && input.latestRun
|
||||
? `[\`${sourceRunId}\`](/${input.prefix}/agents/${input.latestRun.agentId}/runs/${sourceRunId})`
|
||||
: "unknown";
|
||||
const missingDisposition = input.successfulRunHandoffEvidence?.missingDisposition ?? "clear_next_step";
|
||||
return [
|
||||
"Paperclip exhausted the bounded corrective handoff for a successful run that still has no valid issue disposition.",
|
||||
"",
|
||||
"This is not a runtime/adapter crash report. The source run succeeded; the remaining problem is the missing `done`, `in_review`, `blocked`, delegated follow-up, or explicit continuation path.",
|
||||
"",
|
||||
"## Safe Evidence",
|
||||
"",
|
||||
`- Source issue: ${sourceIssue}`,
|
||||
`- Source run: ${sourceRunLink}`,
|
||||
`- Corrective handoff run: ${runLink}`,
|
||||
`- Source assignee: ${agentUiLink(input.sourceAssignee ?? null, input.prefix)}`,
|
||||
`- Latest issue status: \`${input.issue.status}\``,
|
||||
`- Latest handoff run status: \`${input.latestRun?.status ?? "unknown"}\``,
|
||||
`- Normalized cause: \`${SUCCESSFUL_RUN_MISSING_STATE_REASON}\``,
|
||||
`- Missing disposition: \`${missingDisposition}\``,
|
||||
"- Suggested manager action: choose and record a valid issue disposition without copying transcript content.",
|
||||
"",
|
||||
"## Required Action",
|
||||
"",
|
||||
"- Inspect the source issue and run metadata, not raw transcript excerpts.",
|
||||
"- Choose a valid issue disposition: `done`/`cancelled`, `in_review` with an owner, `blocked` with first-class blockers, delegated follow-up work, or an explicit continuation path.",
|
||||
"- When the source issue has a clear owner and disposition, mark this recovery issue done.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const retryReason = readNonEmptyString(parseObject(input.latestRun?.contextSnapshot)?.retryReason) ?? "unknown";
|
||||
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
|
||||
|
||||
|
|
@ -1331,6 +1425,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
issue: typeof issues.$inferSelect;
|
||||
latestRun: LatestIssueRun;
|
||||
previousStatus: "todo" | "in_progress";
|
||||
recoveryCause?: StrandedRecoveryCause;
|
||||
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
|
||||
}) {
|
||||
if (isStrandedIssueRecoveryIssue(input.issue)) return null;
|
||||
|
||||
|
|
@ -1341,15 +1437,22 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
if (!ownerAgentId) return null;
|
||||
|
||||
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
|
||||
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
|
||||
const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue";
|
||||
let recovery: Awaited<ReturnType<typeof issuesSvc.create>>;
|
||||
try {
|
||||
recovery = await issuesSvc.create(input.issue.companyId, {
|
||||
title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
|
||||
title: recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
? `Recover missing next step ${input.issue.identifier ?? input.issue.title}`
|
||||
: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
|
||||
description: buildStrandedIssueRecoveryDescription({
|
||||
issue: input.issue,
|
||||
latestRun: input.latestRun,
|
||||
previousStatus: input.previousStatus,
|
||||
prefix,
|
||||
recoveryCause,
|
||||
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
|
||||
sourceAssignee,
|
||||
}),
|
||||
status: "todo",
|
||||
priority: input.issue.priority,
|
||||
|
|
@ -1357,6 +1460,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
projectId: input.issue.projectId,
|
||||
goalId: input.issue.goalId,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
|
||||
originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
originId: input.issue.id,
|
||||
originRunId: input.latestRun?.id ?? null,
|
||||
|
|
@ -1364,6 +1468,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
input.issue.companyId,
|
||||
input.issue.id,
|
||||
recoveryCause,
|
||||
input.latestRun?.id ?? "no-run",
|
||||
].join(":"),
|
||||
billingCode: input.issue.billingCode,
|
||||
|
|
@ -1380,21 +1485,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: recovery.id,
|
||||
sourceIssueId: input.issue.id,
|
||||
strandedRunId: input.latestRun?.id ?? null,
|
||||
},
|
||||
recoveryCause,
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: recovery.id,
|
||||
taskId: recovery.id,
|
||||
wakeReason: "issue_assigned",
|
||||
source: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
sourceIssueId: input.issue.id,
|
||||
strandedRunId: input.latestRun?.id ?? null,
|
||||
},
|
||||
recoveryCause,
|
||||
}),
|
||||
});
|
||||
|
||||
return recovery;
|
||||
|
|
@ -1512,7 +1619,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
issue: typeof issues.$inferSelect;
|
||||
previousStatus: "todo" | "in_progress";
|
||||
latestRun: LatestIssueRun;
|
||||
comment: string;
|
||||
comment?: string;
|
||||
recoveryCause?: StrandedRecoveryCause;
|
||||
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
|
||||
}) {
|
||||
if (isStrandedIssueRecoveryIssue(input.issue)) {
|
||||
return escalateStrandedRecoveryIssueInPlace({
|
||||
|
|
@ -1526,6 +1635,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
issue: input.issue,
|
||||
previousStatus: input.previousStatus,
|
||||
latestRun: input.latestRun,
|
||||
recoveryCause: input.recoveryCause,
|
||||
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
|
||||
});
|
||||
const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
|
||||
const nextBlockerIds = recoveryIssue
|
||||
|
|
@ -1538,10 +1649,29 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
if (!updated) return null;
|
||||
|
||||
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
|
||||
const recoveryOwner = recoveryIssue?.assigneeAgentId ? await getAgent(recoveryIssue.assigneeAgentId) : null;
|
||||
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
|
||||
let notice: SuccessfulRunHandoffNotice | null = null;
|
||||
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON && input.successfulRunHandoffEvidence) {
|
||||
notice = buildSuccessfulRunHandoffExhaustedNotice({
|
||||
issue: input.issue,
|
||||
sourceRun: input.successfulRunHandoffEvidence.sourceRunId
|
||||
? { id: input.successfulRunHandoffEvidence.sourceRunId, status: "succeeded" }
|
||||
: null,
|
||||
correctiveRun: input.latestRun ? { id: input.latestRun.id, status: input.latestRun.status } : null,
|
||||
sourceAssignee,
|
||||
recoveryIssue,
|
||||
recoveryOwner,
|
||||
latestIssueStatus: input.issue.status,
|
||||
latestHandoffRunStatus: input.latestRun?.status ?? "unknown",
|
||||
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
|
||||
});
|
||||
}
|
||||
const recoveryLine = recoveryIssue
|
||||
? [
|
||||
"",
|
||||
`- Recovery issue: ${issueUiLink({ identifier: recoveryIssue.identifier, id: recoveryIssue.id }, prefix)}`,
|
||||
`- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`,
|
||||
"- Next action: the recovery owner should either restore a live execution path or record the manual resolution, then mark the recovery issue done.",
|
||||
].join("\n")
|
||||
: [
|
||||
|
|
@ -1550,7 +1680,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
|
||||
].join("\n");
|
||||
|
||||
await issuesSvc.addComment(input.issue.id, `${input.comment}${recoveryLine}`, {});
|
||||
if (notice) {
|
||||
await issuesSvc.addComment(input.issue.id, notice.body, {}, {
|
||||
authorType: "system",
|
||||
presentation: notice.presentation,
|
||||
metadata: notice.metadata,
|
||||
});
|
||||
} else {
|
||||
await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {});
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: input.issue.companyId,
|
||||
|
|
@ -1558,14 +1696,19 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
actorId: "system",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
action: "issue.updated",
|
||||
action: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
? "issue.successful_run_handoff_escalated"
|
||||
: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: input.issue.id,
|
||||
details: {
|
||||
identifier: input.issue.identifier,
|
||||
status: "blocked",
|
||||
previousStatus: input.previousStatus,
|
||||
source: "recovery.reconcile_stranded_assigned_issue",
|
||||
source: input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
? "recovery.reconcile_successful_run_handoff_missing_state"
|
||||
: "recovery.reconcile_stranded_assigned_issue",
|
||||
recoveryCause: input.recoveryCause ?? "stranded_assigned_issue",
|
||||
latestRunId: input.latestRun?.id ?? null,
|
||||
latestRunStatus: input.latestRun?.status ?? null,
|
||||
latestRunErrorCode: input.latestRun?.errorCode ?? null,
|
||||
|
|
@ -1596,6 +1739,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
productiveContinuationObserved: 0,
|
||||
successfulContinuationObserved: 0,
|
||||
orphanBlockersAssigned: 0,
|
||||
successfulRunHandoffEscalated: 0,
|
||||
escalated: 0,
|
||||
skipped: 0,
|
||||
issueIds: [] as string[],
|
||||
|
|
@ -1713,6 +1857,28 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const handoffEvidence = isExhaustedSuccessfulRunHandoff(latestRun);
|
||||
if (handoffEvidence) {
|
||||
if (!handoffEvidence.exhausted) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
issue,
|
||||
previousStatus: "in_progress",
|
||||
latestRun,
|
||||
recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
successfulRunHandoffEvidence: handoffEvidence,
|
||||
});
|
||||
if (updated) {
|
||||
result.successfulRunHandoffEscalated += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isSuccessfulInProgressContinuationRun(latestRun)) {
|
||||
const successfulRun = latestRun;
|
||||
|
||||
|
|
@ -2393,6 +2559,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
projectId: recoveryIssue.projectId,
|
||||
goalId: recoveryIssue.goalId,
|
||||
assigneeAgentId: ownerSelection.agentId,
|
||||
assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides(),
|
||||
originKind: RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation,
|
||||
originId: input.finding.incidentKey,
|
||||
originFingerprint: livenessRecoveryLeafFingerprint(input.finding),
|
||||
|
|
@ -2473,15 +2640,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
payload: withRecoveryModelProfileHint({
|
||||
issueId: escalation.id,
|
||||
sourceIssueId: issue.id,
|
||||
recoveryIssueId: recoveryIssue.id,
|
||||
incidentKey: input.finding.incidentKey,
|
||||
},
|
||||
}),
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: null,
|
||||
contextSnapshot: {
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
issueId: escalation.id,
|
||||
taskId: escalation.id,
|
||||
wakeReason: "issue_assigned",
|
||||
|
|
@ -2489,7 +2656,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||
sourceIssueId: issue.id,
|
||||
recoveryIssueId: recoveryIssue.id,
|
||||
incidentKey: input.finding.incidentKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
logger.warn({
|
||||
|
|
|
|||
295
server/src/services/recovery/successful-run-handoff.test.ts
Normal file
295
server/src/services/recovery/successful-run-handoff.test.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
|
||||
SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
buildFinishSuccessfulRunHandoffIdempotencyKey,
|
||||
buildSuccessfulRunHandoffExhaustedNotice,
|
||||
buildSuccessfulRunHandoffRequiredNotice,
|
||||
decideSuccessfulRunHandoff,
|
||||
isIdempotentFinishSuccessfulRunHandoffWakeStatus,
|
||||
isSuccessfulRunHandoffRequiredNoticeBody,
|
||||
} from "./successful-run-handoff.js";
|
||||
|
||||
const run = {
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
status: "succeeded",
|
||||
contextSnapshot: { issueId: "issue-1" },
|
||||
} as any;
|
||||
|
||||
const issue = {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Finish backend handoff",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
executionState: null,
|
||||
} as any;
|
||||
|
||||
const agent = {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
status: "idle",
|
||||
} as any;
|
||||
|
||||
function decide(overrides: Partial<Parameters<typeof decideSuccessfulRunHandoff>[0]> = {}) {
|
||||
return decideSuccessfulRunHandoff({
|
||||
run,
|
||||
issue,
|
||||
agent,
|
||||
livenessState: "advanced",
|
||||
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
|
||||
taskKey: "issue-1",
|
||||
hasActiveExecutionPath: false,
|
||||
hasQueuedWake: false,
|
||||
hasPendingInteractionOrApproval: false,
|
||||
hasExplicitBlockerPath: false,
|
||||
hasOpenRecoveryIssue: false,
|
||||
hasPauseHold: false,
|
||||
budgetBlocked: false,
|
||||
idempotentWakeExists: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("successful run handoff decision", () => {
|
||||
it("queues one corrective handoff wake for a successful progress run without a visible next action", () => {
|
||||
const decision = decide();
|
||||
|
||||
expect(decision.kind).toBe("enqueue");
|
||||
if (decision.kind !== "enqueue") return;
|
||||
expect(decision.idempotencyKey).toBe("finish_successful_run_handoff:issue-1:run-1:1");
|
||||
expect(decision.payload).toMatchObject({
|
||||
issueId: "issue-1",
|
||||
sourceRunId: "run-1",
|
||||
handoffRequired: true,
|
||||
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
missingDisposition: "clear_next_step",
|
||||
handoffAttempt: 1,
|
||||
maxHandoffAttempts: 1,
|
||||
resumeIntent: true,
|
||||
resumeFromRunId: "run-1",
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
expect(decision.contextSnapshot).toMatchObject({
|
||||
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
handoffRequired: true,
|
||||
modelProfile: "cheap",
|
||||
});
|
||||
expect(decision.instruction).toContain("Resolve the missing disposition before creating or revising any new artifacts");
|
||||
expect(decision.instruction).toContain("Choose **exactly one** outcome");
|
||||
expect(decision.instruction).toContain("record an explicit continuation path");
|
||||
});
|
||||
|
||||
it("does not queue when the issue already has a valid disposition", () => {
|
||||
expect(decide({ issue: { ...issue, status: "done" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue status done is a valid disposition",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue when a successful run records an accepted next-action path", () => {
|
||||
expect(decide({ issue: { ...issue, status: "in_review" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue status in_review is a valid disposition",
|
||||
});
|
||||
expect(decide({ issue: { ...issue, status: "blocked" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue status blocked is a valid disposition",
|
||||
});
|
||||
expect(decide({ hasPendingInteractionOrApproval: true })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "pending interaction or approval owns the next action",
|
||||
});
|
||||
expect(decide({ hasActiveExecutionPath: true })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue already has an active execution path",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue when another wake or dependency path already owns the next action", () => {
|
||||
expect(decide({ hasQueuedWake: true })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue already has a queued or deferred wake",
|
||||
});
|
||||
expect(decide({ hasExplicitBlockerPath: true })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "explicit blocker path owns the next action",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue when a successful run has no progress signal", () => {
|
||||
expect(decide({ livenessState: null, detectedProgressSummary: null })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "successful run did not produce handoff-relevant progress",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat adapter or runtime failures as missing-disposition handoffs", () => {
|
||||
expect(decide({ run: { ...run, status: "failed", errorCode: "adapter_failed" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "source run did not succeed",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue on missing-comment retry bookkeeping runs", () => {
|
||||
expect(decide({ run: { ...run, issueCommentStatus: "retry_exhausted" } as any })).toEqual({
|
||||
kind: "skip",
|
||||
reason: "missing issue comment retry owns the next action",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not loop from a corrective handoff run", () => {
|
||||
expect(decide({
|
||||
run: {
|
||||
...run,
|
||||
id: "run-2",
|
||||
contextSnapshot: {
|
||||
issueId: "issue-1",
|
||||
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
handoffRequired: true,
|
||||
},
|
||||
} as any,
|
||||
})).toEqual({
|
||||
kind: "skip",
|
||||
reason: "source run is already a corrective handoff run",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not queue for issue monitor maintenance runs", () => {
|
||||
expect(decide({
|
||||
run: {
|
||||
...run,
|
||||
contextSnapshot: {
|
||||
issueId: "issue-1",
|
||||
source: "issue.monitor",
|
||||
wakeReason: "issue_monitor_due",
|
||||
},
|
||||
} as any,
|
||||
})).toEqual({
|
||||
kind: "skip",
|
||||
reason: "issue monitor run owns its own recovery path",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a stable one-attempt idempotency key", () => {
|
||||
expect(buildFinishSuccessfulRunHandoffIdempotencyKey({
|
||||
issueId: "issue-1",
|
||||
sourceRunId: "run-1",
|
||||
})).toBe("finish_successful_run_handoff:issue-1:run-1:1");
|
||||
});
|
||||
|
||||
it("allows failed or cancelled corrective wakes to be retried", () => {
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("queued")).toBe(true);
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("claimed")).toBe(true);
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("completed")).toBe(true);
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("failed")).toBe(false);
|
||||
expect(isIdempotentFinishSuccessfulRunHandoffWakeStatus("cancelled")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds the required system notice with hidden structured metadata", () => {
|
||||
const notice = buildSuccessfulRunHandoffRequiredNotice({
|
||||
issue: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
identifier: "PAP-1",
|
||||
title: "Finish backend handoff",
|
||||
status: "in_progress",
|
||||
} as any,
|
||||
run: {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
status: "succeeded",
|
||||
} as any,
|
||||
agent: {
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
name: "CodexCoder",
|
||||
} as any,
|
||||
detectedProgressSummary: "Run produced concrete action evidence: 1 issue comment(s)",
|
||||
});
|
||||
|
||||
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY);
|
||||
expect(notice.presentation).toEqual({
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
title: "Missing issue disposition",
|
||||
detailsDefaultOpen: false,
|
||||
});
|
||||
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Required action",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "issue_link", identifier: "PAP-1" }),
|
||||
expect.objectContaining({ type: "agent_link", name: "CodexCoder" }),
|
||||
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: "22222222-2222-4222-8222-222222222222" }),
|
||||
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
|
||||
expect.objectContaining({ type: "key_value", label: "Detected progress" }),
|
||||
]),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("builds the exhausted system notice with recovery metadata", () => {
|
||||
const notice = buildSuccessfulRunHandoffExhaustedNotice({
|
||||
issue: {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
identifier: "PAP-1",
|
||||
title: "Finish backend handoff",
|
||||
status: "in_progress",
|
||||
} as any,
|
||||
sourceRun: { id: "22222222-2222-4222-8222-222222222222", status: "succeeded" } as any,
|
||||
correctiveRun: { id: "44444444-4444-4444-8444-444444444444", status: "failed" } as any,
|
||||
sourceAssignee: { id: "33333333-3333-4333-8333-333333333333", name: "CodexCoder" } as any,
|
||||
recoveryIssue: {
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
identifier: "PAP-2",
|
||||
title: "Recover missing next step PAP-1",
|
||||
status: "todo",
|
||||
} as any,
|
||||
recoveryOwner: { id: "66666666-6666-4666-8666-666666666666", name: "CTO" } as any,
|
||||
latestIssueStatus: "in_progress",
|
||||
latestHandoffRunStatus: "failed",
|
||||
missingDisposition: "clear_next_step",
|
||||
});
|
||||
|
||||
expect(notice.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
|
||||
expect(notice.presentation).toMatchObject({
|
||||
kind: "system_notice",
|
||||
tone: "danger",
|
||||
detailsDefaultOpen: false,
|
||||
});
|
||||
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Recovery owner",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "issue_link", identifier: "PAP-2" }),
|
||||
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CTO" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Run evidence",
|
||||
rows: expect.arrayContaining([
|
||||
expect.objectContaining({ type: "run_link", label: "Source run" }),
|
||||
expect.objectContaining({ type: "run_link", label: "Corrective handoff run" }),
|
||||
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
|
||||
]),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("recognizes new notices and legacy markdown headings for fallback deduplication", () => {
|
||||
expect(isSuccessfulRunHandoffRequiredNoticeBody(SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY)).toBe(true);
|
||||
expect(isSuccessfulRunHandoffRequiredNoticeBody("## Successful run missing issue disposition\n\nold body")).toBe(true);
|
||||
expect(isSuccessfulRunHandoffRequiredNoticeBody("## This issue still needs a next step\n\nold body")).toBe(true);
|
||||
expect(isSuccessfulRunHandoffRequiredNoticeBody("Unrelated comment")).toBe(false);
|
||||
});
|
||||
});
|
||||
405
server/src/services/recovery/successful-run-handoff.ts
Normal file
405
server/src/services/recovery/successful-run-handoff.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import type { IssueCommentMetadata, IssueCommentPresentation, RunLivenessState } from "@paperclipai/shared";
|
||||
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
|
||||
|
||||
export const FINISH_SUCCESSFUL_RUN_HANDOFF_REASON = "finish_successful_run_handoff";
|
||||
export const SUCCESSFUL_RUN_MISSING_STATE_REASON = "successful_run_missing_state";
|
||||
export const DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS = 1;
|
||||
export const SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY =
|
||||
"Paperclip needs a disposition before this issue can continue.";
|
||||
export const SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY =
|
||||
"Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner.";
|
||||
export const LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES = [
|
||||
"## This issue still needs a next step",
|
||||
"## Successful run missing issue disposition",
|
||||
] as const;
|
||||
|
||||
export const SUCCESSFUL_RUN_HANDOFF_OPTIONS = [
|
||||
"mark_done_or_cancelled",
|
||||
"send_for_review_or_ask_for_input",
|
||||
"mark_blocked",
|
||||
"delegate_or_continue_from_checkpoint",
|
||||
] as const;
|
||||
|
||||
const PRODUCTIVE_SUCCESS_LIVENESS_STATES = new Set<RunLivenessState>([
|
||||
"advanced",
|
||||
"completed",
|
||||
"blocked",
|
||||
"needs_followup",
|
||||
]);
|
||||
|
||||
const IDEMPOTENT_HANDOFF_WAKE_STATUSES = [
|
||||
"queued",
|
||||
"deferred_issue_execution",
|
||||
"claimed",
|
||||
"completed",
|
||||
];
|
||||
const IDEMPOTENT_HANDOFF_WAKE_STATUS_SET = new Set<string>(IDEMPOTENT_HANDOFF_WAKE_STATUSES);
|
||||
|
||||
export function isIdempotentFinishSuccessfulRunHandoffWakeStatus(status: string) {
|
||||
return IDEMPOTENT_HANDOFF_WAKE_STATUS_SET.has(status);
|
||||
}
|
||||
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
type IssueRow = Pick<
|
||||
typeof issues.$inferSelect,
|
||||
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId" | "executionState"
|
||||
>;
|
||||
type AgentRow = Pick<typeof agents.$inferSelect, "id" | "companyId" | "status">;
|
||||
type NoticeIssue = Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
|
||||
type NoticeRun = Pick<typeof heartbeatRuns.$inferSelect, "id" | "status">;
|
||||
type NoticeAgent = Pick<typeof agents.$inferSelect, "id" | "name">;
|
||||
type NullableNoticeAgent = NoticeAgent | null | undefined;
|
||||
type NullableNoticeIssue = NoticeIssue | null | undefined;
|
||||
type NullableNoticeRun = NoticeRun | null | undefined;
|
||||
|
||||
export type SuccessfulRunHandoffNotice = {
|
||||
body: string;
|
||||
presentation: IssueCommentPresentation;
|
||||
metadata: IssueCommentMetadata;
|
||||
};
|
||||
|
||||
export type SuccessfulRunHandoffDecision =
|
||||
| {
|
||||
kind: "enqueue";
|
||||
idempotencyKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
instruction: string;
|
||||
}
|
||||
| {
|
||||
kind: "skip";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
function metadataText(value: unknown, fallback = "unknown") {
|
||||
const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
|
||||
const resolved = text.length > 0 ? text : fallback;
|
||||
return resolved.length > 2000 ? `${resolved.slice(0, 1997)}...` : resolved;
|
||||
}
|
||||
|
||||
function keyValueRow(label: string, value: unknown): IssueCommentMetadata["sections"][number]["rows"][number] {
|
||||
return { type: "key_value", label, value: metadataText(value) };
|
||||
}
|
||||
|
||||
function issueLinkRow(
|
||||
label: string,
|
||||
issue: NullableNoticeIssue,
|
||||
): IssueCommentMetadata["sections"][number]["rows"][number] {
|
||||
if (!issue) return keyValueRow(label, "unknown");
|
||||
return {
|
||||
type: "issue_link",
|
||||
label,
|
||||
issueId: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
};
|
||||
}
|
||||
|
||||
function runLinkRow(
|
||||
label: string,
|
||||
run: NullableNoticeRun,
|
||||
): IssueCommentMetadata["sections"][number]["rows"][number] {
|
||||
if (!run) return keyValueRow(label, "unknown");
|
||||
return { type: "run_link", label, runId: run.id, title: run.status };
|
||||
}
|
||||
|
||||
function agentLinkRow(
|
||||
label: string,
|
||||
agent: NullableNoticeAgent,
|
||||
): IssueCommentMetadata["sections"][number]["rows"][number] {
|
||||
if (!agent) return keyValueRow(label, "unknown");
|
||||
return { type: "agent_link", label, agentId: agent.id, name: agent.name };
|
||||
}
|
||||
|
||||
function systemNoticePresentation(input: {
|
||||
tone: IssueCommentPresentation["tone"];
|
||||
title: string;
|
||||
}): IssueCommentPresentation {
|
||||
return {
|
||||
kind: "system_notice",
|
||||
tone: input.tone,
|
||||
title: input.title,
|
||||
detailsDefaultOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSuccessfulRunHandoffRequiredNoticeBody(body: string) {
|
||||
const trimmed = body.trim();
|
||||
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY ||
|
||||
LEGACY_SUCCESSFUL_RUN_HANDOFF_NOTICE_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function buildSuccessfulRunHandoffRequiredNotice(input: {
|
||||
issue: NoticeIssue;
|
||||
run: NoticeRun;
|
||||
agent: NoticeAgent;
|
||||
detectedProgressSummary: string;
|
||||
}): SuccessfulRunHandoffNotice {
|
||||
return {
|
||||
body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
|
||||
presentation: systemNoticePresentation({
|
||||
tone: "warning",
|
||||
title: "Missing issue disposition",
|
||||
}),
|
||||
metadata: {
|
||||
version: 1,
|
||||
sections: [
|
||||
{
|
||||
title: "Required action",
|
||||
rows: [
|
||||
issueLinkRow("Source issue", input.issue),
|
||||
agentLinkRow("Assignee", input.agent),
|
||||
keyValueRow("Missing disposition", "clear_next_step"),
|
||||
keyValueRow(
|
||||
"Valid dispositions",
|
||||
"done, cancelled, in_review with an owner, blocked with blockers, delegated follow-up, or explicit continuation",
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Run evidence",
|
||||
rows: [
|
||||
runLinkRow("Successful run", input.run),
|
||||
keyValueRow("Run status", input.run.status),
|
||||
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
|
||||
keyValueRow("Detected progress", input.detectedProgressSummary),
|
||||
keyValueRow("Automatic retry", "one corrective handoff wake queued"),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSuccessfulRunHandoffExhaustedNotice(input: {
|
||||
issue: NoticeIssue;
|
||||
sourceRun: NullableNoticeRun;
|
||||
correctiveRun: NullableNoticeRun;
|
||||
sourceAssignee: NullableNoticeAgent;
|
||||
recoveryIssue: NullableNoticeIssue;
|
||||
recoveryOwner: NullableNoticeAgent;
|
||||
latestIssueStatus: string;
|
||||
latestHandoffRunStatus: string;
|
||||
missingDisposition: string;
|
||||
}): SuccessfulRunHandoffNotice {
|
||||
return {
|
||||
body: SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
|
||||
presentation: systemNoticePresentation({
|
||||
tone: "danger",
|
||||
title: "Missing disposition recovery blocked",
|
||||
}),
|
||||
metadata: {
|
||||
version: 1,
|
||||
sections: [
|
||||
{
|
||||
title: "Recovery owner",
|
||||
rows: [
|
||||
issueLinkRow("Source issue", input.issue),
|
||||
issueLinkRow("Recovery issue", input.recoveryIssue),
|
||||
agentLinkRow("Recovery owner", input.recoveryOwner),
|
||||
agentLinkRow("Source assignee", input.sourceAssignee),
|
||||
keyValueRow("Suggested action", "choose and record a valid issue disposition without copying transcript content"),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Run evidence",
|
||||
rows: [
|
||||
runLinkRow("Source run", input.sourceRun),
|
||||
runLinkRow("Corrective handoff run", input.correctiveRun),
|
||||
keyValueRow("Latest issue status", input.latestIssueStatus),
|
||||
keyValueRow("Latest handoff run status", input.latestHandoffRunStatus),
|
||||
keyValueRow("Normalized cause", SUCCESSFUL_RUN_MISSING_STATE_REASON),
|
||||
keyValueRow("Missing disposition", input.missingDisposition),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFinishSuccessfulRunHandoffIdempotencyKey(input: {
|
||||
issueId: string;
|
||||
sourceRunId: string;
|
||||
attempt?: number;
|
||||
}) {
|
||||
return [
|
||||
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
input.issueId,
|
||||
input.sourceRunId,
|
||||
String(input.attempt ?? 1),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export async function findExistingFinishSuccessfulRunHandoffWake(
|
||||
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_HANDOFF_WAKE_STATUSES),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isCorrectiveHandoffRun(run: HeartbeatRunRow) {
|
||||
const context = readRecord(run.contextSnapshot);
|
||||
return context.handoffRequired === true ||
|
||||
readString(context.wakeReason) === FINISH_SUCCESSFUL_RUN_HANDOFF_REASON;
|
||||
}
|
||||
|
||||
function isIssueMonitorMaintenanceRun(run: HeartbeatRunRow) {
|
||||
const context = readRecord(run.contextSnapshot);
|
||||
const wakeReason = readString(context.wakeReason);
|
||||
const source = readString(context.source);
|
||||
return Boolean(wakeReason?.startsWith("issue_monitor") || source?.startsWith("issue.monitor"));
|
||||
}
|
||||
|
||||
function isProductiveSuccessfulRun(input: {
|
||||
livenessState: RunLivenessState | null;
|
||||
detectedProgressSummary: string | null;
|
||||
}) {
|
||||
if (input.livenessState && PRODUCTIVE_SUCCESS_LIVENESS_STATES.has(input.livenessState)) return true;
|
||||
return Boolean(input.detectedProgressSummary);
|
||||
}
|
||||
|
||||
export function buildSuccessfulRunHandoffInstruction(input: {
|
||||
issueIdentifier: string | null;
|
||||
sourceRunId: string;
|
||||
}) {
|
||||
const issueLabel = input.issueIdentifier ?? "this issue";
|
||||
return [
|
||||
`Your previous run on ${issueLabel} succeeded, but the issue is still in \`in_progress\` and Paperclip cannot identify a valid issue disposition.`,
|
||||
"",
|
||||
"Resolve the missing disposition before creating or revising any new artifacts. Choose **exactly one** outcome and perform the matching Paperclip action:",
|
||||
"",
|
||||
"**Is the issue finished?**",
|
||||
"1. Mark it `done` (scope complete) or `cancelled` (intentionally stopped).",
|
||||
"",
|
||||
"**Does someone else need to look at it?**",
|
||||
"2. Move it to `in_review` with a real reviewer path — `executionState.currentParticipant`, a human owner via `assigneeUserId`, a pending issue-thread interaction, or a linked pending approval.",
|
||||
"",
|
||||
"**Can it not continue right now?**",
|
||||
"3. Mark it `blocked` with first-class blockers (`blockedByIssueIds`) or a clearly named unblock owner/action.",
|
||||
"",
|
||||
"**Is there more work to do?**",
|
||||
`4. Either delegate follow-up work (create/link a follow-up issue and block this one on it, or close this issue if its scope is independently complete) or record an explicit continuation path with \`resumeIntent: true\`, \`resumeFromRunId: ${input.sourceRunId}\`, and a concrete next action.`,
|
||||
"",
|
||||
"Comments, document revisions, work-product writes, and continuation summaries are supporting evidence only — they do not satisfy this handoff unless the issue state/path also records one valid disposition.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function decideSuccessfulRunHandoff(input: {
|
||||
run: HeartbeatRunRow;
|
||||
issue: IssueRow | null;
|
||||
agent: AgentRow | null;
|
||||
livenessState: RunLivenessState | null;
|
||||
detectedProgressSummary: string | null;
|
||||
taskKey: string | null;
|
||||
hasActiveExecutionPath: boolean;
|
||||
hasQueuedWake: boolean;
|
||||
hasPendingInteractionOrApproval: boolean;
|
||||
hasExplicitBlockerPath: boolean;
|
||||
hasOpenRecoveryIssue: boolean;
|
||||
hasPauseHold: boolean;
|
||||
budgetBlocked: boolean;
|
||||
idempotentWakeExists: boolean;
|
||||
}): SuccessfulRunHandoffDecision {
|
||||
const { run, issue, agent } = input;
|
||||
|
||||
if (run.status !== "succeeded") return { kind: "skip", reason: "source run did not succeed" };
|
||||
if (isCorrectiveHandoffRun(run)) return { kind: "skip", reason: "source run is already a corrective handoff run" };
|
||||
if (isIssueMonitorMaintenanceRun(run)) return { kind: "skip", reason: "issue monitor run owns its own recovery path" };
|
||||
if (run.issueCommentStatus === "retry_queued" || run.issueCommentStatus === "retry_exhausted") {
|
||||
return { kind: "skip", reason: "missing issue comment retry owns the next action" };
|
||||
}
|
||||
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 (issue.assigneeUserId) return { kind: "skip", reason: "issue is human-owned" };
|
||||
if (issue.status !== "in_progress") return { kind: "skip", reason: `issue status ${issue.status} is a valid disposition` };
|
||||
if (issue.executionState) return { kind: "skip", reason: "issue has execution policy state" };
|
||||
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
|
||||
return { kind: "skip", reason: `agent status ${agent.status} is not invokable` };
|
||||
}
|
||||
if (!isProductiveSuccessfulRun(input)) {
|
||||
return { kind: "skip", reason: "successful run did not produce handoff-relevant progress" };
|
||||
}
|
||||
if (input.hasActiveExecutionPath) return { kind: "skip", reason: "issue already has an active execution path" };
|
||||
if (input.hasQueuedWake) return { kind: "skip", reason: "issue already has a queued or deferred wake" };
|
||||
if (input.hasPendingInteractionOrApproval) {
|
||||
return { kind: "skip", reason: "pending interaction or approval owns the next action" };
|
||||
}
|
||||
if (input.hasExplicitBlockerPath) return { kind: "skip", reason: "explicit blocker path owns the next action" };
|
||||
if (input.hasOpenRecoveryIssue) return { kind: "skip", reason: "open recovery issue owns the ambiguity" };
|
||||
if (input.hasPauseHold) return { kind: "skip", reason: "issue is under an active pause hold" };
|
||||
if (input.budgetBlocked) return { kind: "skip", reason: "budget hard stop blocks corrective wake" };
|
||||
if (input.idempotentWakeExists) {
|
||||
return { kind: "skip", reason: "corrective handoff wake already exists for this source run" };
|
||||
}
|
||||
|
||||
const instruction = buildSuccessfulRunHandoffInstruction({
|
||||
issueIdentifier: issue.identifier,
|
||||
sourceRunId: run.id,
|
||||
});
|
||||
const payload = withRecoveryModelProfileHint({
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
sourceIssueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
handoffRequired: true,
|
||||
handoffReason: SUCCESSFUL_RUN_MISSING_STATE_REASON,
|
||||
missingDisposition: "clear_next_step",
|
||||
validDispositionOptions: [...SUCCESSFUL_RUN_HANDOFF_OPTIONS],
|
||||
detectedProgressSummary: input.detectedProgressSummary,
|
||||
handoffAttempt: 1,
|
||||
maxHandoffAttempts: DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
||||
resumeIntent: true,
|
||||
followUpRequested: true,
|
||||
resumeFromRunId: run.id,
|
||||
...(input.taskKey ? { taskKey: input.taskKey } : {}),
|
||||
instruction,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "enqueue",
|
||||
idempotencyKey: buildFinishSuccessfulRunHandoffIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
}),
|
||||
payload,
|
||||
instruction,
|
||||
contextSnapshot: withRecoveryModelProfileHint({
|
||||
...payload,
|
||||
wakeReason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||
livenessState: input.livenessState,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue