Files
PaperClipAI/server/src/services/native-runtime/native-human-self-handoff.test.ts
T
DottaandClaude Fable 5.2 b2779054d4 fix(issues): let a native run hand its own task to a person without cancelling itself (#20884)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A task has one assignee. An agent often finishes its work by handing
its own task to a person for review.
> - The task update route stops the outgoing run before it saves a
reassignment.
> - Since #16082, a legacy run that hands its own task to a person is
exempt, so it finishes its turn. Native runs were not exempt.
> - On staging, a native run (ACPX Claude, 11.5 minutes of work) sent
`PATCH /api/issues/{id}` to hand its task to a person. The route
cancelled that run with `issue_reassigned`. The agent saw
`api_transport_failure`. The task moved to the person, but it kept a
stale execution blocker ("Saved work recovered. Send a message to
continue.").
> - The native runtime assumes a running run still owns its task. So
widening the exemption alone is not enough: the run then fails after its
result is accepted, and its finalizer can act on the person's task.
> - This pull request applies the exemption to native runs and makes the
native terminal fence, restart recovery and finalizer handle a run that
gave its task away.
> - The benefit is that an agent can hand its task to a person and
finish normally, with no blocker on the person's task.

## Linked Issues or Issue Description

Refs #16082 (legacy exemption). Related: #20541 (agent-to-agent self
handoff) and #18293 (agent key handoff). Those PRs cover other actors
and targets. This PR covers native runs that hand their task to a
person.

**What happened?**

A native agent run handed its own task to a person (`assigneeAgentId:
null`, `assigneeUserId`, status `in_review`) through its signed run
token. The route cancelled the run that sent the request
(`issue_reassigned`, "Cancelled before issue reassignment"). The
cancelled native run then went through workspace recovery. The person's
task kept an execution blocker with `restore_failed`.

**Expected behavior**

The run that hands its own task to a person keeps running and finishes
its turn. The task rests `in_review` with the person. No execution
blocker or recovery action stays on the task. The outgoing run cannot
change the task after the hand-off.

**Steps to reproduce**

1. Run a native agent on its assigned task.
2. From that run, send `PATCH /api/issues/{id}` with `assigneeAgentId:
null`, a person's `assigneeUserId`, and status `in_review`.
3. Before this fix, the route cancels the run with `issue_reassigned`.

**Why the exemption was legacy-only**

#16082 limited the exemption to the run shape of its incident and kept a
verified stop for every other case. The new H2 chaos workload showed why
native needed more than the route change. With only the route widened:

1. The run's terminal fence `assertCurrentWakeCommentsRead` requires the
run to still own the task. It threw
`native_current_wake_comments_binding_changed` after the result was
accepted. The run then failed with `native_session_lease_lost`, and its
finalizer recorded `workspace_finalization_failed` as an agent-owned
recovery on the person's task.
2. A restart after the hand-off left the run `running`, because restart
recovery refused it with `issue_execution_lock_changed`.
3. The native finalizer only superseded a `done` decision. A
`needs_review` report moved the person's task back to `in_progress` and
queued a continuation for the outgoing agent.
4. The REST guard against the outgoing run's later mutations
(`issue_handed_off`) applied to legacy runs only.

## What Changed

- `server/src/routes/issues.ts`: the signed current-run human
self-handoff exemption now applies to native runs. All other checks
stay: exact run, agent, company and task, `agent_jwt` source, human
recipient. For a native run the route first records the hand-off on the
run (`resultJson.humanSelfHandoff`, new `native-human-self-handoff.ts`).
Board reassignment, agent keys, and handoffs to an agent or to nobody
still stop the outgoing run.
- `current-wake-comments.ts`: the terminal fence accepts a run whose
recorded hand-off matches the person who now owns the task. Without the
record (for example a board reassignment), or after a later
reassignment, it still refuses.
- `native-restart-recovery.ts`: after a restart, a handed-off run is not
resumed. It passes the execution-lock check and is then settled by the
existing ownership gate (`task_ownership_or_status_changed`) without
changing the person's task, instead of staying `running`.
- `status-arbiter.ts` and `native-run-finalizer.ts`:
`supersedeHandedOffDecision` keeps the person's task status and strips
retries, continuations, owner wakes, recoveries, blockers and reviewer
bindings, also from decisions that already preserve the status. Two
effects stay: a governed request's interaction, and the
workspace-finalization repair, so saved work is not lost. Review runs
are not affected.
- `server/src/run-task-ownership.ts` (renamed from
`legacy-run-task-ownership.ts`): `assertHandedOffRunTaskOwnership`
rejects later task mutations from the outgoing legacy or native run, in
the route and under the update transaction's locks.
- `tests/restart-chaos/`: new opt-in workload `H2`. The agent saves a
brief, then hands the task to its creator in review as its last step.
The oracle requires the task to rest `in_review` with the creator, every
run to settle without `issue_reassigned`, readable non-empty run
history, and no execution blocker or active recovery action.
- Docs: `doc/execution-semantics.md`, task feature map and agent access
map, chaos README.

## Verification

- Red on `origin/master` (same tests, master source): the route cancels
the native run (500, and 409
`runner_goal_reassignment_stop_unconfirmed`); the finalizer moves the
person's task to `in_progress` with a continuation for the outgoing
agent; a late `done` from the outgoing native run closes the person's
task.
- Red chaos wave on master's server (`--server-from` a master checkout,
`--adapters claude --workloads H2 --faults K1 --matrix`): 0/2 pass. Both
runs ended `cancelled` / `issue_reassigned`
(`T:handoff_did_not_cancel_run`).
- Green focused tests on this branch:
- `native-human-self-handoff.test.ts` (embedded Postgres): hand-off
through the real issue service; the run stays `running`; `done`,
`needs_review` and failed-turn reports leave the task `in_review` with
the person, with `getExecutionBlocker` null and no recovery action; the
terminal fence accepts only the recorded hand-off; a late change from
the outgoing run fails with `issue_handed_off`; pure policy cases.
- `issue-agent-mutation-ownership-routes.test.ts`: native and legacy
self-handoffs persist without `cancelRun`, and native records the
hand-off before the update. Board, agent key, other run, task, agent and
company still stop the run.
- Native runtime suite (restart recovery, wake comments, arbiter,
stale-owner completion), `issues-service`, `low-trust-red-team-routes`:
pass. Server and restart-chaos typecheck pass. `node
scripts/check-maps.mjs` passes.
- Green chaos waves on this branch (local, `--adapters claude
--workloads H2`):
- `--matrix --faults K1 --seed 4459`: 2/2 pass (K0 baseline and a K1
crossing).
- `--tasks 3 --faults K1 --fault-after-seconds 60` and
`--fault-after-seconds 100`: 3/3 pass each. In the first, all three
restarts landed after the hand-off while the runs were live; the runs
settled and the tasks stayed with the person.
- The final waves ran on the pre-rebase head `8a2a3b6a74`. The rebase
onto master only merged the chaos workload lists (`types.ts`,
`config.ts`, `journeys.ts`). No Daytona wave was run.

