Files
paperclip/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
T
Devin Foley d9f91576a0 Add accepted-plan decomposition exact-once guards and UI state (#6831)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies, so
planning approvals and child-issue fan-out are part of the core
control-plane loop.
> - Accepted plans are supposed to be a safe bridge from planning into
execution, especially when agents wake from review decisions and reuse
isolated workspaces.
> - The duplicate-subtask incident showed that an accepted plan revision
could be interpreted more than once across overlapping runs, which broke
the single-source-of-truth model for issue decomposition.
> - Fixing that required tightening the backend contract first:
accepted-plan decomposition needs an exact-once fingerprint, durable
claim state, and retry-safe child creation.
> - Once that backend behavior existed, the board still needed
visibility into what happened, so the issue detail view needed a
dedicated decomposition section instead of forcing operators to
reconstruct child creation from raw activity.
> - This pull request adds the exact-once decomposition primitive,
hardens wake routing and regressions around the incident, and surfaces
decomposition state in the UI so future incidents are both prevented and
easier to inspect.

## What Changed

- Added accepted-plan decomposition semantics to
`doc/execution-semantics.md`, including the exact-once fingerprint,
durable claim/result expectations, and retry/resume behavior.
- Added persistent accepted-plan decomposition claims in the backend,
including schema, shared types/validators, service logic, and issue
routes for creating and listing decomposition state.
- Hardened heartbeat routing so an accepted-plan continuation stays
scoped to the relevant planning issue instead of opportunistically
re-decomposing another accepted issue on the same assignee.
- Added regression coverage for the original failure modes: concurrent
same-parent retries, cross-issue accepted-plan isolation, and partial
child recreation under the same fingerprint.
- Added the `Plan decomposition` issue-detail section plus supporting
API/query-key/activity formatting updates so operators can see revision
status, owner, child counts, and the linked child issues directly in the
UI.
- Included the small follow-up UI fix so the decomposition section still
renders when the issue work mode is no longer `planning`.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/db typecheck`
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts`
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t
"lists persisted decompositions with child issue summaries"`
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t
"accepted plan decomposition"
server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
server/src/__tests__/heartbeat-context-summary.test.ts`
- Manual UI path: create a planning issue without an isolated execution
workspace, add a `plan` document, accept the `request_confirmation`, let
Paperclip create child issues, then reopen the parent issue detail page
and confirm the `Plan decomposition` section shows the accepted
revision, status, idempotent-claim badge, and child links.
- Separate follow-up bug noted during manual UI validation: accepting a
plan on an issue whose run never records `workspace_finalize` is tracked
in `PAPA-445` and is not part of this PR’s fix scope.

## Risks

- This adds a new migration and a large Drizzle snapshot update;
reviewers should confirm the schema shape and generated metadata match
the intended decomposition table.
- The exact-once claim changes sit on the accepted-plan fan-out path, so
regressions there could block legitimate child creation or mis-handle
retries if the claim state machine is wrong.
- The new UI only appears when decomposition records exist; reviewers
should use the manual verification path above rather than expecting
existing issues on a stale local instance to show the section
automatically.
- `PAPA-445` remains an open follow-up for the `workspace_finalize`
accept gate when a planning handoff never records finalize; that bug can
interfere with reproducing the UI flow on isolated workspaces but does
not change the correctness of the exact-once decomposition feature
itself.

> Checked `ROADMAP.md`: this PR is a bug fix / control-plane hardening
change for accepted-plan decomposition, not a new uncoordinated roadmap
feature.

## Model Used

- OpenAI Codex via Paperclip `codex_local` (GPT-5-based coding agent;
exact backend model ID/context window not exposed in the run context),
with repository tool use, shell execution, and code-editing
capabilities.

<img width="806" height="1069" alt="Screenshot 2026-05-27 at 11 05
48 PM"
src="https://github.com/user-attachments/assets/5b00b670-96cd-4470-b0a3-581743bcae28"
/>


## 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>
2026-05-28 23:30:18 -07:00

808 lines
25 KiB
TypeScript

import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { eq, ne } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentRuntimeState,
agentTaskSessions,
agentWakeupRequests,
agents,
companies,
companySkills,
createDb,
documentRevisions,
documents,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
issuePlanDecompositions,
issues,
projects,
projectWorkspaces,
workspaceOperations,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
const execFileAsync = promisify(execFile);
const adapterExecute = vi.hoisted(() => vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "fresh-session" },
sessionDisplayId: "fresh-session",
summary: "Accepted plan workspace refresh test run.",
provider: "test",
model: "test-model",
})));
vi.mock("../adapters/index.js", () => ({
getServerAdapter: () => ({
type: "codex_local",
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
listAdapterModelProfiles: async () => [],
runningProcesses: new Map(),
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres accepted-plan workspace refresh tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function createGitRepo() {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-accepted-plan-repo-"));
await execFileAsync("git", ["init"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.email", "paperclip-test@example.com"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.name", "Paperclip Test"], { cwd: repoRoot });
await writeFile(path.join(repoRoot, "README.md"), "accepted plan workspace refresh\n");
await execFileAsync("git", ["add", "README.md"], { cwd: repoRoot });
await execFileAsync("git", ["commit", "-m", "initial"], { cwd: repoRoot });
return repoRoot;
}
describeEmbeddedPostgres("accepted plan workspace refresh", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-workspace-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
adapterExecute.mockClear();
let idlePolls = 0;
for (let attempt = 0; attempt < 100; attempt += 1) {
const runs = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running");
if (!hasActiveRun) {
idlePolls += 1;
if (idlePolls >= 5) break;
} else {
idlePolls = 0;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
}
await db.delete(issuePlanDecompositions);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(agentTaskSessions);
await db.delete(executionWorkspaces);
await db.delete(activityLog);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(workspaceOperations);
await db.delete(companySkills);
await db.delete(companies);
});
afterAll(async () => {
await db.$client.end();
await tempDb?.cleanup();
});
async function seedAcceptedPlanClaim(args: {
companyId: string;
issueId: string;
ownerAgentId: string;
status?: "in_flight" | "completed";
}) {
const documentId = randomUUID();
const revisionId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId: args.companyId,
title: "Plan",
format: "markdown",
latestBody: "Plan body",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
createdByAgentId: args.ownerAgentId,
updatedByAgentId: args.ownerAgentId,
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId: args.companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "Plan body",
createdByAgentId: args.ownerAgentId,
});
await db.insert(issueDocuments).values({
companyId: args.companyId,
issueId: args.issueId,
documentId,
key: "plan",
});
await db.insert(issuePlanDecompositions).values({
companyId: args.companyId,
sourceIssueId: args.issueId,
acceptedPlanRevisionId: revisionId,
status: args.status ?? "in_flight",
requestFingerprint: `claim:${args.issueId}`,
requestedChildCount: 1,
requestedChildren: [{ title: "child-1" }],
childIssueIds: [],
ownerAgentId: args.ownerAgentId,
updatedAt: new Date(),
...(args.status === "completed" ? { completedAt: new Date() } : {}),
});
}
it("realizes an isolated workspace and drops stale shared task-session params before executing", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const sharedExecutionWorkspaceId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const repoRoot = await createGitRepo();
tempRoots.push(repoRoot);
await instanceSettingsService(db).updateExperimental({
enableIsolatedWorkspaces: true,
});
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Accepted Plan Workspace Refresh",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(executionWorkspaces).values({
id: sharedExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared planning workspace",
status: "active",
cwd: repoRoot,
providerType: "local_fs",
providerRef: repoRoot,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Implement accepted plan",
status: "in_progress",
workMode: "planning",
priority: "medium",
assigneeAgentId: agentId,
identifier: "PAP-9122",
executionWorkspaceId: sharedExecutionWorkspaceId,
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agentTaskSessions).values({
companyId,
agentId,
adapterType: "codex_local",
taskKey: issueId,
sessionParamsJson: {
sessionId: "stale-shared-session",
cwd: repoRoot,
workspaceId: projectWorkspaceId,
},
sessionDisplayId: "stale-shared-session",
});
adapterExecute.mockImplementationOnce(async () => {
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId));
return {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "fresh-session" },
sessionDisplayId: "fresh-session",
summary: "Accepted plan workspace refresh test run.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
const run = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
},
});
expect(run).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(run!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
expect(adapterExecute).toHaveBeenCalledTimes(1);
const adapterInput = adapterExecute.mock.calls[0]?.[0] as {
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
context: Record<string, unknown>;
};
expect(adapterInput.runtime.sessionId).toBeNull();
expect(adapterInput.runtime.sessionParams).toBeNull();
expect(adapterInput.context.paperclipWorkspace).toEqual(expect.objectContaining({
mode: "isolated_workspace",
strategy: "git_worktree",
}));
expect((adapterInput.context.paperclipWorkspace as { cwd: string }).cwd).not.toBe(repoRoot);
const refreshedIssue = await db
.select({
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(refreshedIssue?.executionWorkspaceId).toBeTruthy();
expect(refreshedIssue?.executionWorkspaceId).not.toBe(sharedExecutionWorkspaceId);
expect(refreshedIssue?.executionWorkspaceSettings).toMatchObject({
mode: "isolated_workspace",
});
const isolatedRows = await db
.select()
.from(executionWorkspaces)
.where(ne(executionWorkspaces.id, sharedExecutionWorkspaceId));
expect(isolatedRows).toHaveLength(1);
expect(isolatedRows[0]).toMatchObject({
mode: "isolated_workspace",
strategyType: "git_worktree",
sourceIssueId: issueId,
});
expect(isolatedRows[0]?.cwd).not.toBe(repoRoot);
}, 20_000);
it("forces a fresh session and suppresses accepted-plan continuation when another issue owns the in-flight claim", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const otherPlanningIssueId = randomUUID();
const agentId = randomUUID();
const repoRoot = await createGitRepo();
tempRoots.push(repoRoot);
await instanceSettingsService(db).updateExperimental({
enableIsolatedWorkspaces: false,
});
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Accepted Plan Routing",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values([
{
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Later planning wake",
status: "in_progress",
workMode: "planning",
priority: "medium",
assigneeAgentId: agentId,
identifier: "PAP-9301",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: otherPlanningIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Earlier accepted plan",
status: "in_progress",
workMode: "planning",
priority: "medium",
assigneeAgentId: agentId,
identifier: "PAP-9302",
createdAt: new Date(),
updatedAt: new Date(),
},
]);
await seedAcceptedPlanClaim({
companyId,
issueId: otherPlanningIssueId,
ownerAgentId: agentId,
status: "in_flight",
});
await db.insert(agentTaskSessions).values({
companyId,
agentId,
adapterType: "codex_local",
taskKey: issueId,
sessionParamsJson: {
sessionId: "stale-cross-issue-session",
cwd: repoRoot,
},
sessionDisplayId: "stale-cross-issue-session",
});
adapterExecute.mockImplementationOnce(async () => {
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId));
return {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "fresh-session" },
sessionDisplayId: "fresh-session",
summary: "Suppressed cross-issue accepted-plan continuation.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
const run = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId,
interactionId: "interaction-cross-issue",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
mutation: "interaction",
},
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_blockers_resolved",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
},
});
expect(run).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(run!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
expect(adapterExecute).toHaveBeenCalledTimes(1);
const adapterInput = adapterExecute.mock.calls[0]?.[0] as {
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
context: Record<string, unknown>;
};
expect(adapterInput.runtime.sessionId).toBeNull();
expect(adapterInput.runtime.sessionParams).toBeNull();
expect(adapterInput.context.acceptedPlanWakeRouting).toEqual(expect.objectContaining({
reason: "other_issue_claim_in_flight",
otherActiveClaimIssueId: otherPlanningIssueId,
otherActiveClaimIdentifier: "PAP-9302",
}));
expect(adapterInput.context.paperclipTaskMarkdown).toContain("Make the plan only.");
expect(adapterInput.context.paperclipTaskMarkdown).not.toContain("Create child issues from the approved plan only");
}, 20_000);
it("guards cross-issue accepted-plan retries even when the waking issue is standard work mode", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const otherPlanningIssueId = randomUUID();
const agentId = randomUUID();
const repoRoot = await createGitRepo();
tempRoots.push(repoRoot);
await instanceSettingsService(db).updateExperimental({
enableIsolatedWorkspaces: false,
});
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Accepted Plan Routing",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values([
{
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Implementation wake after accepted plan",
status: "in_progress",
workMode: "standard",
priority: "medium",
assigneeAgentId: agentId,
identifier: "PAP-9401",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: otherPlanningIssueId,
companyId,
projectId,
projectWorkspaceId,
title: "Earlier accepted plan",
status: "in_progress",
workMode: "planning",
priority: "medium",
assigneeAgentId: agentId,
identifier: "PAP-9402",
createdAt: new Date(),
updatedAt: new Date(),
},
]);
await seedAcceptedPlanClaim({
companyId,
issueId: otherPlanningIssueId,
ownerAgentId: agentId,
status: "in_flight",
});
await db.insert(agentTaskSessions).values({
companyId,
agentId,
adapterType: "codex_local",
taskKey: issueId,
sessionParamsJson: {
sessionId: "stale-standard-cross-issue-session",
cwd: repoRoot,
},
sessionDisplayId: "stale-standard-cross-issue-session",
});
adapterExecute.mockImplementationOnce(async () => {
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId));
return {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "fresh-session" },
sessionDisplayId: "fresh-session",
summary: "Suppressed cross-issue accepted-plan continuation for a standard-work wake.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
const run = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId,
interactionId: "interaction-standard-cross-issue",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
mutation: "interaction",
},
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
},
});
expect(run).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(run!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
expect(adapterExecute).toHaveBeenCalledTimes(1);
const adapterInput = adapterExecute.mock.calls[0]?.[0] as {
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
context: Record<string, unknown>;
};
expect(adapterInput.runtime.sessionId).toBeNull();
expect(adapterInput.runtime.sessionParams).toBeNull();
expect(adapterInput.context.acceptedPlanWakeRouting).toEqual(expect.objectContaining({
reason: "other_issue_claim_in_flight",
otherActiveClaimIssueId: otherPlanningIssueId,
otherActiveClaimIdentifier: "PAP-9402",
}));
expect(adapterInput.context.paperclipTaskMarkdown).toContain("Issue: \"PAP-9401\"");
expect(adapterInput.context.paperclipTaskMarkdown).not.toContain("Create child issues from the approved plan only");
}, 20_000);
it("preserves accepted-plan continuation resume state when the wake issue owns the in-flight claim", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const repoRoot = await createGitRepo();
tempRoots.push(repoRoot);
await instanceSettingsService(db).updateExperimental({
enableIsolatedWorkspaces: false,
});
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Accepted Plan Retry",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Accepted plan retry",
status: "in_progress",
workMode: "planning",
priority: "medium",
assigneeAgentId: agentId,
identifier: "PAP-9303",
createdAt: new Date(),
updatedAt: new Date(),
});
await seedAcceptedPlanClaim({
companyId,
issueId,
ownerAgentId: agentId,
status: "in_flight",
});
await db.insert(agentTaskSessions).values({
companyId,
agentId,
adapterType: "codex_local",
taskKey: issueId,
sessionParamsJson: {
sessionId: "accepted-plan-retry-session",
cwd: repoRoot,
},
sessionDisplayId: "accepted-plan-retry-session",
});
adapterExecute.mockImplementationOnce(async () => {
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId));
return {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "accepted-plan-retry-session" },
sessionDisplayId: "accepted-plan-retry-session",
summary: "Resumed accepted-plan continuation for the same issue.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
const run = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId,
interactionId: "interaction-same-issue",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
mutation: "interaction",
},
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_blockers_resolved",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
},
});
expect(run).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(run!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
expect(adapterExecute).toHaveBeenCalledTimes(1);
const adapterInput = adapterExecute.mock.calls[0]?.[0] as {
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
context: Record<string, unknown>;
};
expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session");
expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined();
expect(adapterInput.context.paperclipTaskMarkdown).toContain("Create child issues from the approved plan only");
}, 20_000);
});