Files
PaperClipAI/server/src/services/issue-tree-resume.ts
T
DottaandClaude Opus 5.5 7e3caa7bac fix(tree-control): resume work a pause gate refused after release (#19421)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board can pause an issue tree. A pause stops work. Resume or
release must continue that work once.
> - PR #19394 made release re-dispatch a run that a pause Stop
cancelled.
> - That rule covers only a run that an operator Stop ended and that the
hold snapshot named.
> - A scheduled retry that comes due during a pause is not stopped. The
dispatch gate cancels it with `issue_paused`. The release did not see it
as owed work, so the task stalled with no run.
> - This pull request makes every gate that refuses work for a pause
record the covering hold. Release then re-dispatches that work exactly
once.
> - The benefit is that a paused task with a parked retry or a queued
run continues after release, with no manual wake.

## Linked Issues or Issue Description

Refs #19394

**What happened?**
A child task had a `workspace_busy` scheduled retry. The board paused
the parent tree. The retry came due during the pause. The
scheduled-retry gate cancelled it with `issue_paused` ("Scheduled retry
suppressed because the issue is held by an active subtree pause hold").
The board released the hold. Four minutes later the child was still
`in_progress` with no run, no execution blocker, and no new activity.

**Expected behavior**
Release (or `mode: "resume"`) re-dispatches the refused work exactly
once. No reconciliation and no user message are necessary.
Stranded-issue recovery does not add a second run.

**Steps to reproduce**
1. Give an assigned child task a parked `workspace_busy` scheduled
retry.
2. Pause the parent tree.
3. Let the retry come due while the pause is active. The gate cancels it
with `issue_paused`.
4. Release the pause. No run starts for the child.

**Paperclip version or commit**
master at 4d49a31286 (includes #19394).

**Agent adapter(s) involved**
Not adapter-specific (core bug).

## What Changed

- `issue-tree-control.ts`: add `ISSUE_PAUSED_ERROR_CODE` and
`pauseSuppressionResult()`. A gate records `treeHoldSuppression: {
holdId, rootIssueId }` on a run that it refused for a pause.
- `findOwedPauseResume` now treats the latest run as owed when it is an
`issue_paused` cancellation under a pause hold. It uses the recorded
hold. For an unmarked run (a scheduled retry cancelled before this
change), it uses a snapshot pause that was active at cancellation. The
operator Stop rule does not change.
- Scheduled-retry gate (`run-dispatch/adapters/postgres.ts`,
`cancelSuppressedRetryInTx`): locks the pause row `FOR SHARE` and
records the covering hold. A concurrent release either sees the refusal,
or the retry sees no pause and runs normally.
- Queued-run claim gate (`heartbeat/queue.ts`): cancels with
`issue_paused` and the hold marker, after the agent start lock is
released. Before, the cancel re-entered that lock from inside it, and
the agent's queue stalled until the 30-second stale-lock timeout. After
the refusal is saved, the gate re-checks the hold and resumes the task
itself if release already finished (`resumeIfPauseAlreadyReleased`).
- Release (`issue-tree-resume.ts`, route): revisits the snapshot members
and every task with a run refused for that hold, also when `wakeAgents`
is set. This covers tasks created under the tree after the snapshot and
tasks moved out before release.
- `packages/db`: partial index
`heartbeat_runs_tree_hold_suppression_idx` (migration 0436, `CREATE
INDEX CONCURRENTLY`, registered as a top-level migration) for that
lookup.
- Docs: `doc/execution-semantics.md` (gate-refused work, race handling,
non-assignee and pre-fix scope) and `doc/DATABASE.md` (index).

## Verification

- Red before the fix, green after: `pnpm --filter @paperclipai/server
exec vitest run src/__tests__/issue-tree-pause-resume-run.test.ts` (21
tests).
- "resumes a parked retry the pause suppressed exactly once after
release / resume / legacy": before, `expected [] to have a length of 1`
(no resumed run). After, one `issue_tree_resumed` run with
`interruptedRunId` = the suppressed retry, no execution blocker, and the
task finished. `reconcileStrandedAssignedIssues` added no run during the
hold or after release.
- "resumes a queued run the claim gate refused during the hold exactly
once": before, the test timed out at 15 s (start-lock re-entry). After,
the run is cancelled with `issue_paused` and the hold marker, and
release re-dispatches it once.
- "resumes a refused retry on a task moved out of the paused tree before
release" and "... on a task outside the snapshot when release also wakes
members": review findings, one resume each.
- "leaves a due retry parked when the pause is released while the gate
refuses it": the test waits until the gate blocks on an uncommitted
release. The retry is not cancelled and runs once. Without the `FOR
SHARE` lock the gate does not wait and the test fails.
- "resumes work a gate refused after the release that should have
resumed it had finished": the late-refusal re-check resumes once, and a
repeated check does not dispatch again.
- "does not resume a paused-code cancellation that no pause hold
covered": negative case.
- Related suites pass locally: `src/__tests__/issue-tree*`,
`src/modules/run-dispatch`, `src/services/heartbeat/queue.test.ts`,
`heartbeat-dependency-scheduling`, `heartbeat-workspace-busy`, and
`packages/db` `client.test.ts`.
- I did not run a live staging check here. Staging APIs are not
reachable from this environment.

## Risks

- Migration 0436 adds a partial index on `heartbeat_runs`. It is built
concurrently and covers only cancelled runs with the marker, so it is
small.
- The scheduled-retry gate now takes a `FOR SHARE` row lock on the pause
hold before it cancels. Release updates that row, so the two serialize
briefly. Release does not lock issues or runs, so the lock order cannot
deadlock with the gate's issue-then-run locks.
- The queued-run claim gate now records `issue_paused` in place of the
generic `cancelled`. Stranded recovery already classifies `issue_paused`
as non-retryable. The pause resume owns that work, so recovery does not
add a second run.
- Scope limits (documented): a non-assignee run refused by a pause is
not replayed, the same as non-assignee wakes that admission drops during
a hold. Among pre-fix rows, only scheduled retries carry `issue_paused`,
so only those fall back to the snapshot. A pre-fix row whose hold is
already released does not self-recover; the staging task that showed
this bug needs one manual wake, or a new pause and release.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with tool use (shell, file edits, test execution).

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:03:15 -06:00

281 lines
15 KiB
TypeScript

import { and, asc, eq, gt, ne, sql } from "drizzle-orm";
import { agentWakeupRequests, heartbeatRuns, issueTreeHolds, issues, type Db } from "@paperclipai/db";
import { isUniqueViolation } from "../db-errors.js";
import { logActivity } from "./activity-log.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
import { getExecutionBlocker } from "./execution-blocker.js";
import { ISSUE_TREE_RESUME_EXECUTABLE_STATUSES, issueTreeControlService } from "./issue-tree-control.js";
type ResumeActor = {
actorType: "user" | "agent" | "system";
actorId: string;
agentId?: string | null;
runId?: string | null;
agentApiKeyId?: string | null;
};
type Wakeup = (agentId: string, opts: {
source: "assignment";
triggerDetail: "system";
reason: string;
payload: Record<string, unknown>;
requestedByActorType: ResumeActor["actorType"];
requestedByActorId: string;
idempotencyKey: string;
contextSnapshot: Record<string, unknown>;
}) => Promise<unknown>;
/** A claim older than this with no saved wake belongs to an interrupted dispatcher. */
export const PAUSE_RESUME_CLAIM_STALE_MS = 60_000;
/** Queue admission accepts one saved wake per interrupted run under this key. */
export const PAUSE_RESUME_WAKE_INDEX = "agent_wakeup_requests_tree_hold_resume_uq";
const SWEEP_PAGE_SIZE = 25;
export function pauseResumeWakeKey(runId: string) {
return `tree-hold-resume:${runId}`;
}
const SYSTEM_ACTOR: ResumeActor = { actorType: "system", actorId: "system" };
function activity(db: Db, companyId: string, actor: ResumeActor, action: string, issueId: string,
details: Record<string, unknown>) {
return logActivity(db, {
companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId ?? null,
runId: actor.runId ?? null, agentApiKeyId: actor.agentApiKeyId ?? null, action,
entityType: "issue", entityId: issueId, details,
});
}
/** Compare-and-set the run's resume record. Only the writer that wins dispatches. */
async function setResumeState(db: Db, input: {
companyId: string; runId: string; next: Record<string, unknown>; expectedClaimedAt?: string | null;
}) {
const guard = input.expectedClaimedAt === undefined
? sql`not (coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) ? 'treeHoldResume')`
: sql`${heartbeatRuns.resultJson}->'treeHoldResume'->>'claimedAt' is not distinct from ${input.expectedClaimedAt}`;
return db.transaction(async tx => {
// A pending claim arms the recovery sweep in the same commit, so a crash
// before the wake is saved is retried. Intent is registered before the write.
if (input.next.state === "pending") await notifyDeliveryWork(tx, DELIVERY_QUEUES.executionContinuation);
const [row] = await tx.update(heartbeatRuns).set({
resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ treeHoldResume: input.next })}::jsonb`,
}).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId),
eq(heartbeatRuns.status, "cancelled"), guard)).returning({ id: heartbeatRuns.id });
return Boolean(row);
});
}
/** A saved, admitted resume wake (queued, deferred, coalesced, or run) for the interrupted run. */
async function findPauseResumeReceipt(db: Db, companyId: string, agentId: string, runId: string) {
const [receipt] = await db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.idempotencyKey, pauseResumeWakeKey(runId)),
ne(agentWakeupRequests.status, "skipped"))).limit(1);
return Boolean(receipt);
}
async function dispatch(db: Db, input: {
companyId: string; issueId: string; agentId: string; runId: string; holdId: string;
claimedAt: string; rootIssueId: string | null; actor: ResumeActor; wakeup: Wakeup;
}) {
const saved = await input.wakeup(input.agentId, {
source: "assignment",
triggerDetail: "system",
reason: "issue_tree_resumed",
payload: { issueId: input.issueId, rootIssueId: input.rootIssueId, holdId: input.holdId, interruptedRunId: input.runId },
requestedByActorType: input.actor.actorType,
requestedByActorId: input.actor.actorId,
// The saved wake request is the durable receipt the recovery sweep checks.
idempotencyKey: pauseResumeWakeKey(input.runId),
contextSnapshot: {
issueId: input.issueId,
taskId: input.issueId,
wakeReason: "issue_tree_resumed",
source: "issue.tree_resume",
rootIssueId: input.rootIssueId,
holdId: input.holdId,
interruptedRunId: input.runId,
},
}).then((run) => run ? "saved" as const : "not_admitted" as const, (error: unknown) => {
// Another dispatcher already saved this run's resume wake; it owns the work.
if (isUniqueViolation(error, PAUSE_RESUME_WAKE_INDEX)) return "duplicate" as const;
throw error;
});
if (saved === "duplicate") return;
// Admission can skip the wake, for example when a new pause landed after the
// eligibility check. A skipped request is no receipt, so the claim stays
// pending and the sweep retries once the task is runnable again.
if (saved === "not_admitted" && !await findPauseResumeReceipt(db, input.companyId, input.agentId, input.runId)) return;
await setResumeState(db, { companyId: input.companyId, runId: input.runId, expectedClaimedAt: input.claimedAt,
next: { holdId: input.holdId, state: "delivered", claimedAt: input.claimedAt, deliveredAt: new Date().toISOString() } });
}
/**
* Re-dispatch, once, the work a subtree pause interrupted on each task that is
* no longer paused. Both a hold release and the executor that settles a
* cancelled preparation call this, so neither ordering loses the work. A task
* that still needs execution reconciliation keeps its blocker and is skipped;
* its obligation stays unclaimed for a later settlement or release. A claim
* whose wake was never saved (for example after a restart) stays pending and
* is finished by `deliverPendingPauseResumes`.
*/
export async function resumePauseInterruptedWork(
db: Db,
input: {
companyId: string; issueIds: string[]; actor: ResumeActor; wakeup: Wakeup; rootIssueId?: string | null;
/** Also revisit tasks whose work a gate refused for this hold, wherever they are now. */
suppressedByHoldId?: string; excludeIssueIds?: string[];
},
) {
const treeControl = issueTreeControlService(db);
const failures: Array<{ issueId: string; message: string }> = [];
const suppressedIssueIds = input.suppressedByHoldId
? await listPauseSuppressedIssueIds(db, input.companyId, input.suppressedByHoldId) : [];
const excluded = new Set(input.excludeIssueIds ?? []);
for (const issueId of new Set([...input.issueIds, ...suppressedIssueIds])) {
if (excluded.has(issueId)) continue;
try {
const target = await treeControl.findOwedPauseResume(input.companyId, issueId);
if (!target) continue;
const blocker = await getExecutionBlocker(db, input.companyId, issueId);
if (blocker) {
await activity(db, input.companyId, input.actor, "issue.tree_resume_wake_skipped", issueId, {
holdId: target.holdId, interruptedRunId: target.interruptedRunId, cause: blocker.cause,
});
continue;
}
const claimedAt = new Date().toISOString();
if (!await setResumeState(db, { companyId: input.companyId, runId: target.interruptedRunId,
next: { holdId: target.holdId, state: "pending", claimedAt } })) continue;
await dispatch(db, { companyId: input.companyId, issueId, agentId: target.agentId, runId: target.interruptedRunId,
holdId: target.holdId, claimedAt, rootIssueId: input.rootIssueId ?? null, actor: input.actor, wakeup: input.wakeup });
} catch (error) {
// A pending claim stays recoverable; the sweep retries it after it goes stale.
const message = error instanceof Error ? error.message : String(error);
failures.push({ issueId, message });
await Promise.resolve(activity(db, input.companyId, input.actor, "issue.tree_resume_wake_failed", issueId,
{ error: message })).catch(() => null);
}
}
return failures;
}
/**
* Tasks with a run a dispatch gate refused for this hold. The gate also covers
* tasks created under the tree after the hold's snapshot, and a task can move
* out of the tree before release, so the refused runs name them.
*/
async function listPauseSuppressedIssueIds(db: Db, companyId: string, holdId: string) {
const rows = await db.selectDistinct({
issueId: sql<string | null>`coalesce(${heartbeatRuns.issueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId')`,
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.status, "cancelled"),
// Matches the partial index heartbeat_runs_tree_hold_suppression_idx.
sql`${heartbeatRuns.resultJson} ? 'treeHoldSuppression'`,
sql`${heartbeatRuns.resultJson}->'treeHoldSuppression'->>'holdId' = ${holdId}`,
));
return rows.flatMap((row) => row.issueId ? [row.issueId] : []);
}
/**
* A gate that refused work for a pause re-checks the hold after its refusal is
* saved. A release that committed in between did not see the refusal, so the
* gate resumes that task itself; the resume claim keeps it to one dispatch.
*/
export async function resumeIfPauseAlreadyReleased(db: Db, input: {
companyId: string; issueId: string; holdId: string; wakeup: Wakeup;
}) {
const [hold] = await db.select({ status: issueTreeHolds.status }).from(issueTreeHolds)
.where(and(eq(issueTreeHolds.companyId, input.companyId), eq(issueTreeHolds.id, input.holdId)));
if (hold?.status === "active") return [];
return resumePauseInterruptedWork(db, {
companyId: input.companyId, issueIds: [input.issueId], actor: SYSTEM_ACTOR, wakeup: input.wakeup,
});
}
function pendingResumePredicate(staleBefore: Date) {
return and(eq(heartbeatRuns.status, "cancelled"),
sql`${heartbeatRuns.resultJson}->'treeHoldResume'->>'state' = 'pending'`,
sql`(${heartbeatRuns.resultJson}->'treeHoldResume'->>'claimedAt')::timestamptz <= ${staleBefore.toISOString()}::timestamptz`);
}
/** True while a claimed resume has no durable receipt yet. */
export async function hasPendingPauseResume(db: Db) {
const [row] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.where(sql`${heartbeatRuns.status} = 'cancelled' and ${heartbeatRuns.resultJson}->'treeHoldResume'->>'state' = 'pending'`)
.limit(1);
return Boolean(row);
}
/**
* Finish resume claims whose dispatcher stopped between claiming and saving the
* wake (for example a restart). A saved wake with the stable key is the receipt.
* Otherwise the task's current state is checked again before one dispatch.
*/
export async function deliverPendingPauseResumes(db: Db, wakeup: Wakeup, now = new Date()) {
const staleBefore = new Date(now.getTime() - PAUSE_RESUME_CLAIM_STALE_MS);
// Page through the stale claims: a claim still waiting on a pause or blocker
// is skipped without a write, so any fixed batch could starve the rest. Each
// pass visits every stale claim in bounded pages.
let after: string | null = null;
for (;;) {
const rows: PendingResumeRow[] = await db.select({
id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
createdAt: heartbeatRuns.createdAt, resultJson: heartbeatRuns.resultJson,
issueId: sql<string | null>`coalesce(${heartbeatRuns.issueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId')`,
}).from(heartbeatRuns)
.where(and(pendingResumePredicate(staleBefore), after ? gt(heartbeatRuns.id, after) : undefined))
.orderBy(asc(heartbeatRuns.id)).limit(SWEEP_PAGE_SIZE);
if (!rows.length) return;
after = rows[rows.length - 1]!.id;
await deliverPendingPage(db, wakeup, now, rows);
if (rows.length < SWEEP_PAGE_SIZE) return;
}
}
type PendingResumeRow = {
id: string; companyId: string; agentId: string; createdAt: Date;
resultJson: Record<string, unknown> | null; issueId: string | null;
};
async function deliverPendingPage(db: Db, wakeup: Wakeup, now: Date, rows: PendingResumeRow[]) {
for (const run of rows) {
const claim = run.resultJson?.treeHoldResume as { holdId?: unknown; claimedAt?: unknown } | undefined;
if (typeof claim?.claimedAt !== "string" || typeof claim.holdId !== "string" || !run.issueId) continue;
const finish = (state: "delivered" | "abandoned", reason?: string) => setResumeState(db, {
companyId: run.companyId, runId: run.id, expectedClaimedAt: claim.claimedAt as string,
next: { holdId: claim.holdId, state, claimedAt: claim.claimedAt, settledAt: now.toISOString(), ...(reason ? { reason } : {}) },
});
try {
if (await findPauseResumeReceipt(db, run.companyId, run.agentId, run.id)) { await finish("delivered"); continue; }
const [issue] = await db.select({ status: issues.status, assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId }).from(issues)
.where(and(eq(issues.companyId, run.companyId), eq(issues.id, run.issueId)));
const [newer] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, run.companyId),
sql`coalesce(${heartbeatRuns.issueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${run.issueId}`,
// JavaScript dates drop microseconds, so exclude the run itself by id.
sql`${heartbeatRuns.id} <> ${run.id}`,
sql`${heartbeatRuns.createdAt} >= ${run.createdAt.toISOString()}::timestamptz`)).limit(1);
if (!issue || issue.assigneeAgentId !== run.agentId || issue.assigneeUserId || newer
|| !ISSUE_TREE_RESUME_EXECUTABLE_STATUSES.includes(issue.status)) {
await finish("abandoned", "task_changed");
continue;
}
// A paused or blocked task keeps its claim; release or settlement revisits it.
if (await issueTreeControlService(db).getActivePauseHoldGate(run.companyId, run.issueId)
|| await getExecutionBlocker(db, run.companyId, run.issueId)) continue;
const claimedAt = now.toISOString();
if (!await setResumeState(db, { companyId: run.companyId, runId: run.id, expectedClaimedAt: claim.claimedAt,
next: { holdId: claim.holdId, state: "pending", claimedAt } })) continue;
await dispatch(db, { companyId: run.companyId, issueId: run.issueId, agentId: run.agentId, runId: run.id,
holdId: claim.holdId, claimedAt, rootIssueId: null, actor: SYSTEM_ACTOR, wakeup });
} catch (error) {
await Promise.resolve(activity(db, run.companyId, SYSTEM_ACTOR, "issue.tree_resume_wake_failed", run.issueId,
{ error: error instanceof Error ? error.message : String(error), interruptedRunId: run.id })).catch(() => null);
}
}
}