Add guarded task reassignment to the native runner

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-19 10:51:36 -05:00
1 parent f589660ec0
commit 00e91d2503
39 files changed
+989 -56

No files matched your search

@@ -26,7 +26,7 @@ list, not a disposition — they are never exposed as tools. See
[capability disposition](capability-disposition.md) and
[authorization and exposure](capability-authorization-and-exposure.md).
The catalog holds **38 tools**: 14 always-agent tools and 24 optional tools
The catalog holds **39 tools**: 14 always-agent tools and 25 optional tools
across 10 groups.
<!-- These counts are drift-checked against src/tools/capability-semantic-tool-catalog.ts
@@ -39,12 +39,12 @@ by src/catalog/catalog-docs.test.ts; update the catalog, not the numbers. -->
`finish_task`, `block_task`, `request_review`, `write_document`,
`request_human_input`, `register_deliverable`, `inspect_operation_result`.
### Optional tools (24), by group
### Optional tools (25), by group
| Group | Tools |
| --- | --- |
| discovery | `search_tasks`, `list_agents`, `list_projects`, `list_goals` |
| delegation_dependencies | `create_task`, `set_dependencies` |
| delegation_dependencies | `create_task`, `reassign_task`, `set_dependencies` |
| governance | `list_approvals`, `request_approval`, `decide_approval`, `comment_on_approval` |
| cases | `list_cases`, `upsert_case` |
| workspace_runtime | `get_workspace_runtime`, `control_workspace_service` |
@@ -96,3 +96,24 @@ that every optional group is present.
- [Authorization and exposure](capability-authorization-and-exposure.md)
- [Mock ControlPlanePort](capability-mock-control-plane-port.md)
- [Eval conformance](capability-eval-conformance.md)
## Reassigning existing tasks
`reassign_task` moves another existing company task to an eligible agent. Read
`search_tasks` first and send the observed `expectedAssigneeActorId` (nullable)
and `expectedStatusVersion`, the new `assigneeActorId`, a handoff `reason`, and a
stable `idempotencyKey`. It requires `delegation:tasks:assign` and production
`tasks:assign` authorization in standard mode. The same task retains its
documents, dependencies, scope, priority, and history.
The old execution and any live goal must stop before ownership changes. Active
work returns to todo; backlog and blocked work retain their status and are not
started. Assignment changes advance the observed version, including ordinary
API changes, to reject stale handoffs. A durable audit receipt makes retries
idempotent across caller runs; retrying after failed dispatch repairs the wake
with a stable key and an owner/status/version guard.
The current caller task, conversations, human-assigned tasks, completed or
cancelled tasks, and pending reviews cannot be reassigned with this tool. Use
`create_task` to delegate new work from the current task. A stop failure or
concurrent ownership change fails without applying the handoff.
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
@@ -1522,6 +1522,119 @@
"title": "Control workspace service",
"version": 1
},
{
"allowedModes": [
"standard",
"skill_test"
],
"description": "Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.",
"effect": "write",
"inputSchema": {
"additionalProperties": false,
"properties": {
"assigneeActorId": {
"description": "New company agent ID from list_agents.",
"minLength": 1,
"type": "string"
},
"expectedAssigneeActorId": {
"description": "Current assigneeAgentId from search_tasks; null means unassigned.",
"type": [
"string",
"null"
]
},
"expectedStatusVersion": {
"description": "Current statusVersion from search_tasks.",
"minimum": 0,
"type": "integer"
},
"idempotencyKey": {
"description": "Caller-stable retry key.",
"maxLength": 240,
"minLength": 1,
"type": "string"
},
"reason": {
"description": "Why the task should move and context for the new owner.",
"maxLength": 20000,
"minLength": 1,
"type": "string"
},
"taskId": {
"description": "Existing task ID from search_tasks.",
"minLength": 1,
"type": "string"
}
},
"required": [
"idempotencyKey",
"taskId",
"assigneeActorId",
"expectedAssigneeActorId",
"expectedStatusVersion",
"reason"
],
"type": "object"
},
"operationId": "reassign_task",
"outputSchema": {
"additionalProperties": false,
"properties": {
"commandId": {
"description": "Stable command identifier.",
"maxLength": 200,
"minLength": 1,
"type": "string"
},
"disposition": {
"enum": [
"applied",
"duplicate"
]
},
"entityRefs": {
"description": "Entities affected by the operation.",
"items": {
"minLength": 1,
"type": "string"
},
"maxItems": 200,
"type": "array",
"uniqueItems": true
},
"scheduledWakeIds": {
"description": "Wake identifiers scheduled by the operation.",
"items": {
"minLength": 1,
"type": "string"
},
"maxItems": 200,
"type": "array",
"uniqueItems": true
},
"stateRevision": {
"minimum": 0,
"type": "integer"
}
},
"required": [
"commandId",
"disposition",
"stateRevision",
"entityRefs",
"scheduledWakeIds"
],
"type": "object"
},
"placement": "optional",
"requiredClaims": [
"delegation:tasks:assign"
],
"schema": "paperclip.semantic-action.v1",
"title": "Reassign task",
"version": 1
},
{
"allowedModes": [
"standard",
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:cf243ac6e38f931e556db65b7593f7ed58ffb4d0a2543c90219b0cc723840196",
"catalogSha256": "sha256:6b9eeb1ef2fe014bacc9d1acbb443ffdbe9588167c5b808e2ff6f5e6add20a33",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "8a86a398f09ed822e7fd9acbbcd08a007921562ad21df4306bb47c45e32d0b60",
"sha256": "142abbf9009e5d48a1bc91287af1dc15866c34439d587dee9d06eb31ce77d07c",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -7,7 +7,7 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
"actions": 46,
"actions": 47,
"legacyRequirements": 106
},
"actions": [
@@ -1426,6 +1426,26 @@
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "reassign_task",
"ownership": "optional_agent_tool",
"surfaces": [
"scenario",
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:reassign_task",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::reassign_task has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:reassign_task"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::reassign_task has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "sync_company_skills",
"ownership": "optional_agent_tool",
@@ -57,8 +57,8 @@
},
{
"id": "delegation_dependencies",
"description": "Create delegated work and maintain dependency edges.",
"operationIds": ["create_task", "set_dependencies"]
"description": "Create or reassign delegated work and maintain dependency edges.",
"operationIds": ["create_task", "reassign_task", "set_dependencies"]
},
{
"id": "governance",
@@ -180,9 +180,9 @@
"legacyGroup": 6,
"name": "Subtasks",
"owner": "optional delegation tools",
"operationIds": ["create_task"],
"operationIds": ["create_task", "reassign_task"],
"controlPlaneOperationIds": ["route_wake"],
"realSurface": "company issue create, child issue, assignment, and wake services",
"realSurface": "company issue create, child issue, guarded reassignment, and wake services",
"mockStateDomains": ["company", "task", "actor", "blockers", "wake", "audit"],
"prpEvidence": "company/task state diff, audit reference, and continuation wake evidence",
"gap": "create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency."
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **46 reconciled semantic operations** (14 always, 32 optional), and **106 scenarios in 16 behavior groups**.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **47 reconciled semantic operations** (14 always, 33 optional), and **106 scenarios in 16 behavior groups**.
## Axis 1: PRP v1 event and command families
@@ -87,7 +87,7 @@ Placement has exactly three outcomes:
`answer_status_question`, `block_task`, `finish_task`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`.
### Optional operations (32) and grant groups (13)
### Optional operations (33) and grant groups (13)
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
@@ -95,7 +95,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| --- | --- | --- | --- |
| `discovery` | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals` | `discovery:agents:read`<br>`discovery:goals:read`<br>`discovery:projects:read`<br>`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. |
| `projects` | `create_project`<br>`list_project_repositories` | none | Project creation and authorized repository discovery through the live company/run authority. |
| `delegation_dependencies` | `create_task`<br>`set_dependencies` | `delegation:tasks:create`<br>`dependencies:write` | Create delegated work and maintain dependency edges. |
| `delegation_dependencies` | `create_task`<br>`reassign_task`<br>`set_dependencies` | `delegation:tasks:assign`<br>`delegation:tasks:create`<br>`dependencies:write` | Create or reassign delegated work and maintain dependency edges. |
| `governance` | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `governance:approvals:comment`<br>`governance:approvals:decide`<br>`governance:approvals:read`<br>`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. |
| `cases` | `list_cases`<br>`upsert_case` | `cases:read`<br>`cases:write` | Read and update case summaries without reusing issue-document authority. |
| `workspace_runtime` | `get_workspace_runtime`<br>`control_workspace_service` | `workspace:control`<br>`workspace:read` | Inspect and control the active issue workspace runtime. |
@@ -147,6 +147,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `read_document` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`<br>`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`<br>`scenario_mock` | `unbound`<br>redacted tool-result item event; secret value never reaches the wire<br>catalog PRP status: `audit_pending` |
| `reassign_task` | `optional_agent_tool` | `delegation:tasks:assign` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:reassign_task` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority.reassign_task`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
| `register_deliverable` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:register_deliverable` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `report_progress` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
| `request_approval` | `optional_agent_tool` | `governance:approvals:request` | `standard`<br>`skill_test` | `governance` | `required` | no | `semantic_command:request_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
@@ -173,7 +174,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| [`st` — Status](#behavior-group-st-status) | always tools + control-plane arbitration | `answer_status_question`<br>`finish_task`<br>`block_task`<br>`request_review` | `reconcile_run`<br>`append_audit_record` | issue PATCH, review/liveness policy, and native finalization arbitration | `task`<br>`comments`<br>`interactions`<br>`blockers`<br>`audit`<br>`run` | 8 | semantic operation receipt, work assessment, issue-status decision, and terminal causality | Production semantic binding and additive typed operation/conflict receipts remain unimplemented. |
| [`cm` — Comments](#behavior-group-cm-comments) | always tools | `get_task_history`<br>`report_progress` | `append_audit_record` | issue comment list/get/create routes | `task`<br>`comments`<br>`actor`<br>`idempotency`<br>`audit` | 6 | bounded read result or idempotent comment-write receipt plus audit reference | Active-task binding is unbound; cross-task comment mutation is deliberately outside V1. |
| [`se` — Search](#behavior-group-se-search) | optional discovery tools | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals`<br>`list_project_repositories` | none | company issue search and agent/project/goal list/get routes | `company`<br>`task`<br>`actor`<br>`project`<br>`goal` | 4 | bounded redacted read projections through tool-result item events | Goal operations remain scenario-only. Project and repository discovery contracts are delivered by the live server authority; they are not implemented by the mock command dispatcher. |
| [`su` — Subtasks](#behavior-group-su-subtasks) | optional delegation tools | `create_task` | `route_wake` | company issue create, child issue, assignment, and wake services | `company`<br>`task`<br>`actor`<br>`blockers`<br>`wake`<br>`audit` | 4 | company/task state diff, audit reference, and continuation wake evidence | create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency. |
| [`su` — Subtasks](#behavior-group-su-subtasks) | optional delegation tools | `create_task`<br>`reassign_task` | `route_wake` | company issue create, child issue, guarded reassignment, and wake services | `company`<br>`task`<br>`actor`<br>`blockers`<br>`wake`<br>`audit` | 4 | company/task state diff, audit reference, and continuation wake evidence | create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency. |
| [`bl` — Blockers](#behavior-group-bl-blockers) | always/optional tools + control plane | `block_task`<br>`set_dependencies` | `schedule_blocker_wake`<br>`route_wake` | issue relations, blocker projection, liveness validation, and blocker wake services | `task`<br>`blockers`<br>`wake`<br>`actor`<br>`audit`<br>`fault` | 5 | dependency diff, block receipt, attention routing, and issue-status decision | set_dependencies is production-bound for the active issue; block_task remains unbound, and cancelled-blocker receipts still need typed additive evidence. |
| [`dp` — Documents and plans](#behavior-group-dp-documents-and-plans) | always tools; restore optional; destructive lifecycle control-plane-only | `list_documents`<br>`read_document`<br>`list_document_revisions`<br>`write_document` | `append_audit_record` | issue document list/read/upsert/revision/restore/lock/unlock/delete routes | `task`<br>`documents`<br>`interactions`<br>`idempotency`<br>`audit`<br>`fault` | 3 | bounded reads and revision-safe write/conflict/denial receipts with revision lineage | restore_document_revision is an approved optional-tool gap; lock/unlock/delete are intentionally control-plane-only. |
| [`ix` — Interactions](#behavior-group-ix-interactions) | always tools + addressed resolver | `request_human_input` | `route_wake` | issue-thread interaction create/respond/accept/reject/withdraw services | `task`<br>`documents`<br>`interactions`<br>`wake`<br>`actor`<br>`idempotency` | 9 | interaction proposal/materialization/response/delivery and attention events | Production binding and semantic resolution receipts are unbound. |
@@ -456,9 +457,9 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **38** operations.
- Live dispatcher catalog: **34** operations.
- Shared: **26**; union/canonical authority: **46**.
- Scenario/eval catalog: **39** operations.
- Live dispatcher catalog: **35** operations.
- Shared: **27**; union/canonical authority: **47**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_approval`, `get_approval_context`, `list_project_repositories`, `schedule_wake`, `search_api`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
@@ -43,7 +43,7 @@ describe("capability-semantic-tools.md stays in sync with the catalog", () => {
expect(Number(alwaysHeader)).toBe(always.length);
expect(Number(optionalHeader)).toBe(optional.length);
// Guard against the historical 36-vs-37 drift the reconciliation plan flagged.
expect(SCENARIO_CATALOG.length).toBe(38);
expect(SCENARIO_CATALOG.length).toBe(39);
});
it("lists exactly the catalog's operation ids in the counts region", () => {
@@ -19,10 +19,10 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..")
describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(38);
expect(summary.liveCount).toBe(34);
expect(summary.sharedCount).toBe(26);
expect(summary.unionCount).toBe(46);
expect(summary.scenarioCount).toBe(39);
expect(summary.liveCount).toBe(35);
expect(summary.sharedCount).toBe(27);
expect(summary.unionCount).toBe(47);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
expect(summary.liveOnly).toEqual([
@@ -52,7 +52,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(46);
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(47);
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
// Neither catalog may contain an operation absent from the canonical source.
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
@@ -86,7 +86,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(46);
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(47);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -112,7 +112,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
live_codex: 33,
live_codex: 34,
scenario_mock: 12,
test_only: 1,
});
@@ -69,7 +69,7 @@ describe("semantic action catalog", () => {
(action) => action.operationId,
);
expect(operationIds).toHaveLength(33);
expect(operationIds).toHaveLength(34);
expect(new Set(operationIds).size).toBe(operationIds.length);
expect(operationIds).not.toContain("generic_api_request");
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);
@@ -1,3 +1,4 @@
import { reassignTaskAction } from "../protocol-actions/reassign-task.js";
import type {
PaperclipJsonSchema,
PaperclipSemanticActionDescriptor,
@@ -414,6 +415,9 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
),
outputSchema: operationReceipt,
}),
descriptor({
...reassignTaskAction.live.descriptor, placement: "optional", effect: "write",
}),
descriptor({
operationId: "set_dependencies",
title: "Set task dependencies",
@@ -22,6 +22,7 @@ export type PaperclipSemanticActionId =
| "get_approval_context"
| "get_workspace_runtime"
| "control_workspace_service"
| "reassign_task"
| "set_dependencies"
| "create_skill"
| "create_project"
@@ -487,7 +487,7 @@ function derivedRecordItems(
}
}
if (operationId === "create_task" || operationId === "set_dependencies") {
if (operationId === "create_task" || operationId === "reassign_task" || operationId === "set_dependencies") {
const created = refsOf("task")
.filter((id) => id !== state.tasks[0]?.id)
.map((ref) => state.tasks.find((candidate) => candidate.id === ref))
@@ -40,6 +40,7 @@ export interface CapabilityFixtureActor {
}
export interface CapabilityFixtureTask {
statusVersion?: number;
id: string;
companyId: string;
identifier: string;
@@ -391,6 +392,7 @@ export type CapabilitySemanticCommand =
contentRef: string;
title: string;
})
| (CapabilityBaseCommand & { kind: "reassign_task"; targetTaskId: string; assigneeActorId: string; expectedAssigneeActorId: string | null; expectedStatusVersion: number; reason: string })
| (CapabilityBaseCommand & { kind: "set_dependencies"; blockedByTaskIds: string[] })
| (CapabilityBaseCommand & {
kind: "create_task";
@@ -482,6 +484,7 @@ export const CAPABILITY_COMMAND_REQUIRED_CLAIMS = {
resolve_human_input: ["control_plane:interactions"],
register_deliverable: [],
set_dependencies: ["dependencies:write"],
reassign_task: ["delegation:tasks:assign"],
create_task: ["delegation:tasks:create"],
request_approval: ["governance:approvals:request"],
decide_approval: ["governance:approvals:decide"],
@@ -601,7 +604,7 @@ export function createCapabilityFixtureState(seed: CapabilityFixtureSeed = {}):
activeRunId: null,
company,
actors: structuredClone(actors),
tasks: structuredClone(tasks),
tasks: structuredClone(tasks).map(task => ({ ...task, statusVersion: task.statusVersion ?? 0 })),
outOfScopeTaskIds: structuredClone(seed.outOfScopeTaskIds ?? []),
comments: structuredClone(seed.comments ?? []),
documents: structuredClone(seed.documents ?? []),
@@ -891,6 +891,32 @@ export class CapabilityMockControlPlaneAdapter implements CapabilityMockControlP
entityRefs.push(`artifact:${artifact.id}`, `work-product:${workProduct.id}`);
break;
}
case "reassign_task": {
const target = this.#task(command.targetTaskId);
const assignee = this.#actor(command.assigneeActorId);
this.#assertCompany(run.companyId, target.companyId, assignee.companyId);
if (target.id === task.id) throw new CapabilityMockControlPlaneError("reassignment_current_task", "Delegate with create_task instead of reassigning the caller");
if (["done", "cancelled", "in_review"].includes(target.status)) throw new CapabilityMockControlPlaneError("reassignment_state_denied", "Task cannot be reassigned in this state");
if (assignee.status !== "active") throw new CapabilityMockControlPlaneError("reassignment_target_unavailable", "Assignee must be active");
if (target.assigneeActorId !== command.expectedAssigneeActorId || (target.statusVersion ?? 0) !== command.expectedStatusVersion) {
throw new CapabilityMockControlPlaneError("reassignment_conflict", "Refresh the task before deciding again");
}
requireText(command.reason, "reassignment reason");
entityRefs.push(`task:${target.id}`);
if (target.assigneeActorId === assignee.id) break;
if (target.executionRunId) {
const previous = this.#state.runs.find(candidate => candidate.id === target.executionRunId);
if (previous) previous.status = "cancelled";
}
target.assigneeActorId = assignee.id;
target.statusVersion = (target.statusVersion ?? 0) + 1;
target.checkoutRunId = null;
target.executionRunId = null;
if (target.status === "in_progress") target.status = "todo";
this.#appendComment(target.id, run.actorId, command.reason);
if (target.status === "todo") scheduledWakeIds.push(this.#scheduleWake(assignee.id, target.id, "manual", { reason: command.reason }, 0));
break;
}
case "set_dependencies": {
this.#replaceDependencies(task, command.blockedByTaskIds);
if (command.blockedByTaskIds.length > 0) {
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { CapabilityMockControlPlaneAdapter } from "./capability-mock-control-plane-adapter.js";
async function fixture(status: "todo" | "blocked" | "done" = "todo", claims = ["delegation:tasks:assign"]) {
const base = new CapabilityMockControlPlaneAdapter().snapshot();
const adapter = new CapabilityMockControlPlaneAdapter({
actors: [...base.actors, { ...base.actors[0]!, id: "recipient", name: "Recipient" }],
tasks: [...base.tasks, { ...base.tasks[0]!, id: "target", identifier: "MCK-2", title: "Existing work", status, statusVersion: 0 }],
});
await adapter.start();
await adapter.openFixtureRun({ identity: { runId: "run-1", sessionId: "session-1", companyId: base.company.id, issueId: base.tasks[0]!.id, agentId: base.actors[0]!.id }, backendKind: "mock", sourceInstanceId: "reassignment-test", capabilities: claims });
const call = { runId: "run-1", idempotencyKey: "handoff", command: { kind: "reassign_task" as const, targetTaskId: "target", assigneeActorId: "recipient", expectedAssigneeActorId: base.actors[0]!.id, expectedStatusVersion: 0, reason: "Recipient owns implementation" } };
return { adapter, call, base };
}
describe("mock task reassignment conformance", () => {
it("changes one existing owner, schedules one wake, and deduplicates replay", async () => {
const { adapter, call } = await fixture();
const result = await adapter.applyCommand(call);
await adapter.applyCommand(call);
const state = adapter.snapshot();
expect(state.tasks).toHaveLength(2);
expect(state.tasks.find(task => task.id === "target")).toMatchObject({ assigneeActorId: "recipient", statusVersion: 1, status: "todo" });
expect(state.wakes).toHaveLength(1);
expect(result.scheduledWakeIds).toEqual([state.wakes[0]!.id]);
expect(state.comments.filter(comment => comment.taskId === "target")).toHaveLength(1);
});
it("preserves blocked state without a wake", async () => {
const { adapter, call } = await fixture("blocked");
await adapter.applyCommand(call);
expect(adapter.snapshot().tasks.find(task => task.id === "target")).toMatchObject({ status: "blocked", assigneeActorId: "recipient" });
expect(adapter.snapshot().wakes).toHaveLength(0);
});
it.each(["claim", "owner", "version", "self", "terminal", "key-conflict"])("rejects %s without a second ownership effect", async kind => {
const { adapter, call, base } = await fixture(kind === "terminal" ? "done" : "todo", kind === "claim" ? [] : undefined);
if (kind === "owner") call.command.expectedAssigneeActorId = "someone-else";
if (kind === "version") call.command.expectedStatusVersion = 99;
if (kind === "self") call.command.targetTaskId = base.tasks[0]!.id;
if (kind === "key-conflict") { await adapter.applyCommand(call); call.command.reason = "Conflicting request"; }
const before = adapter.snapshot();
await expect(adapter.applyCommand(call)).rejects.toThrow();
expect(adapter.snapshot().tasks).toEqual(before.tasks);
expect(adapter.snapshot().wakes).toEqual(before.wakes);
});
});
@@ -1,3 +1,4 @@
import { reassignTaskAction } from "./reassign-task.js";
import { createSkillAction } from "./create-skill.js";
import { createProjectAction } from "./create-project.js";
import { listProjectRepositoriesAction } from "./list-project-repositories.js";
@@ -90,6 +91,7 @@ export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
scheduleWakeAction,
searchTasksAction,
setDependenciesAction,
reassignTaskAction,
syncCompanySkillsAction,
upsertCaseAction,
writeDocumentAction,
@@ -0,0 +1,287 @@
/** Canonical definition and documentation for `reassign_task`. */
export const reassignTaskAction = {
"id": "reassign_task",
"canonical": {
"operationId": "reassign_task",
"surfaces": [
"scenario",
"live"
],
"placement": "optional_agent_tool",
"optionalGroup": "delegation_dependencies",
"requiredClaims": [
"delegation:tasks:assign"
],
"taskModes": [
"standard",
"skill_test"
],
"sideEffectClass": "company_write",
"idempotency": "required",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "PaperclipRunnerToolAuthority.reassign_task",
"prpEvidence": "semantic-operation item event plus company-entity state diff and audit record",
"prpBindingStatus": "bound",
"legacyAliases": []
},
"documentation": {
"title": "Reassign task",
"description": "Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run\u2019s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.",
"note": null
},
"examples": {
"call": {
"operationId": "reassign_task",
"input": {
"idempotencyKey": "reassign-example",
"taskId": "task-delegated",
"assigneeActorId": "actor-manager",
"expectedAssigneeActorId": "actor-engineer",
"expectedStatusVersion": 0,
"reason": "The manager owns the next step."
}
},
"scenarioCall": {
"operationId": "reassign_task",
"idempotencyKey": "reassign-example",
"input": {
"taskId": "task-delegated",
"assigneeActorId": "actor-manager",
"expectedAssigneeActorId": "actor-engineer",
"expectedStatusVersion": 0,
"reason": "The manager owns the next step."
}
},
"success": {
"ok": true,
"operationId": "reassign_task",
"result": {
"commandId": "reassign-example",
"disposition": "applied",
"stateRevision": 1,
"entityRefs": [
"task-delegated"
],
"scheduledWakeIds": [
"wake-example"
]
}
}
},
"live": {
"order": 46,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "reassign_task",
"version": 1,
"title": "Reassign task",
"description": "Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run\u2019s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.",
"exposure": "optional",
"requiredClaims": [
"delegation:tasks:assign"
],
"allowedModes": [
"standard",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {
"idempotencyKey": {
"type": "string",
"minLength": 1,
"maxLength": 240,
"description": "Caller-stable retry key."
},
"taskId": {
"type": "string",
"minLength": 1,
"description": "Existing task ID from search_tasks."
},
"assigneeActorId": {
"type": "string",
"minLength": 1,
"description": "New company agent ID from list_agents."
},
"expectedAssigneeActorId": {
"type": [
"string",
"null"
],
"description": "Current assigneeAgentId from search_tasks; null means unassigned."
},
"expectedStatusVersion": {
"type": "integer",
"minimum": 0,
"description": "Current statusVersion from search_tasks."
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 20000,
"description": "Why the task should move and context for the new owner."
}
},
"required": [
"idempotencyKey",
"taskId",
"assigneeActorId",
"expectedAssigneeActorId",
"expectedStatusVersion",
"reason"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"commandId": {
"type": "string",
"description": "Stable command identifier.",
"minLength": 1,
"maxLength": 200
},
"disposition": {
"enum": [
"applied",
"duplicate"
]
},
"stateRevision": {
"type": "integer",
"minimum": 0
},
"entityRefs": {
"type": "array",
"description": "Entities affected by the operation.",
"items": {
"type": "string",
"minLength": 1
},
"maxItems": 200,
"uniqueItems": true
},
"scheduledWakeIds": {
"type": "array",
"description": "Wake identifiers scheduled by the operation.",
"items": {
"type": "string",
"minLength": 1
},
"maxItems": 200,
"uniqueItems": true
}
},
"required": [
"commandId",
"disposition",
"stateRevision",
"entityRefs",
"scheduledWakeIds"
],
"additionalProperties": false
}
}
},
"scenario": {
"order": 40,
"descriptor": {
"operationId": "reassign_task",
"version": 1,
"title": "Reassign task",
"description": "Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run\u2019s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.",
"inputSchema": {
"type": "object",
"properties": {
"taskId": {
"type": "string",
"minLength": 1,
"description": "Existing task ID from search_tasks."
},
"assigneeActorId": {
"type": "string",
"minLength": 1,
"description": "New company agent ID from list_agents."
},
"expectedAssigneeActorId": {
"type": [
"string",
"null"
],
"description": "Current assigneeAgentId from search_tasks; null means unassigned."
},
"expectedStatusVersion": {
"type": "integer",
"minimum": 0,
"description": "Current statusVersion from search_tasks."
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 20000,
"description": "Why the task should move and context for the new owner."
}
},
"required": [
"taskId",
"assigneeActorId",
"expectedAssigneeActorId",
"expectedStatusVersion",
"reason"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"schema": {
"type": "string",
"enum": [
"paperclip.capability.tool-result.v1"
]
},
"ok": {
"type": "boolean"
},
"operationId": {
"const": "reassign_task"
},
"operationResultId": {
"type": "string",
"minLength": 1
},
"value": {},
"commandResult": {},
"authorization": {}
},
"required": [
"schema",
"ok",
"operationId",
"operationResultId",
"value",
"commandResult",
"authorization"
],
"additionalProperties": false
},
"disposition": "optional_agent_tool",
"optionalGroup": "delegation_dependencies",
"requiredClaims": [
"delegation:tasks:assign"
],
"taskModes": [
"standard",
"skill_test"
],
"sideEffectClass": "company_write",
"idempotency": "required",
"redaction": [],
"mockCommandMapping": {
"kind": "semantic_command",
"commandKind": "reassign_task"
}
}
}
} as const;
@@ -28,7 +28,7 @@ const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Objec
request_human_input: "documents", register_deliverable: "documents", finish_task: "active_task",
block_task: "active_task", request_review: "active_task", search_tasks: "discovery",
list_agents: "discovery", get_agent: "discovery", create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
set_dependencies: "delegation", list_approvals: "governance", get_approval: "governance",
set_dependencies: "delegation", reassign_task: "delegation", list_approvals: "governance", get_approval: "governance",
get_approval_context: "governance", request_approval: "governance",
decide_approval: "governance", comment_on_approval: "governance",
get_workspace_runtime: "workspace", control_workspace_service: "workspace",
@@ -267,7 +267,7 @@ export class CapabilitySemanticDispatcher {
(query === undefined || `${task.identifier} ${task.title} ${task.description ?? ""}`.toLowerCase().includes(query)) &&
(statuses.length === 0 || statuses.includes(task.status)),
).slice(0, limit);
return readSuccess(state.revision, { tasks });
return readSuccess(state.revision, { tasks: tasks.map(task => ({ ...task, statusVersion: task.statusVersion ?? 0 })) });
}
case "list_approvals":
return readSuccess(state.revision, { approvals: state.approvals });
@@ -375,6 +375,9 @@ export class CapabilitySemanticDispatcher {
case "request_review":
command = { kind: "request_review", taskId, summary: requiredString(input.summary) };
break;
case "reassign_task":
command = { kind: "reassign_task", taskId, targetTaskId: requiredString(input.taskId), assigneeActorId: requiredString(input.assigneeActorId), expectedAssigneeActorId: input.expectedAssigneeActorId === null ? null : requiredString(input.expectedAssigneeActorId), expectedStatusVersion: Number(input.expectedStatusVersion), reason: requiredString(input.reason) };
break;
case "set_dependencies":
command = { kind: "set_dependencies", taskId, blockedByTaskIds: optionalStringArray(input.blockedByTaskIds) };
break;
@@ -35,7 +35,7 @@ const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
get_approval_context: "governance",
get_workspace_runtime: "workspace",
control_workspace_service: "workspace",
set_dependencies: "delegation",
set_dependencies: "delegation", reassign_task: "delegation",
create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
request_approval: "governance",
decide_approval: "governance",
@@ -94,7 +94,7 @@ describe("Capability semantic catalog and authorization", () => {
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
expect(new Set(names).size).toBe(names.length);
expect(names).toHaveLength(34);
expect(names).toHaveLength(35);
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
@@ -39,6 +39,7 @@ export type CapabilitySemanticOperationId =
| "get_approval_context"
| "get_workspace_runtime"
| "control_workspace_service"
| "reassign_task"
| "set_dependencies"
| "create_skill"
| "create_project"
@@ -1583,6 +1583,8 @@ function commandForOperation(
priority: typeof input.priority === "string" ? input.priority as never : undefined,
blockedByTaskIds: optionalStringArray(input.blockedByTaskIds),
};
case "reassign_task":
return { kind: "reassign_task", targetTaskId: requireString(input.taskId), assigneeActorId: requireString(input.assigneeActorId), expectedAssigneeActorId: input.expectedAssigneeActorId === null ? null : requireString(input.expectedAssigneeActorId), expectedStatusVersion: Number(input.expectedStatusVersion), reason: requireString(input.reason) };
case "set_dependencies":
return { kind: "set_dependencies", blockedByTaskIds: optionalStringArray(input.blockedByTaskIds) ?? [] };
case "request_approval":
+19 -1
View File
@@ -3649,6 +3649,7 @@ interface WakeupOptions {
issueStateGuard?: {
statuses: string[];
assigneeAgentId: string;
statusVersion?: number;
};
/** Keep causally distinct external chat continuations out of an existing run. */
allowRunCoalescing?: boolean;
@@ -24072,6 +24073,21 @@ export function heartbeatService(
runnerRemoteProviderPackPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH?.trim() ||
null,
stopTaskForReassignment: async (target) => {
await settleLiveRunnerGoalBeforeInterrupt(db, target);
if (!target.runId) return;
const prior = await getRun(target.runId);
if (!prior || prior.companyId !== target.companyId || prior.agentId !== target.agentId) {
throw conflict("Reassignment run binding changed");
}
const stopped = await cancelRunInternal(target.runId, "Cancelled for task reassignment", {
errorCode: "issue_reassigned", suppressImmediateRecovery: true,
resultJson: { reassignmentStopConfirmed: true },
});
if (stopped && ["running", "queued", "scheduled_retry"].includes(stopped.status)) {
throw conflict("The previous run did not stop; reassignment was not applied");
}
},
enqueueWakeup,
onSpawn: async (meta) => {
markDispatchStarted();
@@ -26717,6 +26733,7 @@ export function heartbeatService(
conversationUserId: issues.conversationUserId,
conversationState: issues.conversationState,
status: issues.status,
statusVersion: issues.statusVersion,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
@@ -26942,7 +26959,8 @@ export function heartbeatService(
if (
issueStateGuard &&
(!issueStateGuard.statuses.includes(issue.status) ||
issue.assigneeAgentId !== issueStateGuard.assigneeAgentId)
issue.assigneeAgentId !== issueStateGuard.assigneeAgentId ||
(issueStateGuard.statusVersion !== undefined && issue.statusVersion !== issueStateGuard.statusVersion))
) {
await tx.insert(agentWakeupRequests).values({
...durableReceiptFields,
+6
View File
@@ -10819,6 +10819,12 @@ export function issueService(db: Db) {
projectGoalId: nextProjectGoalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
});
// Ownership changes invalidate observed handoff versions even if status
// stays the same, including an A -> B -> A assignment race.
if ((issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== receiptExisting.assigneeAgentId)
|| (issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== receiptExisting.assigneeUserId)) {
patch.statusVersion = sql`${issues.statusVersion} + 1` as unknown as number;
}
// Reasserting Blocked or changing its blockers is a fresh decision even
// when the status string stays the same. Invalidate recovery's prior
// status receipt without treating comment recency as blocking intent.
@@ -6889,6 +6889,7 @@ export async function executePaperclipNativeSession(input: {
runnerRemoteCodexPath?: string | null;
runnerRemoteCodexNpmSpec?: string | null;
runnerRemoteProviderPackPath?: string | null;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
enqueueWakeup?: (
agentId: string,
options: {
@@ -9933,6 +9934,7 @@ export async function createRunnerdBackend(input: {
runnerRemoteProviderPackPath?: string | null;
trace?: NativeRunTrace;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
enqueueWakeup?: (
agentId: string,
options: {
@@ -10048,6 +10050,7 @@ async function createRunnerdBackendWithinSessionClaim(
: undefined,
currentWakeComments: currentWakeComments ?? undefined,
chatAttachmentReadScope: input.chatAttachmentReadScope,
stopTaskForReassignment: input.stopTaskForReassignment,
enqueueWakeup: input.enqueueWakeup,
});
const authorityEpoch = new SessionToolAuthorityEpoch(
@@ -90,7 +90,7 @@ describe("PaperclipRunnerToolAuthority", () => {
issueId,
runId,
});
expect(authority.definitions()).toHaveLength(26);
expect(authority.definitions()).toHaveLength(27);
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
expect(questions.description).toContain("ask only the next unanswered question");
expect(questions.description).toContain("Never infer answers");
@@ -1,3 +1,5 @@
import { assertAssignableAgent } from "../agent-assignability.js";
import { authorizationService } from "../authorization.js";
import { handoffPlanContext } from "./handoff-plan-context.js";
import { callCreateSkillTool } from "../skill-tools.js";
import { callProjectTool } from "../project-tools.js";
@@ -23,12 +25,14 @@ import { workspaceFileResourceService } from "../workspace-file-resources.js";
import { badRequest, forbidden } from "../../errors.js";
import { searchRunnerApi } from "./runner-api-catalog.js";
import { executeRunnerApi, validateRunnerApiCall, RUNNER_API_MAX_BYTES, type RunnerApiFile } from "./runner-api-client.js";
import { and, desc, eq, isNull, notInArray } from "drizzle-orm";
import { and, desc, eq, isNull, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
agentWakeupRequests,
chatEndpoints,
chatConversations,
documentRevisions,
heartbeatRuns,
issueApprovals,
@@ -76,7 +80,7 @@ const IMPLEMENTED_OPERATIONS = new Set([
"search_api", "call_api",
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
"create_skill", "create_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
"create_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
"list_documents", "read_document", "list_document_revisions", "write_document",
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
]);
@@ -106,6 +110,7 @@ type Binding = {
readRemoteWorkspaceFile?: RemoteWorkspaceFileReader;
currentWakeComments?: CurrentWakeCommentsBinding;
chatAttachmentReadScope?: NativeChatAttachmentReadScope;
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
enqueueWakeup?: (agentId: string, options: {
source: "assignment";
triggerDetail: "system";
@@ -115,7 +120,7 @@ type Binding = {
requestedByActorType: "agent";
requestedByActorId: string;
contextSnapshot: Record<string, unknown>;
issueStateGuard?: { statuses: string[]; assigneeAgentId: string };
issueStateGuard?: { statuses: string[]; assigneeAgentId: string; statusVersion?: number };
}) => Promise<unknown>;
};
@@ -467,6 +472,7 @@ export class PaperclipRunnerToolAuthority {
(await captureRunIdentity(this.db, this.binding)).context?.id ?? null);
case "create_task": return this.#createTask(input,
(await captureRunIdentity(this.db, this.binding)).context?.id ?? null);
case "reassign_task": return this.#reassignTask(input);
case "set_dependencies": return this.#setDependencies(input);
case "register_deliverable": return this.#registerDeliverable(input);
default: throw new Error("paperclip_runner_tool_not_bound");
@@ -909,6 +915,131 @@ export class PaperclipRunnerToolAuthority {
return result;
}
async #reassignTask(input: Record<string, unknown>): Promise<unknown> {
const key = requiredString(input.idempotencyKey);
const taskId = requiredUuid(input.taskId);
const assigneeAgentId = requiredUuid(input.assigneeActorId);
const expectedOwner = input.expectedAssigneeActorId === null ? null : requiredUuid(input.expectedAssigneeActorId);
const expectedVersion = input.expectedStatusVersion;
const reason = requiredString(input.reason);
if (!Number.isSafeInteger(expectedVersion) || Number(expectedVersion) < 0 || key.length > 240 || reason.length > 20_000) {
throw new Error("paperclip_runner_tool_input_invalid");
}
if (taskId === this.binding.issueId) {
throw new Error("paperclip_runner_reassignment_current_task: delegate with create_task; this tool cannot interrupt its own caller");
}
const durableKey = `reassign:${this.binding.issueId}:${key}`;
const fingerprint = createHash("sha256").update(canonicalJson(input)).digest("hex");
const priorReceipt = async (tx: Db): Promise<Record<string, unknown> | null> => {
const [prior] = await tx.select({ details: activityLog.details }).from(activityLog).where(and(
eq(activityLog.companyId, this.binding.companyId), eq(activityLog.action, "issue.reassigned"),
sql`${activityLog.details}->>'reassignmentKey' = ${durableKey}`,
)).limit(1);
if (!prior) return null;
if (prior.details?.inputFingerprint !== fingerprint) throw new Error("paperclip_runner_tool_idempotency_conflict");
return record(prior.details?.receipt);
};
const authorize = async (tx: Db, run: typeof heartbeatRuns.$inferSelect) => {
const [target] = await tx.select().from(issues).where(and(eq(issues.id, taskId), eq(issues.companyId, this.binding.companyId))).for("update");
if (!target) throw new Error("paperclip_runner_task_not_found");
const actor = { type: "agent" as const, source: "agent_jwt" as const, companyId: this.binding.companyId,
agentId: this.binding.agentId, runId: this.binding.runId, onBehalfOfUserId: run.responsibleUserId };
for (const action of ["issue:read", "tasks:assign"] as const) {
const decision = await authorizationService(tx).decide({ actor, action, resource: {
type: "issue", companyId: this.binding.companyId, issueId: taskId, projectId: target.projectId,
parentIssueId: target.parentId, assigneeAgentId: action === "tasks:assign" ? assigneeAgentId : target.assigneeAgentId,
assigneeUserId: action === "tasks:assign" ? null : target.assigneeUserId, status: target.status,
} });
if (!decision.allowed) throw forbidden(decision.explanation);
}
await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" });
const [externalChat] = await tx.select({ id: chatConversations.id }).from(chatConversations).where(and(
eq(chatConversations.companyId, this.binding.companyId), eq(chatConversations.issueId, taskId),
)).limit(1);
if (externalChat) throw new Error("paperclip_runner_reassignment_conversation_locked");
return target;
};
const validate = (target: typeof issues.$inferSelect) => {
if (target.conversationAgentId) throw new Error("paperclip_runner_reassignment_conversation_locked");
if (["done", "cancelled", "in_review"].includes(target.status) || record(target.executionState).status === "pending") {
throw new Error("paperclip_runner_reassignment_state_denied");
}
if (target.assigneeUserId || target.assigneeAgentId !== expectedOwner || target.statusVersion !== expectedVersion) {
throw new Error("paperclip_runner_reassignment_conflict: refresh search_tasks before deciding again");
}
};
// Stop outside row locks: the runtime needs those locks to acknowledge cancellation.
// A second locked check below prevents any intervening ownership/version change.
const prepared = await this.db.transaction(async (tx) => {
const context = await this.#lockAuthorizedMutationContext(tx as unknown as Db);
if (context.issue.workMode !== "standard") throw new Error("paperclip_runner_tool_mode_denied");
const target = await authorize(tx as unknown as Db, context.run);
if (await priorReceipt(tx as unknown as Db)) return null;
validate(target);
return target;
});
if (prepared && prepared.assigneeAgentId && prepared.assigneeAgentId !== assigneeAgentId) {
if (!this.binding.stopTaskForReassignment && prepared.executionRunId) {
throw new Error("paperclip_runner_reassignment_stop_unavailable");
}
await this.binding.stopTaskForReassignment?.({ companyId: this.binding.companyId, issueId: taskId,
agentId: prepared.assigneeAgentId, runId: prepared.executionRunId });
}
let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
const result = await this.#withMutationReceipt("reassign_task", key, input, async (tx, context) => {
if (context.issue.workMode !== "standard") throw new Error("paperclip_runner_tool_mode_denied");
const target = await authorize(tx, context.run);
const prior = await priorReceipt(tx);
if (prior) return prior;
validate(target);
if (target.executionRunId && target.executionRunId !== prepared?.executionRunId) {
throw new Error("paperclip_runner_reassignment_conflict: a new execution started; refresh before retrying");
}
if (target.executionRunId && target.assigneeAgentId !== assigneeAgentId) {
const [run] = await tx.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, target.executionRunId));
if (run && ["running", "queued", "scheduled_retry"].includes(run.status)) throw new Error("paperclip_runner_reassignment_stop_unconfirmed");
}
const changed = target.assigneeAgentId !== assigneeAgentId;
const updated = changed ? await issueService(tx).update(taskId, {
companyGuard: this.binding.companyId, assigneeAgentId, assigneeUserId: null,
status: target.status === "in_progress" ? "todo" : target.status,
statusVersion: target.statusVersion + 1, actorAgentId: this.binding.agentId,
}, tx) : target;
if (!updated) throw new Error("paperclip_runner_task_not_found");
const receipt = {
commandId: `reassign-task:${taskId}:${updated.statusVersion}`, disposition: changed ? "applied" : "duplicate",
stateRevision: updated.statusVersion, entityRefs: [taskId],
scheduledWakeIds: changed && updated.status === "todo" ? [`${durableKey}:${updated.statusVersion}`] : [],
};
if (changed) await issueService(tx).addComment(taskId, reason, { agentId: this.binding.agentId, runId: this.binding.runId });
const activity = await persistActivity(tx, {
companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId,
agentId: this.binding.agentId, runId: this.binding.runId, issueId: taskId,
action: "issue.reassigned", entityType: "issue", entityId: taskId,
details: { source: "paperclip_runner_protocol", reassignmentKey: durableKey, inputFingerprint: fingerprint,
previousAssigneeAgentId: target.assigneeAgentId, assigneeAgentId, reason, changed, receipt },
});
publication = activity.publication;
return receipt;
}, { beforeReceiptReplay: async (tx, context) => { await authorize(tx, context.run); } }) as {
stateRevision: number; scheduledWakeIds: string[];
};
if (publication) publishActivity(publication);
// Replay repairs a failed dispatch. The durable wake key and state/version guard
// prevent duplicate successors and prevent waking a later, unrelated assignment.
if (result.scheduledWakeIds.length && this.binding.enqueueWakeup) {
await this.binding.enqueueWakeup(assigneeAgentId, {
source: "assignment", triggerDetail: "system", reason: "issue_assigned",
payload: { issueId: taskId, mutation: "reassign_task", reason },
idempotencyKey: result.scheduledWakeIds[0]!, requestedByActorType: "agent", requestedByActorId: this.binding.agentId,
contextSnapshot: { issueId: taskId, source: "paperclip_runner.reassign_task", reassignmentReason: reason },
issueStateGuard: { statuses: ["todo"], assigneeAgentId, statusVersion: result.stateRevision },
});
}
return result;
}
async #setDependencies(input: Record<string, unknown>): Promise<unknown> {
const idempotencyKey = requiredString(input.idempotencyKey);
if (!Array.isArray(input.blockedByTaskIds)) {
@@ -48,7 +48,7 @@ function dedicatedTools(method: string, path: string): string[] {
if (/\/issues\/\{[^}]+\}\/comments$/.test(path)) return method === "GET" ? ["get_task_history"] : ["report_progress"];
if (/\/issues\/\{[^}]+\}\/documents/.test(path)) return method === "DELETE" ? [] : method === "GET" ? ["list_documents", "read_document", "list_document_revisions"] : ["write_document"];
if (/\/issues$/.test(path)) return method === "GET" ? ["search_tasks"] : ["create_task"];
if (/\/issues\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["get_task_context"] : ["set_dependencies", "finish_task", "block_task", "request_review"];
if (/\/issues\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["get_task_context"] : ["reassign_task", "set_dependencies", "finish_task", "block_task", "request_review"];
if (/\/agents$/.test(path) && method === "GET") return ["list_agents"];
if (/\/agents\/(me|\{[^}]+\})$/.test(path) && method === "GET") return ["get_agent"];
if (/\/approvals$/.test(path) && method === "GET") return ["list_approvals"];
@@ -0,0 +1,165 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, and } from "drizzle-orm";
import { activityLog, agents, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { issueService } from "../issues.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
describe("runner task reassignment", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("runner-reassign-");
db = createDb(temporary.connectionString);
});
afterAll(async () => { await temporary?.cleanup(); });
async function fixture(status = "todo") {
const companyId = randomUUID(), agentId = randomUUID(), nextId = randomUUID(), issueId = randomUUID(), targetId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Reassignment", issuePrefix: `R${companyId.slice(0, 6)}` });
await db.insert(agents).values([
{ id: agentId, companyId, name: "Manager", role: "ceo", status: "active", adapterType: "paperclip_runner" },
{ id: nextId, companyId, name: "Engineer", role: "engineer", status: "active", adapterType: "paperclip_runner" },
]);
await db.insert(issues).values([
{ id: issueId, companyId, title: "Coordinate", status: "in_progress", assigneeAgentId: agentId },
{ id: targetId, companyId, title: "Existing work", status, assigneeAgentId: agentId, description: "Keep the existing plan and context" },
]);
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const enqueueWakeup = vi.fn().mockResolvedValue({ id: randomUUID() });
const stopTaskForReassignment = vi.fn().mockResolvedValue(undefined);
const binding = { companyId, agentId, issueId, runId, enqueueWakeup, stopTaskForReassignment };
const authority = new PaperclipRunnerToolAuthority(db, binding);
const call = { tool: "reassign_task", callId: "handoff", arguments: { taskId: targetId, assigneeActorId: nextId, expectedAssigneeActorId: agentId, expectedStatusVersion: 0, reason: "Engineer owns implementation; retain the existing plan.", idempotencyKey: "handoff" } };
const target = async () => (await db.select().from(issues).where(eq(issues.id, targetId)))[0]!;
return { companyId, agentId, nextId, issueId, targetId, runId, authority, binding, call, target, enqueueWakeup, stopTaskForReassignment };
}
it("moves existing work once, persists context and audit, and dispatches a guarded wake", async () => {
const f = await fixture();
const receipt = await f.authority.execute(f.call);
expect(await f.target()).toMatchObject({ assigneeAgentId: f.nextId, status: "todo", statusVersion: 1, description: "Keep the existing plan and context" });
await expect(f.authority.execute({ ...f.call, callId: "retry" })).resolves.toEqual(receipt);
expect(f.stopTaskForReassignment).toHaveBeenCalledTimes(1);
expect(f.enqueueWakeup).toHaveBeenCalledWith(f.nextId, expect.objectContaining({ issueStateGuard: { statuses: ["todo"], assigneeAgentId: f.nextId, statusVersion: 1 } }));
expect(f.enqueueWakeup.mock.calls[0]![1].idempotencyKey).toEqual(f.enqueueWakeup.mock.calls[1]![1].idempotencyKey);
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "issue.reassigned")))).toHaveLength(1);
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.targetId))).toHaveLength(1);
});
it.each(["blocked", "backlog"])("preserves %s and does not wake work prematurely", async status => {
const f = await fixture(status);
await f.authority.execute(f.call);
expect(await f.target()).toMatchObject({ assigneeAgentId: f.nextId, status });
expect(f.enqueueWakeup).not.toHaveBeenCalled();
});
it("replays across a replacement caller run without a second mutation", async () => {
const f = await fixture();
const receipt = await f.authority.execute(f.call);
const replacement = randomUUID();
await db.insert(heartbeatRuns).values({ id: replacement, companyId: f.companyId, agentId: f.agentId, nativeIssueId: f.issueId, runtimeMode: "native", status: "running", invocationSource: "assignment", triggerDetail: "system" });
await db.update(issues).set({ executionRunId: replacement }).where(eq(issues.id, f.issueId));
await expect(new PaperclipRunnerToolAuthority(db, { ...f.binding, runId: replacement }).execute(f.call)).resolves.toEqual(receipt);
expect((await f.target()).statusVersion).toBe(1);
});
it("repairs a dispatch failure by replaying the committed receipt", async () => {
const f = await fixture();
f.enqueueWakeup.mockRejectedValueOnce(new Error("queue unavailable"));
await expect(f.authority.execute(f.call)).rejects.toThrow("queue unavailable");
expect((await f.target()).assigneeAgentId).toBe(f.nextId);
await f.authority.execute(f.call);
expect(f.stopTaskForReassignment).toHaveBeenCalledTimes(1);
expect(f.enqueueWakeup).toHaveBeenCalledTimes(2);
});
it("rejects key reuse with different input and stale ownership/version", async () => {
const f = await fixture();
await f.authority.execute(f.call);
await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, reason: "Changed intent" } })).rejects.toThrow("idempotency_conflict");
await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, idempotencyKey: "stale" } })).rejects.toThrow("reassignment_conflict");
});
it("stops active execution before transferring ownership", async () => {
const f = await fixture("in_progress");
const oldRun = randomUUID();
await db.insert(heartbeatRuns).values({ id: oldRun, companyId: f.companyId, agentId: f.agentId, nativeIssueId: f.targetId, runtimeMode: "native", status: "running", invocationSource: "assignment", triggerDetail: "system" });
await db.update(issues).set({ executionRunId: oldRun, checkoutRunId: oldRun }).where(eq(issues.id, f.targetId));
f.stopTaskForReassignment.mockImplementationOnce(async () => {
expect((await f.target()).assigneeAgentId).toBe(f.agentId);
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, oldRun));
});
await f.authority.execute(f.call);
expect(await f.target()).toMatchObject({ assigneeAgentId: f.nextId, status: "todo", executionRunId: null, checkoutRunId: null });
});
it("does not transfer ownership if stop fails or a new run races the stop", async () => {
const f = await fixture();
f.stopTaskForReassignment.mockRejectedValueOnce(new Error("stop failed"));
await expect(f.authority.execute(f.call)).rejects.toThrow("stop failed");
expect((await f.target()).assigneeAgentId).toBe(f.agentId);
f.stopTaskForReassignment.mockImplementationOnce(async () => {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "running", invocationSource: "assignment", triggerDetail: "system" });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.targetId));
});
await expect(f.authority.execute(f.call)).rejects.toThrow("reassignment_conflict");
expect(f.enqueueWakeup).not.toHaveBeenCalled();
});
it.each(["done", "cancelled", "in_review"])("rejects %s before interrupting work", async status => {
const f = await fixture(status);
await expect(f.authority.execute(f.call)).rejects.toThrow("state_denied");
expect(f.stopTaskForReassignment).not.toHaveBeenCalled();
});
it("rejects conversations and the caller’s own task", async () => {
const f = await fixture();
await db.update(issues).set({ conversationAgentId: f.agentId, conversationUserId: "test-user", conversationState: "active", status: "in_progress" }).where(eq(issues.id, f.targetId));
await expect(f.authority.execute(f.call)).rejects.toThrow("conversation_locked");
await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, taskId: f.issueId } })).rejects.toThrow("current_task");
expect(f.stopTaskForReassignment).not.toHaveBeenCalled();
});
it.each(["planning", "ask"])("denies reassignment from %s mode", async workMode => {
const f = await fixture();
await db.update(issues).set({ workMode }).where(eq(issues.id, f.issueId));
await expect(f.authority.execute(f.call)).rejects.toThrow("mode_denied");
});
it("denies foreign targets and unavailable assignees", async () => {
const f = await fixture(), foreign = await fixture();
await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, taskId: foreign.targetId } })).rejects.toThrow("task_not_found");
await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, assigneeActorId: foreign.agentId } })).rejects.toThrow();
await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.nextId));
await expect(f.authority.execute(f.call)).rejects.toThrow();
expect(f.stopTaskForReassignment).not.toHaveBeenCalled();
});
it("rejects an ownership ABA race through the existing issue service", async () => {
const f = await fixture();
await issueService(db).update(f.targetId, { assigneeAgentId: f.nextId });
await issueService(db).update(f.targetId, { assigneeAgentId: f.agentId });
expect((await f.target()).statusVersion).toBe(2);
await expect(f.authority.execute(f.call)).rejects.toThrow("reassignment_conflict");
expect(f.stopTaskForReassignment).not.toHaveBeenCalled();
});
it("respects protected assignment policies instead of inheriting board authority", async () => {
const f = await fixture();
await db.update(agents).set({ permissions: { authorizationPolicy: { protectedAgent: { blockAssignment: true } } } }).where(eq(agents.id, f.nextId));
await expect(f.authority.execute(f.call)).rejects.toThrow();
expect((await f.target()).assigneeAgentId).toBe(f.agentId);
expect(f.stopTaskForReassignment).not.toHaveBeenCalled();
});
it("rejects writes after the caller loses its run binding", async () => {
const f = await fixture();
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
await expect(f.authority.execute(f.call)).rejects.toThrow("binding_not_authorized");
expect((await f.target()).assigneeAgentId).toBe(f.agentId);
});
});
+9 -4
View File
@@ -132,18 +132,23 @@ minutes, the cell to thirty minutes, and cleanup explicitly deletes the
sandbox rather than waiting for Daytona's idle timeout.
`agent-chat` (**Persistent Agent Chat**) adds six workflows on `legacy-codex`,
`legacy-claude`, `runner-codex`, and `runner-acpx-claude`: **24 local cells**.
`legacy-claude`, `runner-codex`, and `runner-acpx-claude`: **26 local cells**.
They cover continuity across server restart, fresh context after `/new`,
Stop/reset/resume, draft/revise/approve/plan handoff, clarification with existing
project reuse, and a new project with two repository URLs. Each cell opens the
production chat surface and resolves the backing issue through the chat API.
The source conversation must settle to `in_review` / `waiting`; handed-off
execution tasks must finish with their initial Plan and output documents.
Reset runs are retained separately from the 68 expected provider turns in this
Reset runs are retained separately from the 72 expected provider turns in this
suite. Cancelled turns and execution-task runs remain included in billing and
cleanup. The production chat directive is injected normally; fixtures do not
replace it with completion instructions. Daytona is excluded.
The native Codex and Claude profiles also cover reassignment of existing ready
and backlog tasks. The oracle verifies stable task IDs, preserved descriptions,
assignment audit evidence, exactly one successful successor run and its output
document, no backlog execution, and a usable source conversation after reload.
```bash
# Run these after deterministic checks, with the required provider keys set.
pnpm test:e2e:runner -- --id agent-chat.legacy-codex.local.continuity-restart
@@ -168,7 +173,7 @@ Both suites save and restore experimental settings. Browser E2E always starts a
throwaway instance; never point the authenticated suite at the running demo.
Missing provider credentials fail paid preflight and are not passing coverage.
The default `--all` selection is 167 cells (144 local and 23 Daytona) and 363
The default `--all` selection is 169 cells (146 local and 23 Daytona) and 367
expected paid agent turns. The explicit-only everyday suite adds 35 catalog cells
and is excluded from `--all`. Follow-up steps remain ordered within their cell; all other
cells are independent. Narrow selectors are strongly recommended while
@@ -455,7 +460,7 @@ Set `RUNNER_E2E_AWS_ENABLED=true` to route paid cells to the repository-scoped
ephemeral AWS RunsOn fleet selected by
`runs-on/fleet=paperclip-public-pr-x64/env=public-ci`. Any other value uses the
proven GitHub-hosted `ubuntu-latest` target. Set `RUNNER_E2E_MAX_PARALLEL` to an
integer from 1–100 on AWS (default 100). The 167-cell default selection takes more than
integer from 1–100 on AWS (default 100). The 169-cell default selection takes more than
one wave at that limit; use suite selectors for smaller campaigns. The fallback runner retains its 1–57 limit and
default of 32. Multi-turn steps are sequential inside their cell while
independent cells overlap. Artifacts and merged HTML/JUnit/normalized reports
+7 -7
View File
@@ -41,10 +41,10 @@ describe("runner E2E catalog", () => {
expect(localIntegrityTasks).toHaveLength(2);
expect(openRouterBreadthTasks).toHaveLength(3);
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
23, 38, 52, 24, 42, 14, 10, 2,
23, 38, 52, 26, 42, 14, 10, 2,
]);
expect(validateRunnerCatalog()).toHaveLength(205);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(205);
expect(validateRunnerCatalog()).toHaveLength(207);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(207);
expect(
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
).toHaveLength(42);
@@ -68,7 +68,7 @@ describe("runner E2E catalog", () => {
(total, execution) => total + execution.task.expectedRunCount,
0,
),
).toBe(363);
).toBe(367);
expect(
runnerTasks.find((task) => task.id === "plan-revise-accept")
?.attemptTimeoutMs,
@@ -561,10 +561,10 @@ describe("runner E2E selectors", () => {
const jobs = buildMatrixJobs(
selectRunnerExecutions(parseRunnerSelectors(["--all"])),
);
expect(jobs).toHaveLength(167);
expect(jobs).toHaveLength(169);
expect(jobs.filter((job) => job.needsDaytona)).toHaveLength(23);
expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(144);
expect(new Set(jobs.map((job) => job.executionId)).size).toBe(167);
expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(146);
expect(new Set(jobs.map((job) => job.executionId)).size).toBe(169);
expect(
jobs.find(
(job) =>
+3 -2
View File
@@ -929,8 +929,9 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
description: "Task-backed conversations, session resets, and project plan handoff.",
groups: ["chat"],
profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)),
environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 24,
definitionMetadata: { version: 1, resetRunsCountedSeparately: true },
environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 26,
excludedExecutionIds: ["legacy-codex", "legacy-claude"].map(profile => `agent-chat.${profile}.local.reassign-task`),
definitionMetadata: { version: 2, resetRunsCountedSeparately: true },
},
...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []),
{
+1
View File
@@ -10,6 +10,7 @@ export function chatMarker(
}
export const CHAT_CASES = [
["reassign-task", "Reassign existing work and preserve queued context", 2],
["continuity-restart", "Conversation continuity across restart", 3],
["new-session", "Fresh context within preserved history", 2],
["stop-new-resume", "Stop, reset, and resume", 3],
+26 -3
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import type { AskUserQuestionsPayload } from "../../packages/shared/src/types/issue.js";
import {
assertChatHandoff,
assertChatReassignment,
assertChatTaskHandoff,
chatQuestionPresentation,
chatRunFailure,
@@ -95,11 +96,11 @@ describe("chat acceptance contracts", () => {
);
expect(chatMarker("CHAT", "one-1")).not.toBe(chatMarker("CHAT", "two-1"));
});
it("has exactly six workflows on the four chosen local profiles", () => {
it("covers existing workflows and native reassignment on the chosen local profiles", () => {
const matrix = runnerMatrix.filter(
(cell) => cell.suite.id === "agent-chat",
);
expect(matrix).toHaveLength(24);
expect(matrix).toHaveLength(26);
expect(new Set(matrix.map((cell) => cell.profile.id))).toEqual(
new Set([
"legacy-codex",
@@ -108,7 +109,7 @@ describe("chat acceptance contracts", () => {
"runner-acpx-claude",
]),
);
expect(new Set(matrix.map((cell) => cell.task.id)).size).toBe(6);
expect(new Set(matrix.map((cell) => cell.task.id)).size).toBe(7);
expect(
matrix.every(
(cell) =>
@@ -363,3 +364,25 @@ describe("chat acceptance contracts", () => {
).toBe(false);
});
});
describe("reassignment outcome oracle", () => {
const evidence = () => ({ readyId: "ready", queuedId: "queued", teammateId: "riley",
tasks: [{ id: "ready", companyId: "co", title: "Ready", status: "done", assigneeAgentId: "riley" }, { id: "queued", companyId: "co", title: "Later", status: "backlog", assigneeAgentId: "riley" }],
runs: [{ id: "successor", companyId: "co", agentId: "riley", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "ready" } }],
audit: [{ action: "issue.reassigned", details: { source: "paperclip_runner_protocol" } }], outputBody: "Launch CHECK123", marker: "CHECK123",
});
it("accepts persisted ownership and exactly one successful successor", () => {
expect(() => assertChatReassignment(evidence())).not.toThrow();
});
it.each(["owner", "duplicate", "missing-run", "missing-audit", "backlog-started", "missing-output"])("rejects %s evidence", defect => {
const data = evidence();
if (defect === "owner") data.tasks[0]!.assigneeAgentId = "old";
if (defect === "duplicate") data.tasks.push({ ...data.tasks[0]!, id: "replacement" });
if (defect === "missing-run") data.runs = [];
if (defect === "missing-audit") data.audit = [];
if (defect === "backlog-started") data.runs.push({ ...data.runs[0]!, id: "early", contextSnapshot: { issueId: "queued" } });
if (defect === "missing-output") data.outputBody = "I reassigned it";
expect(() => assertChatReassignment(data)).toThrow();
});
});
+51
View File
@@ -249,12 +249,30 @@ export async function sendChatMessage(page: Page, message: string) {
await page.getByTestId("task-chat-composer-send").last().click();
}
/** Independent durable oracle: prose alone cannot make a reassignment pass. */
export function assertChatReassignment(input: {
readyId: string; queuedId: string; teammateId: string; tasks: ChatIssue[]; runs: ChatRun[];
audit: Array<{ action: string; details?: Record<string, unknown> }>; outputBody: string; marker: string;
}) {
expect(input.tasks.map(task => task.id).sort()).toEqual([input.readyId, input.queuedId].sort());
expect(input.tasks.find(task => task.id === input.readyId)).toMatchObject({ status: "done", assigneeAgentId: input.teammateId });
expect(input.tasks.find(task => task.id === input.queuedId)).toMatchObject({ status: "backlog", assigneeAgentId: input.teammateId });
const successor = input.runs.filter(run => run.contextSnapshot?.issueId === input.readyId);
expect(successor).toHaveLength(1);
expect(successor[0]).toMatchObject({ agentId: input.teammateId, status: "succeeded", runtimeMode: "native" });
expect(input.runs.filter(run => run.contextSnapshot?.issueId === input.queuedId)).toHaveLength(0);
expect(input.audit.filter(row => row.action === "issue.reassigned" && row.details?.source === "paperclip_runner_protocol")).toHaveLength(1);
expect(input.outputBody).toContain(input.marker);
}
export async function runChatFlow(input: {
page: Page;
api: RunnerApi;
fixtures: LiveFixtureValues;
execution: MatrixExecution;
nonce: string;
workspacePath: string;
restart: () => Promise<void>;
observe: (issue: ChatIssue, runs: ChatRun[]) => void;
capture: (id: string, label: string, file: string) => Promise<void>;
@@ -465,6 +483,39 @@ export async function runChatFlow(input: {
}
expect(issue!.id).toBe(initialId);
await noTasks();
} else if (caseId === "reassign-task") {
const config = execution.profile.buildAgent({
environmentId: f.environment.id, environmentFixtureId: execution.environment.id,
workspacePath: input.workspacePath, secretRefs: f.secretRefs, executionId: nonce,
});
const teammate = await api.post<{ id: string }>(`/api/companies/${f.company.id}/agents`, {
...config, name: "Riley Reassignment", role: "engineer", reportsTo: f.agent.id,
instructionsBundle: { entryFile: "AGENTS.md", files: { "AGENTS.md": "Complete the assigned work and save the requested Paperclip document." } },
});
const queued = await api.post<ChatIssue>(`/api/companies/${f.company.id}/issues`, {
title: `Later checklist ${nonce}`, description: `Preserve this deferred scope ${draftMarker}.`,
status: "backlog", assigneeAgentId: f.agent.id,
});
const ready = await api.post<ChatIssue>(`/api/companies/${f.company.id}/issues`, {
title: `Ready checklist ${nonce}`, status: "todo",
description: `Write a short launch checklist as a Paperclip document attached to this task, including ${marker}. Complete this task after saving it.`,
});
await turn(`Assign the existing Ready checklist ${nonce} task to Riley Reassignment so Riley completes it. Also move the existing Later checklist ${nonce} task from you to Riley, keeping it in backlog. Preserve both tasks and their descriptions. Explain the handoff briefly here. Do not create replacement tasks or start the backlog work.`, 2);
const observedTasks = await tasks();
const readyAfter = await api.get<ChatIssue>(`/api/issues/${ready.id}`);
const queuedAfter = await api.get<ChatIssue>(`/api/issues/${queued.id}`);
const output = await readChatOutputDocument(api, ready.id, marker);
const activity = await api.get<Array<{ action: string; details?: Record<string, unknown> }>>(`/api/issues/${ready.id}/activity`);
assertChatReassignment({ readyId: ready.id, queuedId: queued.id, teammateId: teammate.id, tasks: observedTasks, runs,
audit: activity, outputBody: output.body, marker });
expect(queuedAfter).toMatchObject({ assigneeAgentId: teammate.id, status: "backlog", description: `Preserve this deferred scope ${draftMarker}.` });
expect(readyAfter).toMatchObject({ assigneeAgentId: teammate.id, status: "done" });
expect(issue!).toMatchObject({ assigneeAgentId: f.agent.id, conversationState: "waiting" });
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 });
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
await expect(page.getByRole("link", { name: readyAfter.identifier! }).first()).toBeVisible();
await input.capture("chat-reassignment", "Reassignment completed in Agent Chat", "chat-reassignment.png");
await input.evidence("chat-reassignment.json", { tasks: observedTasks, runs, activity, output, teammateId: teammate.id });
} else {
let existingProject: { id: string; name: string } | undefined;
let acceptedPlan: Plan | undefined;
+1 -1
View File
@@ -814,7 +814,7 @@ for (const execution of executions) {
matcherResults = story.evidence.checks.map(check => ({ matcher: { kind: "json_path" as const, path: check.id, expected: true }, passed: check.passed, detail: check.detail }));
} else if (execution.task.flow === "agent_chat") {
const chat = await runChatFlow({
page, api, fixtures, execution, nonce,
page, api, fixtures, execution, nonce, workspacePath,
restart: () => restartIsolatedPaperclipServer({ api, requestId: `chat-${nonce}`, deadlineAt: startedAtMs + deadlineMs }),
observe: (chatIssue, chatRuns) => { issue = chatIssue; selectedRuns = chatRuns; },
capture: captureScreenshot,