mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
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:
1 parent
d7b04a8b6d
commit
b2779054d4
21 files changed
+507
-32
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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", {
|
||||
@@ -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;
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
|
||||
@@ -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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user