mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Add guarded task reassignment to the native runner
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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] : []),
|
||||
{
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user