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>
This commit is contained in:
DottaandClaude Fable 5.2 authored and GitHub committed 2026-11-11 15:30:59 -06:00
1 parent d7b04a8b6d
commit b2779054d4
21 files changed
+507 -32

No files matched your search

+6 -3
View File
@@ -74,16 +74,19 @@ An issue has at most one assignee.
This is a hard invariant. Paperclip is single-assignee by design.
A legacy agent run can hand its own task to a human without cancelling itself.
An agent run, legacy or native, can hand its own task to a human without cancelling itself.
The REST request must use the run's signed token and match the current agent,
company, task, and active run. The assignment and requested status persist while
the current turn finishes normally. This avoids losing the handoff when workspace
restoration takes longer than the stop timeout. The former agent no longer owns
the task. Its later task mutations are rejected, including under the update
transaction's task and run locks. Read and comment access follows normal policy.
A native run's task tools stop acting for the task once it is handed off, and the
run's result is kept as history: its finalizer preserves the person's task status
and adds no continuation, blocker, or recovery to it.
Board-initiated reassignments, agent API keys, native executions, and handoffs to
another agent still require the outgoing execution to stop before ownership changes.
Board-initiated reassignments, agent API keys, and handoffs to another agent still
require the outgoing execution to stop before ownership changes.
A native Stop can take longer than the request. When its durable Stop fence is
committed, the reassignment applies at once and the new assignee's wake waits
behind the stopping run. When that run releases the task, its Stop still does not
+1 -1
View File
@@ -193,7 +193,7 @@ API: `present` — PATCH /api/issues/{id} assigneeAgentId/assigneeUserId.
Agent access: `partial` — REST/call_api; CLI agent assignment only; native reassign_task for another eligible task, never own active task.
Policy: tasks:assign with exact target/project/parent; eligible agent or active person, single assignee. Stop old run before transfer; unconfirmed stop409. Narrow signed legacy current-run self→human handoff can finish; late outgoing edits denied; board/native/key/agent transfer keeps stop rules. Native reassignment requires a retry key, reason, expected owner and status version, denies current task/conversations/terminal/in_review/pending review; preserves blocked/backlog and durable wake replay.
Policy: tasks:assign with exact target/project/parent; eligible agent or active person, single assignee. Stop old run before transfer; unconfirmed stop409. Narrow signed legacy or native current-run self→human handoff can finish; late outgoing edits denied; a native finalizer preserves the person's task; board/key/agent transfer keeps stop rules. Native reassignment requires a retry key, reason, expected owner and status version, denies current task/conversations/terminal/in_review/pending review; preserves blocked/backlog and durable wake replay.
Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7a`; [issues.ts](../../server/src/routes/issues.ts), [paperclip-runner-tool-authority.ts](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts), [issue.ts](../../cli/src/commands/client/issue.ts), [reassign-task.ts](../../packages/paperclip-runner/src/protocol-actions/reassign-task.ts), [issue-agent-mutation-ownership-routes.test.ts](../../server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts), [task-reassignment.test.ts](../../server/src/services/native-runtime/task-reassignment.test.ts). No live execution claimed.
+1 -1
View File
@@ -119,7 +119,7 @@ uses a real disposable server and paused process agent, with no inference, to
check both task interfaces. Done and cancelled tasks hide the paused-agent
warning; reopening restores it. The agent stays paused and no run starts.
Manual: For a disposable task owned by a legacy agent, have its current run
Manual: For a disposable task owned by a legacy or native agent, have its current run
assign the task to a human with status In review. Verify that assignment and
status persist, that the current run finishes without `issue_reassigned`, and
that slow workspace restoration does not turn the task Blocked. Try a later edit
@@ -174,6 +174,7 @@ const mockRequestTaskRetry = vi.hoisted(() => vi.fn());
const mockOnboardingFirstTaskDependenciesAllowed = vi.hoisted(() => vi.fn(async () => undefined));
const mockGetExperimental = vi.hoisted(() => vi.fn());
const mockResumeWorkspaceFinalization = vi.hoisted(() => vi.fn());
const mockRecordNativeHumanSelfHandoff = vi.hoisted(() => vi.fn(async () => true));
function registerRouteMocks() {
vi.doMock("../services/instance-settings.js", async () => {
@@ -194,6 +195,7 @@ function registerRouteMocks() {
vi.doMock("../services/native-runtime/native-workspace-owner-recovery.js", () => ({ resumeNativeWorkspaceAfterOwnerStop: mockResumeWorkspaceFinalization }));
vi.doMock("../services/native-runtime/native-workspace-export-retry.js", () => ({ retryNativeWorkspaceExport: mockRetryWorkspaceExport }));
vi.doMock("../services/workspace-recovery.js", () => ({ requestWorkspaceRecovery: mockRequestWorkspaceRecovery }));
vi.doMock("../services/native-runtime/native-human-self-handoff.js", () => ({ recordNativeHumanSelfHandoff: mockRecordNativeHumanSelfHandoff }));
vi.doMock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
trackErrorHandlerCrash: vi.fn(),
@@ -2936,8 +2938,11 @@ describe("agent issue mutation checkout ownership", () => {
}));
});
it.each(["in_progress", "in_review"])("persists a human handoff from %s without waiting for its own slow shutdown", async status => {
it.each([
["legacy", "in_progress"], ["legacy", "in_review"], ["native", "in_progress"], ["native", "in_review"],
])("persists a %s run's human handoff from %s without cancelling the run", async (runtimeMode, status) => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status, executionRunId: ownerRunId }));
mockHeartbeatService.getRun.mockResolvedValue({ ...runningRun, runtimeMode });
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("Execution is still stopping; termination has not been verified."));
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
@@ -2946,6 +2951,14 @@ describe("agent issue mutation checkout ownership", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject(handoff);
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
if (runtimeMode === "native") {
expect(mockRecordNativeHumanSelfHandoff).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
runId: ownerRunId, agentId: ownerAgentId, issueId, assigneeUserId: "board-user" }));
expect(mockRecordNativeHumanSelfHandoff.mock.invocationCallOrder[0])
.toBeLessThan(mockIssueService.update.mock.invocationCallOrder[0]!);
} else {
expect(mockRecordNativeHumanSelfHandoff).not.toHaveBeenCalled();
}
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({
...handoff,
actorRunId: ownerRunId,
@@ -2959,6 +2972,17 @@ describe("agent issue mutation checkout ownership", () => {
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
it("refuses a native hand-off when the run can no longer record it, without saving the assignment", async () => {
mockHeartbeatService.getRun.mockResolvedValue({ ...runningRun, runtimeMode: "native" });
mockRecordNativeHumanSelfHandoff.mockResolvedValueOnce(false);
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
.patch(`/api/issues/${issueId}`).send(handoff);
expect(res.status, JSON.stringify(res.body)).toBe(409);
expect(res.body.code ?? res.body.details?.code).toBe("native_self_handoff_run_changed");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it("recognizes the caller when the active run is resolved without an issue execution lock", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_review" }));
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(runningRun);
@@ -3011,15 +3035,15 @@ describe("agent issue mutation checkout ownership", () => {
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
});
it.each(["board", "agent_key", "other_run", "native", "other_issue", "other_agent", "other_company"])(
it.each(["board", "agent_key", "other_run", "native_board", "other_issue", "other_agent", "other_company"])(
"still requires a verified stop for %s reassignment", async scenario => {
let actor: Record<string, unknown> = { ...ownerActor(), source: "agent_jwt" };
if (scenario === "board") actor = boardActor();
if (scenario === "board" || scenario === "native_board") actor = boardActor();
if (scenario === "agent_key") actor = ownerActor();
const run = {
...runningRun,
...(scenario === "other_run" ? { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}),
...(scenario === "native" ? { runtimeMode: "native" } : {}),
...(scenario === "native_board" ? { runtimeMode: "native" } : {}),
...(scenario === "other_issue" ? { contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } } : {}),
...(scenario === "other_agent" ? { agentId: peerAgentId } : {}),
...(scenario === "other_company" ? { companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}),
@@ -3301,10 +3325,7 @@ describe("agent issue mutation checkout ownership", () => {
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({ status: "in_review" });
});
it("does not intercept a native run handing its task to a human", async () => {
// A native handoff still stops the outgoing run first; confirm that stop
// so the request reaches the update instead of the stop-unconfirmed 409.
mockHeartbeatService.cancelRun.mockResolvedValue({ ...nativeRun, status: "cancelled" });
it("does not intercept a native run handing its task to a human, and does not cancel that run", async () => {
const handoff = { assigneeAgentId: null, assigneeUserId: "board-user", status: "in_review" };
const res = await request(createApp(runActor(), createRunContextDb({}, ownerAgentId, ownerRunId, [], true)))
@@ -3312,10 +3333,8 @@ describe("agent issue mutation checkout ownership", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject(handoff);
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(ownerRunId, expect.anything(), expect.anything());
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
expect(mockIssueService.update).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.cancelRun.mock.invocationCallOrder[0])
.toBeLessThan(mockIssueService.update.mock.invocationCallOrder[0]!);
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject(handoff);
});
+22 -3
View File
@@ -22,7 +22,8 @@ import { activityService } from "../services/activity.js";
import { listIssueSummaries } from "../services/issue-summaries.js";
import { listIssueStatusBlockerAttention } from "../services/issue-status-presentation.js";
import { assertNoCaseHandoffMutation } from "../services/heartbeat/case-handoff-lifecycle.js";
import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js";
import { assertHandedOffRunTaskOwnership } from "../run-task-ownership.js";
import { recordNativeHumanSelfHandoff } from "../services/native-runtime/native-human-self-handoff.js";
import { runRetainsIssueIdentity } from "../services/task-repository-acquisition.js";
import { withConversationReadState } from "../services/agent-chat-read-state.js";
import { markIssueReadSchema } from "@paperclipai/shared";
@@ -5824,7 +5825,7 @@ export function issueRoutes(
if (req.actor.source === "agent_jwt" && req.actor.runId &&
issue.assigneeAgentId === null && issue.assigneeUserId) {
const run = await heartbeat.getRun(req.actor.runId);
if (run?.agentId === actorAgentId) assertLegacyRunTaskOwnership(run, issue);
if (run?.agentId === actorAgentId) assertHandedOffRunTaskOwnership(run, issue);
}
// Task-watchdog runs receive a scoped *grant* to mutate issues inside the
// watched subtree. This must be evaluated before the base assignee-ownership
@@ -15550,17 +15551,35 @@ export function issueRoutes(
agentId: existing.assigneeAgentId,
});
const runToStopForReassignment = await resolveActiveIssueRun(existing);
// The run handing its own task to a person finishes its turn instead of
// cancelling itself. Native and legacy alike: the reassignment clears
// the execution lock, the native tool binding and finalizer stop acting
// for a task the run no longer owns, and later REST mutations from the
// outgoing run are rejected (assertHandedOffRunTaskOwnership).
const isHumanSelfHandoff =
req.actor.type === "agent" &&
req.actor.source === "agent_jwt" &&
nextAssigneeAgentId === null &&
typeof nextAssigneeUserId === "string" &&
req.actor.agentId === existing.assigneeAgentId &&
runToStopForReassignment?.runtimeMode === "legacy" &&
(runToStopForReassignment?.runtimeMode === "legacy" || runToStopForReassignment?.runtimeMode === "native") &&
runToStopForReassignment.id === req.actor.runId &&
runToStopForReassignment.agentId === req.actor.agentId &&
runToStopForReassignment.companyId === existing.companyId &&
runToStopForReassignment.contextSnapshot?.issueId === existing.id;
// Record the native hand-off on the run before the task changes, so the
// run's terminal fence accepts the task it gave away. A failed update
// leaves the record unused: the fence also requires the person to own it.
if (isHumanSelfHandoff && runToStopForReassignment.runtimeMode === "native" &&
!await recordNativeHumanSelfHandoff(db, { companyId: existing.companyId, runId: runToStopForReassignment.id,
agentId: runToStopForReassignment.agentId, issueId: existing.id, assigneeUserId: nextAssigneeUserId as string })) {
// The run is no longer the running native run of this task: do not
// save an assignment its own terminal fence would then refuse.
throw conflict("The run handing off this task is no longer running it. Read the task again and retry.", {
code: "native_self_handoff_run_changed",
runId: runToStopForReassignment.id,
});
}
if (runToStopForReassignment && !isHumanSelfHandoff) {
let cancelled: Awaited<ReturnType<typeof heartbeat.cancelRun>> = null;
try {
@@ -1,6 +1,11 @@
import { forbidden } from "./errors.js";
export function assertLegacyRunTaskOwnership(
/**
* A run that handed its own task to a person keeps running to finish its turn,
* but it no longer owns the task. Reject its later task mutations, legacy or
* native, so the person's task cannot be changed by the outgoing run.
*/
export function assertHandedOffRunTaskOwnership(
run: {
companyId: string;
runtimeMode: string;
@@ -13,7 +18,7 @@ export function assertLegacyRunTaskOwnership(
assigneeUserId: string | null;
},
) {
if (run?.runtimeMode === "legacy" && run.companyId === issue.companyId &&
if ((run?.runtimeMode === "legacy" || run?.runtimeMode === "native") && run.companyId === issue.companyId &&
run.contextSnapshot?.issueId === issue.id &&
issue.assigneeAgentId === null && issue.assigneeUserId) {
throw forbidden("This task is assigned to a human; the outgoing run can no longer change it", {
+2 -2
View File
@@ -48,7 +48,7 @@ import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notificatio
import { recordChatHandoff, recordAcceptedChatHandoff, lockAcceptedChatHandoffSource, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js";
import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js";
import { agentRunWritesRevoked, assertAgentRunWriteAllowed } from "../agent-run-cancellation.js";
import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js";
import { assertHandedOffRunTaskOwnership } from "../run-task-ownership.js";
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
import { externalConversationStateSql, nonIdleSlackIssueCondition, resumeSlackConversation } from "./slack-conversation-state.js";
import { documentService } from "./documents.js";
@@ -11908,7 +11908,7 @@ export function issueService(db: Db) {
const actorRun = await assertAgentRunWriteAllowed(tx, receiptExisting.companyId, {
agentId: actorAgentId, runId: actorRunId, stopId: actorRunStopId,
});
assertLegacyRunTaskOwnership(actorRun, receiptExisting);
assertHandedOffRunTaskOwnership(actorRun, receiptExisting);
}
if (blockedByIssueIds !== undefined) {
await assertOnboardingFirstTaskDependenciesAllowed(tx as Db, {
@@ -14,6 +14,7 @@ import type { SourceTrustMetadata } from "@paperclipai/shared";
import { createRunSecretRedactionRegistry } from "../run-secret-redaction.js";
import { sanitizeQuarantinedCommentForHigherTrust } from "../source-trust.js";
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js";
import { nativeRunHandedOffTask } from "./native-human-self-handoff.js";
export const READ_CURRENT_WAKE_COMMENTS_TOOL_NAME =
"read_current_wake_comments";
@@ -759,6 +760,7 @@ export async function assertCurrentWakeCommentsRead(
resultJson: heartbeatRuns.resultJson,
contextSnapshot: heartbeatRuns.contextSnapshot,
issueAssigneeAgentId: issues.assigneeAgentId,
issueAssigneeUserId: issues.assigneeUserId,
issueExecutionRunId: issues.executionRunId,
})
.from(heartbeatRuns)
@@ -782,6 +784,10 @@ export async function assertCurrentWakeCommentsRead(
.limit(1)
.then((rows) => rows[0] ?? null);
if (!locked) throw new Error("native_current_wake_comments_binding_changed");
// The run handed its own task to a person: it finishes its turn without
// task authority. Its finalizer preserves the person's task.
const handedOff = nativeRunHandedOffTask(locked.resultJson, { id: input.issueId,
assigneeAgentId: locked.issueAssigneeAgentId, assigneeUserId: locked.issueAssigneeUserId });
if (readNativeReviewAssignmentContext(locked.contextSnapshot)) {
// Accepting a review can release the child's execution lock. The exact
// resolving run may still finish; it does not regain task write access.
@@ -791,10 +797,12 @@ export async function assertCurrentWakeCommentsRead(
if (!review || (review.interaction.status === "pending" && locked.issueExecutionRunId !== input.runId)) {
throw new Error("native_current_wake_comments_binding_changed");
}
} else if (locked.issueAssigneeAgentId !== input.agentId || locked.issueExecutionRunId !== input.runId) {
} else if (!handedOff && (locked.issueAssigneeAgentId !== input.agentId || locked.issueExecutionRunId !== input.runId)) {
throw new Error("native_current_wake_comments_binding_changed");
}
const currentBinding = await resolveCurrentWakeCommentsBinding(
// After its own hand-off the run no longer resolves a current binding (it
// does not own the task). It still must have read the wake it started with.
const currentBinding = handedOff ? expectedBinding ?? null : await resolveCurrentWakeCommentsBinding(
tx as unknown as Db,
{
companyId: input.companyId,
@@ -0,0 +1,198 @@
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 });
});
});
@@ -0,0 +1,38 @@
import { and, eq, sql } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
/**
* A native run that hands its own task to a person keeps running to finish its
* turn. This run-scoped record names that hand-off, so the run's own terminal
* fences accept a task it gave away on purpose. It grants no task authority:
* the task's tools, REST mutations and status decisions already stop acting
* for a task that belongs to a person.
*/
export const NATIVE_HUMAN_SELF_HANDOFF_KEY = "humanSelfHandoff";
export async function recordNativeHumanSelfHandoff(db: Db, input: {
companyId: string; runId: string; agentId: string; issueId: string; assigneeUserId: string;
}): Promise<boolean> {
const record = { issueId: input.issueId, assigneeUserId: input.assigneeUserId, recordedAt: new Date().toISOString() };
const updated = await db.update(heartbeatRuns)
.set({ resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb)
|| jsonb_build_object(${NATIVE_HUMAN_SELF_HANDOFF_KEY}::text, ${JSON.stringify(record)}::jsonb)` })
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, input.agentId), eq(heartbeatRuns.runtimeMode, "native"),
eq(heartbeatRuns.nativeIssueId, input.issueId), eq(heartbeatRuns.status, "running")))
.returning({ id: heartbeatRuns.id });
return updated.length > 0;
}
/** The task now belongs to the person this run handed it to. */
export function nativeRunHandedOffTask(
resultJson: unknown,
issue: { id: string; assigneeAgentId: string | null; assigneeUserId: string | null },
): boolean {
if (issue.assigneeAgentId !== null || !issue.assigneeUserId) return false;
const handoff = resultJson && typeof resultJson === "object"
? (resultJson as Record<string, unknown>)[NATIVE_HUMAN_SELF_HANDOFF_KEY] : null;
if (!handoff || typeof handoff !== "object") return false;
const { issueId, assigneeUserId } = handoff as Record<string, unknown>;
return issueId === issue.id && assigneeUserId === issue.assigneeUserId;
}
@@ -1,5 +1,6 @@
import { carriesInheritedRunnerIdentity } from "./native-inherited-runner-retry.js";
import { readPendingNativeRunStop } from "./native-cancellation-request.js";
import { nativeRunHandedOffTask } from "./native-human-self-handoff.js";
import { CONTROLLER_LEASE_RENEW_MS } from "../controller-instance-lease.js";
import { currentControllerProcessStamp, UNKNOWN_PROCESS_SPACE } from "../controller-process-identity.js";
import { readNativeSessionResumptionAgent, restoreNativeSessionExecution } from "./native-session-resumption-admission.js";
@@ -1115,7 +1116,7 @@ export async function claimNativeRestartRecoveries(input: {
const [issue] = await tx
.select({ id: issues.id, companyId: issues.companyId, status: issues.status,
executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId,
assigneeAgentId: issues.assigneeAgentId })
assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId })
.from(issues)
.where(and(eq(issues.id, candidate.issueId), eq(issues.companyId, candidate.companyId)))
.for("update", { skipLocked: true });
@@ -1263,7 +1264,12 @@ export async function claimNativeRestartRecoveries(input: {
const deferred = await deferRemoteRetry("issue_execution_lock_changed");
if (deferred) return deferred;
}
if (row.issueExecutionRunId !== row.run.id && !(stopCleanup && row.issueExecutionRunId === null) && !releasedRemoteRetry && !admission.exhausted) {
// The run handed its own task to a person. It is not resumed: the
// ownership gate below settles it (task_ownership_or_status_changed)
// without touching the person's task, instead of leaving it running.
const handedOff = row.issueExecutionRunId === null && nativeRunHandedOffTask(row.run.resultJson, issue);
if (row.issueExecutionRunId !== row.run.id && !(stopCleanup && row.issueExecutionRunId === null) && !releasedRemoteRetry
&& !admission.exhausted && !handedOff) {
return {
kind: "blocked",
runId: row.run.id,
@@ -62,6 +62,7 @@ import type { PrpIgnoredAttentionRequest, PrpTerminalState } from "@paperclipai/
import {
arbitrateNativeStatus,
NATIVE_STATUS_ARBITER_POLICY_VERSION,
supersedeHandedOffDecision,
supersedeStaleOwnerCompletion,
type NativeAuthoritativeIssueStatus,
} from "./status-arbiter.js";
@@ -1706,6 +1707,7 @@ export async function finalizeNativeRun(input: {
// new owner's task. The status committer rechecks this under the issue lock.
if (!isConversation(authoritativeIssue)) {
proposedDecision = supersedeStaleOwnerCompletion(proposedDecision, authoritativeIssue, run.agentId, run.id);
if (!reviewContext) proposedDecision = supersedeHandedOffDecision(proposedDecision, authoritativeIssue);
}
if (proposedDecision.statusAction === "done") {
const continuation = record(run.contextSnapshot?.executionContinuation);
@@ -132,6 +132,49 @@ export function supersedeStaleOwnerCompletion(
};
}
/**
* Effects that give the outgoing agent more work on the task, wake it, or bind
* a blocker or reviewer to the task. None of them is valid once the task
* belongs to a person.
*/
const OUTGOING_AGENT_TASK_EFFECTS: ReadonlySet<NativeStatusEffect["kind"]> = new Set([
"enqueue_continuation", "schedule_retry", "notify_owner", "record_recovery",
"bind_blocker", "bind_reviewer", "create_delegated_issue", "accept_replacement_turn",
]);
/**
* A run that handed its own task to a person keeps running and finishes its
* turn. Its result is history: it cannot move the person's task, wake or
* retry the outgoing agent, or bind a blocker to the task. This also applies
* to decisions that already preserve the status.
*
* Two effects stay. A governed request still opens its interaction: the gate
* belongs to the request, not to the task owner. A failed workspace
* finalization still records its repair, so the run's saved work is not lost.
*/
export function supersedeHandedOffDecision(
decision: NativeStatusDecision,
issue: { status: string; assigneeAgentId: string | null; assigneeUserId: string | null },
): NativeStatusDecision {
if (issue.assigneeAgentId !== null || !issue.assigneeUserId) return decision;
// A finalization error only stays for a failed workspace save: it repairs
// and re-runs the save of the run's work. Other causes are task recovery.
const effects = decision.effects.filter(effect => !OUTGOING_AGENT_TASK_EFFECTS.has(effect.kind)
&& !(effect.kind === "record_finalization_error" && effect.cause !== "workspace_finalization_failed"));
const governed = effects.some(effect => effect.kind === "create_interaction");
if (decision.statusAction === "preserve" || governed) {
return effects.length === decision.effects.length ? decision : { ...decision, effects };
}
return {
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
statusAction: "preserve",
toStatus: issue.status as NativeAuthoritativeIssueStatus,
reasonCode: "completion_owner_superseded",
unblockDescriptor: null,
effects,
};
}
/** Pure authority boundary: model prose is evidence, never a status command. */
export function arbitrateNativeStatus(input: {
assessment: NativeEvidenceAssessment;
+7 -1
View File
@@ -354,6 +354,12 @@ minutes. In staging class R, recovery then found the remote Runner gone
outage. The server stops with SIGTERM and stays down for the hold. Then the
next deploy generation starts. Without the option, a K15 restarts at once.
The range is drawn only for K15 cells, so other seeds keep their plans.
- **H2** (opt-in workload, staging class H) has the agent save `brief`, then hand
the task to the person who created it with status `in_review` as its last
step. The task must rest `in_review` with that person, no run of the task may
end `issue_reassigned`, and the task must have no execution blocker or active
recovery action. Run it with a native adapter, for example
`--adapters claude --workloads H2`.
- **T1** (opt-in workload) has the agent run one shell command in the
foreground for about 100 s (50 ticks of 2 s). The command prints the loop's
own clock window. The agent then saves `brief`, whose first line is
@@ -1433,7 +1439,7 @@ re-arms the task's own triggered faults instead of using their offsets.
| `grader.ts` | Pure R1–R6 grading over the samples, plus the universal checks. |
| `universal.ts`, `universal-audit.ts` | U1–U4 on every task, and their audit over saved waves. |
| `contention.ts` | L1 workload, its accounting oracle and the `--db-pressure` writers. |
| `workloads.ts` | A1 brief document, B1 python CLI with tests, B5 long bench, C1 comment while running, M1 delegation, G1 approval across a restart, H1 budget hard stop across a restart, F1 absolute symlink kept in the workspace, T1 long shell tool, with their T-oracles. |
| `workloads.ts` | A1 brief document, B1 python CLI with tests, B5 long bench, C1 comment while running, M1 delegation, G1 approval across a restart, H1 budget hard stop across a restart, F1 absolute symlink kept in the workspace, T1 long shell tool, H2 hand-off to a person, with their T-oracles. |
| `journeys.ts`, `journey-driver.ts`, `journey-oauth-app.ts`, `journey-matrix.ts` | Q2 and Q3 definitions and oracles, the board's side of each, Q3's loopback OAuth/MCP app, and the `--matrix` planner. |
| `evidence.ts`, `ledger.ts` | Evidence bundle for each failure and the JSONL ledger plus `SUMMARY.md`. |
| `daytona-image.ts` | Docker-free extraction of the image's runnerd and provider pack, cached per digest, and the controller/image content-ID check. |
+1 -1
View File
@@ -204,7 +204,7 @@ export function parseCommand(argv: readonly string[], now = Date.now()): ParsedC
env,
adapters,
// The governance scenarios (G1, H1), the journeys (Q1–Q4) and the contention run (L1) are opt-in: H1 needs a wave of its own.
workloads: list<WorkloadId>(values.workloads, workloadChoices, "workloads", workloadChoices.filter(id => id !== "G1" && id !== "H1" && id !== "R1" && id !== "S1" && id !== "L1" && id !== "F1" && id !== "T1" && id !== "Q4"
workloads: list<WorkloadId>(values.workloads, workloadChoices, "workloads", workloadChoices.filter(id => id !== "G1" && id !== "H1" && id !== "R1" && id !== "S1" && id !== "L1" && id !== "F1" && id !== "T1" && id !== "Q4" && id !== "H2"
&& !takesJourneyTriggers(id) && !isRewakeWorkload(id) && (id !== "M1" || adapters.length >= 2))),
// A crash-point wave schedules no fault; K1 is the restart its self-kill gets.
faults: allFaults.filter((fault): fault is FaultKind => !isProviderFault(fault) && !isDiskFullFault(fault) && !isSessionFault(fault) && fault !== "K0"),
+2 -1
View File
@@ -54,8 +54,9 @@ export function outageOverlapMs(record: TaskRecord, faults: readonly FaultReceip
}, 0);
}
/** The status a task rests in when it succeeds: `done`, or `cancelled` for a G1 request the board rejected. */
/** The status a task rests in when it succeeds: `done`, `cancelled` for a G1 request the board rejected, `in_review` for an H2 hand-off. */
export function expectedFinalStatus(record: TaskRecord): string {
if (record.plan.workload === "H2") return "in_review";
return record.plan.workload === "G1" && g1Decision(record.plan.nonce) === "reject" ? "cancelled" : "done";
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { parseCommand } from "./config.js";
import { expectedFinalStatus } from "./grader.js";
import type { OracleArtifacts, TaskRecord } from "./types.js";
import { workloadDefinitions } from "./workloads.js";
const NONCE = "a1b2c3d4";
const brief = `# Brief\nCHAOS-${NONCE}\n- one\n- two\n- three\n`;
const artifacts = (handoff: Partial<NonNullable<OracleArtifacts["handoff"]>> = {}, issueStatus = "in_review"): OracleArtifacts => ({
issueStatus, documents: { brief }, agentComments: [], files: {}, commands: {}, workspacePath: null,
handoff: { assigneeAgentId: null, assigneeUserId: "board-user", createdByUserId: "board-user", executionBlocker: false,
activeRecoveryAction: false, runs: [{ id: "run-1", status: "succeeded", errorCode: null }], ...handoff },
});
const grade = (value: OracleArtifacts) => workloadDefinitions.H2.grade(NONCE, value);
const failed = (value: OracleArtifacts) => grade(value).checks.filter(check => !check.ok).map(check => check.name);
describe("H2: the agent's own hand-off to a person (staging class H)", () => {
it("passes when the run finished and the person holds the task in review", () => {
expect(grade(artifacts()).verdict).toBe("pass");
});
it("fails the staging shape: the hand-off cancelled its own run and left an execution blocker", () => {
expect(failed(artifacts({ runs: [{ id: "run-1", status: "cancelled", errorCode: "issue_reassigned" }], executionBlocker: true })))
.toEqual(["handoff_did_not_cancel_run", "no_execution_blocker"]);
});
it("fails when the task did not reach the creator in review", () => {
expect(failed(artifacts({ assigneeAgentId: "agent-1", assigneeUserId: null }, "in_progress")))
.toEqual(["issue_in_review", "handed_to_creator"]);
});
it("fails when the run history could not be read or is empty", () => {
expect(failed(artifacts({ runs: null }))).toEqual(["handoff_did_not_cancel_run"]);
expect(failed(artifacts({ runs: [] }))).toEqual(["handoff_did_not_cancel_run"]);
});
it("accepts a run that a restart after the hand-off settled without the task", () => {
expect(grade(artifacts({ runs: [{ id: "run-1", status: "failed", errorCode: "native_restart_recovery_blocked" }] })).verdict).toBe("pass");
});
it("fails when a run never settled", () => {
expect(failed(artifacts({ runs: [{ id: "run-1", status: "running", errorCode: null }] }))).toEqual(["handoff_did_not_cancel_run"]);
});
it("expects the task to rest in review, and runs only when asked for", () => {
expect(expectedFinalStatus({ plan: { workload: "H2", nonce: NONCE } } as TaskRecord)).toBe("in_review");
const defaults = parseCommand([]);
expect(defaults.mode === "wave" && defaults.options.workloads).not.toContain("H2");
const chosen = parseCommand(["--workloads", "H2", "--adapters", "claude"]);
expect(chosen.mode === "wave" && chosen.options.workloads).toEqual(["H2"]);
});
});
+1 -1
View File
@@ -35,7 +35,7 @@ export type JourneyTrigger = (typeof JOURNEY_TRIGGERS)[number];
/** Each workload's own fault boundaries, for the journey × fault matrix. */
export const WORKLOAD_TRIGGERS: Record<WorkloadId, readonly TriggerId[]> = {
A1: ["time"], B1: ["time"], B5: ["tool"], C1: ["comment", "result"], M1: ["time"], G1: ["approval"],
H1: ["paused"], R1: ["time"], S1: ["stop"], L1: ["time"], F1: ["time"], T1: ["time"], Q1: JOURNEY_TRIGGERS, Q2: JOURNEY_TRIGGERS, Q3: JOURNEY_TRIGGERS, Q4: ["time"],
H1: ["paused"], R1: ["time"], S1: ["stop"], L1: ["time"], F1: ["time"], T1: ["time"], H2: ["time"], Q1: JOURNEY_TRIGGERS, Q2: JOURNEY_TRIGGERS, Q3: JOURNEY_TRIGGERS, Q4: ["time"],
W2: ["time"], W2Q: ["time"], W2C: ["time"],
};
+13
View File
@@ -986,6 +986,18 @@ export class TaskObserver {
return children;
}
/** H2: the owner at rest and every run's terminal state (an `issue_reassigned` cancel is the staging failure). */
private async collectHandoff(issueId: string, issue: Json | null | undefined): Promise<NonNullable<OracleArtifacts["handoff"]>> {
const runs = await this.api.get<Json[]>(`/api/issues/${issueId}/runs`).catch(() => null);
const text = (value: unknown) => typeof value === "string" ? value : null;
return {
assigneeAgentId: text(issue?.assigneeAgentId), assigneeUserId: text(issue?.assigneeUserId),
createdByUserId: text(issue?.createdByUserId),
executionBlocker: Boolean(issue?.executionBlocker), activeRecoveryAction: Boolean(issue?.activeRecoveryAction),
runs: Array.isArray(runs) ? runs.map(toRunSample).map(run => ({ id: run.id, status: run.status, errorCode: run.errorCode })) : null,
};
}
async collectArtifacts(): Promise<OracleArtifacts> {
const definition = this.definition;
const issueId = this.record.issueId!;
@@ -1053,6 +1065,7 @@ export class TaskObserver {
runs: await this.api.get<Json[]>(`/api/issues/${issueId}/runs`, 10_000).then(rows => (rows ?? []).map(run => ({ id: String(run.runId ?? run.id),
status: String(run.status), errorCode: run.errorCode ?? null, createdAt: run.createdAt ?? null, finishedAt: run.finishedAt ?? null }))).catch(() => []),
...(this.journey ? { journey: await this.journey.collect(issueId).finally(() => this.journey!.close()) } : {}),
...(this.plan.workload === "H2" ? { handoff: await this.collectHandoff(issueId, issue) } : {}),
};
}
+15 -1
View File
@@ -32,9 +32,10 @@ export type TaskFaultKind = FaultKind | "K13" | "K0" | ProviderFaultKind | DiskF
* L1: high-output, high-accounting run for database contention (contention.ts).
* F1: an absolute symlink pointing out of the repository workspace, kept at rest (staging save-back class F).
* T1: a 100-second shell tool in the agent's workspace, so a fault lands while the remote Runner runs a tool (staging class R).
* H2: the agent's own last step hands the task to the person who created it, in review (staging class H).
* W2, W2Q, W2C: the same task woken again 0–60 s after its run succeeded (rewake.ts), many tasks on one agent.
*/
export const WORKLOAD_IDS = ["A1", "B1", "B5", "C1", "M1", "G1", "H1", "R1", "S1", "Q1", "L1", "Q2", "Q3", "Q4", "F1", "T1", "W2", "W2Q", "W2C"] as const;
export const WORKLOAD_IDS = ["A1", "B1", "B5", "C1", "M1", "G1", "H1", "R1", "S1", "Q1", "L1", "Q2", "Q3", "Q4", "F1", "T1", "W2", "W2Q", "W2C", "H2"] as const;
export type WorkloadId = (typeof WORKLOAD_IDS)[number];
export const ADAPTER_IDS = ["codex", "claude", "opencode"] as const;
@@ -363,6 +364,19 @@ export interface OracleArtifacts {
rewake?: import("./rewake.js").RewakeArtifacts;
/** `--long-turn-minutes`: the task's runs, for the harness's own long-turn evidence (long-turn.ts). */
runs?: import("./rewake.js").RewakeArtifacts["runs"];
/** H2: who owns the task at rest, and how its runs ended. */
handoff?: HandoffArtifacts;
}
/** H2: the task's owner and execution state at rest, and each run's terminal state. */
export interface HandoffArtifacts {
assigneeAgentId: string | null;
assigneeUserId: string | null;
createdByUserId: string | null;
executionBlocker: boolean;
activeRecoveryAction: boolean;
/** Null when the run history could not be read: the run checks then fail. */
runs: Array<{ id: string; status: string; errorCode: string | null }> | null;
}
/** What the board did for a governance task (G1, H1), and what the observer saw. */
+48
View File
@@ -151,6 +151,41 @@ const briefDescription = (nonce: string) => [
"Do not create files or other tasks. When the document is saved, mark this task done.",
].join("\n");
/** H2: the brief, then the agent's own last step hands the task to the person who created it, in review. */
const handoffDescription = (nonce: string) => [
"Write a short brief on why idempotent retries matter for background job queues.",
"Save it as this issue's document with key `brief` (title \"Brief\"), in markdown.",
`The document must contain the line \`${briefMarker(nonce)}\` exactly, on its own line,`,
"and at least three bullet points that start with \"- \".",
"Do not create files or other tasks. Do not mark this task done.",
"When the document is saved, hand this task to the person who created it for review, as your last step:",
"update this task with `assigneeAgentId` null, `assigneeUserId` set to the task's `createdByUserId`, and status `in_review`.",
"Then end your turn.",
].join("\n");
/**
* H2 (staging class H): the agent's own hand-off to a person must not cancel its run. The task rests in review with
* that person, every run of the task settled without `issue_reassigned`, and nothing blocks the task's execution.
*/
export function handoffChecks(artifacts: OracleArtifacts): OracleResult["checks"] {
const handoff = artifacts.handoff;
const runs = handoff?.runs ?? null;
const reassignedRuns = (runs ?? []).filter(run => run.errorCode === "issue_reassigned").map(run => run.id);
// A restart after the hand-off settles the run as failed (it is not resumed without the task); it must settle.
const unsettled = (runs ?? []).filter(run => !["succeeded", "cancelled", "failed", "timed_out"].includes(run.status));
return [
{ name: "issue_in_review", ok: artifacts.issueStatus === "in_review", detail: `status=${artifacts.issueStatus ?? "unknown"}` },
{ name: "handed_to_creator", ok: Boolean(handoff?.createdByUserId) && handoff!.assigneeAgentId === null
&& handoff!.assigneeUserId === handoff!.createdByUserId,
detail: `agent=${handoff?.assigneeAgentId ?? "none"} user=${handoff?.assigneeUserId ?? "none"} creator=${handoff?.createdByUserId ?? "none"}` },
{ name: "handoff_did_not_cancel_run", ok: Boolean(runs?.length) && reassignedRuns.length === 0 && unsettled.length === 0,
detail: runs === null ? "run history unavailable" : reassignedRuns.length ? `issue_reassigned=${reassignedRuns.join(",")}`
: `runs=${runs.map(run => `${run.id}:${run.status}`).join(",") || "none"}` },
{ name: "no_execution_blocker", ok: Boolean(handoff) && !handoff!.executionBlocker && !handoff!.activeRecoveryAction,
detail: `blocker=${handoff?.executionBlocker ?? "unknown"} recovery=${handoff?.activeRecoveryAction ?? "unknown"}` },
];
}
const delegateDescription = (nonce: string, agents?: WorkloadAgents) => {
const self = agents?.self.name ?? "yourself";
const other = agents?.other.name ?? "yourself";
@@ -247,6 +282,19 @@ export const workloadDefinitions: Record<WorkloadId, WorkloadDefinition> = {
return result([statusCheck(artifacts), ...briefChecks(artifacts.documents.brief, [briefMarker(nonce)]), ...budgetChecks(artifacts)]);
},
},
H2: {
id: "H2",
label: "The agent's own hand-off to a person, in review",
usesRepository: false,
fireWindowMs: [10_000, 45_000],
budgetMs: 10 * 60_000,
title: nonce => `Chaos H2 handoff ${nonce}`,
description: handoffDescription,
collect: { documents: ["brief"], files: () => [], commands: [] },
grade(nonce, artifacts) {
return result([...handoffChecks(artifacts), ...briefChecks(artifacts.documents.brief, [briefMarker(nonce)])]);
},
},
M1: {
id: "M1",
label: "Delegate to two subtasks",