mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A person asks the chat agent (the controller) to act on tasks that other agents own > - When a task's automatic recovery stops, for example after three runner losses, the task is blocked and the person gets a Retry control on it. Retry rechecks the task's gates and keeps the failed attempt's evidence > - The blocker told every reader "Try again or send a new message to continue". A controller read that as its own instruction. It found that Retry is board-only, then resumed the task with a comment and reassigned the task to itself > - That skips the assignee's budget and hold gates and hides the failed-run Retry from the person > - This pull request names Retry as the person's control in the blocker text, and tells the controller to point the person to it > - The benefit is that "try that task again" leads to the supported, gated Retry instead of an agent takeover ## Linked Issues or Issue Description Refs #20270 (automatic recovery after a lost local runner) **What happened?** In the chat eval `chat-features.runner-opencode.local.recovery.retry-exhausted`, a person asked the CEO agent to "Try the website task again" after the website task's native recovery was exhausted. The agent read the task's `executionBlocker` (`canRetry: true`, nextAction "... Try again or send a new message to continue once the previous execution has stopped."). It found that `POST /api/issues/{id}/retry` is board-only. It then posted a `resume: true` comment, which moved the task from blocked to todo, and reassigned the task to itself to re-run it. The task lost its failed-run Retry control, and the original assignee's gates were not checked. **Expected behavior** The agent links the original task, says why it stopped, and tells the person to use Retry there. It takes the work over only when the person explicitly asks for that. **Steps to reproduce** 1. Let a native run exhaust its automatic recovery (for example, three runner losses). 2. In the controller chat, ask it to try that task again. 3. The agent comments to resume the task, or reassigns it, instead of pointing to Retry. ## What Changed - `execution-blocker.ts`: the text that is added when a person can retry is now `EXECUTION_BLOCKER_PERSON_RETRY_ACTION`: "A person can use Retry on this task, or send it a new message, to continue once the previous execution has stopped. Agents cannot retry it for them." - `agent-conversations.ts`: a new chat guidance paragraph. When another task's automatic recovery has stopped, the supported next step is the person's Retry on that task. The controller links the task and explains why it stopped. It does not reassign the task, change its status, comment on it to resume it, or create a replacement task, unless the person explicitly asks it to take the work over. ## Verification - `board-access-denial.test.ts`: the directive contains the new rule. - `execution-blocker-read.test.ts`: a retryable blocker's nextAction ends with the person-Retry text. - 54 tests pass locally for these two files. - Paid live run of `recovery.retry-exhausted` with this change and #20270 on the eval branch: the run ID will be added below. ## Risks - Low. Only text changes. Any UI that shows the blocker's nextAction shows the new sentence instead of "Try again or send a new message ...". - No schema or saved-state change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use and code 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 - [ ] All Paperclip CI gates are green - [ ] 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>
160 lines
10 KiB
TypeScript
160 lines
10 KiB
TypeScript
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { agents, companies, createDb, heartbeatRuns, issueRecoveryActions, issues, type Db } from "@paperclipai/db";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
|
import { EXECUTION_BLOCKER_PERSON_RETRY_ACTION, getExecutionBlocker } from "./execution-blocker.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
describe.skipIf(!support.supported)("execution blocker PostgreSQL read projection", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("execution-blocker-read-");
|
|
db = createDb(database.connectionString);
|
|
}, 60_000);
|
|
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
|
|
|
|
async function fixture(patch: Partial<typeof heartbeatRuns.$inferInsert> = {}, extraEvidence: Record<string, unknown> = {}) {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Blocker fixture", issuePrefix: companyId.slice(0, 8) });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Blocker agent", adapterType: "process", status: "active" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Blocker task", status: "blocked", assigneeAgentId: agentId });
|
|
const payload = randomBytes(128 * 1024).toString("base64");
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "failed", runtimeMode: "legacy",
|
|
startedAt: new Date("2020-01-01T00:00:00Z"), finishedAt: new Date("2020-01-01T00:01:00Z"),
|
|
errorCode: "adapter_failed", error: "Fixture failure", contextSnapshot: { issueId, prompt: payload },
|
|
runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner", adapterConfig: { instructions: payload } }, instructions: payload },
|
|
stdoutExcerpt: payload, ...patch,
|
|
}).returning();
|
|
await db.insert(issueRecoveryActions).values({ companyId, sourceIssueId: issueId, kind: "active_run_watchdog",
|
|
cause: "legacy_execution_requires_reconciliation", fingerprint: run.id, status: "active", nextAction: "Inspect the source run.",
|
|
evidence: { runId: run.id, ...extraEvidence } });
|
|
return { companyId, agentId, issueId, run };
|
|
}
|
|
|
|
function recordRunReads(fullRows = false) {
|
|
const reads: unknown[][] = [];
|
|
const recorded = new Proxy(db, { get(target, key) {
|
|
if (key !== "select") return Reflect.get(target, key);
|
|
return (...fields: Parameters<Db["select"]>) => {
|
|
const builder = target.select(...fields), from = builder.from.bind(builder);
|
|
builder.from = ((...args: Parameters<typeof from>) => {
|
|
const query = args[0] === heartbeatRuns && fullRows ? target.select().from(heartbeatRuns) : from(...args);
|
|
if (args[0] === heartbeatRuns) {
|
|
const execute = query.execute.bind(query);
|
|
query.execute = async (...executeArgs: Parameters<typeof execute>) => {
|
|
const rows = await execute(...executeArgs); reads.push(rows); return rows;
|
|
};
|
|
}
|
|
return query;
|
|
}) as typeof builder.from;
|
|
return builder;
|
|
};
|
|
}});
|
|
return { recorded, reads };
|
|
}
|
|
|
|
async function compare(f: Awaited<ReturnType<typeof fixture>>) {
|
|
const baseline = recordRunReads(true), projected = recordRunReads();
|
|
const expected = await getExecutionBlocker(baseline.recorded, f.companyId, f.issueId);
|
|
const result = await getExecutionBlocker(projected.recorded, f.companyId, f.issueId);
|
|
expect(expected).not.toBeNull();
|
|
expect(result).toEqual(expected);
|
|
expect(projected.reads).toHaveLength(baseline.reads.length);
|
|
expect(projected.reads.flat()).toHaveLength(1);
|
|
expect(Buffer.byteLength(JSON.stringify(projected.reads))).toBeLessThan(4096);
|
|
expect(Buffer.byteLength(JSON.stringify(baseline.reads))).toBeGreaterThan(128 * 1024);
|
|
return result;
|
|
}
|
|
|
|
it("preserves blocker and retry decisions without returning prompts, profiles, or output", async () => {
|
|
const f = await fixture();
|
|
expect(await compare(f)).toMatchObject({ runId: f.run.id, agentId: f.agentId, canRetry: true, canContinue: false });
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)))[0]).toEqual(f.run);
|
|
expect(await getExecutionBlocker(db, randomUUID(), f.issueId)).toBeNull();
|
|
});
|
|
|
|
it("names Retry as the person's control, so an agent reading the blocker does not re-run the work", async () => {
|
|
const f = await fixture();
|
|
const blocker = await getExecutionBlocker(db, f.companyId, f.issueId);
|
|
expect(blocker).toMatchObject({ canRetry: true });
|
|
expect(blocker!.nextAction).toBe(`Inspect the source run. ${EXECUTION_BLOCKER_PERSON_RETRY_ACTION}`);
|
|
expect(EXECUTION_BLOCKER_PERSON_RETRY_ACTION).toMatch(/^A person can use Retry on this task/);
|
|
expect(EXECUTION_BLOCKER_PERSON_RETRY_ACTION).toContain("Agents cannot retry it for them.");
|
|
});
|
|
|
|
it("retains the claimed adapter and startup receipts for a cancelled pre-provider run", async () => {
|
|
const f = await fixture({ status: "cancelled", startedAt: null, errorCode: "operator_interrupted",
|
|
resultJson: { startupCancellation: { beforeNativeSelection: true }, startupPreparationSettledAt: "2020-01-01T00:01:00Z" } });
|
|
expect(await compare(f)).toMatchObject({ canRetry: true, canContinue: false });
|
|
await db.update(heartbeatRuns).set({ processPid: 424242 }).where(eq(heartbeatRuns.id, f.run.id));
|
|
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ canRetry: false });
|
|
});
|
|
|
|
it.each(["valid", "number", "missing"])("preserves typed pre-dispatch interaction evidence (%s)", async kind => {
|
|
const context = { source: "connection_intent.resolved", connectionIntentResolution: "existing_connection",
|
|
interactionKind: "connection_intent", interactionStatus: "expired", mutation: "interaction",
|
|
...(kind === "missing" ? {} : { interactionId: kind === "valid" ? randomUUID() : 42 }) };
|
|
const f = await fixture({ status: "cancelled", errorCode: "issue_waiting_for_response", contextSnapshot: context,
|
|
resultJson: { stopReason: "issue_waiting_for_response", timeoutSource: "stale_queued_run_gate",
|
|
preDispatchResponseWait: { version: 1, providerWorkStarted: false } } });
|
|
expect(await compare(f)).toMatchObject({ canRetry: kind === "valid" });
|
|
});
|
|
|
|
it.each([true, false])("preserves native no-workspace evidence and cwd presence (%s)", async hasCwd => {
|
|
const f = await fixture({ runtimeMode: "native", driverKind: "grok_bot_mcp" }, {
|
|
workspaceRestoreFailure: "restore_unsafe_archive",
|
|
workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [] },
|
|
});
|
|
const input = { schema: "paperclip.native-execution-input.v6",
|
|
binding: { runId: f.run.id, companyId: f.companyId, agentId: f.agentId, issueId: f.issueId },
|
|
provider: { kind: "grok_bot" }, workspace: { access: "none", ...(hasCwd ? { cwd: null } : {}) },
|
|
task: { description: randomBytes(128 * 1024).toString("base64") },
|
|
runtimeContext: { instructions: randomBytes(128 * 1024).toString("base64") } };
|
|
await db.update(heartbeatRuns).set({ nativeIssueId: f.issueId,
|
|
runnerProfileJson: { ...f.run.runnerProfileJson, nativeExecutionInput: input } }).where(eq(heartbeatRuns.id, f.run.id));
|
|
expect(await compare(f)).toMatchObject({ workspaceRepairRequired: !hasCwd, canRetry: false });
|
|
const [stored] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id));
|
|
expect(stored.runnerProfileJson?.nativeExecutionInput).toEqual(input);
|
|
});
|
|
|
|
it.each(["run", "company", "agent", "issue", "provider", "schema", "access", "workspace", "binding", "missing_binding",
|
|
"null_input", "array_input", "scalar_input", "number_input"])(
|
|
"does not relax native workspace recovery for invalid input (%s)", async kind => {
|
|
const f = await fixture({ runtimeMode: "native", driverKind: "grok_bot_mcp" }, {
|
|
workspaceRestoreFailure: "restore_unsafe_archive",
|
|
workspaceRestoreRecovery: { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [] },
|
|
});
|
|
const input: Record<string, unknown> = {
|
|
schema: "paperclip.native-execution-input.v6",
|
|
binding: { runId: f.run.id, companyId: f.companyId, agentId: f.agentId, issueId: f.issueId },
|
|
provider: { kind: "grok_bot" }, workspace: { access: "none", cwd: null },
|
|
task: { description: randomBytes(128 * 1024).toString("base64") },
|
|
};
|
|
const binding = input.binding as Record<string, unknown>;
|
|
if (kind === "run") binding.runId = randomUUID();
|
|
if (kind === "company") binding.companyId = randomUUID();
|
|
if (kind === "agent") binding.agentId = randomUUID();
|
|
if (kind === "issue") binding.issueId = randomUUID();
|
|
if (kind === "provider") input.provider = { kind: "codex" };
|
|
if (kind === "schema") input.schema = "unsupported";
|
|
if (kind === "access") input.workspace = { cwd: null };
|
|
if (kind === "workspace") input.workspace = "none";
|
|
if (kind === "binding") input.binding = [];
|
|
if (kind === "missing_binding") delete input.binding;
|
|
const selectedInput = kind === "null_input" ? null : kind === "array_input" ? []
|
|
: kind === "scalar_input" ? "input" : kind === "number_input" ? 42 : input;
|
|
await db.update(heartbeatRuns).set({ nativeIssueId: f.issueId,
|
|
runnerProfileJson: { ...f.run.runnerProfileJson, nativeExecutionInput: selectedInput } }).where(eq(heartbeatRuns.id, f.run.id));
|
|
expect(await compare(f)).toMatchObject({ workspaceRepairRequired: true, canRetry: false });
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)))[0].runnerProfileJson?.nativeExecutionInput).toEqual(selectedInput);
|
|
},
|
|
);
|
|
|
|
it.each([null, [], "scalar", 42, { issueId: 42, taskId: false }].map(context => ({ context })))("preserves decisions for malformed context $context", async ({ context }) => {
|
|
const f = await fixture({ contextSnapshot: context as Record<string, unknown> | null });
|
|
expect(await compare(f)).toMatchObject({ canRetry: true });
|
|
});
|
|
});
|