## Risks

- The outgoing native turn keeps running after the person owns the task.
Its native task tools already require the run to own the task, so they
refuse (`paperclip_runner_tool_binding_not_authorized`). Its REST
mutations are rejected with `issue_handed_off`.
- A restart after the hand-off settles the run as failed
(`native_restart_recovery_blocked`) instead of resuming it. The person's
task is not changed and needs no Retry.
- The finalizer rule covers any native run whose task belongs to a
person at finalization, not only a self-handoff. Before this change such
a run could still move the person's task and queue its own continuation.
- Saved state: `resultJson.humanSelfHandoff` is a new optional run key.
An older server ignores it, so a run handed off on the new server and
finished on an old one fails its fence as today. No schema change.
- No API shape change.

> 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 (Anthropic), model ID `claude-opus-5-5`, through Claude Code
with tool use and code execution. The session does not expose the
context window or reasoning mode.

## 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 Fable 5.2 <noreply@anthropic.com>
2026-11-11 15:30:59 -06:00

199 lines
15 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, authUsers, companies, companyMemberships, completionContracts, createDb, heartbeatRuns, issueRecoveryActions,
issues, nativeRunFinalizations, statusDecisions, type Db } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { getExecutionBlocker } from "../execution-blocker.js";
import { issueService } from "../issues.js";
import { NativeRunCoordinatorStore } from "./native-run-coordinator-store.js";
import { finalizeNativeRun } from "./native-run-finalizer.js";
import { assertCurrentWakeCommentsRead } from "./current-wake-comments.js";
import { recordNativeHumanSelfHandoff } from "./native-human-self-handoff.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION, supersedeHandedOffDecision, type NativeStatusDecision } from "./status-arbiter.js";
describe("handed-off task decision policy", () => {
const incomplete: NativeStatusDecision = { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: "in_progress",
toStatus: "in_progress", reasonCode: "completion_evidence_incomplete", unblockDescriptor: null,
effects: [{ kind: "enqueue_continuation", agentId: "agent-a", summary: "Finish", idempotencyKey: "k", continuationKind: "same_agent" }] };
const person = { status: "in_review", assigneeAgentId: null, assigneeUserId: "user-1" };
it("preserves a person's task against the outgoing run's decision", () => {
expect(supersedeHandedOffDecision(incomplete, person)).toEqual({ ...incomplete, statusAction: "preserve",
toStatus: "in_review", reasonCode: "completion_owner_superseded", effects: [] });
});
it.each([
["the agent still owns the task", { ...person, assigneeAgentId: "agent-a", assigneeUserId: null }],
["the task is unassigned", { ...person, assigneeUserId: null }],
])("leaves the decision alone when %s", (_label, issue) => {
expect(supersedeHandedOffDecision(incomplete, issue)).toBe(incomplete);
});
it("keeps a governed request's interaction, without waking the outgoing agent", () => {
const interaction = { kind: "create_interaction", gate: { kind: "approval", id: "a" } };
const gated = { ...incomplete, statusAction: "in_review", toStatus: "in_review", reasonCode: "governed_gate_pending",
effects: [interaction, { kind: "notify_owner", agentId: "agent-a", reason: "governed_gate_pending" }] } as unknown as NativeStatusDecision;
expect(supersedeHandedOffDecision(gated, person)).toEqual({ ...gated, effects: [interaction] });
});
it("drops a failed turn's retry of the outgoing agent from a preserving decision", () => {
const failedTurn: NativeStatusDecision = { ...incomplete, statusAction: "preserve", toStatus: "in_review",
reasonCode: "run_failed_partial_evidence_preserved",
effects: [{ kind: "release_checkout" }, { kind: "schedule_retry", cause: "x", summary: "Retry", agentId: "agent-a" }] };
expect(supersedeHandedOffDecision(failedTurn, person)).toEqual({ ...failedTurn, effects: [{ kind: "release_checkout" }] });
});
it("drops a finalization error that is not a workspace save failure", () => {
const monitorLost: NativeStatusDecision = { ...incomplete, statusAction: "preserve", toStatus: "in_review",
reasonCode: "native_monitor_authority_lost", effects: [{ kind: "record_finalization_error",
cause: "native_monitor_authority_lost", nextAction: "Reschedule", agentId: "agent-a" }] };
expect(supersedeHandedOffDecision(monitorLost, person)).toEqual({ ...monitorLost, effects: [] });
});
it("keeps the workspace repair of a failed finalization, so saved work is not lost", () => {
const finalizationFailed: NativeStatusDecision = { ...incomplete, statusAction: "preserve", toStatus: "in_review",
reasonCode: "finalization_failed_claim_preserved", effects: [{ kind: "record_finalization_error",
cause: "workspace_finalization_failed", nextAction: "Repair", agentId: "agent-a" }] };
expect(supersedeHandedOffDecision(finalizationFailed, person)).toBe(finalizationFailed);
});
});
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_HUMAN_HANDOFF_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
/**
* A native run that hands its own task to a person keeps running and finishes
* its turn. The task stays with that person: no execution blocker, no
* recovery action, and the outgoing run cannot change the task afterward.
*/
describe.skipIf(!support.supported)("native run hands its own task to a person", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: Db;
beforeAll(async () => {
if (!external) database = await startEmbeddedPostgresTestDatabase("native-human-handoff-");
db = createDb(external ?? database!.connectionString);
}, 60_000);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
async function runningNativeTask() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
const runId = randomUUID(), sessionId = randomUUID(), runnerId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Handoff fixture", issuePrefix: companyId.slice(0, 8) });
await db.insert(agents).values({ id: agentId, companyId, name: "Nibbler", adapterType: "paperclip_runner" });
await db.insert(authUsers).values({ id: userId, name: "Reviewer", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
await db.insert(issues).values({ id: issueId, companyId, title: "Draft the plan", status: "in_progress",
assigneeAgentId: agentId, createdByUserId: userId, description: "Draft the plan and hand it to me for review." });
const [contract] = await db.insert(completionContracts).values({ companyId, issueId, revision: 1,
schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "low", completionAuthority: "agent_claim_policy",
incompleteCriteriaPolicy: "preserve_non_terminal", contractJson: { revision: "test", objective: "Draft the plan",
criteria: [{ id: "objective", requirement: "Complete the task" }] },
canonicalSha256: `handoff-${issueId}`, createdByActorType: "system", createdByActorId: "test" }).returning();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, nativeSessionId: sessionId,
runnerInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256,
status: "running", runtimeMode: "native", invocationSource: "assignment", contextSnapshot: { issueId, source: "issue.assigned" } });
await db.update(issues).set({ executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, issueId));
const port = new NativeRunCoordinatorStore(db, { companyId, issueId, runId, agentId, normalizedSessionId: sessionId,
runnerSourceInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256,
completionContractRevision: "test", completionContractCriterionIds: ["objective"] });
return { companyId, agentId, issueId, userId, runId, port };
}
type Fixture = Awaited<ReturnType<typeof runningNativeTask>>;
/** The REST PATCH the agent sends through its run token (route exemption applied). */
/** What the route does for the run's own hand-off: record it on the run, then save the assignment. */
async function handOffToPerson(f: Fixture, options: { record?: boolean } = {}) {
if (options.record !== false) {
expect(await recordNativeHumanSelfHandoff(db, { companyId: f.companyId, runId: f.runId, agentId: f.agentId,
issueId: f.issueId, assigneeUserId: f.userId })).toBe(true);
}
return issueService(db).update(f.issueId, { assigneeAgentId: null, assigneeUserId: f.userId, status: "in_review",
actorAgentId: f.agentId, actorRunId: f.runId } as never);
}
it("lets the run's terminal fence accept the task it handed to a person, and only that hand-off", async () => {
const binding = (f: Fixture) => ({ companyId: f.companyId, issueId: f.issueId, runId: f.runId, agentId: f.agentId });
const recorded = await runningNativeTask();
await handOffToPerson(recorded);
await expect(assertCurrentWakeCommentsRead(db, binding(recorded), null)).resolves.toBeUndefined();
// Without the run's record (for example a board reassignment), the fence still refuses.
const unrecorded = await runningNativeTask();
await handOffToPerson(unrecorded, { record: false });
await expect(assertCurrentWakeCommentsRead(db, binding(unrecorded), null)).rejects.toThrow("native_current_wake_comments_binding_changed");
// A truncated chat wake: after the hand-off the run must still have read the wake it started with.
const wake = { bindingDigest: "wake-digest" } as unknown as Parameters<typeof assertCurrentWakeCommentsRead>[2];
// It passes the binding comparison and reaches the read requirement, which it has not met.
await expect(assertCurrentWakeCommentsRead(db, binding(recorded), wake)).rejects.toThrow("native_current_wake_comments_unread");
// A later reassignment to someone else is not the hand-off this run made.
const other = randomUUID();
await db.insert(authUsers).values({ id: other, name: "Other", email: `${other}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.update(issues).set({ assigneeUserId: other }).where(eq(issues.id, recorded.issueId));
await expect(assertCurrentWakeCommentsRead(db, binding(recorded), null)).rejects.toThrow("native_current_wake_comments_binding_changed");
});
const results = {
done: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Plan drafted and handed to the reviewer.",
completionClaim: { contractRevision: "test", objectiveSatisfied: true,
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: ["task:self"] }], remainingWork: [] },
evidence: [{ ref: "task:self" }], verification: [], attentionRequests: [], artifacts: [] },
needs_review: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "needs_review", summary: "Waiting for the reviewer.",
completionClaim: { contractRevision: "test", objectiveSatisfied: false,
criteria: [{ criterionId: "objective", status: "unknown", evidenceRefs: [] }],
remainingWork: [{ description: "Reviewer decides", blocksCompletion: true }] },
evidence: [], verification: [], attentionRequests: [], artifacts: [] },
} as const;
it.each(["done", "needs_review"] as const)("finishes normally after a %s report and leaves no execution blocker", async disposition => {
const f = await runningNativeTask();
await handOffToPerson(f);
const handed = (await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]!;
expect(handed).toMatchObject({ status: "in_review", assigneeAgentId: null, assigneeUserId: f.userId, executionRunId: null });
// The run that handed off is still running; nothing cancelled it.
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toMatchObject({ status: "running", errorCode: null });
const result = JSON.parse(JSON.stringify(results[disposition]).replaceAll("task:self", `task:${f.issueId}`));
await f.port.completeRun({ result, terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded",
turnTerminalState: "completed", reportedWorkDisposition: disposition }, turnId: `handoff-${disposition}` });
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false });
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, coordinator!.decisionId!));
expect(decision).toMatchObject({ toStatus: "in_review", reasonCode: "completion_owner_superseded",
decisionJson: expect.objectContaining({ statusAction: "preserve", effects: [] }) });
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({
status: "in_review", assigneeAgentId: null, assigneeUserId: f.userId,
});
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
});
it("does not retry the outgoing agent after a failed turn", async () => {
const f = await runningNativeTask();
await handOffToPerson(f);
const result = JSON.parse(JSON.stringify(results.needs_review));
await f.port.completeRun({ result, terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "failed",
turnTerminalState: "failed", reportedWorkDisposition: "needs_review" }, turnId: "handoff-failed" });
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false });
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.id, coordinator!.decisionId!));
expect(coordinator!.phase).toBe("committed");
expect((decision!.decisionJson as { effects: Array<{ kind: string }> }).effects
.filter(effect => ["schedule_retry", "enqueue_continuation", "record_recovery", "bind_blocker", "notify_owner"].includes(effect.kind)))
.toEqual([]);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({
status: "in_review", assigneeAgentId: null, assigneeUserId: f.userId,
});
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]);
});
it("rejects the outgoing native run's later change to the person's task", async () => {
const f = await runningNativeTask();
await handOffToPerson(f);
await expect(issueService(db).update(f.issueId, { status: "done", actorAgentId: f.agentId, actorRunId: f.runId } as never))
.rejects.toMatchObject({ details: { code: "issue_handed_off" } });
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ status: "in_review", assigneeUserId: f.userId });
});
});