Files
PaperClipAI/server/src/services/workspace-recovery-dependency-finalize.test.ts
T
DottaandClaude Opus 5.5 4c04c20c9e fix(recovery): wake a parent whose refused completion waited on dependencies that later clear (#20803)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A parent task that waits on blocker tasks resumes through the
`issue_blockers_resolved` wake
> - A periodic dependency sweep sends that wake when a blocked task's
dependencies become ready and no wake covers the current blocked cycle
> - The sweep skips a task that its own agent blocked, because that
agent waits on something outside the blocker graph
> - A native completion that Paperclip refuses for unresolved
dependencies is also recorded as a block by the agent's own run
(`durable_dependency_blocker_bound`). The sweep treated it as a
self-block. It woke the task only if a blocker later completed or
finalized on its workspace
> - When readiness cleared another way, nothing woke the task. It stayed
`blocked` with no next action
> - This pull request makes the sweep treat that refusal as a dependency
wait, and count a "Recover saved files" finalize without a workspace as
the blocker settling
> - The benefit is that a blocked task is not a silent dead end: it
resumes once, automatically, when its dependencies clear

## Linked Issues or Issue Description

Refs #20728

**What happened?**
A parent task had three done child tasks that block it. One child was
still listed as "saving its files", because a "Recover saved files"
finalize had no workspace. The parent's agent tried to finish. Paperclip
refused the completion because dependencies were unresolved and set the
parent `blocked` (`durable_dependency_blocker_bound`). After #20728 was
deployed, the parent's dependencies were ready. But the task stayed
`blocked` with no wake, no Retry, and no next action. A person had to
post a comment to wake it.

**Expected behavior**
When the last blocker of a blocked task clears, by status or by finalize
settlement, Paperclip wakes the assignee once. This also applies to
tasks already blocked before a deploy that changes readiness.

**Steps to reproduce**
1. Block a task on a done blocker whose workspace finalize is still
pending.
2. Let the assignee agent's native run report the task done. Paperclip
sets the task `blocked` with `durable_dependency_blocker_bound`.
3. Make the blocker settle without a later blocker completion or a
finalize on its workspace (for example, a recovery finalize with no
workspace, or a deploy that changes the readiness rule).
4. Run the dependency sweep. Before this fix it reports
`selfBlockedSkipped: 1` and sends no wake.

## What Changed

- `server/src/services/recovery/service.ts`
(`wasBlockedByAssigneeAgent`): a native block with reason
`durable_dependency_blocker_bound` is not a self-block. The dependency
owns its wake. Other native blocks and agent blocks keep the self-block
rule.
- The same function counts a later succeeded `workspace_finalize` with
`owningService: workspace_recovery` and no workspace as the blocker
settling, which matches the readiness rule from #20728.
- `server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts`:
regression tests for the refused-completion shape (one wake, no second
wake), the recovery finalize without a workspace, and a safety case for
a different native block reason.
- `server/src/services/workspace-recovery-dependency-finalize.test.ts`:
a characterization test that a real "Recover saved files" signals the
dependency sweep for the company. This path already existed; the test
keeps it.
- `doc/execution-semantics.md`: documents when the dependency sweep
wakes after the last blocker clears.

## Verification

- Red (unfixed `service.ts`): `npx vitest run
src/__tests__/heartbeat-issue-liveness-escalation.test.ts -t "refused
for unresolved|still holds a native block|recovery finalize without a
workspace"` fails 2 of 3 tests with `selfBlockedSkipped` 1 instead of 0.
- Green (fixed):
`src/__tests__/heartbeat-issue-liveness-escalation.test.ts` passes 44 of
44.
- Related suites pass:
`src/__tests__/issue-dependency-wakeups-routes.test.ts`,
`src/__tests__/heartbeat-dependency-scheduling.test.ts`,
`src/services/workspace-recovery-dependency-finalize.test.ts`,
`src/__tests__/issue-recovery-dependencies.test.ts` (43 tests). Server
`tsc --noEmit` passes.
- `pnpm verify` exits 2 because a doc change is a global CI input.
Required CI supplies broad coverage.
- Staging evidence (read only): the affected parent's activity shows
`native-status-committer` set it `blocked` with
`durable_dependency_blocker_bound`, and no later blocker completion or
finalize.

## Risks

- Previous-release story: no persisted shape changes. Tasks already
`blocked` with `durable_dependency_blocker_bound` and ready dependencies
are picked up by the existing dependency sweep after deploy (it visits
every blocked task at startup and on recovery signals). No migration.
- Bounded: the wake uses the cycle-aware ready-state key, so one blocked
cycle gets one wake. The sweep keeps its live-run, queued-wake,
pending-interaction, and pause-hold checks.
- No loop: if the woken agent blocks the task again while its
dependencies are ready, the reason is not
`durable_dependency_blocker_bound`, so the self-block rule applies.
- The recovery finalize rule only applies to a finalize on the blocker
task itself with `owningService: workspace_recovery` and no workspace.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, code search, test execution, read-only
staging API inspection).

