diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index a7f91e7ba13..6eac6f41144 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -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 diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index dbb21285648..8dba337d8c5 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -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. diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 1f958e134fe..f566c388a9f 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -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 diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 95352a968fe..d31598d2555 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -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 = { ...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); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ae3aa79aae0..e23a85c2e88 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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> = null; try { diff --git a/server/src/legacy-run-task-ownership.ts b/server/src/run-task-ownership.ts similarity index 59% rename from server/src/legacy-run-task-ownership.ts rename to server/src/run-task-ownership.ts index 2c9bfbf5b6a..126941e24ab 100644 --- a/server/src/legacy-run-task-ownership.ts +++ b/server/src/run-task-ownership.ts @@ -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", { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b71f2dd7457..f34437d8be0 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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, { diff --git a/server/src/services/native-runtime/current-wake-comments.ts b/server/src/services/native-runtime/current-wake-comments.ts index 2f82eafd516..8c94a28079f 100644 --- a/server/src/services/native-runtime/current-wake-comments.ts +++ b/server/src/services/native-runtime/current-wake-comments.ts @@ -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, diff --git a/server/src/services/native-runtime/native-human-self-handoff.test.ts b/server/src/services/native-runtime/native-human-self-handoff.test.ts new file mode 100644 index 00000000000..10643f3dbb1 --- /dev/null +++ b/server/src/services/native-runtime/native-human-self-handoff.test.ts @@ -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> | 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>; + + /** 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[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 }); + }); +}); diff --git a/server/src/services/native-runtime/native-human-self-handoff.ts b/server/src/services/native-runtime/native-human-self-handoff.ts new file mode 100644 index 00000000000..b664fcd87e3 --- /dev/null +++ b/server/src/services/native-runtime/native-human-self-handoff.ts @@ -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 { + 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)[NATIVE_HUMAN_SELF_HANDOFF_KEY] : null; + if (!handoff || typeof handoff !== "object") return false; + const { issueId, assigneeUserId } = handoff as Record; + return issueId === issue.id && assigneeUserId === issue.assigneeUserId; +} diff --git a/server/src/services/native-runtime/native-restart-recovery.ts b/server/src/services/native-runtime/native-restart-recovery.ts index 88dbb08f7f2..d74c98558c2 100644 --- a/server/src/services/native-runtime/native-restart-recovery.ts +++ b/server/src/services/native-runtime/native-restart-recovery.ts @@ -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, diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index 69b6ca12c3a..22924cf868e 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -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); diff --git a/server/src/services/native-runtime/status-arbiter.ts b/server/src/services/native-runtime/status-arbiter.ts index ef691a0f897..a7fb4c0baa1 100644 --- a/server/src/services/native-runtime/status-arbiter.ts +++ b/server/src/services/native-runtime/status-arbiter.ts @@ -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 = 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; diff --git a/tests/restart-chaos/README.md b/tests/restart-chaos/README.md index ee87afb64aa..178c74b5ecb 100644 --- a/tests/restart-chaos/README.md +++ b/tests/restart-chaos/README.md @@ -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. | diff --git a/tests/restart-chaos/config.ts b/tests/restart-chaos/config.ts index 0dba1ec0ed8..26041745e0b 100644 --- a/tests/restart-chaos/config.ts +++ b/tests/restart-chaos/config.ts @@ -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(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(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"), diff --git a/tests/restart-chaos/grader.ts b/tests/restart-chaos/grader.ts index fd8e0f93893..8796bad647d 100644 --- a/tests/restart-chaos/grader.ts +++ b/tests/restart-chaos/grader.ts @@ -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"; } diff --git a/tests/restart-chaos/h2-handoff.test.ts b/tests/restart-chaos/h2-handoff.test.ts new file mode 100644 index 00000000000..0c16fbdde8b --- /dev/null +++ b/tests/restart-chaos/h2-handoff.test.ts @@ -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> = {}, 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"]); + }); +}); diff --git a/tests/restart-chaos/journeys.ts b/tests/restart-chaos/journeys.ts index 7d49bc0494e..612985360a8 100644 --- a/tests/restart-chaos/journeys.ts +++ b/tests/restart-chaos/journeys.ts @@ -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 = { 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"], }; diff --git a/tests/restart-chaos/observer.ts b/tests/restart-chaos/observer.ts index c86614d119b..910f5e28c9a 100644 --- a/tests/restart-chaos/observer.ts +++ b/tests/restart-chaos/observer.ts @@ -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> { + const runs = await this.api.get(`/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 { const definition = this.definition; const issueId = this.record.issueId!; @@ -1053,6 +1065,7 @@ export class TaskObserver { runs: await this.api.get(`/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) } : {}), }; } diff --git a/tests/restart-chaos/types.ts b/tests/restart-chaos/types.ts index efd17f66eb0..5c5f3333dab 100644 --- a/tests/restart-chaos/types.ts +++ b/tests/restart-chaos/types.ts @@ -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. */ diff --git a/tests/restart-chaos/workloads.ts b/tests/restart-chaos/workloads.ts index e54ebda7f2f..52762f9660c 100644 --- a/tests/restart-chaos/workloads.ts +++ b/tests/restart-chaos/workloads.ts @@ -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 = { 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",