From 7bc03e0acd12142f31132365e13cc2eb6a6e9c73 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:49:18 -0500 Subject: [PATCH] feat(runner): default harnesses to full auto and support task reassignment (#13686) ## Thinking Path > - Paperclip lets people manage AI agents and their work. > - Agent Chat uses native runners to save plans and coordinate tasks. > - Provider defaults differed across harnesses and could stop unattended work at a second permission gate. > - Agents also lacked a dedicated tool to move existing work to another agent safely. > - This change defaults native providers to full automatic permission for provider tools and connected tools. > - A guarded reassignment tool preserves task identity, stops the previous run, and schedules the new owner once. > - Codex and Claude chat acceptance tests now use production permission defaults. ## Linked Issues or Issue Description **Subsystem affected** Native runner, ACPX Claude permission policy, task authority, and Agent Chat acceptance tests. **Problem or motivation** A user can authorize an agent to save a plan or create a task, but Claude's default provider gate can still stop that action. Reassignment needs a dedicated operation that preserves context and avoids concurrent owners or unintended recovery runs. **Proposed solution** Default Claude/ACPX to `approve-all`, OpenCode to `allow`, and Codex to `never`. Apply the defaults at configuration, execution, fresh-session, resume, driver, and proxy boundaries. Keep explicit permission settings and server-side company, claim, task-mode, and approval checks. Add `reassign_task` with version checks, durable idempotency, audited cancellation, and guarded successor scheduling. **Alternatives considered** A Paperclip-only allowlist still blocks provider tools and other connections during unattended work. Full automatic permission is the requested product default. Recreating a task discards its identity and history. Updating assignment without stopping the previous run can leave two agents working on the same task. **Roadmap alignment** This extends the existing planning, delegated work, governed tool access, and recovery features. It adds no new service or schema migration. Recent related tasks and open PRs were checked for duplicate work. **Additional context** Related: #13678 (Agent Chat tools and recovery), #13677 (remote runner startup). The stacked legacy-adapter companion is #13693. This also fixes the deployed-server artifact fallback needed to stage the current runner binary. ## What Changed - Default Claude/ACPX to `approve-all`, OpenCode to `allow`, and Codex to `never`, including missing settings at direct driver and proxy entry points. These defaults cover provider tools and connected tools. Preserve explicitly configured restrictive modes. - Include assigned approval reads using canonical side-effect classifications, so verifying a recorded approval does not trigger another provider gate. Paperclip approval decisions still enforce controller authority. - Carry the new permission mode through server configuration, execution contracts, recovery identity, TypeScript, and Rust. Keep `approve-paperclip` as an optional restricted mode, with exact SDK rules and closed unknown requests. It is not a default. - Add `reassign_task` to the semantic catalog, controller, mock authority, and generated contracts. - Guard reassignment with company authorization, expected owner and version, protected-state checks, and durable retry receipts. - Honor explicit backlog task creation atomically with the initial plan, without scheduling a wake. Preserve backlog holds regardless of dependency readiness. - Stop active work before changing ownership. Restore the prior owner through a guarded, idempotent wake if final handoff validation fails. Keep intentional reassignment stops out of failure recovery. Preserve backlog and blocked states without waking them early. - Add authorization, concurrency, replay, stop, and permission boundary regressions. Add Codex and Claude chat reassignment cases and run native chat cases with production defaults. - Clarify shared runner guidance: save plans and Paperclip documents directly with `write_document`; create and register a local file only when a downloadable file is requested. - Document provider defaults and the operator choices for existing agents. ## Verification - Current head `d82fbb0f03546d27cecf072250e4172e0b1ee662`: **55 checks passed**, with two intentional skips. [PR checks](https://github.com/paperclipai/paperclip/pull/13686/checks). - Greptile reviewed that exact head at **5/5**. The security reviewer acknowledged the intended full-auto default, and the acknowledged discussions are resolved. - Full workspace `pnpm -r typecheck` and `pnpm build` passed locally after rebasing onto current master. Targeted adapter/server, runner, API, default/resume, and heartbeat configuration tests passed. - **All six real-provider acceptance cases passed on their first attempt, with cleanup passing:** plan handoff, task reassignment, and backlog creation/status, each on native Claude and Codex. Evidence records Claude's effective `approve-all` mode. [Campaign and downloadable evidence](https://github.com/paperclipai/paperclip/actions/runs/35469926548). - The live campaign tested combined revision `a37881c824dcd7170380fc4b788732fc743e5da7`. The final PR heads add only a heartbeat test expectation correction; application code is unchanged from that live-tested revision. - The campaign's result-enforcement job passed. Its separate report publisher failed because the trusted workflow's `patchedDependencies` configuration differs from its frozen lockfile. All six results and screenshots remain available as GitHub artifacts. The overall manual workflow is red for this publishing failure. - Full-suite coverage is supplied by the passing CI partitions. The separate unsharded local run was stopped after the corresponding CI partitions passed; it is not counted as a completed local run. - Reassignment tests cover stale state, cross-company access, denied authority, cancellation failure, compensating wake, and idempotent retries. Backlog tests verify the original creation audit, saved plan, exact task count, and absence of task-bound runs. ## Risks - Agents with no explicit permission mode now receive full provider tool permission, including connected tools. This is a deliberate broad default. Existing explicit restrictive modes still apply. Controller authorization, company isolation, workspace boundaries, and Paperclip governance remain in force. - Reassignment crosses run cancellation and task ownership transactions. Durable stop intent, revalidation, audit receipts, and guarded queue dispatch cover interruptions and retries. - The new permission enum requires a current runner artifact. The remote artifact fallback uses the same resolved controller binary for upload and execution. - Live provider behavior remains subject to the selected model. Targeted live results do not qualify the full catalog. ## Model Used OpenAI Codex, based on GPT-6, with code execution and repository tools. The exact deployment model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DEVELOPING.md | 14 +- .../src/paperclip-runner-permissions.test.ts | 19 +- .../src/paperclip-runner-permissions.ts | 12 +- .../codex-local/src/ui/build-config.test.ts | 2 +- packages/paperclip-runner/README.md | 23 +- .../paperclip-runner/docs/adding-a-harness.md | 2 +- .../docs/capability-semantic-tools.md | 29 +- .../capability/semantic-tool-contracts.json | 2 +- .../generated/semantic-action-catalog.json | 121 +++++++- .../evals/native-execution-seeded.json | 2 +- .../paperclip-runner/protocol/manifest.json | 2 +- .../runner-core/src/acpx_provider_session.rs | 26 ++ .../spec/capability/protocol-coverage.json | 22 +- .../spec/operation-groups/source.json | 8 +- .../spec/paperclip-agent-operation-groups.md | 15 +- .../src/backends/runtime-context.test.ts | 4 +- .../src/backends/runtime-context.ts | 1 + .../src/catalog/catalog-docs.test.ts | 2 +- .../src/catalog/reconciliation.test.ts | 14 +- .../catalog/semantic-action-catalog.test.ts | 2 +- .../src/catalog/semantic-action-catalog.ts | 7 +- .../src/catalog/semantic-action-types.ts | 1 + .../src/cli/acpx-runtime-sidecar.ts | 3 +- .../opencode-proxy-permission-mode.test.ts | 6 +- .../src/cli/opencode-proxy-permission-mode.ts | 2 +- .../src/contracts/harness-driver.ts | 2 +- .../src/contracts/native-execution.ts | 6 +- .../drivers/acpx/codex-acpx-driver.test.ts | 11 + .../src/drivers/acpx/codex-acpx-driver.ts | 4 +- .../acpx/codex-runtime-adapter.test.ts | 7 +- .../src/drivers/acpx/codex-runtime-adapter.ts | 6 +- .../src/drivers/acpx/driver-profile.ts | 3 +- .../drivers/acpx/permission-policy.test.ts | 36 ++- .../src/drivers/acpx/permission-policy.ts | 38 ++- .../src/drivers/acpx/recovery-identity.ts | 2 +- .../src/drivers/acpx/runtime-host.test.ts | 14 +- .../src/drivers/acpx/runtime-sandbox.ts | 6 +- .../codex/codex-app-server-driver-impl.ts | 6 +- ...-app-server-driver.context-control.test.ts | 4 +- .../codex/codex-app-server-driver.test.ts | 4 +- .../src/drivers/codex/codex-driver-values.ts | 1 + .../src/eval/workflow-evals.test.ts | 6 +- .../src/issue-thread/live-projection.ts | 2 +- .../src/live/runnerd-codex-transport.test.ts | 6 +- .../src/live/runnerd-codex-transport.ts | 4 +- .../capability-control-plane-types.ts | 6 +- ...ability-mock-control-plane-adapter.test.ts | 19 +- .../capability-mock-control-plane-adapter.ts | 30 +- .../mock-core/task-creation-backlog.test.ts | 20 ++ .../src/mock-core/task-reassignment.test.ts | 45 +++ .../src/protocol-actions/create-task.ts | 12 +- .../src/protocol-actions/index.ts | 2 + .../src/protocol-actions/reassign-task.ts | 284 ++++++++++++++++++ .../src/semantic-tools/discovery.ts | 2 +- .../src/semantic-tools/dispatcher.ts | 6 +- .../src/semantic-tools/paperclip-discovery.ts | 2 +- .../src/semantic-tools/semantic-tools.test.ts | 2 +- .../src/semantic-tools/types.ts | 1 + .../tools/capability-semantic-tool-runtime.ts | 3 + server/src/__tests__/adapter-routes.test.ts | 4 +- .../wake-queue/adapters/postgres.test.ts | 24 ++ .../modules/wake-queue/adapters/postgres.ts | 9 +- .../services/acknowledged-native-stop.test.ts | 15 +- .../src/services/acknowledged-native-stop.ts | 21 +- .../heartbeat-runner-provider-config.test.ts | 4 +- server/src/services/heartbeat.ts | 28 +- server/src/services/issues.ts | 6 + .../native-execution-input.test.ts | 8 +- .../native-runtime/native-execution-input.ts | 4 +- .../native-session-executor.test.ts | 13 +- .../native-runtime/native-session-executor.ts | 16 +- .../native-session-resume.test.ts | 4 +- .../paperclip-runner-tool-authority.test.ts | 2 +- .../paperclip-runner-tool-authority.ts | 183 ++++++++++- .../native-runtime/provider-profile.ts | 4 +- .../native-runtime/runner-api-catalog.ts | 2 +- .../task-creation-backlog.test.ts | 68 +++++ .../native-runtime/task-reassignment.test.ts | 203 +++++++++++++ tests/runner-e2e/README.md | 17 +- tests/runner-e2e/catalog.test.ts | 30 +- tests/runner-e2e/catalog.ts | 20 +- tests/runner-e2e/chat-cases.ts | 2 + tests/runner-e2e/chat-flow.test.ts | 57 +++- tests/runner-e2e/chat-flow.ts | 94 ++++++ tests/runner-e2e/runner.spec.ts | 2 +- 85 files changed, 1605 insertions(+), 178 deletions(-) create mode 100644 packages/paperclip-runner/src/mock-core/task-creation-backlog.test.ts create mode 100644 packages/paperclip-runner/src/mock-core/task-reassignment.test.ts create mode 100644 packages/paperclip-runner/src/protocol-actions/reassign-task.ts create mode 100644 server/src/services/native-runtime/task-creation-backlog.test.ts create mode 100644 server/src/services/native-runtime/task-reassignment.test.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index ec09bf74eb1..dd0698608c9 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1021,8 +1021,9 @@ cancellation remain enforced; the snapshot is removed when the provider exits. Native Codex is qualified only with `codexPermissionMode: "never"`. The create and edit surfaces do not offer `on-request` or `untrusted`, and a persisted unsupported value fails with remediation instead of being silently coerced. -OpenCode retains `allow`, `ask`, and `deny`; ACPX retains `approve-all`, -`approve-reads`, and `deny-all`. Codex conversion keeps a non-empty model and +OpenCode defaults to `allow`, with explicit `ask` and `deny` options; ACPX +defaults to `approve-all`, with explicit `approve-paperclip`, `approve-reads`, +and `deny-all` options. Codex conversion keeps a non-empty model and otherwise stores the shared `gpt-5.6-sol` default. The native execution boundary applies the same default to older runner rows whose model is missing or blank. @@ -1030,10 +1031,11 @@ For an Agent Chat test drive, enable **Agent Chat** in Experimental settings and configure two agents with Paperclip Runner: native Codex and ACPX Claude. Connect the Claude account through the agent's **AI connection** section (or supply an explicit supported provider credential); an ambient Claude CLI login alone is -not a credential source for its isolated runner home. The conservative ACPX -permission mode fails closed when no coordinator decision is available, including -for MCP tools. Use `approve-all` only for agents authorized to perform the test's -tool actions; the server still enforces company and action permissions. +not a credential source for its isolated runner home. The default +`approve-all` setting approves harness operations across assigned tools and +connections, including provider-native tools. Company permissions, approval +gates, and workspace isolation still apply. Explicit restrictive modes remain +restrictive; omitted settings use full auto. Test questions, saved plan revisions, approval before task handoff, status lookups, and `/new` preserving chat history. Hiring additionally requires the diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts index e23b957a577..077f5336436 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.test.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { PAPERCLIP_RUNNER_DEFAULT_MODELS, + paperclipRunnerTransitionConfig, isPaperclipRunnerProvider, resolvePaperclipRunnerModel, resolvePaperclipRunnerPermissionMode, @@ -16,15 +17,27 @@ describe("Paperclip Runner permission defaults", () => { expect(resolvePaperclipRunnerPermissionMode("codex", "untrusted")).toBe("never"); }); - it("uses interactive defaults for dormant non-Codex providers", () => { + it("defaults Claude and OpenCode to full auto", () => { expect(resolvePaperclipRunnerPermissionMode("opencode", undefined)).toBe( - "ask", + "allow", ); expect(resolvePaperclipRunnerPermissionMode("acpx", undefined)).toBe( - "approve-reads", + "approve-all", ); }); + it.each(["approve-paperclip", "approve-reads", "deny-all", "approve-all"])("preserves explicit Claude %s settings", (mode) => { + expect(resolvePaperclipRunnerPermissionMode("acpx", mode)).toBe(mode); + }); + + it.each([ + ["claude_local", "acpxPermissionMode", "approve-all"], + ["codex_local", "codexPermissionMode", "never"], + ["opencode_local", "opencodePermissionMode", "allow"], + ])("uses full auto when converting %s to the new runner", (adapter, key, value) => { + expect(paperclipRunnerTransitionConfig(adapter, undefined)).toMatchObject({ [key]: value }); + }); + it("recognizes only exact provider identifiers", () => { expect(isPaperclipRunnerProvider("codex")).toBe(true); expect(isPaperclipRunnerProvider("opencode")).toBe(true); diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index 1824c822e1d..ed9b2dee71e 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -3,7 +3,7 @@ export type PaperclipRunnerProvider = export type CodexPermissionMode = "never" | "on-request" | "untrusted"; export type OpenCodePermissionMode = "allow" | "ask" | "deny"; -export type AcpxPermissionMode = "approve-all" | "approve-reads" | "deny-all"; +export type AcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; export type PaperclipRunnerPermissionMode = CodexPermissionMode | OpenCodePermissionMode | AcpxPermissionMode; @@ -68,7 +68,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = { opencode: { configurable: true, configKey: "opencodePermissionMode", - defaultMode: "ask", + defaultMode: "allow", description: "Controls OpenCode tool permissions inside the assigned Paperclip environment.", options: [ @@ -106,7 +106,7 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = { acpx: { configurable: true, configKey: "acpxPermissionMode", - defaultMode: "approve-reads", + defaultMode: "approve-all", description: "Controls ACPX agent operations inside the assigned Paperclip environment.", options: [ @@ -115,6 +115,12 @@ export const PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES = { label: "Full auto (approve all)", description: "Approve ACPX operations without approval pauses.", }, + { + value: "approve-paperclip", + label: "Automatic Paperclip actions", + description: + "Automatically run assigned Paperclip planning and task tools, including reassignment. Company permissions and approval requirements still apply. Other operations require permission.", + }, { value: "approve-reads", label: "Allow Paperclip reads", diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index bcbdc47935f..d8736bd94d4 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -164,7 +164,7 @@ describe("buildPaperclipRunnerConfig", () => { model: "openrouter/deepseek/deepseek-v4-flash-0731", opencodePermissionMode: "allow", codexPermissionMode: "never", - acpxPermissionMode: "approve-reads", + acpxPermissionMode: "approve-all", }); }); diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index fddbae5fb60..3c37296bfef 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -69,14 +69,19 @@ alias during selection and verification. Users can keep selecting models from the normal Claude catalog or entering custom IDs; unavailable models still fail at the provider rather than silently falling back. -For ACPX Claude, `approve-reads` is shown as **Allow Paperclip reads**. The host -intersects the run's public tools with the implementation catalog's read effects -and writes exact MCP permission rules into the isolated Claude settings. The -`paperclip` connection is always the runner's authenticated tool bridge; ambient -MCP configuration is excluded. Tool hints and provider permission metadata cannot -grant access. Unassigned tools, writes, external tools, and provider-native -operations do not receive automatic read permission. Protocol completion and -task-delivery controls keep their existing separate allowance. +ACPX Claude defaults to `approve-all`, shown as **Full auto (approve all)**. +OpenCode defaults to `allow`; native Codex defaults to `never` (no approval +pauses). These defaults cover all assigned tools and connections, including +provider-native operations. Full auto is resolved consistently for agent +creation, adapter conversion, direct driver launches, and fresh/resumed turns. +Explicitly stored restrictive modes still apply. + +The runner's authenticated bridge and controller still enforce company access, +action claims, task modes, and governed approvals. Provider permission defaults +do not change workspace isolation or grant credentials or connection access. +`approve-paperclip` remains an optional narrower mode for assigned planning and +task tools; `approve-reads` allows assigned reads; `deny-all` rejects requests. +None of these restrictive modes is the default. This runtime has no interactive permission handler. An operation that still requires approval stops the turn with `approval_required`. The server marks the @@ -408,3 +413,5 @@ then open the protocol inspector to review events and reducer state. Expand a Terminal row and its nested **Debug details** disclosure to inspect every canonical event retained for that command. The header marker `🖇️ v0.1.2` identifies the current console iteration. + +`create_task` accepts an optional initial `status` of `backlog` or `todo`. Use `backlog` when the user wants a saved task or plan without execution: assignment and the initial plan are committed without scheduling a wake, even when dependencies are already complete. Omitting status preserves immediate delegation (`todo`, or `blocked` for unresolved dependencies). diff --git a/packages/paperclip-runner/docs/adding-a-harness.md b/packages/paperclip-runner/docs/adding-a-harness.md index d4216bcf7f3..1b5251f188b 100644 --- a/packages/paperclip-runner/docs/adding-a-harness.md +++ b/packages/paperclip-runner/docs/adding-a-harness.md @@ -11,7 +11,7 @@ identity, isolation behavior, and conformance coverage are defined. |---|---|---|---| | Codex | `codexPermissionMode` | `never`, `on-request`, `untrusted` | `never` | | OpenCode | `opencodePermissionMode` | `allow`, `ask`, `deny` | `allow` | -| ACPX (Claude, Codex) | `acpxPermissionMode` | `approve-all`, `approve-reads`, `deny-all` | `approve-reads` | +| ACPX (Claude, Codex) | `acpxPermissionMode` | `approve-all`, `approve-paperclip`, `approve-reads`, `deny-all` | `approve-all` | The browser-safe source of truth for labels, defaults, and configuration validation is `PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES` in diff --git a/packages/paperclip-runner/docs/capability-semantic-tools.md b/packages/paperclip-runner/docs/capability-semantic-tools.md index dff75b3b022..a09753d159d 100644 --- a/packages/paperclip-runner/docs/capability-semantic-tools.md +++ b/packages/paperclip-runner/docs/capability-semantic-tools.md @@ -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. `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,26 @@ 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. An audited reassignment stop does not create a recovery blocker or +retry the outgoing run when it ends without a semantic completion result. +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. diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json index 4c4c24b0180..98f162d65b6 100644 --- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json +++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json @@ -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","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","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":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never infer answers, answer your own card, or treat clarification as approval. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or substitute call_api. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","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":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","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":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_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"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_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"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","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":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","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":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","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":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_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"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","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":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","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":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","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":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","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":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}}] +[{"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","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","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":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never infer answers, answer your own card, or treat clarification as approval. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or substitute call_api. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","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":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","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":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_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"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_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"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","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":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","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":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","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":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_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"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","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":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","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":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","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":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","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":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"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.","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"},"name":"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"}}] diff --git a/packages/paperclip-runner/generated/semantic-action-catalog.json b/packages/paperclip-runner/generated/semantic-action-catalog.json index 90644e83e5e..d331990c2bf 100644 --- a/packages/paperclip-runner/generated/semantic-action-catalog.json +++ b/packages/paperclip-runner/generated/semantic-action-catalog.json @@ -1522,6 +1522,118 @@ "title": "Control workspace service", "version": 1 }, + { + "allowedModes": [ + "standard" + ], + "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", @@ -1861,7 +1973,7 @@ "standard", "skill_test" ], - "description": "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution.", + "description": "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.", "effect": "write", "inputSchema": { "additionalProperties": false, @@ -1922,6 +2034,13 @@ "null" ] }, + "status": { + "description": "Initial status. Use backlog to save work without execution. Defaults to todo (blocked when dependencies are unresolved).", + "enum": [ + "backlog", + "todo" + ] + }, "title": { "description": "Task title.", "maxLength": 500, diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json index 4092923f2b9..bc13d0f9247 100644 --- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json +++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json @@ -24,7 +24,7 @@ "prpVersion": 1, "nativeExecutionVersion": 1, "catalogVersion": 1, - "catalogSha256": "sha256:cf243ac6e38f931e556db65b7593f7ed58ffb4d0a2543c90219b0cc723840196", + "catalogSha256": "sha256:3e8ece5670302fc3dac8782793f59f98f4992d78542565caae1ccb2a37cc0988", "driverContractVersion": 1, "driverKind": "paperclip-deterministic", "driverVersion": "1.0.0" diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index fc4661a127a..3c0ced189d1 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -160,7 +160,7 @@ }, { "path": "fixtures/evals/native-execution-seeded.json", - "sha256": "8a86a398f09ed822e7fd9acbbcd08a007921562ad21df4306bb47c45e32d0b60", + "sha256": "09e442ca5f4adb9237d765601d8341f53b43607532390704cacd7c4167eb4e02", "expectation": "accept", "compatibilityCase": "canonical" }, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index 4bc3d2d3ed8..09038b02424 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -29,6 +29,7 @@ const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991; #[serde(rename_all = "kebab-case")] pub enum AcpxPermissionMode { ApproveAll, + ApprovePaperclip, ApproveReads, DenyAll, } @@ -1306,3 +1307,28 @@ mod tests { assert!(sidecar_tools[0].get("operationId").is_none()); } } + +#[cfg(test)] +mod permission_mode_tests { + use super::AcpxPermissionMode; + + #[test] + fn paperclip_permission_mode_round_trips_without_widening_legacy_modes() { + for (name, mode) in [ + ("approve-paperclip", AcpxPermissionMode::ApprovePaperclip), + ("approve-reads", AcpxPermissionMode::ApproveReads), + ("approve-all", AcpxPermissionMode::ApproveAll), + ("deny-all", AcpxPermissionMode::DenyAll), + ] { + let value = serde_json::json!(name); + assert_eq!( + serde_json::from_value::(value.clone()).unwrap(), + mode + ); + assert_eq!(serde_json::to_value(mode).unwrap(), value); + } + assert!( + serde_json::from_value::(serde_json::json!("unknown")).is_err() + ); + } +} diff --git a/packages/paperclip-runner/spec/capability/protocol-coverage.json b/packages/paperclip-runner/spec/capability/protocol-coverage.json index b3de1393ab4..7fd0fff92c9 100644 --- a/packages/paperclip-runner/spec/capability/protocol-coverage.json +++ b/packages/paperclip-runner/spec/capability/protocol-coverage.json @@ -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", diff --git a/packages/paperclip-runner/spec/operation-groups/source.json b/packages/paperclip-runner/spec/operation-groups/source.json index f5aa9d84aea..ea704ff57c7 100644 --- a/packages/paperclip-runner/spec/operation-groups/source.json +++ b/packages/paperclip-runner/spec/operation-groups/source.json @@ -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." diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md index 8e0670ab172..0432fdb02b4 100644 --- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md +++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md @@ -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`
`list_agents`
`get_agent`
`list_projects`
`list_goals` | `discovery:agents:read`
`discovery:goals:read`
`discovery:projects:read`
`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. | | `projects` | `create_project`
`list_project_repositories` | none | Project creation and authorized repository discovery through the live company/run authority. | -| `delegation_dependencies` | `create_task`
`set_dependencies` | `delegation:tasks:create`
`dependencies:write` | Create delegated work and maintain dependency edges. | +| `delegation_dependencies` | `create_task`
`reassign_task`
`set_dependencies` | `delegation:tasks:assign`
`delegation:tasks:create`
`dependencies:write` | Create or reassign delegated work and maintain dependency edges. | | `governance` | `list_approvals`
`get_approval`
`get_approval_context`
`request_approval`
`decide_approval`
`comment_on_approval` | `governance:approvals:comment`
`governance:approvals:decide`
`governance:approvals:read`
`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. | | `cases` | `list_cases`
`upsert_case` | `cases:read`
`cases:write` | Read and update case summaries without reusing issue-document authority. | | `workspace_runtime` | `get_workspace_runtime`
`control_workspace_service` | `workspace:control`
`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`
`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` | | `read_document` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`
`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`
`scenario_mock` | `unbound`
redacted tool-result item event; secret value never reaches the wire
catalog PRP status: `audit_pending` | +| `reassign_task` | `optional_agent_tool` | `delegation:tasks:assign` | `standard` | `company_write` | `required` | no | `semantic_command:reassign_task` | `scenario` + `live`
`live_codex` | `PaperclipRunnerToolAuthority.reassign_task`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `bound` | | `register_deliverable` | `always_agent_tool` | none | `standard`
`planning`
`skill_test` | `task_write` | `required` | no | `semantic_command:register_deliverable` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` | | `report_progress` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` | | `request_approval` | `optional_agent_tool` | `governance:approvals:request` | `standard`
`skill_test` | `governance` | `required` | no | `semantic_command:request_approval` | `scenario` + `live`
`live_codex` | `unbound`
approval lifecycle plus governed-wait continuation and audit events
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`
`finish_task`
`block_task`
`request_review` | `reconcile_run`
`append_audit_record` | issue PATCH, review/liveness policy, and native finalization arbitration | `task`
`comments`
`interactions`
`blockers`
`audit`
`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`
`report_progress` | `append_audit_record` | issue comment list/get/create routes | `task`
`comments`
`actor`
`idempotency`
`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`
`list_agents`
`get_agent`
`list_projects`
`list_goals`
`list_project_repositories` | none | company issue search and agent/project/goal list/get routes | `company`
`task`
`actor`
`project`
`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`
`task`
`actor`
`blockers`
`wake`
`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`
`reassign_task` | `route_wake` | company issue create, child issue, guarded reassignment, and wake services | `company`
`task`
`actor`
`blockers`
`wake`
`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`
`set_dependencies` | `schedule_blocker_wake`
`route_wake` | issue relations, blocker projection, liveness validation, and blocker wake services | `task`
`blockers`
`wake`
`actor`
`audit`
`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`
`read_document`
`list_document_revisions`
`write_document` | `append_audit_record` | issue document list/read/upsert/revision/restore/lock/unlock/delete routes | `task`
`documents`
`interactions`
`idempotency`
`audit`
`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`
`documents`
`interactions`
`wake`
`actor`
`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. diff --git a/packages/paperclip-runner/src/backends/runtime-context.test.ts b/packages/paperclip-runner/src/backends/runtime-context.test.ts index 5d85e16278d..ced708bea54 100644 --- a/packages/paperclip-runner/src/backends/runtime-context.test.ts +++ b/packages/paperclip-runner/src/backends/runtime-context.test.ts @@ -69,8 +69,10 @@ describe("native runtime context files", () => { ); }); - it("requires requested file deliverables before completion in ordinary native tasks", () => { + it("distinguishes durable Paperclip documents from requested file deliverables", () => { const constraints = nativeTaskConstraints(runtimeInput("/bundle", "AGENTS.md")).join("\n"); + expect(constraints).toContain("Paperclip documents directly with write_document"); + expect(constraints).toContain("unless the user also requests a downloadable file"); expect(constraints).toContain("register_deliverable"); expect(constraints).toContain("deliverable:"); expect(constraints).toContain("download link"); diff --git a/packages/paperclip-runner/src/backends/runtime-context.ts b/packages/paperclip-runner/src/backends/runtime-context.ts index ef72b1c4f07..0baee46cacc 100644 --- a/packages/paperclip-runner/src/backends/runtime-context.ts +++ b/packages/paperclip-runner/src/backends/runtime-context.ts @@ -104,6 +104,7 @@ export function nativeTaskConstraints(input: NativeExecutionInput): string[] { return [ "Use only the assigned skills and provider-native tools.", "Use Paperclip semantic tools for coordination and finalization.", + "Save requested plans and Paperclip documents directly with write_document. A saved Paperclip document is already a durable deliverable. Do not create a local file, compute file hashes, or call register_deliverable for it unless the user also requests a downloadable file. Cite the saved document in your completion evidence and final response.", "When the requested result is a file, use register_deliverable before paperclip_finish. Compute its exact byte size and SHA-256, register the workspace-relative file, cite deliverable: from the receipt as completion evidence, and include /api/attachments//content as the download link in your answer. A bare workspace filename is not a delivered result. For repository edits, cite an accessible PR or registered work product. Preserve existing work; do not upload unrelated files. If file publication fails, fix it or report the concrete blocker instead of claiming the file is delivered.", ...(answeredQuestionConstraint ? [answeredQuestionConstraint] : []), finalResponseConstraint, diff --git a/packages/paperclip-runner/src/catalog/catalog-docs.test.ts b/packages/paperclip-runner/src/catalog/catalog-docs.test.ts index 56f2e1a0415..055503d1b7a 100644 --- a/packages/paperclip-runner/src/catalog/catalog-docs.test.ts +++ b/packages/paperclip-runner/src/catalog/catalog-docs.test.ts @@ -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", () => { diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts index 12f46328e08..b339834422a 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts @@ -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, }); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts index 40831bc0e85..f50e99e98e2 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts @@ -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); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts index eeeb4e8842b..d5f19d5151c 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts @@ -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", @@ -468,7 +472,7 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [ descriptor({ operationId: "create_task", title: "Create task", - description: "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution.", + description: "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.", placement: "optional", effect: "write", requiredClaims: ["delegation:tasks:create"], @@ -481,6 +485,7 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [ initialPlan: nullableText("Remaining execution steps to persist as the task plan. Exclude completed planning, approval, and handoff steps; cite the source plan revision and approval. A copied plan is not a new approval gate."), description: nullableText("Child task description."), assigneeActorId: nullableText("Optional actor assignee.", 200), + status: { enum: ["backlog", "todo"], description: "Initial status. Use backlog to save work without execution. Defaults to todo (blocked when dependencies are unresolved)." }, priority: { enum: ["critical", "high", "medium", "low"] }, blockedByTaskIds: stringArray("Initial blocker task identifiers."), }, diff --git a/packages/paperclip-runner/src/catalog/semantic-action-types.ts b/packages/paperclip-runner/src/catalog/semantic-action-types.ts index 7be978cb978..1dbf220cc81 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-types.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-types.ts @@ -22,6 +22,7 @@ export type PaperclipSemanticActionId = | "get_approval_context" | "get_workspace_runtime" | "control_workspace_service" + | "reassign_task" | "set_dependencies" | "create_skill" | "create_project" diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 0086211758f..bf64c5c53c8 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1133,13 +1133,14 @@ function requiredPermissionMode( ): AcpxSidecarOpenParams["permissionMode"] { if ( value === "approve-all" || + value === "approve-paperclip" || value === "approve-reads" || value === "deny-all" ) { return value; } throw new Error( - "permissionMode must be approve-all, approve-reads, or deny-all", + "permissionMode must be approve-all, approve-paperclip, approve-reads, or deny-all", ); } diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.test.ts b/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.test.ts index 3a0bc988aa6..909009e7a4f 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.test.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.test.ts @@ -10,9 +10,9 @@ describe("OpenCode runnerd proxy permission mode", () => { }, ); - it("defaults an unset or empty mode to ask", () => { - expect(parseOpenCodeProxyPermissionMode(undefined)).toBe("ask"); - expect(parseOpenCodeProxyPermissionMode(" ")).toBe("ask"); + it("defaults an unset or empty mode to allow", () => { + expect(parseOpenCodeProxyPermissionMode(undefined)).toBe("allow"); + expect(parseOpenCodeProxyPermissionMode(" ")).toBe("allow"); }); it("rejects an unknown mode", () => { diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.ts b/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.ts index 16048074c75..5a704a8c7c8 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-permission-mode.ts @@ -4,7 +4,7 @@ export function parseOpenCodeProxyPermissionMode( value: string | undefined, ): OpenCodeProxyPermissionMode { const configured = value?.trim(); - if (!configured) return "ask"; + if (!configured) return "allow"; if ( configured === "allow" || configured === "ask" diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index 8caa64fcbdc..3400b419c9c 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -453,7 +453,7 @@ export interface AcpxSessionIdentity { requestedModel: string; effectiveModel: string; /** Missing on legacy snapshots; those used the historical approve-reads behavior. */ - permissionMode?: "approve-all" | "approve-reads" | "deny-all"; + permissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; providerLifetimeFenceCandidates: readonly [number, number, number]; } diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index b18d1f41542..b5a31b85f7f 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -80,7 +80,7 @@ export interface NativeAwsAgentCoreProfileSnapshot { export type NativeAcpxAgent = "pi" | "claude" | "codex"; export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted"; export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny"; -export type NativeAcpxPermissionMode = "approve-all" | "approve-reads" | "deny-all"; +export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; export interface NativeAcpxProfileSnapshot { driverKind: "acpx_runtime"; @@ -534,8 +534,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput throw new NativeExecutionInputError("input.provider.agent must be pi, claude, or codex"); } if (isV4) { - if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") { - throw new NativeExecutionInputError("input.provider.permissionMode must be approve-all, approve-reads, or deny-all"); + if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-paperclip" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") { + throw new NativeExecutionInputError("input.provider.permissionMode must be approve-all, approve-paperclip, approve-reads, or deny-all"); } } else if (provider.permissionPolicy !== "interactive") { throw new NativeExecutionInputError("input.provider.permissionPolicy must be interactive"); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index 989981713f3..077249018b9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -21,6 +21,17 @@ import type { import type { AcpxRecoveryWorkspaceLease } from "./runtime-sandbox.js"; describe("Codex ACPX harness driver", () => { + it.each([ + ["claude", "claude-sonnet-5"], ["codex", "gpt-5.6-sol"], + ] as const)("launches %s in full auto when no mode is supplied", async (agent, model) => { + const fixture = driverFixture({ agent, model, permissionMode: undefined }); + const session = await fixture.driver.openSession({ + runId: "run-default-permissions", normalizedSessionId: "session-1", workingDirectory: "/workspace", + }); + expect(fixture.hostOptions?.permissionMode).toBe("approve-all"); + await session.close({ reason: "default permission verified" }); + }); + it("rejects a pre-aborted open before starting host admission", async () => { const fixture = driverFixture(); const controller = new AbortController(); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index dee21eb6f9f..6334a91fc6b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -435,7 +435,7 @@ export class CodexAcpxDriver implements HarnessDriver { workingDirectory: input.workingDirectory, agent: this.#options.agent ?? "codex", model: this.#options.model, - permissionMode: this.#options.permissionMode ?? "approve-reads", + permissionMode: this.#options.permissionMode ?? "approve-all", systemInstructions: this.#options.systemInstructions, environment: this.#options.environment, managedCodexCredentialSourcePath: @@ -1903,7 +1903,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { !/^sha256:[a-f0-9]{64}$/.test(identity.profileDigest) || !/^sha256:[a-f0-9]{64}$/.test(identity.workspaceDigest) || (identity.permissionMode !== undefined && - !["approve-all", "approve-reads", "deny-all"].includes( + !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( identity.permissionMode, )) || !validProviderLifetimeFenceCandidates( diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index f9bd7620050..9d53e2cab1b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -28,7 +28,7 @@ const HANDLE: AcpRuntimeHandle = { }; describe("Codex ACPX runtime adapter", () => { - it("stops a turn with an actionable error when approval has no handler", async () => { + it.each(["approve-reads", "approve-paperclip"] as const)("%s stops unassigned operations when approval has no handler", async (permissionMode) => { const runtime = fakeRuntime(); let runtimeOptions: AcpRuntimeOptions | undefined; let signal: AbortSignal | undefined; @@ -47,12 +47,14 @@ describe("Codex ACPX runtime adapter", () => { } as ReturnType; }); const options = openOptions(fakeCommand()); - options.permissionMode = "approve-reads"; + options.permissionMode = permissionMode; const port = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(), createRuntime: (created) => { runtimeOptions = created; return runtime; }, }); + expect(runtimeOptions!.permissionMode).toBe("approve-reads"); + expect(runtimeOptions!.permissionPolicy).toMatchObject({ defaultAction: "escalate" }); const turn = port.startTurn({ text: "Attempt a write.", requestId: "permission-test" }); try { await runtimeOptions!.onPermissionRequest!({ @@ -1635,6 +1637,7 @@ describe("Codex ACPX runtime adapter", () => { ["codex", "gpt-5.6-sol", "deny-all", { outcome: "reject_once" }], ["claude", "claude-sonnet-5", "approve-all", { outcome: "allow_once" }], ["claude", "claude-sonnet-5", "approve-reads", { outcome: "reject_once" }], + ["claude", "claude-sonnet-5", "approve-paperclip", { outcome: "reject_once" }], ["claude", "claude-sonnet-5", "deny-all", { outcome: "reject_once" }], ] as const)( "applies the %s/%s ACPX profile's %s mode without an implicit prompt bridge", diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 264b1e3afa4..1d237ed8d28 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -277,7 +277,11 @@ export async function openQualifiedAcpxRuntime( ? "/paperclip-verified/claude-agent-acp" : VERIFIED_COMMAND_SENTINEL] }, }), - permissionMode: options.permissionMode, + // ACPX does not know the Paperclip-specific mode. Exact SDK rules allow + // admitted actions; all remaining requests keep its closed read policy. + permissionMode: options.permissionMode === "approve-paperclip" + ? "approve-reads" + : options.permissionMode, elicitationModes: ["form"], nonInteractivePermissions: "fail", permissionPolicy: { diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts index 97b18805505..7fe49dcbac1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts @@ -15,6 +15,7 @@ import { const ACPX_AGENTS = ["claude", "codex"] as const; const ACPX_PERMISSION_MODES = [ "approve-all", + "approve-paperclip", "approve-reads", "deny-all", ] as const; @@ -114,7 +115,7 @@ export function validateAcpxDriverConfig( return invalid( "permissionMode", "invalid_permission_mode", - "ACPX permission mode must be approve-all, approve-reads, or deny-all.", + "ACPX permission mode must be approve-all, approve-paperclip, approve-reads, or deny-all.", ); } diff --git a/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts b/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts index 0585555e9ce..85241d43444 100644 --- a/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/permission-policy.test.ts @@ -1,21 +1,43 @@ import { describe, expect, it } from "vitest"; +import { capabilityCanonicalOperationsForSurface } from "../../catalog/canonical-operations.js"; import { acpxRuntimePermissionPolicy, - claudeReadPermissionRules, + claudePaperclipPermissionRules, decideAcpxPermission, } from "./permission-policy.js"; describe("ACPX permission policy", () => { + it.each(["approve-reads", "approve-paperclip"] as const)("allows assigned canonical live reads, including approval lookup, in %s", mode => { + const reads = capabilityCanonicalOperationsForSurface("live").filter(action => action.sideEffectClass === "read"); + expect(reads.some(action => action.operationId === "get_approval")).toBe(true); + for (const action of reads) { + expect(claudePaperclipPermissionRules([{ name: action.operationId }], mode)).toEqual([`mcp__paperclip__${action.operationId}`]); + } + expect(claudePaperclipPermissionRules([{ name: "get_approval" }], "deny-all")).toEqual([]); + }); it("uses only catalogued reads assigned to this run, regardless of tool hints", () => { const tools = [ "paperclip__get_task_context", "read_document", "write_document", "call_api", "request_approval", "unknown_read", "mcp__other__get_task_context", ].map((name) => ({ name, annotations: { readOnlyHint: true, effect: "read" } })); - expect(claudeReadPermissionRules(tools)).toEqual([ + expect(claudePaperclipPermissionRules(tools, "approve-reads")).toEqual([ "mcp__paperclip__get_task_context", "mcp__paperclip__read_document", ]); - expect(claudeReadPermissionRules([])).toEqual([]); + expect(claudePaperclipPermissionRules([], "approve-reads")).toEqual([]); + }); + + it("allows assigned Paperclip mutations without admitting unknown or external tools", () => { + const tools = ["paperclip__write_document", "create_task", "reassign_task", "request_approval", + "write_document", "mcp__other__create_task", "Bash", "unknown_write", "mcp__paperclip__create_task", + "decide_approval", "control_workspace_service", "call_api", "create_skill", "schedule_wake"] + .map(name => ({ name, annotations: { readOnlyHint: true } })); + expect(claudePaperclipPermissionRules(tools, "approve-paperclip")).toEqual([ + "mcp__paperclip__create_task", "mcp__paperclip__reassign_task", + "mcp__paperclip__request_approval", "mcp__paperclip__write_document", + ]); + expect(claudePaperclipPermissionRules([], "approve-paperclip")).toEqual([]); + expect(claudePaperclipPermissionRules(tools, "deny-all")).toEqual([]); }); it("maps each configured mode to a closed ACP runtime policy", () => { @@ -25,6 +47,7 @@ describe("ACPX permission policy", () => { expect(acpxRuntimePermissionPolicy("deny-all")).toEqual({ defaultAction: "deny", }); + expect(acpxRuntimePermissionPolicy("approve-paperclip")).toEqual({ defaultAction: "escalate" }); expect(acpxRuntimePermissionPolicy("approve-reads")).toEqual({ defaultAction: "escalate", }); @@ -35,6 +58,9 @@ describe("ACPX permission policy", () => { ["approve-reads", "read", "delegate"], ["approve-reads", "search", "delegate"], ["approve-reads", "execute", "delegate"], + ["approve-paperclip", "read", "delegate"], + ["approve-paperclip", "write", "delegate"], + ["approve-paperclip", "execute", "delegate"], ["deny-all", "read", "reject_once"], ] as const)("%s maps %s to %s", (mode, inferredKind, expected) => { expect( @@ -65,7 +91,7 @@ describe("ACPX permission policy", () => { } }); - it("does not let provider metadata widen approve-reads", () => { + it.each(["approve-reads", "approve-paperclip"] as const)("does not let provider metadata widen %s", (mode) => { for (const [agent, inferredKind, raw, options] of [ [ "codex", @@ -104,7 +130,7 @@ describe("ACPX permission policy", () => { expect( decideAcpxPermission( agent, - "approve-reads", + mode, { inferredKind, raw }, options, ), diff --git a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts index f27d010248a..fcf5e0eb4e8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts +++ b/packages/paperclip-runner/src/drivers/acpx/permission-policy.ts @@ -1,5 +1,6 @@ import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js"; import { paperclipSemanticAction } from "../../catalog/semantic-action-catalog.js"; +import { capabilityCanonicalOperation } from "../../catalog/canonical-operations.js"; import { canonicalRunnerToolName } from "../runner-tool-bridge.js"; import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; @@ -7,7 +8,7 @@ export class AcpxApprovalRequiredError extends Error { readonly code = "approval_required"; constructor() { super( - "Approval required. This operation is not an automatically allowed Paperclip read, " + + "Approval required. This operation is not an automatically allowed Paperclip action, " + "and this runner has no interactive approval handler. Review the operation and " + "update the agent's permission setting before retrying.", ); @@ -15,17 +16,42 @@ export class AcpxApprovalRequiredError extends Error { } } +// Review additions explicitly: a newly catalogued mutation must not inherit +// automatic provider permission. These operations implement the bounded plan, +// task, and handoff workflow; governance decisions, generic APIs, and workspace +// controls deliberately stay outside it. Asking a human does not approve work. +const AUTOMATIC_PAPERCLIP_WORKFLOW_ACTIONS = new Set([ + "report_progress", "answer_status_question", "write_document", + "request_human_input", "register_deliverable", "finish_task", "block_task", + "request_review", "create_task", "reassign_task", "set_dependencies", + "create_project", "request_approval", +]); + /** Exact SDK rules for the run's runner-owned Paperclip MCP connection. */ -export function claudeReadPermissionRules( +export function claudePaperclipPermissionRules( tools: readonly Readonly>[], + mode: NativeAcpxPermissionMode, ): string[] { + if (mode !== "approve-reads" && mode !== "approve-paperclip") return []; const names = tools.flatMap((tool) => { if (typeof tool.name !== "string") return []; const name = canonicalRunnerToolName(tool.name); // Effects come from Paperclip's implementation catalog, never tool hints // or the provider's permission-request metadata. Unknown operations stay // subject to approval even when their names or annotations claim a read. - return paperclipSemanticAction(name)?.effect === "read" + const action = paperclipSemanticAction(name); + // The semantic catalog groups approval reads under "governance". The + // canonical side-effect class distinguishes those reads from decisions. + const liveAction = capabilityCanonicalOperation(name); + const isRead = liveAction + ? liveAction.surfaces.includes("live") && liveAction.sideEffectClass === "read" + : action?.effect === "read"; + // This only bypasses the provider's redundant permission prompt. The + // authenticated bridge and controller still validate run authority, + // company scope, claims, task mode, and governed-action approvals. + return isRead || (action && ( + mode === "approve-paperclip" && AUTOMATIC_PAPERCLIP_WORKFLOW_ACTIONS.has(name) + )) ? [`mcp__paperclip__${name}`] : []; }); @@ -60,7 +86,7 @@ export function acpxRuntimePermissionPolicy( if (mode === "deny-all") return { defaultAction: "deny" }; // ACPX derives permission kinds from provider-originated requests. Until the // host can bind a request to independent authority, no kind is safe to - // auto-approve here. Verified Paperclip reads are allowed earlier at the + // auto-approve here. Assigned Paperclip actions are allowed earlier at the // Claude SDK dispatch boundary. Other requests require an approval handler. return { defaultAction: "escalate" }; } @@ -78,8 +104,8 @@ export function decideAcpxPermission( if (mode === "deny-all") return "reject_once"; if (mode === "approve-all") return "allow_once"; // inferredKind and raw semantic/MCP metadata both originate outside the - // runner trust boundary. Neither can grant local read or semantic authority. - // Verified Paperclip reads use exact SDK rules on the runner-owned MCP + // runner trust boundary. Neither can grant local semantic authority. + // Assigned Paperclip actions use exact SDK rules on the runner-owned MCP // connection. Anything reaching this fallback still requires approval. return "delegate"; } diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts index 0f0ae1bf8d9..61d65fe666a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -351,7 +351,7 @@ function isDigest(value: unknown): value is string { function isPermissionMode(value: unknown): value is NativeAcpxPermissionMode { return ( typeof value === "string" && - ["approve-all", "approve-reads", "deny-all"].includes(value) + ["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes(value) ); } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index f6b637a4f16..d7aeda29e7f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -209,7 +209,11 @@ describe("ACPX runtime host", () => { } finally { await host.close({ reason: "gateway test complete" }); } }); - it("automatically permits only admitted Paperclip reads in the Claude SDK", async () => { + it.each([ + ["approve-reads", ["mcp__paperclip__get_task_context"]], + ["approve-paperclip", ["mcp__paperclip__create_task", "mcp__paperclip__get_task_context", "mcp__paperclip__reassign_task", "mcp__paperclip__write_document"]], + ["deny-all", undefined], + ] as const)("binds exact assigned Claude tools to %s before provider launch", async (permissionMode, allow) => { const fixture = await hostFixture(); const dependencies = fixture.dependencies({ openRuntime: async (options) => { @@ -217,9 +221,7 @@ describe("ACPX runtime host", () => { join(options.launchEnvironment.CLAUDE_CONFIG_DIR!, "settings.json"), "utf8", )); - expect(settings.permissions).toEqual({ - allow: ["mcp__paperclip__get_task_context"], - }); + expect(settings.permissions).toEqual(allow ? { allow } : undefined); expect(options.mcpServers).toMatchObject([ { name: "paperclip", runnerOwned: true }, ]); @@ -232,9 +234,9 @@ describe("ACPX runtime host", () => { ...fixture.options, agent: "claude", model: "claude-sonnet-5", - permissionMode: "approve-reads", + permissionMode, semanticTools: { - tools: ["get_task_context", "write_document", "unknown_read"].map((name) => ({ + tools: ["get_task_context", "write_document", "create_task", "reassign_task", "unknown_read"].map((name) => ({ name, inputSchema: { type: "object" }, // Supplied hints cannot grant write or unknown operations read access. diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index fa90b278918..30d2f4cbb79 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -28,7 +28,7 @@ import { } from "node:path"; import { createSanitizedAcpxSpawnInput } from "./environment.js"; -import { claudeReadPermissionRules } from "./permission-policy.js"; +import { claudePaperclipPermissionRules } from "./permission-policy.js"; import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; import { resolveAcpxRuntimeRoot, @@ -378,8 +378,8 @@ export async function prepareAcpxRuntimeSandbox(input: { `${JSON.stringify({ model: input.binding.requestedModel, availableModels: [input.binding.requestedModel], - ...(input.binding.permissionMode === "approve-reads" - ? { permissions: { allow: claudeReadPermissionRules(input.tools ?? []) } } + ...((input.binding.permissionMode === "approve-reads" || input.binding.permissionMode === "approve-paperclip") + ? { permissions: { allow: claudePaperclipPermissionRules(input.tools ?? [], input.binding.permissionMode) } } : {}), })}\n`, ); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index fc834ee1484..114ec5387c6 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -271,7 +271,7 @@ export class CodexAppServerDriver implements HarnessDriver { this.#options.includeSkillInstructions ?? false, this.#options.environment, ), - approvalPolicy: this.#options.approvalPolicy ?? "untrusted", + approvalPolicy: this.#options.approvalPolicy ?? "never", ...(this.#options.model ? { model: this.#options.model } : {}), ...(this.#direct() ? {} @@ -404,7 +404,7 @@ export class CodexAppServerDriver implements HarnessDriver { this.#options.environment, ), baseInstructions: this.#direct() ? "" : this.#baseInstructions(), - approvalPolicy: this.#options.approvalPolicy ?? "untrusted", + approvalPolicy: this.#options.approvalPolicy ?? "never", ...(this.#options.model ? { model: this.#options.model } : {}), dynamicTools: this.#providerDynamicTools(), persistExtendedHistory: false, @@ -851,7 +851,7 @@ export class CodexAppServerDriver implements HarnessDriver { approvalPolicy: boundedCodexValue( response.approvalPolicy ?? this.#options.approvalPolicy ?? - "untrusted", + "never", ), baseInstructions: this.#baseInstructions(), instructionSources: Array.isArray(response.instructionSources) diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts index 7aa3f760789..ecc4c6167d3 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts @@ -61,7 +61,7 @@ describe("Codex app-server Codex driver", () => { model: "gpt-test", modelProvider: "openai", workingDirectory: WORKSPACE, - approvalPolicy: "untrusted", + approvalPolicy: "never", instructionSources: [], instructionPolicy: { skillInstructions: false, @@ -77,7 +77,7 @@ describe("Codex app-server Codex driver", () => { expect( transport.calls.find((call) => call.method === "thread/start")?.params, ).toMatchObject({ - approvalPolicy: "untrusted", + approvalPolicy: "never", config: { "skills.include_instructions": false, include_apps_instructions: false, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts index 8fc4cc45b73..58d8e547a1b 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test.ts @@ -1251,7 +1251,7 @@ describe("Codex app-server Codex driver", () => { model: "gpt-test", modelProvider: "openai", workingDirectory: TEST_WORKING_DIRECTORY, - approvalPolicy: "untrusted", + approvalPolicy: "never", instructionSources: [], instructionPolicy: { skillInstructions: false, @@ -1267,7 +1267,7 @@ describe("Codex app-server Codex driver", () => { expect( transport.calls.find((call) => call.method === "thread/start")?.params, ).toMatchObject({ - approvalPolicy: "untrusted", + approvalPolicy: "never", config: { "skills.include_instructions": false, include_apps_instructions: false, diff --git a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts index 30069d80985..68c32a3c9a6 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-driver-values.ts @@ -66,6 +66,7 @@ export function parseProviderIdentity( if ( permissionMode !== undefined && permissionMode !== "approve-all" && + permissionMode !== "approve-paperclip" && permissionMode !== "approve-reads" && permissionMode !== "deny-all" ) { diff --git a/packages/paperclip-runner/src/eval/workflow-evals.test.ts b/packages/paperclip-runner/src/eval/workflow-evals.test.ts index 7870f4eb7fd..8d692be7150 100644 --- a/packages/paperclip-runner/src/eval/workflow-evals.test.ts +++ b/packages/paperclip-runner/src/eval/workflow-evals.test.ts @@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => { candidateFailures: 36, }); expect(report.coverage).toMatchObject({ - canonicalOperations: 46, + canonicalOperations: 47, capabilityCases: 106, workflows: 12, stressFindings: 44, stressExclusions: 1, }); - expect(report.coverage.operations).toHaveLength(46); + expect(report.coverage.operations).toHaveLength(47); expect(report.coverage.composedWorkflows).toHaveLength(12); expect( report.coverage.operations.find( @@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => { )?.workflowIds.length, ).toBeGreaterThan(0); expect(renderRunnerWorkflowMarkdown(report)).toContain( - "46 operations · 106 capability cases · 12 workflows", + "47 operations · 106 capability cases · 12 workflows", ); expect(renderRunnerWorkflowJUnit(report)).toContain( 'tests="36" failures="36" skipped="0"', diff --git a/packages/paperclip-runner/src/issue-thread/live-projection.ts b/packages/paperclip-runner/src/issue-thread/live-projection.ts index a091295162d..06f5ceab78e 100644 --- a/packages/paperclip-runner/src/issue-thread/live-projection.ts +++ b/packages/paperclip-runner/src/issue-thread/live-projection.ts @@ -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)) diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index da7244a4fbe..2f5d38c249a 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -1258,8 +1258,8 @@ it("preserves answer and internal wait descriptions in the serialized native too ).toContain("not in the top-level user-facing summary"); }); -it("defaults runnerd ACPX permissions to approve reads", () => { - expect(resolveRunnerdAcpxPermissionMode(undefined)).toBe("approve-reads"); +it("defaults runnerd ACPX permissions to authorized Paperclip actions", () => { + expect(resolveRunnerdAcpxPermissionMode(undefined)).toBe("approve-all"); expect(resolveRunnerdAcpxPermissionMode("deny-all")).toBe("deny-all"); }); @@ -1516,7 +1516,7 @@ it("preserves OpenCode runtime bindings when a durable runner is respawned", () hasRuntimeContext: false, }); expect(defaultPermissionEnvironment.PAPERCLIP_OPENCODE_PERMISSION_MODE).toBe( - "ask", + "allow", ); }); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 54e75690f8d..c244952fdfc 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3084,7 +3084,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: { return { ...createSanitizedOpenCodeRunnerEnvironment(input.options.environment), PAPERCLIP_OPENCODE_PERMISSION_MODE: - input.options.opencodePermissionMode ?? "ask", + input.options.opencodePermissionMode ?? "allow", PAPERCLIP_OPENCODE_RUNTIME_DIR: input.options.opencodeRuntimeDirectory ?? resolve(input.options.stateDirectory ?? tmpdir(), "opencode"), @@ -3155,7 +3155,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: { export function resolveRunnerdAcpxPermissionMode( configured: CapabilityRunnerdCodexTransportOptions["acpxPermissionMode"], ): NonNullable { - return configured ?? "approve-reads"; + return configured ?? "approve-all"; } const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([ diff --git a/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts b/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts index 0fed1d27637..7b646b8f1fc 100644 --- a/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts +++ b/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts @@ -40,6 +40,7 @@ export interface CapabilityFixtureActor { } export interface CapabilityFixtureTask { + statusVersion?: number; id: string; companyId: string; identifier: string; @@ -391,9 +392,11 @@ 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"; + status?: "backlog" | "todo"; title: string; description?: string | null; assigneeActorId?: string | null; @@ -482,6 +485,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 +605,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 ?? []), diff --git a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts index 7a7ce2cfc94..a21225b63c8 100644 --- a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts +++ b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.test.ts @@ -343,7 +343,7 @@ describe("CapabilityMockControlPlaneAdapter", () => { expect(restored.context("run-1")).toEqual(adapter.context("run-1")); }); - it("reconciles terminal state, releases locks, and schedules dependent wakes", async () => { + it.each(["blocked", "backlog"] as const)("reconciles terminal state and respects a %s dependent", async dependentStatus => { const adapter = new CapabilityMockControlPlaneAdapter({ tasks: [ { @@ -368,7 +368,7 @@ describe("CapabilityMockControlPlaneAdapter", () => { identifier: "MCK-2", title: "Dependent task", description: null, - status: "blocked", + status: dependentStatus, priority: "high", workMode: "standard", parentId: null, @@ -440,11 +440,16 @@ describe("CapabilityMockControlPlaneAdapter", () => { checkoutRunId: null, executionRunId: null, }); - expect(snapshot.tasks.find((task) => task.id === "task-2")?.status).toBe("todo"); - expect(snapshot.blockers).toEqual([]); - expect(snapshot.wakes).toMatchObject([ - { taskId: "task-2", reason: "blockers_resolved", status: "scheduled" }, - ]); + expect(snapshot.tasks.find((task) => task.id === "task-2")?.status).toBe(dependentStatus === "backlog" ? "backlog" : "todo"); + if (dependentStatus === "backlog") { + expect(snapshot.blockers).toMatchObject([{ taskId: "task-2", blockedByTaskId: "task-1" }]); + expect(snapshot.wakes).toEqual([]); + } else { + expect(snapshot.blockers).toEqual([]); + expect(snapshot.wakes).toMatchObject([ + { taskId: "task-2", reason: "blockers_resolved", status: "scheduled" }, + ]); + } expect(snapshot.runs[0]?.status).toBe("succeeded"); }); diff --git a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts index 9801d5380be..bb8de32b494 100644 --- a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts +++ b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts @@ -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) { @@ -911,7 +937,7 @@ export class CapabilityMockControlPlaneAdapter implements CapabilityMockControlP identifier: `${this.#state.company.issuePrefix}-${this.#nextCounter("issue-number")}`, title: requireText(command.title, "task title"), description: command.description ?? null, - status: command.blockedByTaskIds?.length ? "blocked" : "todo", + status: command.status === "backlog" ? "backlog" : command.blockedByTaskIds?.length ? "blocked" : "todo", priority: command.priority ?? "medium", workMode: "standard", parentId: task.id, @@ -1237,6 +1263,8 @@ export class CapabilityMockControlPlaneAdapter implements CapabilityMockControlP for (const dependentId of dependentIds) { const dependentTask = this.#task(dependentId); this.#assertCompany(completedTask.companyId, dependentTask.companyId); + // Dependency completion must not release an operator's backlog hold. + if (dependentTask.status === "backlog") continue; const unresolved = this.#state.blockers.filter( (blocker) => { if (blocker.taskId !== dependentId) return false; diff --git a/packages/paperclip-runner/src/mock-core/task-creation-backlog.test.ts b/packages/paperclip-runner/src/mock-core/task-creation-backlog.test.ts new file mode 100644 index 00000000000..3b78677053a --- /dev/null +++ b/packages/paperclip-runner/src/mock-core/task-creation-backlog.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityMockControlPlaneAdapter } from "./capability-mock-control-plane-adapter.js"; + +describe("mock backlog task creation", () => { + it.each([false, true])("preserves an explicit hold and dependencies (blocked=%s)", async blocked => { + const adapter = new CapabilityMockControlPlaneAdapter(); + const base = adapter.snapshot(); + 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: "backlog-test", capabilities: ["delegation:tasks:create"] }); + const call = { runId: "run-1", idempotencyKey: "later", command: { kind: "create_task" as const, title: "Later", status: "backlog" as const, assigneeActorId: base.actors[0]!.id, blockedByTaskIds: blocked ? [base.tasks[0]!.id] : [] } }; + const receipt = await adapter.applyCommand(call); + await adapter.applyCommand(call); + const state = adapter.snapshot(); + expect(state.tasks).toHaveLength(base.tasks.length + 1); + expect(state.tasks.find(task => task.title === "Later")).toMatchObject({ status: "backlog", executionRunId: null, startedAt: null }); + expect(receipt.scheduledWakeIds).toEqual([]); + expect(state.wakes).toEqual(base.wakes); + expect(state.blockers).toHaveLength(base.blockers.length + (blocked ? 1 : 0)); + }); +}); diff --git a/packages/paperclip-runner/src/mock-core/task-reassignment.test.ts b/packages/paperclip-runner/src/mock-core/task-reassignment.test.ts new file mode 100644 index 00000000000..1947e8b97f3 --- /dev/null +++ b/packages/paperclip-runner/src/mock-core/task-reassignment.test.ts @@ -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); + }); +}); diff --git a/packages/paperclip-runner/src/protocol-actions/create-task.ts b/packages/paperclip-runner/src/protocol-actions/create-task.ts index 1acc56b4da0..fba315e0f73 100644 --- a/packages/paperclip-runner/src/protocol-actions/create-task.ts +++ b/packages/paperclip-runner/src/protocol-actions/create-task.ts @@ -29,7 +29,7 @@ export const createTaskAction = { }, "documentation": { "title": "Create task", - "description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.", + "description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.", "note": null }, "examples": { @@ -77,7 +77,7 @@ export const createTaskAction = { "operationId": "create_task", "version": 1, "title": "Create task", - "description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.", + "description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.", "exposure": "optional", "requiredClaims": [ "delegation:tasks:create" @@ -117,6 +117,10 @@ export const createTaskAction = { "description": "Optional agent assignee. Omit to assign the current agent.", "maxLength": 20000 }, + "status": { + "enum": ["backlog", "todo"], + "description": "Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved)." + }, "priority": { "enum": [ "critical", @@ -270,6 +274,10 @@ export const createTaskAction = { "assigneeActorId": { "type": "string" }, + "status": { + "enum": ["backlog", "todo"], + "description": "Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved)." + }, "priority": { "type": "string" }, diff --git a/packages/paperclip-runner/src/protocol-actions/index.ts b/packages/paperclip-runner/src/protocol-actions/index.ts index c71dc4a1fda..c762faae9c9 100644 --- a/packages/paperclip-runner/src/protocol-actions/index.ts +++ b/packages/paperclip-runner/src/protocol-actions/index.ts @@ -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, diff --git a/packages/paperclip-runner/src/protocol-actions/reassign-task.ts b/packages/paperclip-runner/src/protocol-actions/reassign-task.ts new file mode 100644 index 00000000000..a37ece7af5c --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/reassign-task.ts @@ -0,0 +1,284 @@ +/** 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" + ], + "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" + ], + "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" + ], + "sideEffectClass": "company_write", + "idempotency": "required", + "redaction": [], + "mockCommandMapping": { + "kind": "semantic_command", + "commandKind": "reassign_task" + } + } + } +} as const; diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts index 28bbcad11b4..d1bf2bd4335 100644 --- a/packages/paperclip-runner/src/semantic-tools/discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts @@ -28,7 +28,7 @@ const NAMESPACE: Readonly> = 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", diff --git a/packages/paperclip-runner/src/semantic-tools/dispatcher.ts b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts index 94068d72301..86bf6d4d637 100644 --- a/packages/paperclip-runner/src/semantic-tools/dispatcher.ts +++ b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts @@ -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,12 +375,16 @@ 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; case "create_task": command = { kind: "create_task", + status: optionalString(input.status) as "backlog" | "todo" | undefined, taskId, title: requiredString(input.title), description: nullableOptionalString(input.description), diff --git a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts index 6f255660c68..bbc0c523025 100644 --- a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts @@ -35,7 +35,7 @@ const NAMESPACE: Readonly> = 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", diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts index e02fcdccff1..da4afd4f882 100644 --- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts +++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts @@ -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"); diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts index 68ac9449a87..ea9339edf8d 100644 --- a/packages/paperclip-runner/src/semantic-tools/types.ts +++ b/packages/paperclip-runner/src/semantic-tools/types.ts @@ -39,6 +39,7 @@ export type CapabilitySemanticOperationId = | "get_approval_context" | "get_workspace_runtime" | "control_workspace_service" + | "reassign_task" | "set_dependencies" | "create_skill" | "create_project" diff --git a/packages/paperclip-runner/src/tools/capability-semantic-tool-runtime.ts b/packages/paperclip-runner/src/tools/capability-semantic-tool-runtime.ts index fb1eab16bb7..5cb627c1c5d 100644 --- a/packages/paperclip-runner/src/tools/capability-semantic-tool-runtime.ts +++ b/packages/paperclip-runner/src/tools/capability-semantic-tool-runtime.ts @@ -1577,12 +1577,15 @@ function commandForOperation( case "create_task": return { kind: "create_task", + status: input.status as "backlog" | "todo" | undefined, title: requireString(input.title), description: typeof input.description === "string" ? input.description : undefined, assigneeActorId: typeof input.assigneeActorId === "string" ? input.assigneeActorId : undefined, 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": diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index a0de71aa3d5..6f5e857046a 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -357,12 +357,12 @@ describe("adapter routes", () => { }), expect.objectContaining({ key: "opencodePermissionMode", - default: "ask", + default: "allow", meta: { visibleWhen: { key: "provider", value: "opencode" } }, }), expect.objectContaining({ key: "acpxPermissionMode", - default: "approve-reads", + default: "approve-all", meta: { visibleWhen: { key: "provider", value: "acpx" } }, }), expect.objectContaining({ diff --git a/server/src/modules/wake-queue/adapters/postgres.test.ts b/server/src/modules/wake-queue/adapters/postgres.test.ts index 91a68b6b878..812cdec546b 100644 --- a/server/src/modules/wake-queue/adapters/postgres.test.ts +++ b/server/src/modules/wake-queue/adapters/postgres.test.ts @@ -318,6 +318,30 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => { }); } + it("releases an acknowledged native handoff without blocking or restarting the old owner", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent({ companyId }); + const issueId = await seedIssue({ companyId, assigneeAgentId: agentId, status: "in_progress" }); + const runId = await seedRun({ companyId, agentId, status: "cancelled", contextSnapshot: { issueId } }); + await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: issueId, resultJson: { + reassignmentStopRequested: true, + nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId, companyId, issueId, + scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true, + intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID() }, + } }).where(eq(heartbeatRuns.id, runId)); + await db.update(issues).set({ executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, issueId)); + const before = (await db.select().from(issues).where(eq(issues.id, issueId)))[0]; + const wakeId = await seedDeferredWake({ companyId, agentId, issueId }); + const adapter = createPostgresWakeQueueAdapter(db, stubDeps); + const result = await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async () => { throw new Error("must not restart the outgoing owner"); }); + expect(result).toMatchObject({ outcome: { kind: "released" }, postCommitEffects: [] }); + expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ + status: "in_progress", statusVersion: before.statusVersion, assigneeAgentId: agentId, executionRunId: null, checkoutRunId: null, + }); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)))[0].status).toBe("deferred_issue_execution"); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(0); + }); + it.each(["in_progress", "blocked"])("preserves recovery ownership and queued messages when a native task fails from %s", async (status) => { const companyId = await seedCompany(); const agentId = await seedAgent({ companyId }); diff --git a/server/src/modules/wake-queue/adapters/postgres.ts b/server/src/modules/wake-queue/adapters/postgres.ts index 24fd0a21467..c43f0c43936 100644 --- a/server/src/modules/wake-queue/adapters/postgres.ts +++ b/server/src/modules/wake-queue/adapters/postgres.ts @@ -1,4 +1,4 @@ -import { isAcknowledgedNativeStop } from "../../../services/acknowledged-native-stop.js"; +import { isAcknowledgedNativeReassignmentStop, isAcknowledgedNativeStop } from "../../../services/acknowledged-native-stop.js"; import { instanceSettingsService } from "../../../services/instance-settings.js"; import { currentConversationCommentCondition } from "../../../services/agent-conversations.js"; import { getExecutionBlocker } from "../../../services/execution-blocker.js"; @@ -718,7 +718,7 @@ async function recordNativeTerminalRecoveryIfNeeded(tx: Db, run: HeartbeatRunRow ["failed", "timed_out", "interrupted", "cancelled"].includes(run.status) && issue.assigneeAgentId === run.agentId && !["done", "cancelled"].includes(issue.status); - if (!applies || isAcknowledgedNativeStop(run)) return false; + if (!applies || isAcknowledgedNativeStop(run) || isAcknowledgedNativeReassignmentStop(run)) return false; const existing = await tx .select({ id: issueRecoveryActions.id, evidence: issueRecoveryActions.evidence }) @@ -1104,9 +1104,10 @@ export function createPostgresWakeQueueAdapter(db: Db, deps: WakeQueuePostgresAd // next explicit wake adopts those messages atomically when it // queues a run. executionCancellationAcknowledged: - run.status === "cancelled" && + isAcknowledgedNativeReassignmentStop(run) || + (run.status === "cancelled" && (parseObject(run.resultJson?.executionCancellation).state === "acknowledged" || isAcknowledgedNativeStop(run)) && - !interruptedQueue, + !interruptedQueue), }; const preDrain = decidePreDrain(preDrainFacts); diff --git a/server/src/services/acknowledged-native-stop.test.ts b/server/src/services/acknowledged-native-stop.test.ts index a46a78a6342..f8772e5761e 100644 --- a/server/src/services/acknowledged-native-stop.test.ts +++ b/server/src/services/acknowledged-native-stop.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { isAcknowledgedNativeStop } from "./acknowledged-native-stop.js"; +import { isAcknowledgedNativeReassignmentStop, isAcknowledgedNativeStop } from "./acknowledged-native-stop.js"; const run = { id: "run", companyId: "company", nativeIssueId: "issue", status: "cancelled", resultJson: { cancelledByActorType: "user", cancelledByUserId: "board", nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId: "run", companyId: "company", issueId: "issue", @@ -15,3 +15,16 @@ it.each([{ runId: "other" }, { companyId: "other" }, { issueId: "other" }, { dis expect(isAcknowledgedNativeStop({ ...run, resultJson: { ...run.resultJson, nativeCancellation: { ...run.resultJson.nativeCancellation, ...change } } })).toBe(false); }); + +it("recognizes an audited handoff without granting operator Stop semantics", () => { + const handoff = { ...run, resultJson: { reassignmentStopRequested: true, nativeCancellation: run.resultJson.nativeCancellation } }; + expect(isAcknowledgedNativeReassignmentStop(handoff)).toBe(true); + expect(isAcknowledgedNativeStop(handoff)).toBe(false); + expect(isAcknowledgedNativeReassignmentStop({ ...handoff, status: "running" })).toBe(false); + expect(isAcknowledgedNativeReassignmentStop(run)).toBe(false); +}); +it.each([{ runId: "other" }, { companyId: "other" }, { issueId: "other" }, { dispatchState: "pending" }, + { scope: "subtree" }, { acknowledgementAuditId: undefined }])("requires an audited handoff receipt %j", change => { + expect(isAcknowledgedNativeReassignmentStop({ ...run, resultJson: { reassignmentStopRequested: true, + nativeCancellation: { ...run.resultJson.nativeCancellation, ...change } } })).toBe(false); +}); diff --git a/server/src/services/acknowledged-native-stop.ts b/server/src/services/acknowledged-native-stop.ts index 37efd1c84eb..bb6b7fa74c3 100644 --- a/server/src/services/acknowledged-native-stop.ts +++ b/server/src/services/acknowledged-native-stop.ts @@ -4,21 +4,34 @@ import { hasNativeLocalProcessStop } from "./native-local-process-stop.js"; import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js"; /** A server-recorded run-only Stop must not manufacture a recovery incident. */ -export function hasAcknowledgedNativeStopIntent(run: { +function hasAcknowledgedNativeRunStopReceipt(run: { id: string; companyId: string; status: string; nativeIssueId: string | null; resultJson: Record | null; }): boolean { const result = run.resultJson; const intent = result?.nativeCancellation as Record | undefined; - return result?.cancelledByActorType === "user" && - typeof result.cancelledByUserId === "string" && Boolean(result.cancelledByUserId) && - intent?.schema === "paperclip.native-cancellation.v1" && intent.runId === run.id && + return intent?.schema === "paperclip.native-cancellation.v1" && intent.runId === run.id && intent.companyId === run.companyId && intent.issueId === run.nativeIssueId && intent.scope === "run" && intent.reasonCode === "cancellation_run_only" && intent.dispatchState === "acknowledged" && intent.dispatched === true && typeof intent.intentAuditId === "string" && typeof intent.acknowledgementAuditId === "string"; } +export function hasAcknowledgedNativeStopIntent(run: Parameters[0]): boolean { + return run.resultJson?.cancelledByActorType === "user" && + typeof run.resultJson.cancelledByUserId === "string" && Boolean(run.resultJson.cancelledByUserId) && + hasAcknowledgedNativeRunStopReceipt(run); +} + +/** An intentional handoff retains task state and deferred work for its successor. */ +export function hasAcknowledgedNativeReassignmentStopIntent(run: Parameters[0]): boolean { + return run.resultJson?.reassignmentStopRequested === true && hasAcknowledgedNativeRunStopReceipt(run); +} + +export function isAcknowledgedNativeReassignmentStop(run: Parameters[0]): boolean { + return run.status === "cancelled" && hasAcknowledgedNativeReassignmentStopIntent(run); +} + export function isAcknowledgedNativeStop(run: Parameters[0]): boolean { return run.status === "cancelled" && hasAcknowledgedNativeStopIntent(run); } diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index 72b194cac7f..90b78278df7 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -147,7 +147,7 @@ describe("Paperclip Runner native provider configuration", () => { }, ); - it("applies the safe provider permission default from adapter config", () => { + it("applies the full-auto provider permission default from adapter config", () => { expect( resolvePaperclipRunnerNativeProviderInput({ backend: "opencode_server", @@ -159,7 +159,7 @@ describe("Paperclip Runner native provider configuration", () => { ).toEqual({ provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731", - opencodePermissionMode: "ask", + opencodePermissionMode: "allow", }); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 64565ca229f..50ad68d2669 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,7 +1,7 @@ import { readQueuedInteractionResponse } from "./queued-interaction-response.js"; import { AGENT_CHAT_DIRECTIVE, conversationReplay, isConversation, isConversationExecutionWake, isWaitingConversation, prepareConversationTurn, settleConversationTurn } from "./agent-conversations.js"; import { PROCESS_IDENTITY_RECORDED, recordNativeLocalProcessStop } from "./native-local-process-stop.js"; -import { hasAcknowledgedNativeStopIntent, isAcknowledgedNativeStop, acknowledgedNativeStopExecutionHasStopped } from "./acknowledged-native-stop.js"; +import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent, isAcknowledgedNativeStop, acknowledgedNativeStopExecutionHasStopped } from "./acknowledged-native-stop.js"; import { legacyControllerBootId, legacyControllerClaim, renewLegacyControllerLease, hasLiveLegacyController, revokeExpiredLegacyController, watchLegacyControllerLease } from "./legacy-controller-lease.js"; import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js"; import { hasRemoteTerminationReceipt, remoteExecutionHasStopped, remoteTerminationReceipt, stoppedRemoteCleanupScopes } from "./remote-execution-termination.js"; @@ -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; @@ -12848,7 +12849,7 @@ export function heartbeatService( // Teardown can beat the cancellation finalizer. Preserve the acknowledged // user intent instead of reporting an infrastructure interruption. - if (hasAcknowledgedNativeStopIntent(run)) terminalStatus = "cancelled"; + if (hasAcknowledgedNativeStopIntent(run) || hasAcknowledgedNativeReassignmentStopIntent(run)) terminalStatus = "cancelled"; const message = `run terminalized on environment lease release: heartbeat_runs.status was still ${run.status} at teardown`; // Match both "running" and "queued". A queued run has released its lease but @@ -24093,6 +24094,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(); @@ -26738,6 +26754,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, @@ -26963,7 +26980,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, @@ -28618,7 +28636,11 @@ export function heartbeatService( // additional durable fence before its existing cancellation path. if (run.runtimeMode === "native" || (!run.runtimeModeResolvedAt && !running && !control)) { const [fenced] = await db.update(heartbeatRuns).set({ + // Record handoff intent before native cancellation can finalize and release + // the run. Only its audited stop acknowledgement suppresses recovery. resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || + ${JSON.stringify(options.errorCode === "issue_reassigned" && options.resultJson?.reassignmentStopConfirmed === true + ? { reassignmentStopRequested: true } : {})}::jsonb || jsonb_build_object('startupCancellation', jsonb_build_object( 'requestedAt', ${new Date().toISOString()}::text, 'beforeNativeSelection', ${heartbeatRuns.runtimeMode} = 'legacy' diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b3d297bdf44..f2969a029d3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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. diff --git a/server/src/services/native-runtime/native-execution-input.test.ts b/server/src/services/native-runtime/native-execution-input.test.ts index 1a7f082237a..2550ab9dbbe 100644 --- a/server/src/services/native-runtime/native-execution-input.test.ts +++ b/server/src/services/native-runtime/native-execution-input.test.ts @@ -451,6 +451,8 @@ describe("native execution input external-chat framing", () => { { provider: "codex", resumedSession: true }, { provider: "acpx", resumedSession: false }, { provider: "acpx", resumedSession: true }, + { provider: "opencode", resumedSession: false }, + { provider: "opencode", resumedSession: true }, ] as const)("keeps question documentation in the tool on $provider (resumed: $resumedSession)", ({ provider, resumedSession }) => { const input = buildNativeExecutionInput({ companyId: "10000000-0000-4000-8000-000000000001", @@ -462,13 +464,17 @@ describe("native execution input external-chat framing", () => { normalizedSessionId: resumedSession ? "60000000-0000-4000-8000-000000000006" : null, provider, resumedSession, acpxAgent: "claude", - model: provider === "acpx" ? "claude-sonnet-5" : "gpt-5.6-sol", + model: provider === "acpx" ? "claude-sonnet-5" : provider === "opencode" ? "openai/gpt-5.5" : "gpt-5.6-sol", completionContract: { id: "70000000-0000-4000-8000-000000000007", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1", contract: { revision: "1", objective: "Write a welcome after the user's answer", criteria: [{ id: "objective", requirement: "Use the selected tone" }] }, }, runtimeContext: nativeRuntimeContextFixture(), }); + expect(input.provider).toMatchObject(provider === "acpx" + ? { kind: "acpx", permissionMode: "approve-all" } + : provider === "opencode" ? { kind: "opencode", permissionMode: "allow" } + : { kind: "codex", approvalPolicy: "never" }); expect(input.task.prompt).not.toContain("## Questions that need a user response"); expect(input.task.prompt).toContain("Use Paperclip's request_human_input for durable task questions."); expect(input.task.prompt).not.toContain("payload.questionSet"); diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 4fe577aa1df..92d0121010a 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -240,7 +240,7 @@ export function buildNativeExecutionInput(input: { kind: "acpx", agent: acpxProfile!.agent, model: input.model, - permissionMode: input.acpxPermissionMode ?? "approve-reads", + permissionMode: input.acpxPermissionMode ?? "approve-all", profile: { driverKind: acpxProfile!.driverKind, protocolVersion: acpxProfile!.protocolVersion, @@ -258,7 +258,7 @@ export function buildNativeExecutionInput(input: { ? { kind: "opencode", model: input.model, - permissionMode: input.opencodePermissionMode ?? "ask", + permissionMode: input.opencodePermissionMode ?? "allow", } : { kind: "codex", diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 26f73b34013..39362e92f92 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -40,7 +40,6 @@ import { NativeSessionCleanupQuarantinedError, NativeProviderTerminalFailure, NativeSessionProtocolIntegrityError, - defaultCapabilityRunnerdBinary, } from "../../vendor/paperclip-runner/index.js"; import * as issueServiceModule from "../issues.js"; import { @@ -84,7 +83,7 @@ type RunnerTransportOptions = { provider?: "codex" | "opencode" | "acpx"; opencodePermissionMode?: "allow" | "ask" | "deny"; acpxAgent?: "claude" | "codex"; - acpxPermissionMode?: "approve-all" | "approve-reads" | "deny-all"; + acpxPermissionMode?: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; resumeActiveTurnId?: string | null; resumeProviderSession?: { driverSessionId: string; @@ -175,7 +174,6 @@ vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => { >(); return { ...original, - defaultCapabilityRunnerdBinary: vi.fn(original.defaultCapabilityRunnerdBinary), createNativeSessionBackend: state.createBackend, createRunnerdCodexTransport: state.createTransport, executeNativeSession: state.execute, @@ -6476,11 +6474,13 @@ describe("native warm session supervision", () => { }); describe("native session bounded recovery", () => { - it("does not turn an acknowledged Stop before completion into a failure or a retry", async () => { + it.each(["operator", "reassignment"])("does not turn an acknowledged %s Stop before completion into a failure or a retry", async (source) => { const updates: Array<{ table: unknown; values: Record }> = []; const stop: Record = {}; state.execute.mockReset().mockImplementationOnce(async () => { - Object.assign(stop, { cancelledByActorType: "user", cancelledByUserId: "board", nativeCancellation: { + Object.assign(stop, { ...(source === "operator" + ? { cancelledByActorType: "user", cancelledByUserId: "board" } + : { reassignmentStopRequested: true }), nativeCancellation: { schema: "paperclip.native-cancellation.v1", ...execution.binding, scope: "run", reasonCode: "cancellation_run_only", dispatched: true, dispatchState: "acknowledged", intentAuditId: "intent", acknowledgementAuditId: "ack", } }); @@ -10065,7 +10065,7 @@ describe("runnerd provider runtime wiring", () => { const controllerArtifact = join(isolatedStateDirectory, "paperclip-runnerd"); if (staleRunner) { await writeFile(controllerArtifact, "fixture runner artifact"); - vi.mocked(defaultCapabilityRunnerdBinary).mockReturnValueOnce(controllerArtifact); + state.resolveRunnerBinary.mockReturnValueOnce(controllerArtifact); } const syncIn = vi.fn(async () => undefined); const remoteExecute = vi.fn( @@ -10141,6 +10141,7 @@ describe("runnerd provider runtime wiring", () => { "reached-preinstalled-codex-verification", ); if (staleRunner) { + expect(transport.runnerBinary).toBe(controllerArtifact); expect(syncIn).toHaveBeenCalledTimes(1); expect(syncIn).toHaveBeenCalledWith([expect.objectContaining({ files: [expect.objectContaining({ diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 8372e92787e..6d1a3cbf05e 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -2,7 +2,7 @@ import { createLocalNativeQuestionBridge } from "./local-native-question-bridge. import { readVerifiedRemoteWorkspaceFile } from "./remote-deliverable-file.js"; import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; import { nativeCompletionFeedback } from "./native-completion-feedback.js"; -import { hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; +import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js"; import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js"; import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js"; @@ -62,7 +62,6 @@ import { acpxRuntimeSessionDirectoryName, createNativeSessionBackend, createRunnerdCodexTransport, - defaultCapabilityRunnerdBinary, executeNativeSession, applyNativeSessionGoalControl, inspectWarmRunTransition, @@ -5222,7 +5221,7 @@ export function providerSessionIdentityFromDurableProviderState(input: { identity.requestedModel !== expectedModel || identity.effectiveModel !== expectedModel || identity.permissionMode !== input.execution.provider.permissionMode || - !["approve-all", "approve-reads", "deny-all"].includes( + !["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes( String(identity.permissionMode), ) || !Array.isArray(identity.providerLifetimeFenceCandidates) || @@ -6943,6 +6942,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; enqueueWakeup?: ( agentId: string, options: { @@ -8260,7 +8260,7 @@ async function executePaperclipNativeSessionWithinScope( eq(heartbeatRuns.agentId, input.execution.binding.agentId), eq(heartbeatRuns.nativeIssueId, input.execution.binding.issueId), )).limit(1); - if (stoppedRun && hasAcknowledgedNativeStopIntent(stoppedRun)) { + if (stoppedRun && (hasAcknowledgedNativeStopIntent(stoppedRun) || hasAcknowledgedNativeReassignmentStopIntent(stoppedRun))) { const [settled] = await input.db.update(nativeRunFinalizations).set({ phase: "terminal_failure", failureCode: "native_retry_cancelled", nextAttemptAt: null, leaseOwner: null, leaseExpiresAt: null, controlDeadlineAt: null, recoveryState: null, @@ -10118,6 +10118,7 @@ export async function createRunnerdBackend(input: { runnerRemoteProviderPackPath?: string | null; trace?: NativeRunTrace; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise; enqueueWakeup?: ( agentId: string, options: { @@ -10233,6 +10234,7 @@ async function createRunnerdBackendWithinSessionClaim( : undefined, currentWakeComments: currentWakeComments ?? undefined, chatAttachmentReadScope: input.chatAttachmentReadScope, + stopTaskForReassignment: input.stopTaskForReassignment, enqueueWakeup: input.enqueueWakeup, }); const authorityEpoch = new SessionToolAuthorityEpoch( @@ -10623,8 +10625,10 @@ async function createRunnerdBackendWithinSessionClaim( } } if (!usedPreinstalledRunner) { - const sourceBinary = - explicitRemoteBinary ?? defaultCapabilityRunnerdBinary(); + // Upload the same artifact used for the controller identity. The server + // vendors the runner under vendor/paperclip-runner/bin, so the package + // development fallback cannot locate it in a deployed server. + const sourceBinary = controllerRunnerBinary; if (!existsSync(sourceBinary)) { throw new Error("runner_remote_artifact_unavailable"); } diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index 7e8b8383479..beb81064384 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -2519,13 +2519,13 @@ describe("buildNativeExecutionInput wake projection", () => { }, }); expect(defaultOpenCode).toMatchObject({ - provider: { kind: "opencode", permissionMode: "ask" }, + provider: { kind: "opencode", permissionMode: "allow" }, }); expect(defaultAcpx).toMatchObject({ provider: { kind: "acpx", agent: "codex", - permissionMode: "approve-reads", + permissionMode: "approve-all", }, }); expect( diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index eaab6bd5d52..3ea436f3bba 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -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"); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 5912b3b2358..855073b82ca 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -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; enqueueWakeup?: (agentId: string, options: { source: "assignment"; triggerDetail: "system"; @@ -115,7 +120,7 @@ type Binding = { requestedByActorType: "agent"; requestedByActorId: string; contextSnapshot: Record; - issueStateGuard?: { statuses: string[]; assigneeAgentId: string }; + issueStateGuard?: { statuses: string[]; assigneeAgentId: string; statusVersion?: number }; }) => Promise; }; @@ -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"); @@ -756,6 +762,9 @@ export class PaperclipRunnerToolAuthority { async #createTask(input: Record, identityContextId: string | null): Promise { const idempotencyKey = requiredString(input.idempotencyKey); + if (input.status !== undefined && input.status !== "backlog" && input.status !== "todo") { + throw new Error("paperclip_runner_tool_input_invalid"); + } const assigneeAgentId = input.assigneeActorId === null || input.assigneeActorId === undefined ? this.binding.agentId : requiredString(input.assigneeActorId); @@ -809,7 +818,8 @@ export class PaperclipRunnerToolAuthority { description: input.description === null || input.description === undefined ? null : requiredString(input.description), - status: blockedByIssueIds.length > 0 ? "blocked" as const : "todo" as const, + status: input.status === "backlog" ? "backlog" as const + : blockedByIssueIds.length > 0 ? "blocked" as const : "todo" as const, workMode: "standard" as const, priority, assigneeAgentId, @@ -909,6 +919,171 @@ export class PaperclipRunnerToolAuthority { return result; } + + async #reassignTask(input: Record): Promise { + 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 | 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; + }); + // Cancellation and ownership commit cannot share locks. If the second + // phase loses its preconditions, restore runnable work to the still-current + // prior owner. The durable wake key deduplicates retries; the state guard + // closes races with later reassignment, completion, and manual holds. + const restoreInterruptedWork = async (error: unknown) => { + if (!prepared?.executionRunId || !prepared.assigneeAgentId || !this.binding.enqueueWakeup) return; + const [stopped] = await this.db.select().from(heartbeatRuns).where(and( + eq(heartbeatRuns.id, prepared.executionRunId), eq(heartbeatRuns.companyId, this.binding.companyId), + eq(heartbeatRuns.agentId, prepared.assigneeAgentId), eq(heartbeatRuns.status, "cancelled"), + eq(heartbeatRuns.errorCode, "issue_reassigned"), + )); + if (!stopped) return; + const [current] = await this.db.select().from(issues).where(and( + eq(issues.id, taskId), eq(issues.companyId, this.binding.companyId), + )); + if (!current || current.assigneeAgentId !== prepared.assigneeAgentId || current.assigneeUserId || + current.conversationAgentId || !["todo", "in_progress"].includes(current.status) || + record(current.executionState).status === "pending") return; + if (current.executionRunId && current.executionRunId !== prepared.executionRunId) return; + try { + await this.binding.enqueueWakeup(current.assigneeAgentId, { + source: "assignment", triggerDetail: "system", reason: "issue_assigned", + payload: { issueId: taskId, mutation: "reassign_task_rollback", interruptedRunId: prepared.executionRunId }, + idempotencyKey: `${durableKey}:restore:${prepared.executionRunId}:${current.statusVersion}`, + requestedByActorType: "agent", requestedByActorId: this.binding.agentId, + contextSnapshot: { issueId: taskId, source: "paperclip_runner.reassign_task_rollback", forceFreshSession: true }, + issueStateGuard: { statuses: ["todo", "in_progress"], assigneeAgentId: current.assigneeAgentId, statusVersion: current.statusVersion }, + }); + } catch (restoreError) { + throw new AggregateError([error, restoreError], "paperclip_runner_reassignment_restore_failed"); + } + }; + if (prepared && prepared.assigneeAgentId && prepared.assigneeAgentId !== assigneeAgentId) { + if (prepared.executionRunId && (!this.binding.stopTaskForReassignment || !this.binding.enqueueWakeup)) { + throw new Error("paperclip_runner_reassignment_stop_unavailable"); + } + try { + await this.binding.stopTaskForReassignment?.({ companyId: this.binding.companyId, issueId: taskId, + agentId: prepared.assigneeAgentId, runId: prepared.executionRunId }); + } catch (error) { + await restoreInterruptedWork(error); + throw error; + } + } + let publication: Awaited>["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); } }).catch(async (error: unknown) => { + await restoreInterruptedWork(error); + throw error; + }) 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): Promise { const idempotencyKey = requiredString(input.idempotencyKey); if (!Array.isArray(input.blockedByTaskIds)) { diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index a0d7f63b7ff..7cd125783d7 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -109,7 +109,7 @@ export type PaperclipRunnerNativeProviderInput = provider: "acpx"; model: string; acpxAgent: QualifiedPaperclipRunnerAcpxAgent; - acpxPermissionMode: "approve-all" | "approve-reads" | "deny-all"; + acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; }; export class PaperclipRunnerProviderProfileError extends Error { @@ -476,7 +476,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: { acpxPermissionMode: resolvePaperclipRunnerPermissionMode( "acpx", config.acpxPermissionMode, - ) as "approve-all" | "approve-reads" | "deny-all", + ) as "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all", }; } if (profile.provider === "claude_managed") { diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts index fbdbd73b07a..90c1a37b1ab 100644 --- a/server/src/services/native-runtime/runner-api-catalog.ts +++ b/server/src/services/native-runtime/runner-api-catalog.ts @@ -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"]; diff --git a/server/src/services/native-runtime/task-creation-backlog.test.ts b/server/src/services/native-runtime/task-creation-backlog.test.ts new file mode 100644 index 00000000000..2a1bea6a7b8 --- /dev/null +++ b/server/src/services/native-runtime/task-creation-backlog.test.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { and, eq } from "drizzle-orm"; +import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db"; +import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { documentService } from "../documents.js"; +import { issueService } from "../issues.js"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; + +describe("runner backlog task creation", () => { + let temporary: Awaited>; + let db: ReturnType; + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("runner-backlog-"); + db = createDb(temporary.connectionString); + }); + afterAll(async () => { await temporary?.cleanup(); }); + + async function fixture(conversation: boolean) { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Backlog", issuePrefix: `B${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Planner", role: "ceo", status: "active", adapterType: "paperclip_runner" }); + await db.insert(issues).values({ id: issueId, companyId, title: "Coordinate", status: "in_progress", assigneeAgentId: agentId, conversationAgentId: conversation ? agentId : null, conversationUserId: conversation ? "operator" : null, conversationState: conversation ? "active" : null }); + 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(null); + const binding = { companyId, agentId, issueId, runId, enqueueWakeup }; + return { companyId, agentId, issueId, runId, binding, enqueueWakeup, authority: new PaperclipRunnerToolAuthority(db, binding) }; + } + + it.each([false, true])("persists the plan without waking an assigned backlog task (conversation=%s)", async conversation => { + const f = await fixture(conversation); + const call = { tool: "create_task", callId: "save-later", arguments: { idempotencyKey: "save-later", title: "Plan for later", status: "backlog", initialPlan: "1. Check status.\n2. Save the plan.\n3. Confirm creation." } }; + const receipt = await f.authority.execute(call) as { task: { id: string } }; + expect(receipt).toMatchObject({ scheduledWakeIds: [], task: { status: "backlog", assigneeActorId: f.agentId, parentId: conversation ? null : f.issueId } }); + const [task] = await db.select().from(issues).where(eq(issues.id, receipt.task.id)); + expect(task).toMatchObject({ status: "backlog", executionRunId: null, startedAt: null }); + expect(await documentService(db).getIssueDocumentByKey(task!.id, "plan")).toMatchObject({ body: call.arguments.initialPlan }); + expect(f.enqueueWakeup).not.toHaveBeenCalled(); + await expect(f.authority.execute({ ...call, callId: "retry" })).resolves.toEqual(receipt); + + const replacement = randomUUID(); + await db.insert(heartbeatRuns).values({ id: replacement, companyId: f.companyId, agentId: f.agentId, status: "running", runtimeMode: "native", nativeIssueId: f.issueId, 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(call)).resolves.toMatchObject({ task: { id: task!.id, status: "backlog" }, scheduledWakeIds: [] }); + expect(f.enqueueWakeup).not.toHaveBeenCalled(); + const events = await db.select().from(activityLog).where(and(eq(activityLog.entityId, task!.id), eq(activityLog.action, "issue.created"))); + expect(events).toHaveLength(1); + expect(events[0]!.details).toMatchObject({ status: "backlog" }); + }); + + it.each(["todo", "done"])("keeps an explicit backlog hold with a %s prerequisite", async status => { + const f = await fixture(true); + const blockerId = randomUUID(); + await db.insert(issues).values({ id: blockerId, companyId: f.companyId, title: "Prerequisite", status }); + const receipt = await f.authority.execute({ tool: "create_task", callId: "held", arguments: { idempotencyKey: "held", title: "Held task", status: "backlog", blockedByTaskIds: [blockerId] } }) as { task: { id: string } }; + expect(receipt).toMatchObject({ task: { status: "backlog" }, scheduledWakeIds: [] }); + expect(await issueService(db).getRelationSummaries(receipt.task.id)).toMatchObject({ blockedBy: [expect.objectContaining({ id: blockerId })] }); + expect(f.enqueueWakeup).not.toHaveBeenCalled(); + }); + + it.each(["in_progress", "done", "blocked", null])("rejects unsupported initial status %s before mutation", async status => { + const f = await fixture(true); + await expect(f.authority.execute({ tool: "create_task", callId: "invalid", arguments: { idempotencyKey: "invalid", title: "Invalid", status } })).rejects.toThrow(); + expect(await db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(1); + expect(f.enqueueWakeup).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/services/native-runtime/task-reassignment.test.ts b/server/src/services/native-runtime/task-reassignment.test.ts new file mode 100644 index 00000000000..f28276130dd --- /dev/null +++ b/server/src/services/native-runtime/task-reassignment.test.ts @@ -0,0 +1,203 @@ +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>; + let db: ReturnType; + 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(["version", "caller", "stop-error"])("restores the prior owner after a cancelled handoff loses %s", async conflict => { + 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, status: "running", runtimeMode: "native", invocationSource: "assignment", triggerDetail: "system" }); + await db.update(issues).set({ executionRunId: oldRun }).where(eq(issues.id, f.targetId)); + f.stopTaskForReassignment.mockImplementationOnce(async () => { + await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "issue_reassigned" }).where(eq(heartbeatRuns.id, oldRun)); + if (conflict === "version") await db.update(issues).set({ statusVersion: 1 }).where(eq(issues.id, f.targetId)); + if (conflict === "caller") await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId)); + if (conflict === "stop-error") throw new Error("stop acknowledgement failed after cancellation"); + }); + await expect(f.authority.execute(f.call)).rejects.toThrow(); + expect((await f.target()).assigneeAgentId).toBe(f.agentId); + expect(f.enqueueWakeup).toHaveBeenCalledExactlyOnceWith(f.agentId, expect.objectContaining({ + payload: { issueId: f.targetId, mutation: "reassign_task_rollback", interruptedRunId: oldRun }, + issueStateGuard: { statuses: ["todo", "in_progress"], assigneeAgentId: f.agentId, statusVersion: conflict === "version" ? 1 : 0 }, + })); + }); + + it.each(["owner", "closed", "new-run"])("does not compensate over a newer %s decision", async conflict => { + 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, status: "running", runtimeMode: "native", invocationSource: "assignment", triggerDetail: "system" }); + await db.update(issues).set({ executionRunId: oldRun }).where(eq(issues.id, f.targetId)); + f.stopTaskForReassignment.mockImplementationOnce(async () => { + await db.update(heartbeatRuns).set({ status: "cancelled", errorCode: "issue_reassigned" }).where(eq(heartbeatRuns.id, oldRun)); + const newRun = randomUUID(); + if (conflict === "new-run") await db.insert(heartbeatRuns).values({ id: newRun, companyId: f.companyId, + agentId: f.agentId, status: "running", invocationSource: "assignment", triggerDetail: "system" }); + await db.update(issues).set(conflict === "owner" ? { assigneeAgentId: f.nextId, statusVersion: 1 } + : conflict === "closed" ? { status: "done" } : { executionRunId: newRun }).where(eq(issues.id, f.targetId)); + }); + await expect(f.authority.execute(f.call)).rejects.toThrow(); + 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", "skill_test"])("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); + }); + +}); diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index c689024a78e..05ea24825a5 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -132,18 +132,25 @@ 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 chat profiles use production provider permission defaults, rather than +full-auto overrides, for plan handoff, task creation, and reassignment. +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 +175,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 171 cells (148 local and 23 Daytona) and 371 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 +462,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 171-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 @@ -802,3 +809,5 @@ accepted review started before any parent run finished, the harness fails promptly with an unexercised-boundary diagnostic. Missing evidence in separately fetched snapshots does not trigger this rejection. Successful work alone does not prove that this recovery path was tested. + +The native `agent-chat.create-backlog` case saves a plan and assigned backlog task, then asks for its status. It checks the original creation audit, absence of all task runs, plan persistence, and exactly one task, so creating runnable work and correcting its status afterward fails the eval. diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index e911c3af523..955368c0ad6 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -33,6 +33,22 @@ describe("runner E2E catalog", () => { expect(connectionReviewSuite.tasks.every(task => task.flow === "governed_tool_review")).toBe(true); }); + it("tests native chat plans, tasks, and reassignment with production permission defaults", () => { + const suite = runnerSuites.find(suite => suite.id === "agent-chat")!; + for (const id of ["runner-codex", "runner-acpx-claude"]) { + const profile = suite.profiles.find(profile => profile.id === id)!; + const payload = profile.buildAgent({ + executionId: "default-permissions", workspacePath: "/workspace", environmentId: "env-1", environmentFixtureId: "local", + secretRefs: { + [profile.credential]: { type: "secret_ref", secretId: "22222222-2222-4222-8222-222222222222", version: "latest" }, + }, + }); + expect(payload.adapterConfig).not.toHaveProperty("acpxPermissionMode"); + expect(payload.adapterConfig).not.toHaveProperty("codexPermissionMode"); + expect(suite.tasks.map(task => task.id)).toEqual(expect.arrayContaining(["plan-handoff", "reassign-task", "create-backlog"])); + } + }); + it("validates the core, local-integrity, breadth, and warm suites", () => { expect(runnerProfiles).toHaveLength(7); expect(openRouterBreadthProfiles).toHaveLength(4); @@ -41,10 +57,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, 28, 42, 14, 10, 2, ]); - expect(validateRunnerCatalog()).toHaveLength(205); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(205); + expect(validateRunnerCatalog()).toHaveLength(209); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(209); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(42); @@ -68,7 +84,7 @@ describe("runner E2E catalog", () => { (total, execution) => total + execution.task.expectedRunCount, 0, ), - ).toBe(363); + ).toBe(371); expect( runnerTasks.find((task) => task.id === "plan-revise-accept") ?.attemptTimeoutMs, @@ -561,10 +577,10 @@ describe("runner E2E selectors", () => { const jobs = buildMatrixJobs( selectRunnerExecutions(parseRunnerSelectors(["--all"])), ); - expect(jobs).toHaveLength(167); + expect(jobs).toHaveLength(171); 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(148); + expect(new Set(jobs.map((job) => job.executionId)).size).toBe(171); expect( jobs.find( (job) => diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index dde22bde305..c19dd0a8e37 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -197,6 +197,19 @@ function nativeProfile(input: { }; } +/** Chat acceptance exercises the shipped provider defaults, not full-auto fixtures. */ +function defaultPermissionProfile(profile: RunnerProfileFixture): RunnerProfileFixture { + if (profile.generation !== "native") return profile; + return { + ...profile, + buildAgent(input) { + const payload = profile.buildAgent(input); + const { codexPermissionMode: _codex, acpxPermissionMode: _acpx, ...adapterConfig } = payload.adapterConfig as Record; + return { ...payload, adapterConfig }; + }, + }; +} + const claudeLegacyModel = "claude-sonnet-4-6"; if (!claudeModels.some((model) => model.id === claudeLegacyModel)) { throw new Error( @@ -928,9 +941,10 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ id: "agent-chat", label: "Persistent Agent Chat", 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 }, + profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)).map(defaultPermissionProfile), + environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 28, + excludedExecutionIds: ["legacy-codex", "legacy-claude"].flatMap(profile => ["reassign-task", "create-backlog"].map(task => `agent-chat.${profile}.local.${task}`)), + definitionMetadata: { version: 4, resetRunsCountedSeparately: true, permissions: "production-defaults" }, }, ...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []), { diff --git a/tests/runner-e2e/chat-cases.ts b/tests/runner-e2e/chat-cases.ts index a3fcf4cbbd6..a8f2cd31d70 100644 --- a/tests/runner-e2e/chat-cases.ts +++ b/tests/runner-e2e/chat-cases.ts @@ -10,6 +10,8 @@ export function chatMarker( } export const CHAT_CASES = [ + ["create-backlog", "Save a planned task without starting work", 2], + ["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], diff --git a/tests/runner-e2e/chat-flow.test.ts b/tests/runner-e2e/chat-flow.test.ts index 5b705952dda..c0a1a174fa8 100644 --- a/tests/runner-e2e/chat-flow.test.ts +++ b/tests/runner-e2e/chat-flow.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi } from "vitest"; import type { AskUserQuestionsPayload } from "../../packages/shared/src/types/issue.js"; import { + assertChatBacklogCreation, assertChatHandoff, + assertChatReassignment, assertChatTaskHandoff, chatQuestionPresentation, chatRunFailure, @@ -95,11 +97,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(28); expect(new Set(matrix.map((cell) => cell.profile.id))).toEqual( new Set([ "legacy-codex", @@ -108,7 +110,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(8); expect( matrix.every( (cell) => @@ -363,3 +365,52 @@ 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(); + }); +}); + +describe("backlog creation outcome oracle", () => { + const evidence = () => ({ + tasks: [{ id: "held", companyId: "co", title: "Later", status: "backlog", parentId: null, assigneeAgentId: "planner" }], + runs: [] as ChatRun[], ownerId: "planner", marker: "PLAN123", + plan: { body: "Three steps PLAN123", latestRevisionId: "revision-1", updatedAt: "2026-09-19T00:00:00Z" }, + activity: [{ action: "issue.created", details: { status: "backlog", source: "paperclip_runner_protocol" } }], + }); + it("accepts one planned backlog task with no execution", () => { + expect(() => assertChatBacklogCreation(evidence())).not.toThrow(); + }); + it("does not mistake the creating conversation for task execution", () => { + const data = evidence(); + data.runs.push({ id: "creator", companyId: "co", agentId: "planner", status: "succeeded", contextSnapshot: { issueId: "conversation" } }); + expect(() => assertChatBacklogCreation(data)).not.toThrow(); + }); + it.each(["duplicate", "status", "started-then-stopped", "corrected-after-creation", "owner", "plan"])("rejects %s", defect => { + const data = evidence(); + if (defect === "duplicate") data.tasks.push({ ...data.tasks[0]!, id: "duplicate" }); + if (defect === "status") data.tasks[0]!.status = "todo"; + if (defect === "started-then-stopped") data.runs.push({ id: "early", companyId: "co", agentId: "planner", status: "cancelled", contextSnapshot: { issueId: "held" } }); + if (defect === "corrected-after-creation") data.activity[0]!.details.status = "todo"; + if (defect === "owner") data.tasks[0]!.assigneeAgentId = "other"; + if (defect === "plan") data.plan.body = "I saved a plan"; + expect(() => assertChatBacklogCreation(data)).toThrow(); + }); +}); diff --git a/tests/runner-e2e/chat-flow.ts b/tests/runner-e2e/chat-flow.ts index 6becdb69e7b..24d5aa9233c 100644 --- a/tests/runner-e2e/chat-flow.ts +++ b/tests/runner-e2e/chat-flow.ts @@ -49,6 +49,25 @@ type Comment = { type Plan = { body: string; latestRevisionId: string; updatedAt: string }; type ChatOutputDocument = Plan & { id: string; issueId: string; key: string }; +export function assertChatBacklogCreation(input: { + tasks: ChatIssue[]; + runs: ChatRun[]; + ownerId: string; + plan: Plan; + marker: string; + activity: Array<{ action: string; details?: Record }>; +}) { + expect(input.tasks).toHaveLength(1); + expect(input.tasks[0]).toMatchObject({ status: "backlog", parentId: null, assigneeAgentId: input.ownerId }); + expect(input.runs.filter(run => run.contextSnapshot?.issueId === input.tasks[0]!.id)).toHaveLength(0); + expect(input.plan.body).toContain(input.marker); + expect(input.plan.latestRevisionId).toBeTruthy(); + const created = input.activity.filter(row => row.action === "issue.created"); + expect(created).toHaveLength(1); + // Correcting todo after creation still permits an unauthorized start race. + expect(created[0]!.details).toMatchObject({ status: "backlog", source: "paperclip_runner_protocol" }); +} + /** Clarification may request information imperatively rather than end in a question mark. */ export function isChatClarificationReply(body: string): boolean { if (body.includes("?")) return true; @@ -249,12 +268,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 }>; 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; observe: (issue: ChatIssue, runs: ChatRun[]) => void; capture: (id: string, label: string, file: string) => Promise; @@ -465,6 +502,63 @@ export async function runChatFlow(input: { } expect(issue!.id).toBe(initialId); await noTasks(); + } else if (caseId === "create-backlog") { + const project = await api.post<{ id: string }>(`/api/companies/${f.company.id}/projects`, { + name: `Later planning ${nonce}`, description: "Repository-free plans to save for later.", + }); + await turn(`Create exactly one task titled Later checklist ${nonce} in the existing Later planning ${nonce} project. Assign it to yourself but keep it in backlog: do not start or execute it. Save a concise three-step initial plan that contains ${marker}. Reply with its identifier and status. Do not create a replacement task or change other tasks.`, 1); + const saved = (await tasks())[0]!; + expect(saved).toBeTruthy(); + const plan = await api.get(`/api/issues/${saved.id}/documents/plan`); + await turn(`What are the current owner and status of ${saved.identifier}? Just report its saved state. Do not execute it, change its status, or create another task.`, 2); + const observedTasks = await tasks(); + // Issue activity also links the conversation run that created the task. + // Inspect actual run bindings to distinguish creation from execution. + const observedRuns = await allRuns(); + const activity = await api.get }>>(`/api/issues/${saved.id}/activity`); + const persistedPlan = await api.get(`/api/issues/${saved.id}/documents/plan`); + assertChatBacklogCreation({ tasks: observedTasks, runs: observedRuns, ownerId: f.agent.id, plan: persistedPlan, marker, activity }); + expect(observedTasks[0]).toMatchObject({ id: saved.id, projectId: project.id }); + expect(persistedPlan).toEqual(plan); + expect((await comments()).filter(c => c.authorAgentId).at(-1)?.body).toMatch(/backlog/i); + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }); + await expect(page.getByTestId("task-chat-composer-input")).toBeVisible(); + await expect(page.getByRole("link", { name: saved.identifier! }).first()).toBeVisible(); + await input.capture("chat-backlog", "Planned backlog task without execution", "chat-backlog.png"); + await input.evidence("chat-backlog.json", { tasks: observedTasks, runs: observedRuns, activity, plan: persistedPlan }); + } 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(`/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(`/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(`/api/issues/${ready.id}`); + const queuedAfter = await api.get(`/api/issues/${queued.id}`); + const output = await readChatOutputDocument(api, ready.id, marker); + const activity = await api.get }>>(`/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; diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index 19ff9d9cb59..1523fa36706 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -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,