mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 10:21:41 +01:00
## 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>
129 lines
9.0 KiB
TypeScript
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);
|
|
});
|