fix: distinguish cancelled dependencies from pending work

Identify cancelled blocker IDs in dependency receipts and require removing or replacing them instead of waiting for a completion wake. Preserve unresolved status, other blockers, idempotent snapshots and prior receipt compatibility.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 12:19:53 -05:00
1 parent 245e8568d9
commit aba7ec219b
9 files changed
+49 -14

No files matched your search

@@ -23,7 +23,7 @@ export function nativeToolContractFingerprintForTarget(
return `sha256:${createHash("sha256")
.update(
JSON.stringify({
schema: "paperclip.native-tool-contract.v16",
schema: "paperclip.native-tool-contract.v17",
executionTargetKind,
advertisementPolicy: {
// Direct provider threads retain declarations from thread/start.
@@ -37,7 +37,7 @@ export function nativeToolContractFingerprintForTarget(
structuredHumanInput:
"always_advertised_run_issue_agent_binding_gated_current_task_description.v2",
semanticCompletion: "finish_accessible_deliverable_evidence.v4",
nativeProcedures: "hiring_delegation_and_dependency_readiness_guidance.v2",
nativeProcedures: "hiring_delegation_and_dependency_readiness_guidance.v3",
connectorTools: "assigned_resources_and_pinned_skill_bundle.v1",
...(executionTargetKind === "remote"
? { assignedMcpTools: "codex_server_gateway_prp_relay.v1" }
@@ -1044,7 +1044,7 @@ describe("PaperclipRunnerToolAuthority", () => {
},
});
expect(dependencyReceipt).toMatchObject({
dependencyReadiness: { isReady: false, unresolvedTaskIds: [prerequisiteId] },
dependencyReadiness: { isReady: false, unresolvedTaskIds: [prerequisiteId], cancelledTaskIds: [] },
guidance: expect.stringContaining("Recorded dependencies are unfinished"),
});
expect(dependencyReceipt).toMatchObject({ guidance: expect.stringContaining("do not sleep or poll") });
@@ -1070,7 +1070,7 @@ describe("PaperclipRunnerToolAuthority", () => {
arguments: { idempotencyKey: "observe-finished-prerequisite", blockedByTaskIds: [prerequisiteId] },
});
expect(readyReceipt).toMatchObject({
dependencyReadiness: { isReady: true, unresolvedTaskIds: [] },
dependencyReadiness: { isReady: true, unresolvedTaskIds: [], cancelledTaskIds: [] },
scheduledWakeIds: [],
guidance: expect.stringContaining("do not block waiting for these completed tasks"),
});
@@ -1080,13 +1080,17 @@ describe("PaperclipRunnerToolAuthority", () => {
})).resolves.toEqual(dependencyReceipt);
const dependentId = (dependent as { task: { id: string } }).task.id;
await issueService(db).update(dependentId, { status: "cancelled", actorAgentId: agentId });
await expect(authority.execute({
const cancelledReceipt = await authority.execute({
tool: "set_dependencies", callId: "mixed-complete-and-cancelled",
arguments: { idempotencyKey: "mixed-complete-and-cancelled", blockedByTaskIds: [prerequisiteId, dependentId] },
})).resolves.toMatchObject({
dependencyReadiness: { isReady: false, unresolvedTaskIds: [dependentId] },
guidance: expect.stringContaining("Recorded dependencies are unfinished"),
});
expect(cancelledReceipt).toMatchObject({
dependencyReadiness: { isReady: false, unresolvedTaskIds: [dependentId], cancelledTaskIds: [dependentId] },
guidance: expect.stringContaining("Do not wait for those tasks"),
});
expect(cancelledReceipt).toMatchObject({ guidance: expect.stringContaining("preserving every other required blocker") });
expect(cancelledReceipt).toMatchObject({ guidance: expect.stringContaining("Do not treat cancellation as successful completion") });
expect((cancelledReceipt as { guidance: string }).guidance).not.toContain("call paperclip_block");
await expect(
authority.execute({
tool: "create_task",
@@ -1109,7 +1113,7 @@ describe("PaperclipRunnerToolAuthority", () => {
arguments: { idempotencyKey: "clear-finished-prerequisite", blockedByTaskIds: [] },
});
expect(cleared).not.toHaveProperty("guidance");
expect(cleared).toMatchObject({ dependencyReadiness: { isReady: true, unresolvedTaskIds: [] } });
expect(cleared).toMatchObject({ dependencyReadiness: { isReady: true, unresolvedTaskIds: [], cancelledTaskIds: [] } });
await expect(issueService(db).getRelationSummaries(issueId)).resolves.toMatchObject({ blockedBy: [] });
const nextRunId = "00000000-0000-4000-8000-000000000106";
@@ -38,7 +38,7 @@ import { badRequest, forbidden, notFound, HttpError } from "../../errors.js";
import { searchRunnerApi } from "./runner-api-catalog.js";
import { executeRunnerApi, validateRunnerApiCall, RUNNER_API_MAX_BYTES, type RunnerApiFile } from "./runner-api-client.js";
import { acquireRunnerApiResponseSlot, runnerApiCompanyCaptureMaxBytes, RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES, RunnerApiResponseLimitError } from "./runner-api-response-limits.js";
import { and, desc, eq, isNull, notInArray, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
@@ -1334,6 +1334,13 @@ export class PaperclipRunnerToolAuthority {
}, tx);
if (!updated) throw new Error("paperclip_runner_task_not_found");
const readiness = await issueService(tx).getDependencyReadiness(updated.id, tx);
const cancelledBlockers = readiness.unresolvedBlockerIssueIds.length > 0
? await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, this.binding.companyId),
inArray(issues.id, readiness.unresolvedBlockerIssueIds),
eq(issues.status, "cancelled"),
)).orderBy(issues.id)
: [];
return {
commandId: `set-dependencies:${updated.id}:${updated.statusVersion}`,
disposition: "applied",
@@ -1343,11 +1350,14 @@ export class PaperclipRunnerToolAuthority {
dependencyReadiness: {
isReady: readiness.isDependencyReady,
unresolvedTaskIds: readiness.unresolvedBlockerIssueIds,
cancelledTaskIds: cancelledBlockers.map((blocker) => blocker.id),
},
...(blockedByIssueIds.length > 0 ? {
guidance: readiness.isDependencyReady
? "All recorded dependencies are complete and their workspaces are finalized at this update. Review the latest results and continue; do not block waiting for these completed tasks. If more teammate work is required, create a revision task with create_task and record its dependency. A comment on a completed task is not a replacement for assigning new work."
: "Recorded dependencies are unfinished at this update. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn to release the workspace; do not sleep or poll. Check current task state before blocking if a result has arrived. Paperclip resumes the parent when dependencies complete; review the latest results and pending feedback before finishing.",
: cancelledBlockers.length > 0
? "The cancelledTaskIds dependencies are cancelled and will not produce a completion wake. Do not wait for those tasks. Review whether their work is still required, then use set_dependencies to remove obsolete dependencies or replace them with newly assigned tasks while preserving every other required blocker. Do not treat cancellation as successful completion. If a human decision is required, request it explicitly."
: "Recorded dependencies are unfinished at this update. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn to release the workspace; do not sleep or poll. Check current task state before blocking if a result has arrived. Paperclip resumes the parent when dependencies complete; review the latest results and pending feedback before finishing.",
} : {}),
};
});