## 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-11 07:04:13 -06:00

129 lines
9.0 KiB
TypeScript

import { and, eq } from "drizzle-orm";
import { describe, expect, it, vi } from "vitest";
import type { AdapterSandboxExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { randomUUID } from "node:crypto";
import { activityLog, heartbeatRuns, issueRelations, issues, workspaceOperations } from "@paperclipai/db";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
import { issueService } from "./issues.js";
import { recoveryService } from "./recovery/service.js";
import { recoverRetainedWorkspace } from "./workspace-recovery.js";
import { readWorkspaceRecovery } from "./workspace-recovery-receipt.js";
import { useWorkspaceRecoveryFixture, acceptedRemoteExport, action, db } from "./__tests__/workspace-recovery-fixture.js";
const targets = vi.hoisted(() => new Map<string, AdapterSandboxExecutionTarget>());
const deliveryNotices = vi.hoisted(() => [] as Array<{ queue: string; hint: string | undefined }>);
vi.mock("./delivery-work-notifications.js", async importOriginal => {
const actual = await importOriginal<typeof import("./delivery-work-notifications.js")>();
return { ...actual, notifyDeliveryWork: async (...args: Parameters<typeof actual.notifyDeliveryWork>) => {
deliveryNotices.push({ queue: args[1], hint: args[2] });
return actual.notifyDeliveryWork(...args);
} };
});
vi.mock("./environment-execution-target.js", async importOriginal => {
const actual = await importOriginal<typeof import("./environment-execution-target.js")>();
return { ...actual, resolveEnvironmentExecutionTarget: async (input: Parameters<typeof actual.resolveEnvironmentExecutionTarget>[0]) =>
(input.leaseId ? targets.get(input.leaseId) : undefined) ?? actual.resolveEnvironmentExecutionTarget(input) };
});
describe("dependency readiness after a done child's work is recovered", () => {
useWorkspaceRecoveryFixture(targets);
async function recoveredDoneChildWithParent() {
const f = await acceptedRemoteExport();
await recoverRetainedWorkspace(f.input);
expect(readWorkspaceRecovery((await action(f.actionId)).evidence)?.status).toBe("recovered");
const [child] = await db.select().from(issues).where(eq(issues.id, f.issueId));
await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
const [parent] = await db.insert(issues).values({ companyId: f.companyId, title: "Parent",
status: "blocked", priority: "medium", assigneeAgentId: child.assigneeAgentId,
executionWorkspaceId: f.workspaceId }).returning();
await db.insert(issueRelations).values({ companyId: f.companyId, issueId: f.issueId,
relatedIssueId: parent.id, type: "blocks" });
return { ...f, parentId: parent.id };
}
const recoveryFinalize = async (f: { companyId: string; runId: string }) => (await db.select().from(workspaceOperations).where(and(
eq(workspaceOperations.companyId, f.companyId), eq(workspaceOperations.heartbeatRunId, f.runId),
eq(workspaceOperations.phase, "workspace_finalize"), eq(workspaceOperations.status, "succeeded"))))[0]!;
const readiness = (id: string) => issueService(db).getDependencyReadiness(id);
it("releases the parent once Recover saved files has saved a done child's work", async () => {
const f = await recoveredDoneChildWithParent();
await expect(readiness(f.parentId)).resolves.toMatchObject({
isDependencyReady: true, pendingFinalizeBlockerIssueIds: [], unresolvedBlockerIssueIds: [] });
const listed = (await issueService(db).list(f.companyId, { status: "blocked" }) as Array<{ id: string;
blockerAttention?: { pendingFinalizeBlockerIssueIds?: string[] } | null }>).find(issue => issue.id === f.parentId);
expect(listed?.blockerAttention?.pendingFinalizeBlockerIssueIds ?? []).toEqual([]);
}, 30_000);
it("wakes a parent whose completion was refused once the child's recovery saves its files", async () => {
const f = await acceptedRemoteExport();
const [child] = await db.update(issues).set({ status: "done", completedAt: new Date(Date.now() - 120_000), executionWorkspaceId: f.workspaceId })
.where(eq(issues.id, f.issueId)).returning();
await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId, heartbeatRunId: f.runId,
executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "failed",
startedAt: new Date(Date.now() - 90_000), finishedAt: new Date(Date.now() - 89_000) });
const blockedTransitionAt = new Date(Date.now() - 60_000);
const [parent] = await db.insert(issues).values({ companyId: f.companyId, title: "Parent", status: "blocked",
priority: "medium", assigneeAgentId: child.assigneeAgentId, blockedTransitionAt }).returning();
await db.insert(issueRelations).values({ companyId: f.companyId, issueId: f.issueId, relatedIssueId: parent.id, type: "blocks" });
const [parentRun] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: child.assigneeAgentId!,
issueId: parent.id, status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: parent.id } }).returning();
await db.insert(activityLog).values({ companyId: f.companyId, actorType: "system", actorId: "native-status-committer",
runId: parentRun.id, action: "issue.updated", entityType: "issue", entityId: parent.id,
details: { source: "native_status_decision", fromStatus: "in_progress", toStatus: "blocked",
reasonCode: "durable_dependency_blocker_bound" }, createdAt: blockedTransitionAt });
await expect(readiness(parent.id)).resolves.toMatchObject({ isDependencyReady: false, pendingFinalizeBlockerIssueIds: [f.issueId] });
deliveryNotices.length = 0;
await recoverRetainedWorkspace(f.input);
expect(readWorkspaceRecovery((await action(f.actionId)).evidence)?.status).toBe("recovered");
expect(deliveryNotices).toContainEqual({ queue: DELIVERY_QUEUES.resolvedDependencies,
hint: JSON.stringify({ companyId: f.companyId }) });
await expect(readiness(parent.id)).resolves.toMatchObject({ isDependencyReady: true });
const enqueueWakeup = vi.fn(async () => ({ id: randomUUID() }) as never);
const result = await recoveryService(db, { enqueueWakeup }).reconcileResolvedDependencyWakeBackstop({
companyId: f.companyId, issueId: parent.id });
expect(result).toMatchObject({ selfBlockedSkipped: 0, healed: 1 });
expect(enqueueWakeup).toHaveBeenCalledWith(child.assigneeAgentId, expect.objectContaining({
reason: "issue_blockers_resolved", payload: expect.objectContaining({ issueId: parent.id }) }));
}, 30_000);
it("releases the parent when the recovery saved through a snapshot without a workspace", async () => {
const f = await recoveredDoneChildWithParent();
const finalize = await recoveryFinalize(f);
await db.update(workspaceOperations).set({ executionWorkspaceId: null }).where(and(
eq(workspaceOperations.companyId, f.companyId), eq(workspaceOperations.heartbeatRunId, f.runId),
eq(workspaceOperations.phase, "workspace_recovery")));
await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId, heartbeatRunId: f.runId,
executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "failed",
startedAt: new Date(finalize.startedAt.getTime() - 60_000), finishedAt: new Date(finalize.startedAt.getTime() - 59_000) });
await expect(readiness(f.parentId)).resolves.toMatchObject({
isDependencyReady: true, pendingFinalizeBlockerIssueIds: [] });
}, 30_000);
it("keeps a sibling's work that started during the recovery waiting for its own save", async () => {
const f = await recoveredDoneChildWithParent();
const finalize = await recoveryFinalize(f);
const [sibling] = await db.insert(issues).values({ companyId: f.companyId, title: "Sibling", status: "done",
priority: "medium", executionWorkspaceId: f.workspaceId }).returning();
await db.insert(issueRelations).values({ companyId: f.companyId, issueId: sibling.id, relatedIssueId: f.parentId, type: "blocks" });
await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: sibling.id,
executionWorkspaceId: f.workspaceId, phase: "worktree_prepare", status: "succeeded",
startedAt: new Date(finalize.startedAt.getTime() - 1_000), finishedAt: new Date(finalize.startedAt.getTime() - 500) });
await expect(readiness(f.parentId)).resolves.toMatchObject({
isDependencyReady: false, pendingFinalizeBlockerIssueIds: [sibling.id] });
}, 30_000);
it("keeps the barrier for child workspace work started after the recovery", async () => {
const f = await recoveredDoneChildWithParent();
const finalize = await recoveryFinalize(f);
await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId,
executionWorkspaceId: f.workspaceId, phase: "worktree_prepare", status: "succeeded",
startedAt: new Date(finalize.startedAt.getTime() + 1_000), finishedAt: new Date(finalize.startedAt.getTime() + 2_000) });
await expect(readiness(f.parentId)).resolves.toMatchObject({
isDependencyReady: false, pendingFinalizeBlockerIssueIds: [f.issueId] });
}, 30_000);
});