Files
PaperClipAI/server/src/__tests__/issue-tree-control-service.test.ts
T
DottaandPaperclip 5cea9c7c0e fix(ui): show eligible wake targets when resuming tasks (#17521)
## Thinking Path

> - Paperclip manages agents and their assigned tasks.
> - A task pause controls execution without changing task status.
> - Resume can remove that pause and request work from eligible
assignees.
> - The preview counted blocked tasks and former executors as wake
targets.
> - The release route correctly skipped those targets, so the dialog
promised work that did not start.
> - Resume also trusted stale pause snapshots after tasks were reopened.
> - This PR aligns preview with execution eligibility and rechecks
current task state when releasing a pause.

## Linked Issues or Issue Description

**What happened?**

Pause an assigned blocked task, then open Resume. The dialog says that
one task will resume and offers to wake one agent. Applying it only
removes the pause. The route skips blocked and backlog tasks. A former
executor can also inflate the wake count after reassignment.

**Expected behavior**

The dialog must distinguish removing a pause from starting work. Its
wake count must use the current assignees and statuses accepted by the
release route. Blocked work must stay blocked.

**Steps to reproduce**

1. Open an assigned blocked task.
2. Choose Pause work, then Resume task.
3. Inspect the wake count and apply Resume work.

Related runtime recovery work is tracked separately in #17097 and
#17439. This PR does not change remote execution recovery.

## What Changed

- Share the resume status list between preview and release. Count only
current assignees on todo, in-progress, and in-review tasks for resume
wakes.
- Explain that Resume removes the pause. Show the existing preview
warnings, including tasks that remain idle.
- Disable and uncheck the wake option when no agents qualify. Send the
same choice to the server.
- Reevaluate previously skipped pause members after reopening or
releasing another hold. Retain active-pause and unsafe-execution guards.
- Add real PostgreSQL and HTTP-route regressions plus browser coverage
for both task interfaces. Keep draft, ancestor-pause, and
Stop/reconciliation coverage.
- Update the task coordination maps. Add an opt-in dedicated test
database URL for hosts where embedded PostgreSQL cannot start.

## Verification

- Final head `9548eb16736f632cc111a5c13a9a994ec1d95a13`: all 55
completed checks pass, with two optional Storybook checks skipped. [CI
40188094904](https://github.com/paperclipai/paperclip/actions/runs/40188094904)
passes, including typecheck, build, all general/serialized suites, and
all eight E2E shards. Greptile is 5/5 on this commit; the reopened-task
finding is fixed and resolved.
- Red: the real PostgreSQL regression receives three agents instead of
the one eligible current assignee on the original code.
- Green: all 27 tree-control service, route, and unit tests pass. They
cover current assignees, blocked/backlog exclusions, reopened tasks,
company boundaries, remaining pause holds, and execution blockers.
- The reopened-task route regression fails on the previous
implementation: skipped members never reach preflight/wake, and an
unsafe replay releases the hold instead of returning 409. All cases pass
with the fix.
- Browser: all five applicable cases pass together (90 seconds):
blocked-work resume in both interfaces, draft/ancestor-pause cases in
both interfaces, and the process
Stop/pause/reconciliation/restart/cancel lifecycle. The optional
native-runner fixture is skipped locally because its fixture binary is
not configured.
- All 157 task-page component tests pass on the prior commit; subsequent
changes affect the server route and Stop fixture only.
- `pnpm -r typecheck` and `pnpm build` pass before rebase, and CI
typecheck/build passed on the previous PR commit. Server typecheck
passes again after the route fix. The final-head CI run also passes
these full checks.
- `node scripts/check-maps.mjs`, `node scripts/check-token-gates.mjs`,
and `git diff --check` pass.
- Full `pnpm test:run` was attempted but did not complete: this host
cannot initialize embedded PostgreSQL, and unrelated workspace-runtime
tests timed out. The focused database tests and E2E suite use disposable
PostgreSQL 18 instead. All general-test and serialized-test CI shards
passed on the previous PR commit; the final-head CI run also passes
every test shard.
- The first CI run caught the existing Stop test trying to check a
disabled wake option for blocked legacy work. It now asserts zero wake
targets and retains the no-execution-before-reconciliation checks.
- Earlier E2E attempts exposed fixture problems: assignment started the
fixture before it was paused, and the classic interface had two matching
editor nodes. The fixture now enables dispatch after the hold exists and
scopes the final assertion to the task composer. No product assertion
was removed.

## Risks

Resume preview is a snapshot. The release route still checks current
status, assignment, and execution blockers. It does not bypass approval,
budget, or recovery gates. Pause, cancel, and restore keep their
existing affected-agent semantics. The test database override only
accepts a dedicated database name ending in `_test`; it runs migrations
before the suite and closes its clients afterward.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, browser
control, and code execution. The runtime does not expose a more specific
model ID or context-window size.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-25 21:49:52 -05:00

770 lines
26 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq, inArray } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
agentWakeupRequests,
companies,
applyPendingMigrations,
closeRegisteredClients,
createDb,
heartbeatRuns,
issueComments,
issueThreadInteractions,
issueTreeHoldMembers,
issueTreeHolds,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueTreeControlService } from "../services/issue-tree-control.js";
import { issueService } from "../services/issues.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ISSUE_TREE_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue tree control service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issueTreeControlService", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (externalUrl) {
await applyPendingMigrations(externalUrl);
db = createDb(externalUrl);
return;
}
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-tree-control-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(issueThreadInteractions);
await db.delete(activityLog);
await db.delete(issueTreeHoldMembers);
await db.delete(issueTreeHolds);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
if (externalUrl) await closeRegisteredClients(externalUrl);
await tempDb?.cleanup();
});
it("previews a subtree without changing issue statuses", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const rootIssueId = randomUUID();
const runningChildId = randomUUID();
const doneChildId = randomUUID();
const cancelledChildId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "OtherCo",
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "assignment",
status: "running",
contextSnapshot: { issueId: runningChildId },
});
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root",
status: "todo",
priority: "medium",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
},
{
id: runningChildId,
companyId,
parentId: rootIssueId,
title: "Running child",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
executionRunId: runId,
createdAt: new Date("2026-04-21T10:01:00.000Z"),
},
{
id: doneChildId,
companyId,
parentId: rootIssueId,
title: "Done child",
status: "done",
priority: "medium",
createdAt: new Date("2026-04-21T10:02:00.000Z"),
},
{
id: cancelledChildId,
companyId,
parentId: rootIssueId,
title: "Cancelled child",
status: "cancelled",
priority: "medium",
createdAt: new Date("2026-04-21T10:03:00.000Z"),
},
]);
const svc = issueTreeControlService(db);
const preview = await svc.preview(companyId, rootIssueId, { mode: "pause" });
expect(preview.issues.map((issue) => [issue.id, issue.depth, issue.skipped, issue.skipReason])).toEqual([
[rootIssueId, 0, false, null],
[runningChildId, 1, false, null],
[doneChildId, 1, true, "terminal_status"],
[cancelledChildId, 1, true, "terminal_status"],
]);
expect(preview.totals).toMatchObject({
totalIssues: 4,
affectedIssues: 2,
skippedIssues: 2,
activeRuns: 1,
queuedRuns: 0,
affectedAgents: 1,
});
expect(preview.countsByStatus).toMatchObject({ todo: 1, in_progress: 1, done: 1, cancelled: 1 });
expect(preview.activeRuns).toEqual([
expect.objectContaining({ id: runId, issueId: runningChildId, agentId, status: "running" }),
]);
expect(preview.warnings.map((warning) => warning.code)).toContain("running_runs_present");
const [runningChildAfterPreview] = await db
.select()
.from(issues)
.where(eq(issues.id, runningChildId));
expect(runningChildAfterPreview.status).toBe("in_progress");
await expect(svc.preview(otherCompanyId, rootIssueId, { mode: "pause" })).rejects.toMatchObject({
status: 404,
});
});
it("previews a human-owned handoff as static work with no affected agent execution", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: rootIssueId,
companyId,
title: "Human validation handoff",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: "local-board",
createdAt: new Date("2026-04-21T10:10:00.000Z"),
});
const svc = issueTreeControlService(db);
const preview = await svc.preview(companyId, rootIssueId, { mode: "pause" });
expect(preview.issues).toEqual([
expect.objectContaining({
id: rootIssueId,
status: "in_progress",
assigneeAgentId: null,
assigneeUserId: "local-board",
activeRun: null,
skipped: false,
skipReason: null,
}),
]);
expect(preview.totals).toMatchObject({
totalIssues: 1,
affectedIssues: 1,
skippedIssues: 0,
activeRuns: 0,
queuedRuns: 0,
affectedAgents: 0,
});
expect(preview.activeRuns).toEqual([]);
expect(preview.affectedAgents).toEqual([]);
expect(preview.warnings.map((warning) => warning.code)).not.toContain("running_runs_present");
expect(preview.warnings.map((warning) => warning.code)).not.toContain("queued_runs_present");
});
it("creates and releases normalized hold snapshots", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: rootIssueId,
companyId,
title: "Root",
status: "todo",
priority: "medium",
});
const svc = issueTreeControlService(db);
const created = await svc.createHold(companyId, rootIssueId, {
mode: "pause",
reason: "operator requested pause",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
expect(created.hold.status).toBe("active");
expect(created.hold.members).toHaveLength(1);
expect(created.hold.members?.[0]).toMatchObject({
issueId: rootIssueId,
issueStatus: "todo",
skipped: false,
});
const released = await svc.releaseHold(companyId, rootIssueId, created.hold.id, {
reason: "operator resumed",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
expect(released.status).toBe("released");
expect(released.releaseReason).toBe("operator resumed");
expect(released.members).toHaveLength(1);
});
it("cancels non-terminal issue statuses and restores from the cancel snapshot", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
const runningChildId = randomUUID();
const todoChildId = randomUUID();
const doneChildId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root",
status: "done",
priority: "medium",
createdAt: new Date("2026-04-21T10:00:00.000Z"),
},
{
id: runningChildId,
companyId,
parentId: rootIssueId,
title: "Running child",
status: "in_progress",
priority: "medium",
createdAt: new Date("2026-04-21T10:01:00.000Z"),
},
{
id: todoChildId,
companyId,
parentId: rootIssueId,
title: "Todo child",
status: "todo",
priority: "medium",
createdAt: new Date("2026-04-21T10:02:00.000Z"),
},
{
id: doneChildId,
companyId,
parentId: rootIssueId,
title: "Done child",
status: "done",
priority: "medium",
createdAt: new Date("2026-04-21T10:03:00.000Z"),
},
]);
const [pendingInteraction] = await db.insert(issueThreadInteractions).values({
companyId,
issueId: runningChildId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "none",
payload: { version: 1, prompt: "Continue?" },
}).returning();
const svc = issueTreeControlService(db);
const cancel = await svc.createHold(companyId, rootIssueId, {
mode: "cancel",
reason: "bad plan",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
expect(cancel.preview.issues.map((issue) => [issue.id, issue.skipped, issue.skipReason])).toEqual([
[rootIssueId, true, "terminal_status"],
[runningChildId, false, null],
[todoChildId, false, null],
[doneChildId, true, "terminal_status"],
]);
const cancelled = await svc.cancelIssueStatusesForHold(companyId, rootIssueId, cancel.hold.id);
expect(cancelled.updatedIssueIds.sort()).toEqual([runningChildId, todoChildId].sort());
const afterCancel = await db
.select({ id: issues.id, status: issues.status })
.from(issues)
.where(inArray(issues.id, [runningChildId, todoChildId, doneChildId]));
expect(Object.fromEntries(afterCancel.map((issue) => [issue.id, issue.status]))).toMatchObject({
[runningChildId]: "cancelled",
[todoChildId]: "cancelled",
[doneChildId]: "done",
});
const [expiredInteraction] = await db
.select({ status: issueThreadInteractions.status })
.from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, pendingInteraction!.id));
expect(expiredInteraction?.status).toBe("expired");
await db
.update(issues)
.set({ status: "blocked", cancelledAt: null, updatedAt: new Date() })
.where(eq(issues.id, todoChildId));
const restorePreview = await svc.preview(companyId, rootIssueId, { mode: "restore" });
expect(restorePreview.issues.map((issue) => [issue.id, issue.skipped, issue.skipReason])).toEqual([
[rootIssueId, true, "not_cancelled"],
[runningChildId, false, null],
[todoChildId, true, "changed_after_cancel"],
[doneChildId, true, "not_cancelled"],
]);
expect(restorePreview.warnings.map((warning) => warning.code)).toContain("restore_conflicts_present");
const restore = await svc.createHold(companyId, rootIssueId, {
mode: "restore",
reason: "resume useful work",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
const restored = await svc.restoreIssueStatusesForHold(companyId, rootIssueId, restore.hold.id, {
reason: "resume useful work",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
expect(restored.updatedIssueIds).toEqual([runningChildId]);
const afterRestore = await db
.select({ id: issues.id, status: issues.status, checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId })
.from(issues)
.where(inArray(issues.id, [runningChildId, todoChildId, doneChildId]));
expect(Object.fromEntries(afterRestore.map((issue) => [issue.id, issue.status]))).toMatchObject({
[runningChildId]: "todo",
[todoChildId]: "blocked",
[doneChildId]: "done",
});
const holds = await db
.select({ id: issueTreeHolds.id, mode: issueTreeHolds.mode, status: issueTreeHolds.status })
.from(issueTreeHolds)
.where(inArray(issueTreeHolds.id, [cancel.hold.id, restore.hold.id]));
expect(Object.fromEntries(holds.map((hold) => [hold.mode, hold.status]))).toMatchObject({
cancel: "released",
restore: "released",
});
});
it("walks pause-hold ancestry beyond 15 levels for checkout and interaction waives", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issuePath = Array.from({ length: 17 }, () => randomUUID());
const rootIssueId = issuePath[0];
const deepDescendantIssueId = issuePath.at(-1)!;
const rootRunId = randomUUID();
const deepDescendantRunId = randomUUID();
const forgedRunId = randomUUID();
const rootWakeupRequestId = randomUUID();
const deepDescendantWakeupRequestId = randomUUID();
const forgedWakeupRequestId = randomUUID();
const rootCommentId = randomUUID();
const deepDescendantCommentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "SecurityEngineer",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values(
issuePath.map((issueId, index) => ({
id: issueId,
companyId,
parentId: index > 0 ? issuePath[index - 1] : null,
title: `Issue ${index}`,
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
})),
);
await db.insert(issueComments).values([
{
id: rootCommentId,
companyId,
issueId: rootIssueId,
authorUserId: "board-user",
body: "Please answer this root issue question.",
},
{
id: deepDescendantCommentId,
companyId,
issueId: deepDescendantIssueId,
authorUserId: "board-user",
body: "Please answer this deep descendant issue question.",
},
]);
await db.insert(agentWakeupRequests).values([
{
id: rootWakeupRequestId,
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId: rootIssueId, commentId: rootCommentId },
status: "queued",
requestedByActorType: "user",
requestedByActorId: "board-user",
runId: rootRunId,
},
{
id: forgedWakeupRequestId,
companyId,
agentId,
source: "on_demand",
triggerDetail: "manual",
reason: "issue_commented",
payload: { issueId: deepDescendantIssueId, commentId: deepDescendantCommentId },
status: "queued",
requestedByActorType: "agent",
requestedByActorId: agentId,
runId: forgedRunId,
},
{
id: deepDescendantWakeupRequestId,
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId: deepDescendantIssueId, commentId: deepDescendantCommentId },
status: "queued",
requestedByActorType: "user",
requestedByActorId: "board-user",
runId: deepDescendantRunId,
},
]);
await db.insert(heartbeatRuns).values([
{
id: rootRunId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId: rootWakeupRequestId,
contextSnapshot: {
issueId: rootIssueId,
wakeReason: "issue_commented",
commentId: rootCommentId,
wakeCommentId: rootCommentId,
source: "issue.comment",
},
},
{
id: forgedRunId,
companyId,
agentId,
invocationSource: "on_demand",
triggerDetail: "manual",
status: "queued",
wakeupRequestId: forgedWakeupRequestId,
contextSnapshot: {
issueId: deepDescendantIssueId,
wakeReason: "issue_commented",
commentId: deepDescendantCommentId,
wakeCommentId: deepDescendantCommentId,
},
},
{
id: deepDescendantRunId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId: deepDescendantWakeupRequestId,
contextSnapshot: {
issueId: deepDescendantIssueId,
wakeReason: "issue_commented",
commentId: deepDescendantCommentId,
wakeCommentId: deepDescendantCommentId,
source: "issue.comment",
},
},
]);
const treeSvc = issueTreeControlService(db);
await treeSvc.createHold(companyId, rootIssueId, {
mode: "pause",
reason: "operator requested pause",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
const deepDescendantGate = await treeSvc.getActivePauseHoldGate(companyId, deepDescendantIssueId);
expect(deepDescendantGate).toMatchObject({
holdId: expect.any(String),
rootIssueId,
issueId: deepDescendantIssueId,
isRoot: false,
mode: "pause",
});
const issueSvc = issueService(db);
await expect(
issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], randomUUID()),
).rejects.toMatchObject({
status: 409,
details: expect.objectContaining({
rootIssueId,
mode: "pause",
}),
});
await expect(
issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], forgedRunId),
).rejects.toMatchObject({
status: 409,
details: expect.objectContaining({
rootIssueId,
mode: "pause",
}),
});
const checkedOutChild = await issueSvc.checkout(deepDescendantIssueId, agentId, ["todo"], deepDescendantRunId);
expect(checkedOutChild.status).toBe("in_progress");
expect(checkedOutChild.checkoutRunId).toBe(deepDescendantRunId);
const checkedOutRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId);
expect(checkedOutRoot.status).toBe("in_progress");
expect(checkedOutRoot.checkoutRunId).toBe(rootRunId);
await db.update(issues).set({
status: "todo",
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
}).where(eq(issues.id, rootIssueId));
await db.update(issueTreeHolds).set({
status: "released",
releasedAt: new Date(),
releasedByActorType: "user",
releasedByUserId: "board-user",
releaseReason: "switch to full pause",
updatedAt: new Date(),
}).where(eq(issueTreeHolds.rootIssueId, rootIssueId));
await treeSvc.createHold(companyId, rootIssueId, {
mode: "pause",
reason: "full pause",
releasePolicy: { strategy: "manual", note: "full_pause" },
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
const checkedOutLegacyFullPauseRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId);
expect(checkedOutLegacyFullPauseRoot.status).toBe("in_progress");
expect(checkedOutLegacyFullPauseRoot.checkoutRunId).toBe(rootRunId);
});
it("previews only executable current assignees as resume wake targets", async () => {
const companyId = randomUUID();
const rootId = randomUUID();
const readyAgentId = randomUUID();
const parkedAgentId = randomUUID();
const previousAgentId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId, name: "Resume preview", issuePrefix: "RESUME",
});
await db.insert(agents).values([readyAgentId, parkedAgentId, previousAgentId].map((id) => ({
id, companyId, name: id, role: "engineer", status: "idle", adapterType: "process",
})));
const children = ["todo", "in_progress", "in_review", "blocked", "backlog", "done"].map((status) => ({
id: randomUUID(), companyId, parentId: rootId, title: status, status,
assigneeAgentId: status === "blocked" || status === "backlog" ? parkedAgentId : readyAgentId,
}));
await db.insert(issues).values([
{ id: rootId, companyId, title: "Unassigned root", status: "todo" },
...children,
]);
const svc = issueTreeControlService(db);
const pause = await svc.createHold(companyId, rootId, {
mode: "pause",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
expect(pause.hold.members?.find((member) => member.issueId === children[5].id)).toMatchObject({
skipped: true, skipReason: "terminal_status",
});
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, children[5].id));
// A previous executor is observable while stopping, but is not the current assignee to wake.
await db.insert(heartbeatRuns).values({
id: runId, companyId, agentId: previousAgentId, invocationSource: "assignment",
status: "running", contextSnapshot: { issueId: children[0].id },
});
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, children[0].id));
const preview = await svc.preview(companyId, rootId, { mode: "resume" });
expect(preview.totals.affectedIssues).toBe(7);
expect(preview.affectedAgents).toEqual([{ agentId: readyAgentId, issueCount: 4, activeRunCount: 0 }]);
expect(preview.totals.affectedAgents).toBe(1);
expect(preview.warnings).toContainEqual({
code: "resume_tasks_remain_idle",
message: "Blocked, backlog, and unassigned tasks will be unpaused but will not start.",
issueIds: [rootId, children[3].id, children[4].id].sort(),
});
const rows = await db.select().from(issues).where(eq(issues.companyId, companyId));
expect(rows.find((row) => row.id === children[3].id)?.status).toBe("blocked");
expect(rows.find((row) => row.id === children[4].id)?.status).toBe("backlog");
expect((await svc.getHold(companyId, pause.hold.id))?.status).toBe("active");
});
it("resumes subtree pauses by releasing matching pause holds", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const nonSubtreeIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root",
status: "todo",
priority: "medium",
},
{
id: childIssueId,
companyId,
parentId: rootIssueId,
title: "Child",
status: "todo",
priority: "medium",
},
{
id: nonSubtreeIssueId,
companyId,
title: "Unrelated",
status: "todo",
priority: "medium",
},
]);
const treeSvc = issueTreeControlService(db);
const subtreePause = await treeSvc.createHold(companyId, childIssueId, {
mode: "pause",
reason: "pause child only",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
const nonSubtreePause = await treeSvc.createHold(companyId, nonSubtreeIssueId, {
mode: "pause",
reason: "pause unrelated issue",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
const resumed = await treeSvc.createHold(companyId, rootIssueId, {
mode: "resume",
reason: "resume subtree",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
expect(resumed.hold.mode).toBe("resume");
expect(resumed.hold.status).toBe("released");
expect(resumed.resumedPauseHoldIds).toEqual([subtreePause.hold.id]);
const rows = await db
.select({ id: issueTreeHolds.id, status: issueTreeHolds.status, releaseMetadata: issueTreeHolds.releaseMetadata })
.from(issueTreeHolds)
.where(eq(issueTreeHolds.companyId, companyId));
const byId = new Map(rows.map((row) => [row.id, row] as const));
expect(byId.get(subtreePause.hold.id)?.status).toBe("released");
expect(byId.get(nonSubtreePause.hold.id)?.status).toBe("active");
expect(byId.get(resumed.hold.id)?.status).toBe("released");
const releaseMetadata = byId.get(subtreePause.hold.id)?.releaseMetadata as
| Record<string, unknown>
| null;
expect(releaseMetadata).toMatchObject({
resumedByResumeHoldId: resumed.hold.id,
resumeHoldMode: "tree_resume",
resumedPauseHoldId: subtreePause.hold.id,
});
expect((byId.get(resumed.hold.id)?.releaseMetadata as Record<string, unknown> | null)).toMatchObject({
resumedPauseHoldIds: [subtreePause.hold.id],
resumeMode: "subtree",
});
});
});