mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
fix(recovery): default saved-monitor waits and keep task recovery quiet (#16686)
## Thinking Path > - Paperclip manages AI agents and their work. > - A task must keep a durable path to its next action. > - A provider can save a task monitor, then fail to yield because it omits internal continuation fields. > - An interrupted provider-pack check can then be reported as permanent artifact incompatibility. > - The task asks its user to certify recovered workspace files, even though this is an operator responsibility. > - This pull request derives the saved monitor continuation, bounds verification retries, and removes that task form. > - Normal recovery stays quiet. Unresolved holds retain inspection and the existing safety checks. ## Linked Issues or Issue Description **What happened?** A yielded completion without `continuation` is rejected even after the provider saves an eligible monitor on the task. Repeated attempts cannot finish. Remote verification also treats timeouts and missing exit status as artifact incompatibility. The task recovery notice exposes raw diagnostics and asks the task user to certify infrastructure files. **Expected behavior** Use current persisted task state to fill routine continuation fields. Retry interrupted verification reads within a fixed bound. Keep normal recovery in the working state. Show a compact notice only when work cannot safely continue. **Steps to reproduce** 1. Save an eligible monitor on the active task. 2. Call `paperclip_finish` with `reportedWorkDisposition: "yielded"` and omit `continuation`. 3. Return a timed-out remote provider-pack command with empty stderr during recovery. Inspect the resulting task notice. **Paperclip version or commit** Source baseline: `aaeff418cf` (master integrated with its current task visibility and saved-source reconstruction rules). Related changes: #15824, #16104, #16451, #16493, #16690. This fix complements saved-result recovery and verified pack reuse. It does not replace their workspace ownership checks. ## What Changed - Allow omitted or null continuation at the provider input boundary. Read current authorized task context and fill a monitor continuation only for the bound run and an eligible saved monitor. Keep accepted results strict. Apply this to Codex, OpenCode, both direct and production-sidecar ACPX paths, and external drivers. Reserve the semantic call identity during the saved-context read and reject cancellation or a changed run/turn. - Bind the shared completion change and continuation helper in a new Copilot v21 source declaration. Preserve v20 history and its decoder support. Qualification stays pending. - Expose the eligible continuation in task context. Completion admission still checks persisted monitor state. No schedule is created or renewed by the default. - Retry read-only provider verification at most twice per command. Hold exhausted reads with a distinct verification-unavailable cause. Reattachment cannot renew the read budget through an unchanged provider-attempt counter. Keep completed verification failures and wrong versions incompatible. - Remove the task file-certification form. Hide the notice only for a matching durable recovery path without an unknown-action replay hold. Keep missed retries, unsafe outcomes, and unresolved file holds visible. - Add production-component stories for automatic recovery, blocked and expired holds, reader access, persisted repair decisions, source reconstruction, queued continuation, and mobile. Preserve master’s guarded saved-source reconstruction choice after confirmed source destruction. Update the execution contract and access maps. ## Verification - Completion, helper, Codex, OpenCode, ACPX, and profile regressions pass. After the sidecar review fix, 271 tests across the production sidecar and three provider suites pass, plus 72 helper/profile tests. The real sidecar MCP bridge tests cover saved-monitor acceptance, no-monitor refusal, cancellation, duplicate call identity, and raw-versus-forwarded semantic receipt hashes. The OpenCode tests also cover eligible-monitor acceptance and refusal without one. - The fresh full native executor suite passes all 743 tests. Four profile, installation-integrity, and ACPX driver suites pass all 168 tests. The earlier CI fingerprint failure required a new immutable source declaration; its follow-up Docker failure required the matching context allowlist entry. Both fixes are verified locally. Targeted server monitor, tool authority, and provider verification suites pass. The probe tests include a real timed-out subprocess, an unrelated responsive timer, transport errors, and completed integrity rejections. - All 90 tests in the merged task notice, reconstruction choice, recovery display, and lineage suites pass. They cover reader access, live/missed/mismatched recovery, unknown outcomes, saved messages, retry refusal, and removal of the file form. Both review findings have failing-baseline/passing-treatment regressions: identical polling across retry expiry, and a failed refresh after cached running activity. - Browser walkthrough passed for automatic recovery, unresolved details, a 390px mobile story, and the merged saved-source reconstruction and queued states. The reconstruction control starts disabled and normal automatic recovery shows only Working. The stories use production components and fixture API data. They do not prove live cloud recovery. - Token gates, map checks, and Storybook catalog generation pass. The exact Docker context integrity command passes locally after adding the new build declaration to its allowlist. - Workspace typecheck and build pass on the final source, including the versioned identity update. The final server and UI guards pass their focused tests, typechecks, builds, and token gates. The final standalone Storybook builds. The saved-monitor default retains master’s stricter child-task visibility filter. Re-review caught an additional production-sidecar validator before forwarding; the fix now defaults at that boundary and has passing regression controls. After preserving master’s new reconstruction capability, 68 targeted reconstruction, tool-authority, and provider-verification server tests pass. The current-master integration at `2a80050a0d` has a fresh 5/5 review with zero unresolved threads. Its complete CI matrix, including Runner tests, is the merge acceptance record. - On the final master integration, local typecheck, build, and all 90 recovery UI tests pass. A concurrent runner run hit five 5-second sidecar startup timeouts; an isolated sidecar run passed 86 tests and timed out in the unrelated unsupported-bootstrap case. Those local deadline failures remain recorded; the canonical CI Runner lanes use their normal deadlines and pass. The unsharded local full-suite attempt did not complete. It spanned source edits; the fresh native executor run above passes. An unrelated workspace fixture timed out once and then failed to reserve 32 contiguous ports on this host. Its unchanged-base control and CI server suite pass. Full-suite acceptance uses the fresh latest-head CI matrix; this PR does not claim a passing unsharded local run. ## Risks The default applies only to an eligible persisted monitor. An explicit invalid continuation still fails validation. The added read occurs only when a yielded report omits its continuation. Admission rechecks current state after that read. An interrupted verification command can add one extra 30-second probe. Completed rejections stay closed. These probes do not change the pack, launch provider work, or replay external tools. Two inconclusive reads hold the original run. They do not schedule another reattachment with an unchanged provider-attempt counter. This PR’s pending Copilot v21 declaration binds the changed shared contract, resolver, direct driver, and production sidecar sources. The previous v20 declaration is immutable and remains decodable, but launch requires the new exact identity. Qualification remains pending. Task users lose the file-certification form. Board-authorized source-only repair remains available through the resolution API with the original evidence and stop-receipt requirements. This branch does not deploy or recover historical held runs. > This is a bug fix and UI polish within existing recovery behavior. ROADMAP.md was checked. ## Model Used OpenAI Codex, `gpt-6.1-sol`, xhigh reasoning, with tool use and code execution. The host does not expose the context-window limit. ## 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 (changed-area suites; the full local run limitation is recorded above) - [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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
ad9f5edd49
commit
3858d2341d
40 files changed
+851
-327
No files matched your search
@@ -49,6 +49,7 @@ packages/paperclip-runner/test/fixtures/**
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v19-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v19-muse-historical-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v20-identity.json
|
||||
!packages/paperclip-runner/test/fixtures/copilot-profile-v21-identity.json
|
||||
!packages/paperclip-runner/test-fixtures
|
||||
packages/paperclip-runner/test-fixtures/**
|
||||
!packages/paperclip-runner/test-fixtures/pi-acp
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
# Copilot 1.0.88 rich ACP capability audit
|
||||
|
||||
Current integration (2026-10-15): **Copilot profile v20 remains pending** with
|
||||
Current integration (2026-10-17): **Copilot profile v21 remains pending** with
|
||||
digest `sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a`.
|
||||
It binds the shared provider completion contract, direct driver, production sidecar, and controller
|
||||
continuation resolver. Installation verification requires this generated v21
|
||||
identity. The permission policy, executable closure, and qualification gate are
|
||||
unchanged. This source declaration supplies no new paid qualification.
|
||||
|
||||
Historical integration (2026-10-15): **Copilot profile v20 remained pending** with
|
||||
digest `sha256:7fa710c697582b1b4d85cdf833178b1940656cb6efb44ac4d356b2f27e6b3d4b`.
|
||||
Current installation verification requires this generated v20 identity. This
|
||||
source identity declaration supplies no new paid qualification.
|
||||
Its immutable declaration remains available for decoding historical checkpoints;
|
||||
it is no longer the current launch identity.
|
||||
|
||||
Both profile v19 identities remain historical: upstream credential-observer
|
||||
`sha256:38abf359adeef43f1f19230245c6e2051c55fdd79571882ef6f00b7cb2958d93` and Muse
|
||||
|
||||
@@ -423,6 +423,16 @@ An external wait counts as a live or waiting path only when the next move surviv
|
||||
|
||||
A one-shot issue monitor consumes its persisted `nextCheckAt` when it dispatches the assignee wake. If that monitor-consuming run is lost before it records a new disposition or future monitor, Paperclip restores exactly one bounded continuation using the existing process-loss retry limit; if that continuation is also lost, the normal recovery-action escalation owns the next step instead of creating another monitor loop.
|
||||
|
||||
A native provider may omit `continuation` (or send `null`) when it reports
|
||||
`yielded` after saving a monitor. The controller reads current authorized task
|
||||
context and fills the canonical `monitor` continuation only if that same task,
|
||||
run, assignee, work mode, and monitor remain eligible. Completion admission
|
||||
checks that persisted monitor again. This default does not create or renew a
|
||||
schedule. A consumed, cleared, exhausted, or ineligible monitor cannot authorize
|
||||
a passive wait. An explicit `response_wake` still identifies a real response
|
||||
wait; the controller does not invent one from prose. Canonical accepted results
|
||||
still require a complete continuation for `yielded`.
|
||||
|
||||
An unmanaged local process is not a durable action path. Shell jobs started with `&`, `nohup`, local polling loops, detached PTY sessions, adapter child processes, or similar background watchers do not keep an issue live unless Paperclip persists them as a run or pairs a managed runtime service with a monitor, scheduled wake, blocker, or delegated issue that owns the next check. A PID, session id, log file, comment, or promise to check later is evidence only. The process may be killed when the adapter invocation or heartbeat exits and cannot be assumed observable or recoverable by another worker.
|
||||
|
||||
Before a heartbeat finalizes, its issue disposition must therefore be evaluated from durable Paperclip state, not from processes still visible only to that heartbeat. An agent-owned issue may remain `in_progress` after the heartbeat only when another valid action-path primitive already exists. If the only claimed continuation is a local/background watcher, finalization treats the issue as having no live path even when the process has not yet been observed exiting.
|
||||
|
||||
@@ -360,6 +360,28 @@ provider still holds the original bytes or that copy-back succeeded. Operators
|
||||
must verify and recover the exact source separately. Later successful runs and
|
||||
completed tasks do not certify recovery of earlier workspace files.
|
||||
|
||||
### Quiet task recovery and provider verification
|
||||
|
||||
The task conversation does not ask its user to certify recovered infrastructure
|
||||
files. A source-bound recovery with a live native run or an eligible future
|
||||
bounded retry uses the normal working presentation. A missed retry, exhausted
|
||||
budget, unknown action outcome, or unresolved file hold remains visible. The
|
||||
notice gives a brief explanation and a run-inspection link; stored diagnostics
|
||||
and operator guidance remain under Recovery details. Source-only repair remains
|
||||
available through the board-authorized recovery resolution API and its original
|
||||
stop-receipt and evidence checks.
|
||||
|
||||
Remote provider-pack verification retries an interrupted read at most once.
|
||||
Each command retains its 30-second transport timeout. A timeout, signal, absent
|
||||
exit status, or transport exception does not prove artifact incompatibility.
|
||||
Two inconclusive attempts hold the original run with
|
||||
`runner_remote_provider_verification_unavailable`. Reattachment does not spend
|
||||
a new provider attempt, so it cannot renew this exhausted read budget. A
|
||||
completed nonzero verification, digest mismatch, or
|
||||
wrong OpenCode version remains incompatible and cannot authorize a new provider
|
||||
turn. Recovery retains the exact source workspace and existing ownership gates;
|
||||
these read-only retries do not replay tools or alter a retained pack.
|
||||
|
||||
### Continue after confirmed source destruction
|
||||
|
||||
When an old export failed and its exact sandbox has a confirmed destruction
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Automatic task recovery — 2026-10-17
|
||||
|
||||
Outcome: normal execution and recoverable interruptions continue without a task
|
||||
user certifying infrastructure facts. Deliver a reviewed PR and runnable
|
||||
Storybooks from `codex/automatic-task-recovery`. The observed staging incident
|
||||
does not authorize replaying its completed GitHub writes or approving
|
||||
its maintainer completion decision.
|
||||
|
||||
## Acceptance
|
||||
|
||||
| Case | Expected behavior | Evidence |
|
||||
| --- | --- | --- |
|
||||
| Yield after saving an eligible task monitor | Omitted continuation resolves from current server state; the original monitor remains the wake path. | Real tool-boundary regression and server admission tests. |
|
||||
| Yield without a valid wait, stale owner, or invalid explicit continuation | No invented passive wait or successful completion; useful correction feedback. | Refusal controls. |
|
||||
| Provider-pack timeout or unavailable command result | Bounded infrastructure retry, with a diagnostic distinct from a proved integrity mismatch. | Command-boundary fault tests and existing recovery bounds. |
|
||||
| Actual provider-pack mismatch | Preserve exact source work and existing ownership/integrity holds; no speculative replay. | Failure classification and retention controls. |
|
||||
| Automatic recovery, then restored execution | No task repair form or recovery-needed warning; retain ordinary Working/finishing presentation. | Shared production components in Storybook and a running-app walkthrough. |
|
||||
| Recovery cannot safely continue | Concise truthful blocker with inspection; technical repair stays with the operator. | Existing native/legacy holds, saved messages, retry refusal, mobile and keyboard coverage. |
|
||||
|
||||
## Delivery slices and ownership
|
||||
|
||||
The current chat owns implementation and integration. First resolve completion
|
||||
defaults at the shared provider boundary, then classify runtime verification
|
||||
failures, then simplify the shared task recovery notice and its stories. Keep
|
||||
the existing retry budgets and ownership, approval, pause, dependency and archive
|
||||
confinement gates. Do not create a parallel recovery scheduler.
|
||||
|
||||
The first experiment is a missing-continuation tool call against a current saved
|
||||
monitor. Use focused local tests before broader required typecheck/test/build.
|
||||
Storybook uses real production components with simulated API data; it is UI
|
||||
evidence, not proof of provider recovery. Preserve story IDs and update authored
|
||||
map recipes where behavior changes.
|
||||
|
||||
## State
|
||||
|
||||
- Baseline: `cfa9dce295`; incident diagnosis and October 13 chat located.
|
||||
- Observed incident: six rejected finish calls, then graceful interruption and failed
|
||||
reattachment. Published PR #16639 head `d99284b` is preserved.
|
||||
- The runtime verifier currently conflates timeout and nonzero exit as permanent
|
||||
artifact incompatibility. Its empty stderr does not establish a digest mismatch.
|
||||
- Dependencies installed with pinned pnpm 9.15.4. Storybook target: localhost:6195.
|
||||
- Safe monitor inference passes canonical/provider contract, real Codex tool,
|
||||
and persisted server monitor tests. Missing/consumed/cleared schedules cannot
|
||||
create a wait.
|
||||
- The task form is removed. Production-component stories demonstrate quiet
|
||||
automatic recovery, unresolved/expired holds, reader access, and mobile.
|
||||
- Provider verification now retries interrupted reads once, distinguishes
|
||||
incomplete checks from incompatible artifacts, and holds after its own read
|
||||
budget is exhausted. Reattachment cannot renew that budget through an
|
||||
unchanged provider-attempt counter.
|
||||
- OpenCode process-boundary refusal and acceptance pass; all 218 selected
|
||||
provider tests and 84 recovery UI tests pass. Workspace typecheck/build and
|
||||
focused final server/UI checks pass. The standalone Storybook builds.
|
||||
- PR [#16686](https://github.com/paperclipai/paperclip/pull/16686) is published.
|
||||
Its initial review found unchanged-poll expiry and cached-activity read errors;
|
||||
both now have failing-baseline/passing-treatment regressions and fixes. The
|
||||
latest review and CI are the delivery record. The unsharded local full-suite
|
||||
attempt was incomplete and exposed an unrelated workspace port-reservation
|
||||
fixture failure; focused changed-area tests pass, and final full-suite evidence
|
||||
comes from the fresh CI matrix. The source incident remains held pending safe
|
||||
operator inspection; this branch has not been deployed.
|
||||
- The production ACPX sidecar also resolves omitted continuation before strict
|
||||
validation, with real MCP bridge acceptance/refusal, receipt, cancellation,
|
||||
and duplicate-call controls. Other drivers recheck the turn after the context
|
||||
read. This PR's pending Copilot v21 declaration binds the updated shared
|
||||
sources; immutable v20 history and the qualification gate stay intact.
|
||||
- Master integration preserves the newer saved-source reconstruction path after
|
||||
confirmed source destruction. The task keeps its collapsed operator decision
|
||||
and a truthful queued status; automatic run recovery still shows no notice.
|
||||
Production-component stories cover both reconstruction states as well.
|
||||
@@ -104,11 +104,12 @@ receipts, and recovered-file evidence. A successful result does not permit a
|
||||
normal execution retry, and a queued or running source cannot be repaired this
|
||||
way. Later saved messages can continue through ordinary admission after the
|
||||
file hold clears.
|
||||
The board shows repair guidance, an Inspect run link, and a Record file recovery
|
||||
form for operators with runtime access. The form requires recovered-file evidence
|
||||
and confirmation; the API verifies the original stop receipts. The operator API
|
||||
also supports this workflow and returns the exact action and lease IDs. Provider
|
||||
access may require an instance administrator or Cloud support.
|
||||
The task shows a compact operator-attention notice and an Inspect run link.
|
||||
Diagnostics and repair guidance stay under Recovery details. It does not ask the
|
||||
task user to certify recovered infrastructure files. Operators use the guarded
|
||||
resolution API with recovered-file evidence; the API verifies the original stop
|
||||
receipts and returns the exact action and lease IDs. Provider access may require
|
||||
an instance administrator or Cloud support.
|
||||
Then use the provider's operator console to remove that exact retained
|
||||
allocation when its files are no longer needed. Verify its immutable provider
|
||||
allocation ID against the recorded lease before removal. The ordinary reusable
|
||||
|
||||
@@ -113,10 +113,12 @@ supports source-only file repair. Board runtime management and recovery-resoluti
|
||||
authority are required. The request identifies the exact source action/run and
|
||||
records recovered-file evidence; the server verifies the original stop receipt.
|
||||
It preserves the current task status and does not wake or replay the agent. The
|
||||
task form fails closed without permission or source bindings. Agent Chat retains
|
||||
the file diagnosis while this hold exists. The
|
||||
task notice does not expose a file-certification form. The API still fails closed
|
||||
without permission or source bindings. Agent Chat retains the file diagnosis
|
||||
while this hold exists and suppresses the warning only for a verified durable
|
||||
recovery path. The
|
||||
[repair route regressions](../../server/src/__tests__/workspace-restore-repair-route.test.ts)
|
||||
and [file form](../../ui/src/components/WorkspaceFileRecovery.test.tsx) cover the
|
||||
and [task notice](../../ui/src/components/ExecutionBlockerNotice.test.tsx) cover the
|
||||
local contract; they do not independently recover files or qualify a live provider.
|
||||
|
||||
Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at
|
||||
|
||||
@@ -160,15 +160,18 @@ The recovery-card suite covers workspace divergence, reissue, reconcile, and
|
||||
confirmation. [Workspace export](../../ui/src/components/WorkspaceExportRecovery.test.tsx)
|
||||
covers quiet restoration, legacy/operator assistance and resolved states.
|
||||
|
||||
For a retained legacy or native file hold, open the source task after its recovery action
|
||||
has resolved but its files still need recovery. An authorized board operator can
|
||||
record the verified recovered files and backup link without changing the current
|
||||
task status or starting another run. The server must independently validate the
|
||||
original stopped-execution receipt. Agent Chat must show the file hold rather than
|
||||
suggesting another message. The [file recovery form](../../ui/src/components/WorkspaceFileRecovery.test.tsx),
|
||||
[notice](../../ui/src/components/ExecutionBlockerNotice.test.tsx), and
|
||||
For a retained legacy or native file hold, open the source task after its recovery
|
||||
action has resolved but its files still need recovery. The task shows a compact
|
||||
operator-attention notice with inspection and collapsed diagnostics, without a
|
||||
file-certification form. A source-bound live recovery uses the ordinary working
|
||||
state; a missed or exhausted retry and an unknown action outcome remain visible.
|
||||
An authorized board operator can use the resolution API to record verified files
|
||||
and a backup link without changing task status or starting another run. The
|
||||
server independently validates the original stopped-execution receipt. Agent
|
||||
Chat retains the hold rather than suggesting another message. The
|
||||
[notice](../../ui/src/components/ExecutionBlockerNotice.test.tsx) and
|
||||
[repair route](../../server/src/__tests__/workspace-restore-repair-route.test.ts)
|
||||
cover evidence/confirmation, read-only access, stale or missing stop proof,
|
||||
cover quiet live recovery, read-only access, stale or missing stop proof,
|
||||
source-only repair, and preserved task status. These tests use fixtures; separately
|
||||
verify original files and their attached artifact before recording a real repair.
|
||||
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
"requiresProviderPolicy": true
|
||||
},
|
||||
"copilot": {
|
||||
"agentProfileVersion": 20,
|
||||
"agentProfileVersion": 21,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:7fa710c697582b1b4d85cdf833178b1940656cb6efb44ac4d356b2f27e6b3d4b",
|
||||
"commandDigest": "sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a",
|
||||
"requiresProviderPolicy": true,
|
||||
"qualificationStatus": "pending"
|
||||
},
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
|
||||
agent_runtime_package: None,
|
||||
agent_runtime_version: None,
|
||||
command_digest:
|
||||
"sha256:7fa710c697582b1b4d85cdf833178b1940656cb6efb44ac4d356b2f27e6b3d4b",
|
||||
"sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a",
|
||||
requires_provider_policy: true,
|
||||
},
|
||||
"claude" => AcpxReleaseProfile {
|
||||
|
||||
@@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract;
|
||||
assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`);
|
||||
assert.equal(contract.acpxPatchSha256, createHash("sha256")
|
||||
.update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex"));
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v23-identity.json"], ["copilot", "test/fixtures/copilot-profile-v20-identity.json"]]) {
|
||||
for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v23-identity.json"], ["copilot", "test/fixtures/copilot-profile-v21-identity.json"]]) {
|
||||
const identity = await readJson(path);
|
||||
assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion);
|
||||
assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest);
|
||||
|
||||
@@ -2,10 +2,11 @@ import { acpxProfileActivity } from "../drivers/acpx/profile-activity.js";
|
||||
import { appendSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha256 } from "../drivers/semantic-tool-receipt.js";
|
||||
import { startRunnerToolBridge, type RunnerToolCall } from "../drivers/runner-tool-bridge.js";
|
||||
import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js";
|
||||
import { resolveCompletionContinuation } from "../drivers/completion-continuation.js";
|
||||
import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "../drivers/acpx/usage-accounting.js";
|
||||
import { stripTypeScriptTypes } from "node:module";
|
||||
import { cursorPlanToolIdentity, cursorToolIdentity } from "../drivers/acpx/cursor-plan-tool-identity.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -314,6 +315,62 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([true, false])("resolves an omitted continuation before the production sidecar validator only with a saved monitor: %s", async eligible => {
|
||||
const raw = { reportedWorkDisposition: "yielded", summary: "The review fix is published.", evidence: [], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: false, criteria: [], remainingWork: [{ description: "Wait for checks.", blocksCompletion: true }] } };
|
||||
const continuation = { kind: "monitor", summary: "Wait for the saved task monitor.", idempotencyKey: "task-monitor:task:deadline" };
|
||||
const tools = new Map<string, any>(), emitted: any[] = [];
|
||||
const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult });
|
||||
const commands = loadActualToolCommands(tools);
|
||||
const bridge = await startRunnerToolBridge({ handler, captureSemanticReceipt: () => () => {} });
|
||||
try {
|
||||
const response = fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }) });
|
||||
await vi.waitFor(() => expect(emitted.find(event => event.operationId === "get_task_context")).toBeDefined());
|
||||
const read = emitted[0];
|
||||
await commands.resolve({ callId: read.callId, turnId: "test-turn", result: {
|
||||
run: { id: "test-run" }, activeTask: { executionRunId: "test-run" }, completionContinuation: eligible ? continuation : null,
|
||||
}, error: null });
|
||||
if (eligible) {
|
||||
await vi.waitFor(() => expect(tools.has("3")).toBe(true));
|
||||
expect(emitted.map(event => event.operationId)).toEqual(["get_task_context", "paperclip_finish"]);
|
||||
expect(emitted[1].input).toMatchObject({ reportedWorkDisposition: "yielded", continuation });
|
||||
expect(validatePrpStructuredRunResult(emitted[1].input).ok).toBe(true);
|
||||
await commands.resolve({ callId: "3", turnId: "test-turn", result: { accepted: true }, error: null });
|
||||
}
|
||||
const body = await (await response).json();
|
||||
const receipt = readNativeSemanticReceipt({ contents: body.result.content });
|
||||
expect(receipt).toMatchObject({ inputSha256: semanticInputSha256(raw),
|
||||
normalizedInputSha256: eligible ? semanticInputSha256(emitted[1].input) : null, outcome: eligible ? "returned" : "error" });
|
||||
if (!eligible) {
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(body.result.isError).toBe(true);
|
||||
expect(body.result.content[0].text).toContain("no eligible saved monitor");
|
||||
}
|
||||
} finally {
|
||||
for (const pending of tools.values()) { pending.cleanup(); pending.reject(new Error("test cleanup")); }
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps sidecar completion identity reserved and refuses cancellation during its monitor read", async () => {
|
||||
const tools = new Map<string, any>(), emitted: any[] = [], capture = vi.fn(() => vi.fn());
|
||||
const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult });
|
||||
const controller = new AbortController();
|
||||
const call = { tool: "paperclip_finish", callId: "finish", arguments: { reportedWorkDisposition: "yielded", summary: "Wait for checks.", evidence: [], verification: [],
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: false, criteria: [], remainingWork: [] } },
|
||||
signal: controller.signal, captureNormalizedInput: capture };
|
||||
const result = handler(call).catch(error => error);
|
||||
try {
|
||||
await vi.waitFor(() => expect(emitted).toHaveLength(1));
|
||||
await expect(handler(call)).rejects.toThrow("duplicated");
|
||||
} finally { controller.abort(); }
|
||||
expect((await result).message).toContain("cancelled");
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(tools.size).toBe(0);
|
||||
expect(emitted[0].operationId).toBe("get_task_context");
|
||||
});
|
||||
|
||||
it("does not capture normalized authority when actual sidecar validation fails", async () => {
|
||||
const emitted: unknown[] = [], capture = vi.fn(() => vi.fn());
|
||||
const wait = loadWaitForTool({ tools: new Map(), emitted, validate: validatePrpStructuredRunResult });
|
||||
@@ -363,6 +420,7 @@ describe("qualified ACPX runtime sidecar", () => {
|
||||
await expect(pending).rejects.toThrow("fixture write failed");
|
||||
expect(commit).not.toHaveBeenCalled(); expect(tools.size).toBe(0); expect(wire.sequence()).toBe(0);
|
||||
} else {
|
||||
await vi.waitFor(() => expect(tools.has("3")).toBe(true));
|
||||
expect(commit).not.toHaveBeenCalled(); expect(tools.has("3")).toBe(true); expect(wire.sequence()).toBe(1);
|
||||
expect(JSON.parse(wire.writes[0]!).payload.input).toEqual(capture.mock.calls[0]![0]);
|
||||
await loadActualToolCommands(tools).resolve({ callId: "3", turnId: "test-turn", result: { accepted: true }, error: null });
|
||||
@@ -1274,15 +1332,11 @@ function loadWaitForTool(input: {
|
||||
const start = source.indexOf("async function waitForTool");
|
||||
const end = source.indexOf("\nasync function waitForPermission", start);
|
||||
if (start < 0 || end < 0) throw new Error("waitForTool source not found");
|
||||
const functionSource = source
|
||||
.slice(start, end)
|
||||
.replace(
|
||||
"async function waitForTool(call: RunnerToolCall): Promise<unknown>",
|
||||
"async function waitForTool(call)",
|
||||
);
|
||||
const functionSource = stripTypeScriptTypes(source.slice(start, end));
|
||||
const factory = new Function(
|
||||
"boundedIdentity", "tools", "turnId", "emit", "PRP_COMPLETION_TOOL_NAME",
|
||||
"PRP_BLOCK_TOOL_NAME", "validatePrpStructuredRunResult", "boundedSidecarValue", "record", "MAX_PENDING_TOOLS", "stringifyAcpxSidecarFrame",
|
||||
"resolveCompletionContinuation", "runId", "normalizingTools", "randomUUID",
|
||||
`return (${functionSource});`,
|
||||
);
|
||||
return factory(
|
||||
@@ -1300,6 +1354,10 @@ function loadWaitForTool(input: {
|
||||
(value: unknown) => value,
|
||||
512,
|
||||
stringifyAcpxSidecarFrame,
|
||||
resolveCompletionContinuation,
|
||||
"test-run",
|
||||
new Set<string>(),
|
||||
randomUUID,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { parseProviderMode } from "../contracts/provider-mode.js";
|
||||
import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "../drivers/acpx/profile-activity.js";
|
||||
|
||||
import { resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { resolveCompletionContinuation } from "../drivers/completion-continuation.js";
|
||||
import { createInterface } from "node:readline";
|
||||
import { deliverAcpxResponse, requireAcpxResponseDelivery } from "../drivers/acpx/response-delivery.js";
|
||||
import { createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js";
|
||||
@@ -166,6 +167,7 @@ let initializedAgent: QualifiedAcpxAgent | null = null;
|
||||
let initializedModel: string | null = null;
|
||||
let inputClosed = false;
|
||||
const tools = new Map<string, PendingTool>();
|
||||
const normalizingTools = new Set<string>();
|
||||
const inputs = new Map<string, PendingInput>();
|
||||
const permissions = new Map<string, PendingPermission>();
|
||||
|
||||
@@ -694,20 +696,33 @@ async function pumpTurn(
|
||||
|
||||
async function waitForTool(call: RunnerToolCall): Promise<unknown> {
|
||||
const activeTurnId = turnId;
|
||||
if (!activeTurnId || call.signal.aborted) {
|
||||
const activeRunId = runId;
|
||||
if (!activeTurnId || !activeRunId || call.signal.aborted) {
|
||||
throw new Error("ACPX tool call is not bound to an active turn");
|
||||
}
|
||||
const callId = boundedIdentity(call.callId, "callId");
|
||||
if (tools.has(callId)) throw new Error("ACPX tool call is duplicated");
|
||||
if (tools.has(callId) || normalizingTools.has(callId)) throw new Error("ACPX tool call is duplicated");
|
||||
const operationId = boundedIdentity(call.tool, "operationId");
|
||||
if (tools.size >= MAX_PENDING_TOOLS) {
|
||||
if (tools.size + normalizingTools.size >= MAX_PENDING_TOOLS) {
|
||||
throw new Error("ACPX pending tool limit reached");
|
||||
}
|
||||
if (
|
||||
operationId === PRP_COMPLETION_TOOL_NAME ||
|
||||
operationId === PRP_BLOCK_TOOL_NAME
|
||||
) {
|
||||
const validation = validatePrpStructuredRunResult(call.arguments);
|
||||
let completionInput: unknown;
|
||||
normalizingTools.add(callId);
|
||||
try {
|
||||
completionInput = await resolveCompletionContinuation(call.arguments, activeRunId, () => waitForTool({
|
||||
tool: "get_task_context", callId: `continuation:${randomUUID()}`, arguments: {}, signal: call.signal,
|
||||
}));
|
||||
} finally {
|
||||
normalizingTools.delete(callId);
|
||||
}
|
||||
if (turnId !== activeTurnId || runId !== activeRunId || call.signal.aborted) {
|
||||
throw new Error("ACPX completion was cancelled or its binding changed");
|
||||
}
|
||||
const validation = validatePrpStructuredRunResult(completionInput);
|
||||
if (!validation.ok) {
|
||||
throw new Error("ACPX semantic result failed PRP schema validation");
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ describe("provider-neutral completion result schema", () => {
|
||||
expect(validate({ ...report, continuation: { ...report.continuation, kind: "invented" } })).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the controller resolve an omitted provider continuation without weakening canonical results", () => {
|
||||
const report = { ...baseResult, reportedWorkDisposition: "yielded" };
|
||||
const providerValidate = new Ajv2020({ strict: false }).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA);
|
||||
expect(providerValidate(report)).toBe(true);
|
||||
expect(validate(report)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows done with no verification and no actionable attention", () => {
|
||||
expect(validate(structuredClone(baseResult))).toBe(true);
|
||||
});
|
||||
@@ -182,8 +189,8 @@ describe("provider-neutral completion result schema", () => {
|
||||
},
|
||||
};
|
||||
expect(providerValidate(yielded)).toBe(true);
|
||||
expect(providerValidate({ ...yielded, continuation: undefined })).toBe(false);
|
||||
expect(providerValidate({ ...yielded, continuation: null })).toBe(false);
|
||||
expect(providerValidate({ ...yielded, continuation: undefined })).toBe(true);
|
||||
expect(providerValidate({ ...yielded, continuation: null })).toBe(true);
|
||||
expect(providerValidate({ ...yielded, continuation: { kind: "response_wake" } })).toBe(false);
|
||||
expect(providerValidate({
|
||||
...yielded, continuation: { ...yielded.continuation, kind: "same_agent" },
|
||||
|
||||
@@ -370,7 +370,7 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = {
|
||||
...waitContinuationSchema,
|
||||
type: ["object", "null"],
|
||||
description:
|
||||
"Use null or omit this field for done, completed, or needs_review. Only yielded requires a wait object with kind (response_wake or monitor), summary, and idempotencyKey.",
|
||||
"For yielded, omit or use null to reuse this task's eligible saved monitor; the controller reads current task state. Otherwise provide a real response_wake or monitor with summary and idempotencyKey. Use null or omit for done, completed, or needs_review.",
|
||||
},
|
||||
},
|
||||
// Keep the provider-facing root a concrete object. Codex code-mode renders a
|
||||
@@ -378,7 +378,7 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = {
|
||||
// every required field from the model. This equivalent direct conditional
|
||||
// preserves validation without obscuring the object-shaped tool signature.
|
||||
if: { properties: { reportedWorkDisposition: { const: "yielded" } }, required: ["reportedWorkDisposition"] },
|
||||
then: { required: ["continuation"], properties: { continuation: { type: "object" } } },
|
||||
then: { properties: { continuation: { type: ["object", "null"] } } },
|
||||
else: { properties: { continuation: { type: "null" } } },
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type CopilotToolEvidence } from "./copilot-tool-evidence.js";
|
||||
import { resolveCompletionContinuation } from "../completion-continuation.js";
|
||||
import { isProviderMode } from "../../contracts/provider-mode.js";
|
||||
import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "./profile-activity.js";
|
||||
|
||||
@@ -1132,7 +1133,16 @@ class CodexAcpxSession implements HarnessSession {
|
||||
}
|
||||
const tool = canonicalRunnerToolName(call.tool);
|
||||
if (tool === PRP_COMPLETION_TOOL_NAME || tool === PRP_BLOCK_TOOL_NAME) {
|
||||
const validation = validatePrpStructuredRunResult(call.arguments);
|
||||
const completionInput = await resolveCompletionContinuation(call.arguments, this.#input.runId,
|
||||
this.#dynamicToolHandler ? () => this.#dynamicToolHandler!({
|
||||
tool: "get_task_context", callId: `${call.callId}:continuation`,
|
||||
providerSessionId: this.#host.identity().agentSessionId, turnId, arguments: {}, signal: call.signal,
|
||||
}) : undefined);
|
||||
this.#assertOpen();
|
||||
if (this.#activeTurnId !== turnId || this.#pendingTerminal || call.signal.aborted) {
|
||||
throw new Error("The turn ended while resolving completion");
|
||||
}
|
||||
const validation = validatePrpStructuredRunResult(completionInput);
|
||||
if (!validation.ok) throw new Error("Invalid semantic run result");
|
||||
const blocked = validation.result.reportedWorkDisposition === "blocked";
|
||||
if (
|
||||
|
||||
@@ -27,6 +27,7 @@ const SEMANTIC_RECEIPT_SOURCES = [
|
||||
["../../cli/acpx-runtime-sidecar.ts", "semanticSidecarSourceSha256"],
|
||||
["sidecar-protocol.ts", "semanticSidecarProtocolSourceSha256"],
|
||||
["codex-acpx-driver.ts", "semanticDirectDriverSourceSha256"],
|
||||
["../completion-continuation.ts", "semanticContinuationSourceSha256"],
|
||||
["../../protocol/replay-contract.ts", "semanticValidationSourceSha256"],
|
||||
["../../protocol/result-normalization.ts", "semanticNormalizationSourceSha256"],
|
||||
["../../contracts/completion-result.ts", "semanticCompletionContractSourceSha256"],
|
||||
@@ -194,8 +195,24 @@ describe("Copilot build-owned installation", () => {
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("admits the current v20 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v20-identity.json", import.meta.url), "utf8"));
|
||||
it("preserves v20 history while binding the controller-owned monitor continuation", async () => {
|
||||
const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v20-identity.json", import.meta.url), "utf8"));
|
||||
const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v21-identity.json", import.meta.url), "utf8"));
|
||||
expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([
|
||||
"agentProfileVersion", "semanticCompletionContractSourceSha256", "semanticContinuationSourceSha256", "semanticDirectDriverSourceSha256", "semanticSidecarSourceSha256",
|
||||
]);
|
||||
expect(Object.keys(current.declaration).filter(key => !Object.hasOwn(prior.declaration, key))).toEqual(["semanticContinuationSourceSha256"]);
|
||||
expect(Object.keys(prior.declaration).every(key => Object.hasOwn(current.declaration, key))).toBe(true);
|
||||
expect(isSupportedAcpxProfileVersion("copilot", 20)).toBe(true);
|
||||
const sorted = Object.fromEntries(Object.entries(prior.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).toBe(prior.commandDigest);
|
||||
await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot,
|
||||
agentProfileVersion: 20, commandDigest: prior.commandDigest })).rejects.toThrow("exact pinned");
|
||||
expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending");
|
||||
});
|
||||
|
||||
it("admits the current v21 declaration and binds its source policy, receipts and patch hashes", () => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v21-identity.json", import.meta.url), "utf8"));
|
||||
expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY);
|
||||
expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1");
|
||||
expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT);
|
||||
@@ -226,7 +243,7 @@ describe("Copilot build-owned installation", () => {
|
||||
expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]);
|
||||
});
|
||||
it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => {
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v20-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v21-identity.json", import.meta.url), "utf8"));
|
||||
identity.declaration[field] = "0".repeat(64);
|
||||
const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b)));
|
||||
expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest);
|
||||
|
||||
@@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
|
||||
"protocolVersion": 1,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 20,
|
||||
"agentProfileVersion": 21,
|
||||
"agentServerPackage": "@github/copilot",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"agentRuntimePackage": null,
|
||||
"agentRuntimeVersion": null,
|
||||
"commandDigest": "sha256:7fa710c697582b1b4d85cdf833178b1940656cb6efb44ac4d356b2f27e6b3d4b",
|
||||
"commandDigest": "sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a",
|
||||
"qualificationStatus": "pending",
|
||||
"permissionPolicy": "interactive"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfi
|
||||
|
||||
const historicalVersions: Readonly<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
|
||||
pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5],
|
||||
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
|
||||
grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
|
||||
cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
};
|
||||
|
||||
|
||||
+23
@@ -46,6 +46,29 @@ import {
|
||||
import { RUNNERD_CANONICAL_ITEM } from "./codex-driver-values.js";
|
||||
|
||||
describe("Codex app-server Codex driver", () => {
|
||||
it("uses a saved monitor when a provider omits continuation", async () => {
|
||||
const transport = new FakeCodexTransport();
|
||||
const continuation = { kind: "monitor", summary: "Wait for saved monitor", idempotencyKey: "monitor:task:deadline" };
|
||||
const dynamicToolHandler = vi.fn().mockResolvedValue({ run: { id: "monitor-default" },
|
||||
activeTask: { executionRunId: "monitor-default" }, completionContinuation: continuation });
|
||||
const feedback = vi.fn().mockResolvedValue("Monitor wait accepted.");
|
||||
const session = await makeDriver([transport], { dynamicToolHandler, completionFeedback: feedback }).openSession({
|
||||
runId: "monitor-default", normalizedSessionId: "monitor-default", workingDirectory: WORKSPACE,
|
||||
});
|
||||
try {
|
||||
await session.startTurn({ message: { role: "user", text: "Check the published PR later." } });
|
||||
const report = { ...structuredClone(result), reportedWorkDisposition: "yielded" };
|
||||
const response = await transport.invoke({ id: "finish-monitor", method: "item/tool/call", params: {
|
||||
threadId: "thread-1", turnId: "turn-1", callId: "finish-monitor", tool: "paperclip_finish", arguments: report,
|
||||
} });
|
||||
expect(response).toMatchObject({ success: true });
|
||||
expect(dynamicToolHandler).toHaveBeenCalledExactlyOnceWith({ tool: "get_task_context", callId: "finish-monitor:continuation",
|
||||
threadId: "thread-1", turnId: "turn-1", arguments: {} });
|
||||
expect(feedback).toHaveBeenCalledExactlyOnceWith({ ...report, continuation });
|
||||
expect((await session.snapshot()).semanticResult?.result).toEqual({ ...report, continuation });
|
||||
} finally { await session.close(); }
|
||||
});
|
||||
|
||||
it("returns current approval feedback and permits correcting a rejected completion report", async () => {
|
||||
const transport = new FakeCodexTransport();
|
||||
const feedback = vi.fn()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isAcpxCanonicalInputMethod } from "../acpx/profile-extensions.js";
|
||||
import { resolveCompletionContinuation } from "../completion-continuation.js";
|
||||
import { isSemanticToolOutcomeUnknownError } from "../../contracts/native-session-backend.js";
|
||||
import type { HarnessRuntimeRequest, PaperclipQuestionSet } from "../../contracts/harness-driver.js";
|
||||
import {
|
||||
@@ -185,9 +186,19 @@ async function handleServerRequestBody(
|
||||
"Semantic result exceeded the retained payload limit.",
|
||||
);
|
||||
}
|
||||
const validation = validatePrpStructuredRunResult(
|
||||
request.params.arguments,
|
||||
);
|
||||
let completionInput: unknown;
|
||||
try {
|
||||
completionInput = await resolveCompletionContinuation(request.params.arguments, state.runId,
|
||||
state.dynamicToolHandler ? () => state.dynamicToolHandler!({
|
||||
tool: "get_task_context", callId: `${callId}:continuation`, threadId, turnId, arguments: {},
|
||||
}) : undefined);
|
||||
} catch (error) {
|
||||
return rejectedToolCall(boundedText(error instanceof Error ? error.message : error));
|
||||
}
|
||||
if (state.terminal || state.activeTurnId !== turnId) {
|
||||
return rejectedToolCall("The turn ended while resolving completion. The result was not accepted.");
|
||||
}
|
||||
const validation = validatePrpStructuredRunResult(completionInput);
|
||||
if (!validation.ok) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveCompletionContinuation } from "./completion-continuation.js";
|
||||
|
||||
const continuation = { kind: "monitor", summary: "Wait for the saved task monitor.", idempotencyKey: "task-monitor:task:deadline" };
|
||||
const context = { run: { id: "run" }, activeTask: { executionRunId: "run" }, completionContinuation: continuation };
|
||||
const yielded = { reportedWorkDisposition: "yielded", summary: "The review fix is published." };
|
||||
|
||||
describe("controller-owned completion continuation", () => {
|
||||
it.each([undefined, null])("fills an omitted wait from a current server read (%s)", async absent => {
|
||||
const read = vi.fn().mockResolvedValue(context);
|
||||
const report = { ...yielded, continuation: absent };
|
||||
expect(await resolveCompletionContinuation(report, "run", read)).toEqual({ ...yielded, continuation });
|
||||
expect(read).toHaveBeenCalledOnce();
|
||||
expect(report.continuation).toBe(absent);
|
||||
});
|
||||
|
||||
it("accepts the remote proxy's JSON text response", async () => {
|
||||
expect(await resolveCompletionContinuation(yielded, "run", async () => ({ type: "inputText", text: JSON.stringify(context) })))
|
||||
.toEqual({ ...yielded, continuation });
|
||||
});
|
||||
|
||||
it.each([
|
||||
null, { ...context, run: { id: "other" } }, { ...context, activeTask: { executionRunId: "other" } },
|
||||
{ ...context, completionContinuation: null }, { ...context, completionContinuation: { kind: "response_wake" } },
|
||||
"unavailable", { success: false, contentItems: [] },
|
||||
])("does not invent a passive wait from invalid or stale task state: %j", async response => {
|
||||
await expect(resolveCompletionContinuation(yielded, "run", async () => response)).rejects.toThrow("no eligible saved monitor");
|
||||
});
|
||||
|
||||
it("does not replace an explicit report or read state for ordinary completion", async () => {
|
||||
const read = vi.fn();
|
||||
for (const report of [{ ...yielded, continuation: { kind: "invalid" } }, { reportedWorkDisposition: "done" }]) {
|
||||
expect(await resolveCompletionContinuation(report, "run", read)).toBe(report);
|
||||
}
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Fill provider bookkeeping only from a fresh, run-bound server read. */
|
||||
export async function resolveCompletionContinuation(
|
||||
value: unknown,
|
||||
runId: string,
|
||||
readTaskContext?: () => Promise<unknown>,
|
||||
): Promise<unknown> {
|
||||
const report = object(value);
|
||||
if (report.reportedWorkDisposition !== "yielded" || report.continuation != null) return value;
|
||||
const response = readTaskContext ? await readTaskContext() : null;
|
||||
const text = object(response).type === "inputText" ? object(response).text : response;
|
||||
let context: Record<string, unknown>;
|
||||
try { context = object(typeof text === "string" ? JSON.parse(text) : text); }
|
||||
catch { context = {}; }
|
||||
const task = object(context.activeTask);
|
||||
const continuation = object(context.completionContinuation);
|
||||
if (object(context.run).id !== runId || task.executionRunId !== runId ||
|
||||
continuation.kind !== "monitor" ||
|
||||
typeof continuation.summary !== "string" || !continuation.summary.trim() ||
|
||||
typeof continuation.idempotencyKey !== "string" || !continuation.idempotencyKey.trim()) {
|
||||
throw new Error("Cannot yield without a continuation: no eligible saved monitor exists on this task. Save a monitor with set_task_monitor, provide an explicit response_wake for a real response wait, or continue the unfinished work. Do not repeat completed actions.");
|
||||
}
|
||||
return { ...report, continuation: { kind: "monitor", summary: continuation.summary, idempotencyKey: continuation.idempotencyKey } };
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { resolveCompletionContinuation } from "../completion-continuation.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import type { HarnessRuntimeRequest, HarnessRuntimeRequestHandoff, HarnessRuntimeRequestResolution, HarnessDriver, HarnessDriverDescriptor, HarnessSession, OpenHarnessSessionInput, PersistedHarnessSession } from "../../contracts/harness-driver.js";
|
||||
@@ -189,7 +190,17 @@ class RunnerdExternalSession implements HarnessSession {
|
||||
},
|
||||
onSemanticToolInput: async call => {
|
||||
if (call.operationId === "paperclip_finish" || call.operationId === "paperclip_block") {
|
||||
const result = validatePrpStructuredRunResult(call.input);
|
||||
let completionInput: unknown;
|
||||
try {
|
||||
completionInput = await resolveCompletionContinuation(call.input, o.identity.runId,
|
||||
o.dynamicToolHandler ? () => o.dynamicToolHandler!({
|
||||
tool: "get_task_context", callId: `${call.callId}:continuation`,
|
||||
threadId: o.identity.normalizedSessionId, turnId: o.identity.turnId, arguments: {},
|
||||
}) : undefined);
|
||||
} catch (error) {
|
||||
return { result: { error: error instanceof Error ? error.message : "Completion rejected" }, isError: true };
|
||||
}
|
||||
const result = validatePrpStructuredRunResult(completionInput);
|
||||
if (!result.ok || (call.operationId === "paperclip_block") !== (result.ok && result.result.reportedWorkDisposition === "blocked")) {
|
||||
return { result: { error: "Invalid completion report. Use the admitted completion contract and the correct completion tool." }, isError: true };
|
||||
}
|
||||
|
||||
@@ -2120,6 +2120,41 @@ describe("OpenCodeServerDriver", () => {
|
||||
await session.close({ reason: "test" });
|
||||
});
|
||||
|
||||
it.each([true, false])("resolves an omitted continuation through the real OpenCode MCP boundary only with a saved monitor: %s", async eligible => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-monitor-"));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-monitor-workspace-"));
|
||||
roots.push(root, workspace);
|
||||
const continuation = { kind: "monitor", summary: "Wait for the saved task monitor.", idempotencyKey: "task-monitor:saved" };
|
||||
const reads: string[] = [];
|
||||
const accepted: unknown[] = [];
|
||||
const driver = new OpenCodeServerDriver({
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
||||
dynamicToolHandler: async call => {
|
||||
reads.push(call.tool);
|
||||
return { run: { id: "monitor-default" }, activeTask: { executionRunId: "monitor-default" },
|
||||
completionContinuation: eligible ? continuation : null };
|
||||
},
|
||||
completionFeedback: async result => { accepted.push(result); return "Accepted monitor wait."; },
|
||||
});
|
||||
const session = await driver.openSession({ runId: "monitor-default", normalizedSessionId: "monitor-default", workingDirectory: workspace });
|
||||
try {
|
||||
await session.startTurn({ message: { role: "user", text: "monitor-default" } });
|
||||
const events = await collectTurnEvents(session.events());
|
||||
expect(reads).toEqual(["get_task_context"]);
|
||||
if (eligible) {
|
||||
expect(accepted).toMatchObject([{ reportedWorkDisposition: "yielded", continuation }]);
|
||||
expect(events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1);
|
||||
expect((await session.snapshot()).semanticResult?.result).toMatchObject({ continuation });
|
||||
} else {
|
||||
expect(accepted).toEqual([]);
|
||||
expect(events.filter(event => event.eventType === "run.result.proposed")).toEqual([]);
|
||||
expect((await session.snapshot()).semanticResult).toBeNull();
|
||||
}
|
||||
} finally { await session.close({ reason: "monitor default test complete" }); }
|
||||
});
|
||||
|
||||
it("normalizes a structured block result", async () => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { resolveCompletionContinuation } from "../completion-continuation.js";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -1040,7 +1041,15 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
try {
|
||||
if (this.#closed || this.#completionSettlement)
|
||||
throw new Error("A completion is already settling or the session is closed");
|
||||
const validation = validatePrpStructuredRunResult(call.arguments);
|
||||
this.#completionSettlement = new Promise<void>(resolve => { settle = resolve; });
|
||||
const completionInput = await resolveCompletionContinuation(call.arguments, this.#runId,
|
||||
this.#dynamicToolHandler ? () => this.#dynamicToolHandler!({
|
||||
tool: "get_task_context", callId: `${call.callId}:continuation`,
|
||||
threadId: this.#providerSessionId, turnId, arguments: {},
|
||||
}) : undefined);
|
||||
if (this.#closed || this.#activeTurnId !== turnId)
|
||||
throw new Error("The turn ended while resolving completion");
|
||||
const validation = validatePrpStructuredRunResult(completionInput);
|
||||
if (!validation.ok) throw new Error("Invalid semantic result");
|
||||
if (
|
||||
(tool === PRP_BLOCK_TOOL_NAME &&
|
||||
@@ -1069,7 +1078,6 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
const fingerprint = canonicalJson(validation.result);
|
||||
if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint)
|
||||
throw new Error("A different semantic result was already committed");
|
||||
this.#completionSettlement = new Promise<void>(resolve => { settle = resolve; });
|
||||
// Wait for the bound controller before committing or resolving the
|
||||
// provider call. A rejection remains repairable in this same turn.
|
||||
const feedback = this.#completionFeedback
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"commandDigest": "sha256:1dad3d26d91e66c6f911dbfa43e7963f36c0bbf0d7709f4bbb568029a8b4b46a",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "copilot",
|
||||
"agentProfileVersion": 21,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "1.0.88",
|
||||
"protocolPolicyRevision": "copilot-agent-manual-v1",
|
||||
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
|
||||
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
|
||||
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1",
|
||||
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
|
||||
"innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a",
|
||||
"upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0",
|
||||
"patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679",
|
||||
"messageIdentityContract": "copilot-native-message-id-v1",
|
||||
"ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1",
|
||||
"permissionContextContract": "copilot-edit-permission-context-v1",
|
||||
"permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224",
|
||||
"permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce",
|
||||
"permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9",
|
||||
"permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f",
|
||||
"permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4",
|
||||
"permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1",
|
||||
"semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2",
|
||||
"semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791",
|
||||
"semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee",
|
||||
"toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3",
|
||||
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
|
||||
"semanticSidecarSourceSha256": "17371b10bef0acfe7a5980e20fd575165f3e28c91608d38590fdca67d58c4edf",
|
||||
"semanticDirectDriverSourceSha256": "9c27a6ef55d65190af11b597ee2214ba836b4d992cb75d2eccf9c3ea3311d07c",
|
||||
"semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975",
|
||||
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
|
||||
"semanticCompletionContractSourceSha256": "a104278d4d0e063a69bb464938db117dc27691b5bd4590c5a359e11f4917f623",
|
||||
"semanticValidatorsSourceSha256": "38ae5379d47df633564730ec1359a16d21c0d0a68addb5e488b58c697f13362c",
|
||||
"semanticSchemaBundleSourceSha256": "36ad8a83a17111167d3cc4ef4123b36fa648a1825acd59bd90b3f8b566a4d335",
|
||||
"semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c",
|
||||
"semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1",
|
||||
"semanticContinuationSourceSha256": "eb877719ef32a935afa4ec93f0f1c43d58d55bc4e1042965febcac4c45ed851c"
|
||||
}
|
||||
}
|
||||
@@ -193,9 +193,10 @@ async function callFirstPaperclipTool() {
|
||||
async function callTerminalTool(promptBody) {
|
||||
const prompt = parsedPromptText(promptBody);
|
||||
const blocked = String(prompt.message ?? "").includes("block-result");
|
||||
const monitored = String(prompt.message ?? "").includes("monitor-default");
|
||||
const result = {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: blocked ? "blocked" : "done",
|
||||
reportedWorkDisposition: blocked ? "blocked" : monitored ? "yielded" : "done",
|
||||
summary: blocked
|
||||
? "Fake provider is blocked."
|
||||
: "Fake provider completed the task.",
|
||||
|
||||
@@ -9369,6 +9369,18 @@ describe("native session bounded recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("holds exhausted verification without renewing the unchanged reattachment attempt", () => {
|
||||
const cause = nativeSessionFailureSourceCode(new Error(
|
||||
"runner_remote_provider_verification_unavailable: pack unavailable after 2 verification attempts (exitCode=null; timedOut=true)",
|
||||
));
|
||||
expect(cause).toBe("runner_remote_provider_verification_unavailable");
|
||||
for (const attempt of [1, 2, 3]) {
|
||||
expect(nativeSessionFailureDisposition(attempt, new Date(), cause)).toEqual({
|
||||
phase: "terminal_failure", failureCode: cause, nextAttemptAt: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves stable provider and runner failure causes", () => {
|
||||
expect(
|
||||
nativeSessionFailureSourceCode(
|
||||
|
||||
@@ -33,7 +33,7 @@ import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
|
||||
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
|
||||
import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js";
|
||||
import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js";
|
||||
import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js";
|
||||
import { prepareVerifiedRemoteProviderPack, runRemoteProviderVerification } from "./remote-provider-pack.js";
|
||||
import { selectRemotePiCompanion } from "./remote-pi-companion.js";
|
||||
import { readNativeLocalProcessStop, PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
|
||||
import { remoteLeaseCleanupScope } from "../remote-execution-termination.js";
|
||||
@@ -6238,6 +6238,7 @@ export function nativeSessionFailureDisposition(
|
||||
sourceFailureCode === "native_provider_approval_required" ||
|
||||
sourceFailureCode === "native_event_replay_conflict" ||
|
||||
sourceFailureCode === "runner_remote_provider_artifact_incompatible" ||
|
||||
sourceFailureCode === "runner_remote_provider_verification_unavailable" ||
|
||||
sourceFailureCode === "native_provider_terminal_failed" ||
|
||||
sourceFailureCode === "native_current_wake_comments_unread" ||
|
||||
sourceFailureCode === "native_current_wake_comments_changed_after_read" ||
|
||||
@@ -6294,6 +6295,7 @@ export function nativeSessionFailureSourceCode(
|
||||
| "native_session_cleanup_quarantined"
|
||||
| "native_adopted_runner_authentication_timeout"
|
||||
| "runner_remote_provider_artifact_incompatible"
|
||||
| "runner_remote_provider_verification_unavailable"
|
||||
| "provider_process_exited"
|
||||
| "provider_stdout_closed"
|
||||
| "provider_process_output_closed"
|
||||
@@ -6340,6 +6342,9 @@ export function nativeSessionFailureSourceCode(
|
||||
if (/runner_remote_provider_artifact_incompatible/i.test(message)) {
|
||||
return "runner_remote_provider_artifact_incompatible";
|
||||
}
|
||||
if (/runner_remote_provider_verification_unavailable/i.test(message)) {
|
||||
return "runner_remote_provider_verification_unavailable";
|
||||
}
|
||||
if (/provider_process_exited/i.test(message)) {
|
||||
return "provider_process_exited";
|
||||
}
|
||||
@@ -9330,6 +9335,8 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
? "Approval required. Review the operation and update the agent's permission setting before retrying. This runner has no interactive approval handler."
|
||||
: sourceFailureCode === "native_session_cleanup_quarantined"
|
||||
? NATIVE_CLEANUP_OPERATOR_RECOVERY_MESSAGE
|
||||
: sourceFailureCode === "runner_remote_provider_verification_unavailable"
|
||||
? "Provider files could not be verified after two read attempts. Inspect the retained workspace and command transport before retrying the original run."
|
||||
: sourceFailureCode === "native_provider_terminal_failed"
|
||||
? "The provider session is permanently unusable. Verify stopped execution, completed actions, and task context before starting a linked continuation."
|
||||
: recoveryEvidence.recoveryMode === "ambiguous_state"
|
||||
@@ -11808,32 +11815,25 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
"const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}",
|
||||
"for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')",
|
||||
].join(";");
|
||||
const verified = await remoteCommandRunner.execute({
|
||||
await runRemoteProviderVerification(() => remoteCommandRunner.execute({
|
||||
command: providerNodeCommand,
|
||||
args: ["-e", verifyScript, packRoot, expected],
|
||||
cwd: remoteTarget.remoteCwd,
|
||||
bypassSession: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
if (verified.exitCode !== 0 || verified.timedOut) {
|
||||
throw new Error(
|
||||
`runner_remote_provider_artifact_incompatible: provider pack verification failed (${verified.stderr.trim().slice(-1_024)})`,
|
||||
);
|
||||
}
|
||||
}), "provider pack verification");
|
||||
const opencodeCommand = posix.join(
|
||||
packRoot,
|
||||
expectedProviderPackManifest.payload.artifacts.opencodeCommand.path,
|
||||
);
|
||||
const opencodeVersion = await remoteCommandRunner.execute({
|
||||
const opencodeVersion = await runRemoteProviderVerification(() => remoteCommandRunner.execute({
|
||||
command: opencodeCommand,
|
||||
args: ["--version"],
|
||||
cwd: remoteTarget.remoteCwd,
|
||||
bypassSession: true,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
}), "OpenCode version verification");
|
||||
if (
|
||||
opencodeVersion.exitCode !== 0 ||
|
||||
opencodeVersion.timedOut ||
|
||||
opencodeVersion.stdout.trim() !== REMOTE_PROVIDER_PACK_PINS.opencode
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { callTaskWorkspaceTool } from "../task-workspace-tools.js";
|
||||
import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js";
|
||||
import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, readWorkspaceUploadFile, settleRunnerBridgeRead, assertRunnerWorkspacePathAllowed } from "./runner-bridge-tools.js";
|
||||
import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js";
|
||||
import { assertCanManageIssueMonitor, prepareIssueMonitorUpdate, setTaskMonitorSchema, summarizeIssueMonitor } from "../issue-monitors.js";
|
||||
import { assertCanManageIssueMonitor, eligibleIssueMonitorWait, prepareIssueMonitorUpdate, setTaskMonitorSchema, summarizeIssueMonitor } from "../issue-monitors.js";
|
||||
import { readTaskQuestionContext } from "../issue-question-context.js";
|
||||
import { isConversation } from "../agent-conversations.js";
|
||||
import { setIssueTitle } from "../issue-title.js";
|
||||
@@ -604,6 +604,9 @@ export class PaperclipRunnerToolAuthority {
|
||||
return { ...response, apiOperationId: operationId };
|
||||
}
|
||||
case "get_task_context": {
|
||||
const monitorCheckAt = !this.binding.nativeReview && context.issue.workMode === "standard" &&
|
||||
context.issue.executionRunId === this.binding.runId
|
||||
? eligibleIssueMonitorWait(context.issue, this.binding.agentId) : null;
|
||||
const childTasks = await this.db.select().from(issues)
|
||||
.where(and(
|
||||
eq(issues.companyId, this.binding.companyId),
|
||||
@@ -623,6 +626,11 @@ export class PaperclipRunnerToolAuthority {
|
||||
...this.binding, conversationMode: isConversation(context.issue),
|
||||
}),
|
||||
activeTask: redactedTask(context.issue),
|
||||
completionContinuation: monitorCheckAt ? {
|
||||
kind: "monitor",
|
||||
summary: "Wait for the saved task monitor.",
|
||||
idempotencyKey: `task-monitor:${context.issue.id}:${monitorCheckAt}`,
|
||||
} : null,
|
||||
childTasks: visibleChildTasks.slice(0, 100).map(redactedTask),
|
||||
childTasksTruncated: childTasks.length > 100,
|
||||
delegationGuidance: "Reuse existing child tasks for delegated work. Inspect a completed child's comments and documents before creating another task for the same deliverable. Use search_tasks if the child task list is truncated.",
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prepareVerifiedRemoteProviderPack } from "./remote-provider-pack.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { prepareVerifiedRemoteProviderPack, runRemoteProviderVerification } from "./remote-provider-pack.js";
|
||||
|
||||
describe("remote provider verification", () => {
|
||||
const ok = { exitCode: 0, timedOut: false, stdout: "verified", stderr: "", signal: null };
|
||||
it.each([
|
||||
{ ...ok, exitCode: null, timedOut: true },
|
||||
{ ...ok, exitCode: null },
|
||||
{ ...ok, exitCode: 137, signal: "SIGKILL" },
|
||||
])("retries an interrupted read without calling it incompatible: %j", async result => {
|
||||
const execute = vi.fn().mockResolvedValueOnce(result).mockResolvedValueOnce(ok);
|
||||
await expect(runRemoteProviderVerification(execute, "pack")).resolves.toEqual(ok);
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("exhausts its own read budget after two inconclusive reads, including empty stderr", async () => {
|
||||
const execute = vi.fn().mockResolvedValue({ ...ok, exitCode: null, timedOut: true });
|
||||
await expect(runRemoteProviderVerification(execute, "pack"))
|
||||
.rejects.toThrow("runner_remote_provider_verification_unavailable: pack unavailable after 2 verification attempts (exitCode=null; timedOut=true");
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each(["", "manifest mismatch", "dist tree digest mismatch"])("fails closed without retrying a completed rejection: %s", async stderr => {
|
||||
const execute = vi.fn().mockResolvedValue({ ...ok, exitCode: 1, stderr });
|
||||
await expect(runRemoteProviderVerification(execute, "pack")).rejects.toThrow("runner_remote_provider_artifact_incompatible: pack failed (exitCode=1");
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("recovers a transport exception and propagates cancellation", async () => {
|
||||
const execute = vi.fn().mockRejectedValueOnce(new Error("connection unavailable")).mockResolvedValueOnce(ok);
|
||||
await expect(runRemoteProviderVerification(execute, "pack")).resolves.toEqual(ok);
|
||||
const abort = new DOMException("cancelled", "AbortError");
|
||||
execute.mockReset().mockRejectedValue(abort);
|
||||
await expect(runRemoteProviderVerification(execute, "pack")).rejects.toBe(abort);
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("retries after a real timed-out subprocess exits while unrelated work remains responsive", async () => {
|
||||
let attempts = 0;
|
||||
let responsive = false;
|
||||
const unrelated = new Promise<void>(resolve => setTimeout(() => { responsive = true; resolve(); }, 5));
|
||||
const result = await runRemoteProviderVerification(async () => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const result = await promisify(execFile)(process.execPath, ["-e", attempts === 1 ? "setInterval(()=>{},1000)" : "process.stdout.write('verified')"], { timeout: attempts === 1 ? 100 : 2_000 });
|
||||
return { ...ok, ...result };
|
||||
} catch (error) {
|
||||
const failure = error as Error & { killed: boolean; signal: string; stdout: string; stderr: string };
|
||||
if (!failure.killed) throw error;
|
||||
return { ...ok, exitCode: null, timedOut: true, signal: failure.signal, stderr: failure.stderr };
|
||||
}
|
||||
}, "pack");
|
||||
await unrelated;
|
||||
expect({ attempts, responsive, stdout: result.stdout }).toEqual({ attempts: 2, responsive: true, stdout: "verified" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote provider pack reuse", () => {
|
||||
it("keeps a verified staged pack without consulting the image or uploading again", async () => {
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
import type { NativeRunTrace } from "./native-run-trace.js";
|
||||
|
||||
type VerificationCommandResult = {
|
||||
exitCode: number | null;
|
||||
timedOut: boolean;
|
||||
signal?: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
/** Retry only these read-only probes, never provider work or artifact mutations.
|
||||
* Each transport call enforces its own 30s timeout; two attempts is the hard cap.
|
||||
* A completed, failing verifier is incompatible. An interrupted probe proves no
|
||||
* incompatibility. Exhaustion holds the original run: reattachment does not
|
||||
* spend a new provider attempt, so scheduling another attach could loop.
|
||||
*/
|
||||
export async function runRemoteProviderVerification(
|
||||
execute: () => Promise<VerificationCommandResult>,
|
||||
label: string,
|
||||
): Promise<VerificationCommandResult> {
|
||||
let diagnostic = "command unavailable";
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
let result: VerificationCommandResult;
|
||||
try {
|
||||
result = await execute();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") throw error;
|
||||
diagnostic = error instanceof Error ? error.message : String(error);
|
||||
continue;
|
||||
}
|
||||
diagnostic = `exitCode=${result.exitCode}; timedOut=${result.timedOut}; signal=${result.signal ?? "none"}; ${result.stderr.trim().slice(-1_024)}`;
|
||||
if (result.timedOut || result.exitCode === null || result.signal) continue;
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`runner_remote_provider_artifact_incompatible: ${label} failed (${diagnostic})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
throw new Error(`runner_remote_provider_verification_unavailable: ${label} unavailable after 2 verification attempts (${diagnostic.slice(-1_536)})`);
|
||||
}
|
||||
|
||||
/** Reuse only a pack that passes the same full verification as a new upload. */
|
||||
export async function prepareVerifiedRemoteProviderPack(input: {
|
||||
trace?: NativeRunTrace;
|
||||
|
||||
@@ -118,6 +118,25 @@ describeEmbeddedPostgres("native task monitors", () => {
|
||||
expect(audits[0]).toMatchObject({ runId: value.runId, agentId: value.agentId });
|
||||
});
|
||||
|
||||
it("defaults completion only to the current eligible saved monitor and clears it when consumed", async () => {
|
||||
const value = await fixture();
|
||||
const context = () => value.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} });
|
||||
expect(await context()).toMatchObject({ completionContinuation: null });
|
||||
await value.call({ idempotencyKey: "default-schedule", monitor: value.monitor });
|
||||
expect(await context()).toMatchObject({
|
||||
run: { id: value.runId }, activeTask: { executionRunId: value.runId },
|
||||
completionContinuation: { kind: "monitor", summary: "Wait for the saved task monitor.",
|
||||
idempotencyKey: `task-monitor:${value.issueId}:${value.monitor.nextCheckAt}` },
|
||||
});
|
||||
const scheduled = await value.read();
|
||||
await db.update(issues).set(buildIssueMonitorTriggeredPatch({ issue: scheduled,
|
||||
policy: normalizeIssueExecutionPolicy(scheduled.executionPolicy), triggeredAt: new Date() })).where(eq(issues.id, value.issueId));
|
||||
expect(await context()).toMatchObject({ completionContinuation: null });
|
||||
await value.call({ idempotencyKey: "replacement-schedule", monitor: value.monitor });
|
||||
await value.call({ idempotencyKey: "cleared-schedule", monitor: null });
|
||||
expect(await context()).toMatchObject({ completionContinuation: null });
|
||||
});
|
||||
|
||||
it("replaces and clears schedules without re-arming a receipt on replay", async () => {
|
||||
const value = await fixture();
|
||||
const original = { idempotencyKey: "first", monitor: value.monitor };
|
||||
|
||||
@@ -7,12 +7,13 @@ import { ExecutionBlockerNotice } from "./ExecutionBlockerNotice";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
vi.mock("../lib/router", () => ({
|
||||
Link: ({ to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => <a href={to} {...props} />,
|
||||
}));
|
||||
vi.mock("../api/agents", () => ({ agentsApi: { retryFailedRun: vi.fn() } }));
|
||||
vi.mock("../api/activity", () => ({ activityApi: { runsForIssue: vi.fn() } }));
|
||||
vi.mock("../api/issues", () => ({ issuesApi: { recoveryActions: vi.fn() } }));
|
||||
vi.mock("../api/issues", () => ({ issuesApi: { recoveryActions: vi.fn(), listAttachments: vi.fn(), reconstructWorkspace: vi.fn() } }));
|
||||
|
||||
describe("stopped task recovery notice", () => {
|
||||
let root: Root;
|
||||
@@ -22,6 +23,7 @@ describe("stopped task recovery notice", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: null, actions: [] });
|
||||
vi.mocked(issuesApi.listAttachments).mockResolvedValue([]);
|
||||
container = document.createElement("div"); document.body.append(container);
|
||||
root = createRoot(container);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
@@ -94,7 +96,7 @@ describe("stopped task recovery notice", () => {
|
||||
nextAction: "Inspect the original failure and reconcile the previous execution before continuing.",
|
||||
}} />
|
||||
</QueryClientProvider>));
|
||||
expect(container.textContent).toContain("Recovery needed.");
|
||||
expect(container.textContent).toContain("Work paused.");
|
||||
expect(container.textContent).not.toContain("Retry");
|
||||
const link = container.querySelector("a")!;
|
||||
expect(link.textContent).toBe("Inspect run");
|
||||
@@ -103,23 +105,60 @@ describe("stopped task recovery notice", () => {
|
||||
});
|
||||
it("keeps workspace repair guidance and inspection without offering the legacy Retry fallback", async () => {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}>
|
||||
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
|
||||
<ExecutionBlockerNotice companyId="company" issueId="task" issueStatus="blocked" canManage onRetried={onRetried} blocker={{
|
||||
recoveryActionId: "repair", runId: "failed-run", agentId: "agent", cause: "legacy_execution_requires_reconciliation",
|
||||
workspaceRepairRequired: true, canRetry: false, canContinue: false,
|
||||
workspaceRepairRequired: true, canRecordWorkspaceRepair: true, canRetry: false, canContinue: false,
|
||||
nextAction: "The original sandbox is retained. Recover the missing files and record workspace repair evidence.",
|
||||
}} />
|
||||
</QueryClientProvider>));
|
||||
expect(container.textContent).toContain("could not restore the agent's workspace files");
|
||||
expect(container.textContent).toContain("A board operator must recover the files");
|
||||
expect(container.textContent).toContain("The workspace needs operator attention");
|
||||
// The hold also covers unsafe archives and local runs, so the notice must not promise retries or a kept sandbox.
|
||||
expect(container.textContent).not.toContain("retrying");
|
||||
expect(container.textContent).not.toContain("no work is lost");
|
||||
expect(container.querySelector("details")?.textContent).toContain("Recover the missing files");
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
expect(container.querySelector("textarea, input")).toBeNull();
|
||||
expect(container.querySelector("a")?.textContent).toBe("Inspect run");
|
||||
expect(agentsApi.retryFailedRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])("preserves source reconstruction and its queued state: %s", async pending => {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}>
|
||||
<ExecutionBlockerNotice companyId="company" issueId="task" canManage issueStatus="blocked" onRetried={onRetried} blocker={{
|
||||
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", cause: "native_workspace_sync_out_unrecoverable",
|
||||
workspaceRepairRequired: true, canReconstructWorkspace: !pending, workspaceReconstructionPending: pending,
|
||||
workspaceRecoveryStatusVersion: 1, nextAction: pending ? "Continuation from saved sources is queued and waiting for execution checks." : "Inspect surviving source attachments.",
|
||||
}} />
|
||||
</QueryClientProvider>));
|
||||
expect(container.textContent).toContain(pending ? "Continuation queued." : "Continue from saved sources");
|
||||
expect(container.textContent).not.toContain(pending ? "Work paused." : "recovered workspace files");
|
||||
expect(container.querySelector("summary")?.closest("details")?.open).toBe(false);
|
||||
expect(issuesApi.reconstructWorkspace).not.toHaveBeenCalled();
|
||||
if (pending) {
|
||||
expect(container.querySelector("textarea")).toBeNull();
|
||||
expect(issuesApi.listAttachments).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["running", "missed", "wrong-run", "replay-blocked", "delegated"])("uses observed recovery liveness, including for readers: %s", async state => {
|
||||
const action = { id: "recovery", kind: "active_run_watchdog", status: "active", ownerType: "agent",
|
||||
evidence: { runId: state === "wrong-run" ? "other-run" : "failed-run",
|
||||
automaticRecovery: { replay: state === "replay-blocked" ? "blocked" : "held" } },
|
||||
attemptCount: 1, maxAttempts: 3, outcome: state === "delegated" ? "delegated" : null,
|
||||
wakePolicy: state === "delegated" ? null : { kind: "resume_native_run", runId: "failed-run", attempt: 1, maxAttempts: 3, notBefore: "2020-01-01T00:00:00Z" },
|
||||
nativeRunActivity: state === "missed" ? null : { runId: "failed-run", status: "running" },
|
||||
};
|
||||
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: action, actions: [action] } as never);
|
||||
await act(async () => root.render(<QueryClientProvider client={client}>
|
||||
<ExecutionBlockerNotice companyId="company" issueId="live-task" issueStatus="blocked" canManage={false} onRetried={onRetried} blocker={{
|
||||
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent", workspaceRepairRequired: true,
|
||||
cause: "native_continuation_requires_reconciliation", nextAction: "Operator inspection is required.",
|
||||
}} />
|
||||
</QueryClientProvider>));
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
expect(Boolean(container.querySelector('[aria-label="Task recovery"]'))).toBe(state !== "running");
|
||||
});
|
||||
|
||||
it("retries the exact failed run and refreshes the task", async () => {
|
||||
vi.mocked(agentsApi.retryFailedRun).mockResolvedValue({} as never);
|
||||
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
||||
@@ -128,6 +167,53 @@ describe("stopped task recovery notice", () => {
|
||||
expect(onRetried).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reveals a missed retry when every recovery poll returns identical data", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-10-18T00:00:00Z"));
|
||||
try {
|
||||
const action = { id: "recovery", kind: "active_run_watchdog", status: "active", ownerType: "agent", outcome: null,
|
||||
evidence: { runId: "failed-run" }, attemptCount: 1, maxAttempts: 3,
|
||||
wakePolicy: { kind: "resume_native_run", runId: "failed-run", attempt: 1, maxAttempts: 3,
|
||||
notBefore: "2026-10-18T00:00:10Z" }, nativeRunActivity: null };
|
||||
const response = { active: action, actions: [action] };
|
||||
vi.mocked(issuesApi.recoveryActions).mockResolvedValue(response as never);
|
||||
await act(async () => root.render(<QueryClientProvider client={client}>
|
||||
<ExecutionBlockerNotice companyId="company" issueId="expiry-task" issueStatus="blocked" onRetried={onRetried} blocker={{
|
||||
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent",
|
||||
cause: "native_continuation_requires_reconciliation", nextAction: "Inspect the missed recovery.",
|
||||
}} />
|
||||
</QueryClientProvider>));
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
||||
expect(container.querySelector('[aria-label="Task recovery"]')).toBeNull();
|
||||
const before = vi.mocked(issuesApi.recoveryActions).mock.calls.length;
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(41_000); });
|
||||
expect(vi.mocked(issuesApi.recoveryActions).mock.calls.length).toBeGreaterThan(before);
|
||||
expect(container.querySelector('[aria-label="Task recovery"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Inspect the missed recovery.");
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("shows a recovery refresh error instead of trusting cached activity", async () => {
|
||||
const action = { id: "recovery", kind: "active_run_watchdog", status: "active", ownerType: "agent", outcome: null,
|
||||
evidence: { runId: "failed-run" }, attemptCount: 1, maxAttempts: 3,
|
||||
wakePolicy: { kind: "resume_native_run", runId: "failed-run" },
|
||||
nativeRunActivity: { runId: "failed-run", status: "running", workspaceOperationId: null } };
|
||||
vi.mocked(issuesApi.recoveryActions).mockResolvedValue({ active: action, actions: [action] } as never);
|
||||
await act(async () => root.render(<QueryClientProvider client={client}>
|
||||
<ExecutionBlockerNotice companyId="company" issueId="read-error-task" issueStatus="blocked" onRetried={onRetried} blocker={{
|
||||
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent",
|
||||
cause: "native_continuation_requires_reconciliation", nextAction: "Inspect the current run.",
|
||||
}} />
|
||||
</QueryClientProvider>));
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
expect(container.querySelector('[aria-label="Task recovery"]')).toBeNull();
|
||||
vi.mocked(issuesApi.recoveryActions).mockRejectedValue(new Error("Recovery service unavailable"));
|
||||
await act(async () => { await client.refetchQueries({ queryKey: queryKeys.issues.recoveryActions("read-error-task", "recovery", "failed-run") }); });
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Recovery service unavailable");
|
||||
expect(container.textContent).toContain("Inspect the current run.");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"The 'gpt-6.1-sol' model is not supported when using Codex with a ChatGPT account.",
|
||||
"The selected model is not supported by the current ChatGPT connection. Choose a supported model or a compatible AI connection.",
|
||||
@@ -218,6 +304,7 @@ describe("stopped task recovery notice", () => {
|
||||
expect(container.textContent).toContain("Inspect the current run.");
|
||||
});
|
||||
it("refreshes a decision while the same stopped run remains open", async () => {
|
||||
vi.mocked(issuesApi.recoveryActions).mockClear();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const action = { id: "recovery", status: "active", resolutionNote: "Inspection pending", evidence: { runId: "cancelled-run" } };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ExecutionBlocker, Issue } from "@paperclipai/shared";
|
||||
import { WorkspaceFileRecovery } from "./WorkspaceFileRecovery";
|
||||
import { WorkspaceReconstructionRecovery } from "./WorkspaceReconstructionRecovery";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { activityApi } from "../api/activity";
|
||||
@@ -9,6 +8,9 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Link } from "../lib/router";
|
||||
import { deriveRecoveryDisplayState } from "../lib/recovery-display";
|
||||
import { readRecoveryRetryLineage } from "../lib/recovery-lineage";
|
||||
import { useSecondTick } from "../hooks/useSecondTick";
|
||||
|
||||
export function ExecutionBlockerNotice({ companyId, issueId, issueStatus, canManage = false, blocker, onRetried, className }: {
|
||||
className?: string;
|
||||
@@ -23,7 +25,7 @@ export function ExecutionBlockerNotice({ companyId, issueId, issueStatus, canMan
|
||||
const { data: recovery, error: recoveryError } = useQuery({
|
||||
queryKey: queryKeys.issues.recoveryActions(issueId, blocker.recoveryActionId ?? null, blocker.runId ?? null),
|
||||
queryFn: () => issuesApi.recoveryActions(issueId),
|
||||
enabled: canManage && Boolean(blocker.recoveryActionId) && !["done", "cancelled"].includes(issueStatus ?? ""),
|
||||
enabled: Boolean(blocker.recoveryActionId) && !["done", "cancelled"].includes(issueStatus ?? ""),
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
const hold = recovery?.actions.find(action => action.id === blocker.recoveryActionId &&
|
||||
@@ -48,25 +50,23 @@ export function ExecutionBlockerNotice({ companyId, issueId, issueStatus, canMan
|
||||
}
|
||||
},
|
||||
});
|
||||
const now = Date.now();
|
||||
const lineage = hold ? readRecoveryRetryLineage(hold, { now }) : null;
|
||||
const recovering = Boolean(!recoveryError && !runsError && hold && !replayBlocked &&
|
||||
deriveRecoveryDisplayState(hold, { now }) === "in_progress" && lineage?.hasDurablePath);
|
||||
// Identical poll responses do not render again. Recheck a scheduled path's
|
||||
// expiry with the shared clock, and stop ticking once it expires or starts.
|
||||
useSecondTick(Boolean(recovering && lineage?.nextRetryAt && !lineage.liveRunId));
|
||||
if (recovering) return null;
|
||||
const hasDetails = Boolean(blocker.runError || (blocker.workspaceRepairRequired && blocker.nextAction) ||
|
||||
(canManage && hold && (replayBlocked || hold.resolutionNote)));
|
||||
return (
|
||||
<div role="status" aria-label="Task recovery" className={cn("task-context-notice flex flex-wrap items-center justify-between gap-2", className)}>
|
||||
<div className="min-w-0 flex-1 break-words">
|
||||
<p>{blocker.workspaceReconstructionPending ? "Reconstruction queued." : modelRejected ? "Model unavailable." : "Recovery needed."}{blocker.runError && !blocker.workspaceReconstructionPending ? ` ${blocker.runError}` : ""}</p>
|
||||
<p className="font-medium">{blocker.workspaceReconstructionPending ? "Continuation queued." : modelRejected ? "Model unavailable." : "Work paused."}</p>
|
||||
{modelRejected && <p>Choose a supported model or clear the task's model override, then retry.</p>}
|
||||
{blocker.workspaceRepairRequired ? (
|
||||
<>
|
||||
{/* The stored next action is an operator runbook step; lead with what happened. This hold also covers
|
||||
unsafe archives and local runs, so do not claim retries or a retained sandbox here. */}
|
||||
<p>
|
||||
{blocker.workspaceReconstructionPending ? blocker.nextAction : blocker.canReconstructWorkspace
|
||||
? "The workspace was destroyed before its edits were saved. A board operator can continue from surviving source attachments."
|
||||
: "Paperclip could not restore the agent's workspace files after its last run. A board operator must recover the files before the agent can continue."}
|
||||
</p>
|
||||
<details>
|
||||
<summary className="cursor-pointer">Technical details</summary>
|
||||
<p>{blocker.nextAction}</p>
|
||||
</details>
|
||||
</>
|
||||
{blocker.workspaceReconstructionPending ? <p>{blocker.nextAction}</p> : blocker.workspaceRepairRequired ? (
|
||||
<p>The workspace needs operator attention before work can continue.</p>
|
||||
) : <p>{blocker.nextAction}</p>}
|
||||
{Boolean(blocker.savedMessageCount) && (
|
||||
<p>{blocker.savedMessageCount} saved {blocker.savedMessageCount === 1 ? "message is" : "messages are"} waiting for recovery.</p>
|
||||
@@ -87,15 +87,15 @@ export function ExecutionBlockerNotice({ companyId, issueId, issueStatus, canMan
|
||||
<p role="alert" className="w-full text-destructive">{retry.error.message}</p>
|
||||
)}
|
||||
{runsError && <p role="alert" className="w-full text-destructive">{runsError.message}</p>}
|
||||
{canManage && hold && <details className="w-full">
|
||||
{hasDetails && <details className="w-full text-muted-foreground">
|
||||
<summary className="cursor-pointer">Recovery details</summary>
|
||||
{replayBlocked &&
|
||||
{blocker.runError && <p className="break-words">{blocker.runError}</p>}
|
||||
{blocker.workspaceRepairRequired && <p>{blocker.nextAction}</p>}
|
||||
{canManage && replayBlocked &&
|
||||
<p>Paperclip stopped automatic retries to avoid repeating work with an unknown outcome. Inspect the run before requesting continuation.</p>}
|
||||
{hold.resolutionNote && <p>{hold.resolutionNote}</p>}
|
||||
{canManage && hold?.resolutionNote && <p>{hold.resolutionNote}</p>}
|
||||
</details>}
|
||||
{recoveryError && <p role="alert" className="w-full text-destructive">Unable to load recovery details: {recoveryError.message}</p>}
|
||||
{issueStatus && <WorkspaceFileRecovery key={`${blocker.recoveryActionId}:${blocker.runId}`} issueId={issueId}
|
||||
issueStatus={issueStatus} blocker={blocker} canManage={canManage} onRecorded={onRetried} />}
|
||||
{blocker.canReconstructWorkspace && <WorkspaceReconstructionRecovery key={`reconstruct:${blocker.recoveryActionId}:${blocker.runId}`}
|
||||
issueId={issueId} blocker={blocker} canManage={canManage} onRecorded={onRetried} />}
|
||||
</div>
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExecutionBlocker, Issue } from "@paperclipai/shared";
|
||||
import { WorkspaceFileRecovery } from "./WorkspaceFileRecovery";
|
||||
const resolve = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../api/issues", () => ({ issuesApi: { resolveRecoveryAction: resolve } }));
|
||||
const baseBlocker = { recoveryActionId: "held-source", runId: "original-run",
|
||||
workspaceRepairRequired: true, canRecordWorkspaceRepair: true, canRetry: false, canContinue: false, nextAction: "Recover the original files." } as ExecutionBlocker;
|
||||
|
||||
describe.each(["legacy_execution_requires_reconciliation", "native_continuation_requires_reconciliation"] as const)("workspace file recovery: %s", cause => {
|
||||
const blocker = { ...baseBlocker, cause };
|
||||
let root: Root, container: HTMLDivElement, client: QueryClient;
|
||||
const onRecorded = vi.fn();
|
||||
beforeEach(() => {
|
||||
resolve.mockReset().mockResolvedValue({}); onRecorded.mockReset();
|
||||
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
||||
client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); });
|
||||
async function mount(overrides: Partial<Parameters<typeof WorkspaceFileRecovery>[0]> = {}) {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><WorkspaceFileRecovery
|
||||
issueId="task" issueStatus="in_progress" blocker={blocker} canManage onRecorded={onRecorded} {...overrides} />
|
||||
</QueryClientProvider>));
|
||||
}
|
||||
async function enterEvidence() {
|
||||
const textarea = container.querySelector("textarea")!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(textarea,
|
||||
"Recovered and verified both repository patches in the linked backup artifact.");
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
async function confirm() { await act(async () => container.querySelector<HTMLButtonElement>('[role="checkbox"]')!.click()); }
|
||||
async function submit() {
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(b => b.textContent === "Record file recovery")!.click());
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
}
|
||||
it("records an optional verified no-action outcome only for the selected cancelled native run", async () => {
|
||||
await mount({ blocker: { ...blocker, runStatus: "cancelled" } });
|
||||
await enterEvidence(); await confirm();
|
||||
const label = Array.from(container.querySelectorAll("label"))
|
||||
.find(node => node.textContent === "I verified that this run performed no external actions.");
|
||||
if (cause === "native_continuation_requires_reconciliation") {
|
||||
expect(label).toBeDefined();
|
||||
const input = document.getElementById(label!.htmlFor)!;
|
||||
await act(async () => input.click());
|
||||
} else expect(label).toBeUndefined();
|
||||
await submit();
|
||||
expect(resolve).toHaveBeenCalledWith("task", expect.objectContaining({ executionReconciliation: expect.objectContaining({
|
||||
actionOutcome: cause === "native_continuation_requires_reconciliation" ? "not_performed" : "mixed",
|
||||
}) }));
|
||||
});
|
||||
it("leaves external action outcomes uncertified by default for cancelled runs", async () => {
|
||||
await mount({ blocker: { ...blocker, runStatus: "cancelled" } });
|
||||
await enterEvidence(); await confirm(); await submit();
|
||||
expect(resolve).toHaveBeenCalledWith("task", expect.objectContaining({ executionReconciliation: expect.objectContaining({
|
||||
actionOutcome: "mixed", outcomeEvidence: expect.stringContaining("does not replay the source run or certify its external action outcomes"),
|
||||
}) }));
|
||||
});
|
||||
it.each(["runId", "recoveryActionId"] as const)("clears source evidence and confirmations when %s changes", async field => {
|
||||
await mount({ blocker: { ...blocker, runStatus: "cancelled" } });
|
||||
await enterEvidence(); await confirm();
|
||||
const label = Array.from(container.querySelectorAll("label"))
|
||||
.find(node => node.textContent === "I verified that this run performed no external actions.");
|
||||
if (label) await act(async () => document.getElementById(label.htmlFor)!.click());
|
||||
await mount({ blocker: { ...blocker, runStatus: "cancelled", [field]: "another-source" } });
|
||||
expect(container.querySelector("textarea")?.value).toBe("");
|
||||
expect(Array.from(container.querySelectorAll('[role="checkbox"]')).every(node => node.getAttribute("aria-checked") === "false")).toBe(true);
|
||||
expect(Array.from(container.querySelectorAll("button")).find(node => node.textContent === "Record file recovery")?.disabled).toBe(true);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["blocked", "in_progress", "in_review", "done", "cancelled"] as Issue["status"][])("records verified source files without changing the %s task", async issueStatus => {
|
||||
await mount({ issueStatus });
|
||||
const button = Array.from(container.querySelectorAll("button")).find(b => b.textContent === "Record file recovery")!;
|
||||
expect(button.disabled).toBe(true); await enterEvidence(); expect(button.disabled).toBe(true);
|
||||
await confirm(); expect(button.disabled).toBe(false); await submit();
|
||||
expect(resolve).toHaveBeenCalledWith("task", expect.objectContaining({ actionId: "held-source", sourceIssueStatus: issueStatus,
|
||||
outcome: "restored", executionReconciliation: expect.objectContaining({ runId: "original-run", providerStopped: true,
|
||||
workspaceRepairEvidence: "Recovered and verified both repository patches in the linked backup artifact." }) }));
|
||||
expect(onRecorded).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector('[role="status"]')?.textContent).toContain("task status is unchanged");
|
||||
});
|
||||
it("keeps the hold and entered evidence when the server rejects unverified shutdown", async () => {
|
||||
resolve.mockRejectedValueOnce(new Error("The original workspace has not confirmed that it stopped."));
|
||||
await mount(); await enterEvidence(); await confirm(); await submit();
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("not confirmed");
|
||||
expect(container.querySelector("textarea")?.value).toContain("both repository patches");
|
||||
expect(onRecorded).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not offer attestation without board runtime access", async () => {
|
||||
await mount({ canManage: false }); expect(container.querySelector("button")).toBeNull();
|
||||
expect(container.textContent).toContain("board member with runtime access"); expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
{ workspaceRepairRequired: false }, { canRecordWorkspaceRepair: false }, { cause: "native_workspace_sync_out_retry_exhausted" },
|
||||
{ recoveryActionId: null }, { runId: null },
|
||||
])("does not expose file-only repair for other or unbound holds: %j", async patch => {
|
||||
await mount({ blocker: { ...blocker, ...patch } }); expect(container.textContent).toBe("");
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { ExecutionBlocker, Issue } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { Button } from "./ui/button";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
type WorkspaceFileRecoveryProps = {
|
||||
issueId: string;
|
||||
issueStatus: Issue["status"];
|
||||
blocker: ExecutionBlocker;
|
||||
canManage: boolean;
|
||||
onRecorded: () => void;
|
||||
};
|
||||
|
||||
export function WorkspaceFileRecovery(props: WorkspaceFileRecoveryProps) {
|
||||
const { blocker } = props;
|
||||
if (!blocker.workspaceRepairRequired || !blocker.canRecordWorkspaceRepair ||
|
||||
!["legacy_execution_requires_reconciliation", "native_continuation_requires_reconciliation"].includes(blocker.cause)
|
||||
|| !blocker.recoveryActionId || !blocker.runId) return null;
|
||||
return <WorkspaceFileRecoveryForm key={`${props.issueId}:${blocker.recoveryActionId}:${blocker.runId}`} {...props} />;
|
||||
}
|
||||
|
||||
function WorkspaceFileRecoveryForm({ issueId, issueStatus, blocker, canManage, onRecorded }: WorkspaceFileRecoveryProps) {
|
||||
const noteId = useId();
|
||||
const confirmationId = useId();
|
||||
const noActionsId = useId();
|
||||
const [evidence, setEvidence] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [noActions, setNoActions] = useState(false);
|
||||
const canConfirmNoActions = blocker.cause === "native_continuation_requires_reconciliation" && blocker.runStatus === "cancelled";
|
||||
const noActionsConfirmed = canConfirmNoActions && noActions;
|
||||
const record = useMutation({
|
||||
mutationFn: () => issuesApi.resolveRecoveryAction(issueId, {
|
||||
actionId: blocker.recoveryActionId!, outcome: "restored", sourceIssueStatus: issueStatus,
|
||||
resolutionNote: evidence.trim(),
|
||||
executionReconciliation: {
|
||||
runId: blocker.runId!, providerStopped: true, actionOutcome: noActionsConfirmed ? "not_performed" : "mixed",
|
||||
outcomeEvidence: noActionsConfirmed
|
||||
? "The board operator verified that this run performed no external actions. This request records recovered workspace files and does not replay the source run."
|
||||
: "This request records recovered workspace files only. It does not replay the source run or certify its external action outcomes.",
|
||||
workspaceRepairEvidence: evidence.trim(),
|
||||
},
|
||||
}),
|
||||
onSuccess: onRecorded,
|
||||
});
|
||||
return <section aria-label="Record workspace file recovery" className="flex w-full flex-col gap-2">
|
||||
{record.isSuccess ? <p role="status">File recovery recorded. The task status is unchanged. Continue the task when it is ready.</p>
|
||||
: canManage ? <>
|
||||
<Label htmlFor={noteId}>Recovered files and verification</Label>
|
||||
<Textarea id={noteId} value={evidence} onChange={event => setEvidence(event.target.value)} maxLength={12_000}
|
||||
placeholder="Describe how you recovered and verified the files from the original workspace. Include the backup or artifact link."
|
||||
disabled={record.isPending} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id={confirmationId} checked={confirmed} onCheckedChange={value => setConfirmed(value === true)} disabled={record.isPending} />
|
||||
<Label htmlFor={confirmationId}>I recovered and verified the original workspace files.</Label>
|
||||
</div>
|
||||
{canConfirmNoActions && <div className="flex items-center gap-2">
|
||||
<Checkbox id={noActionsId} checked={noActions} onCheckedChange={value => setNoActions(value === true)} disabled={record.isPending} />
|
||||
<Label htmlFor={noActionsId}>I verified that this run performed no external actions.</Label>
|
||||
</div>}
|
||||
<p className="text-muted-foreground">This records file recovery. It keeps the current task status and does not start a run. Paperclip verifies that the original execution stopped.</p>
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={record.isPending || !confirmed || evidence.trim().length < 20} onClick={() => record.mutate()}>
|
||||
{record.isPending ? "Recording…" : "Record file recovery"}
|
||||
</Button>
|
||||
</div>
|
||||
{record.isError && <p role="alert" className="text-destructive">{record.error.message}</p>}
|
||||
</> : <p className="text-muted-foreground">A board member with runtime access can record verified file recovery here.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,69 +1,102 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { FileRecoveryApiFixture } from "../../support/workspace-file-recovery/fixture";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RecoveryNoticeFixture } from "../../support/workspace-file-recovery/fixture";
|
||||
import { ExecutionBlockerNotice } from "@/components/ExecutionBlockerNotice";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
|
||||
import { TaskChatLiveRunPill } from "@/components/task-chat/TaskChatLiveRunPill";
|
||||
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } });
|
||||
client.setQueryData(queryKeys.issues.runs("storybook-recovery"), [
|
||||
{ runId: "stopped-run", agentId: "agent", status: "failed" },
|
||||
]);
|
||||
const meta = {
|
||||
parameters: { docs: { story: { inline: false } }, paperclip: {
|
||||
"capabilities": [
|
||||
"recovery/diagnosis"
|
||||
],
|
||||
"implementation": "production",
|
||||
"boundary": "fixtures",
|
||||
"notes": "Retained-file states cover the production repair form, source-bound evidence and unchanged task status; fixture APIs do not prove live provider recovery."
|
||||
} },
|
||||
id: "task-chat-recovery-notice",
|
||||
title: "Projects and execution/Recovery/Task chat/Recovery notice",
|
||||
component: ExecutionBlockerNotice,
|
||||
decorators: [(Story, context) => <FileRecoveryApiFixture response={context.parameters.fileRecoveryResponse ?? "success"}><QueryClientProvider client={client}><div className="max-w-2xl"><Story /></div></QueryClientProvider></FileRecoveryApiFixture>],
|
||||
parameters: { docs: { story: { inline: false } }, paperclip: {
|
||||
capabilities: ["recovery/diagnosis"], implementation: "production", boundary: "fixtures",
|
||||
notes: "Production task notice with source-bound recovery reads. Automatic recovery stays quiet; unresolved holds expose inspection without infrastructure certification. Fixture APIs do not prove live provider recovery.",
|
||||
} },
|
||||
decorators: [(Story, context) => <RecoveryNoticeFixture key={context.id} state={context.parameters.recoveryState ?? "blocked"}>
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4"><Story /></div>
|
||||
</RecoveryNoticeFixture>],
|
||||
args: {
|
||||
companyId: "storybook-company", issueId: "storybook-recovery", onRetried: () => {},
|
||||
companyId: "storybook-company", issueId: "storybook-recovery", issueStatus: "blocked", canManage: true, onRetried: () => {},
|
||||
blocker: {
|
||||
recoveryActionId: "recovery", runId: "stopped-run", agentId: "agent",
|
||||
cause: "legacy_execution_requires_reconciliation",
|
||||
nextAction: "Automatic recovery stopped. Recorded work is preserved; actions with unverified outcomes will not be repeated.",
|
||||
cause: "native_continuation_requires_reconciliation",
|
||||
nextAction: "The previous action's outcome needs inspection before work can continue.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ExecutionBlockerNotice>;
|
||||
export default meta;
|
||||
export const Stopped: StoryObj<typeof meta> = {};
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Stopped: Story = {};
|
||||
|
||||
const fileHold = {
|
||||
...meta.args.blocker,
|
||||
nextAction: "Recover and verify files from the retained original workspace before continuing.",
|
||||
workspaceRepairRequired: true,
|
||||
canRecordWorkspaceRepair: true,
|
||||
nextAction: "The original workspace is retained. An operator must verify that its previous execution stopped before recovering files.",
|
||||
runError: "runner_remote_provider_artifact_incompatible: provider pack verification failed ()",
|
||||
workspaceRepairRequired: true, canRecordWorkspaceRepair: true,
|
||||
};
|
||||
export const FileRecovery: StoryObj<typeof meta> = { args: { blocker: fileHold, issueStatus: "in_review", canManage: true } };
|
||||
export const FileRecoveryReadOnly: StoryObj<typeof meta> = { args: { blocker: fileHold, issueStatus: "in_review", canManage: false } };
|
||||
export const FileRecoveryRecorded: StoryObj<typeof meta> = {
|
||||
args: { blocker: fileHold, issueStatus: "in_review", canManage: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = canvas.getByRole("button", { name: "Record file recovery" });
|
||||
await expect(button).toBeDisabled();
|
||||
await userEvent.type(canvas.getByRole("textbox"), "The original workspace files were recovered into the attached archive and verified against both repository heads.");
|
||||
await userEvent.click(canvas.getByRole("checkbox"));
|
||||
await userEvent.click(button);
|
||||
await expect(await canvas.findByText("File recovery recorded. The task status is unchanged. Continue the task when it is ready.")).toBeVisible();
|
||||
const noRepairForm: NonNullable<Story["play"]> = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Work paused.")).toBeVisible();
|
||||
await expect(canvas.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
await expect(canvas.getByRole("link", { name: "Inspect run" })).toBeVisible();
|
||||
};
|
||||
export const FileRecovery: Story = { args: { blocker: fileHold }, play: noRepairForm };
|
||||
export const FileRecoveryReadOnly: Story = { args: { blocker: fileHold, canManage: false }, play: noRepairForm };
|
||||
// Preserve existing story IDs for saved inspection links.
|
||||
export const FileRecoveryRecorded: Story = { args: { blocker: fileHold }, parameters: { recoveryState: "recorded" }, play: noRepairForm };
|
||||
export const FileRecoveryRejected: Story = {
|
||||
args: { blocker: fileHold }, parameters: { recoveryState: "rejected" },
|
||||
play: async context => {
|
||||
await noRepairForm(context);
|
||||
const canvas = within(context.canvasElement);
|
||||
await userEvent.click(canvas.getByText("Recovery details"));
|
||||
await expect(canvas.getByText(/previous execution could not be verified stopped/)).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const FileRecoveryRejected: StoryObj<typeof meta> = {
|
||||
args: { blocker: fileHold, issueStatus: "in_review", canManage: true },
|
||||
parameters: { fileRecoveryResponse: "conflict" },
|
||||
export const AutomaticRecovery: Story = {
|
||||
args: { blocker: fileHold }, parameters: { recoveryState: "automatic" },
|
||||
render: args => <>
|
||||
<TaskChatBubble item={{ id: "request", kind: "message", author: "human", authorName: "Dotta", text: "Review the test suite and open a PR with the useful cleanup." }} />
|
||||
<TaskChatLiveRunPill status="running" startedAtMs={null} toolSummary={null} />
|
||||
<ExecutionBlockerNotice {...args} />
|
||||
</>,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const evidence = "The original workspace files were recovered into the attached archive and verified against both repository heads.";
|
||||
await userEvent.type(canvas.getByRole("textbox"), evidence);
|
||||
await userEvent.click(canvas.getByRole("checkbox"));
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Record file recovery" }));
|
||||
await expect(await canvas.findByRole("alert")).toHaveTextContent("The original execution is not verified stopped.");
|
||||
await expect(canvas.getByRole("textbox")).toHaveValue(evidence);
|
||||
await expect(canvas.getByText("Working")).toBeVisible();
|
||||
await expect(canvas.queryByRole("status", { name: "Task recovery" })).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const MissedRecovery: Story = { args: { blocker: fileHold }, parameters: { recoveryState: "expired" }, play: noRepairForm };
|
||||
export const RecoveryReadUnavailable: Story = {
|
||||
args: { blocker: fileHold }, parameters: { recoveryState: "unavailable" },
|
||||
play: async context => {
|
||||
await expect(await within(context.canvasElement).findByRole("alert")).toHaveTextContent("Recovery service unavailable");
|
||||
await noRepairForm(context);
|
||||
},
|
||||
};
|
||||
export const Mobile: Story = { args: { blocker: fileHold }, globals: { viewport: { value: "mobile", isRotated: false } }, play: noRepairForm };
|
||||
export const SourceReconstruction: Story = {
|
||||
args: { blocker: { ...fileHold, canReconstructWorkspace: true, workspaceRecoveryStatusVersion: 1,
|
||||
nextAction: "The original workspace was destroyed. Continue only from verified surviving source attachments." } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Continue from saved sources")).toBeVisible();
|
||||
await expect(canvas.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
await userEvent.click(canvas.getByText("Continue from saved sources"));
|
||||
await expect(canvas.getByText("test-audit.patch (4096 bytes)")).toBeVisible();
|
||||
await expect(canvas.getByRole("button", { name: "Rebuild and continue" })).toBeDisabled();
|
||||
},
|
||||
};
|
||||
export const ReconstructionQueued: Story = {
|
||||
args: { blocker: { ...fileHold, workspaceReconstructionPending: true,
|
||||
nextAction: "Continuation from saved sources is queued and waiting for execution checks." } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Continuation queued.")).toBeVisible();
|
||||
await expect(canvas.queryByText("Work paused.")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -1,18 +1,50 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { recoveryAction } from "../workspace-finalization-recovery/fixtures";
|
||||
|
||||
export function FileRecoveryApiFixture({ response, children }: { response: "success" | "conflict"; children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
export type RecoveryFixtureState = "blocked" | "automatic" | "expired" | "recorded" | "rejected" | "unavailable";
|
||||
|
||||
export function RecoveryNoticeFixture({ state = "blocked", children }: {
|
||||
state?: RecoveryFixtureState; children: ReactNode;
|
||||
}) {
|
||||
const action: IssueRecoveryAction = {
|
||||
...recoveryAction, id: "recovery", sourceIssueId: "storybook-recovery",
|
||||
evidence: { runId: "stopped-run", automaticRecovery: { replay: ["automatic", "expired", "unavailable"].includes(state) ? "held" : "blocked" } },
|
||||
...(["automatic", "expired", "unavailable"].includes(state) ? {
|
||||
status: "active", ownerType: "agent", attemptCount: 1, maxAttempts: 3,
|
||||
wakePolicy: { kind: "resume_native_run", runId: "stopped-run", attempt: 1, maxAttempts: 3, notBefore: "2020-01-01T00:00:00Z" },
|
||||
nativeRunActivity: state !== "expired" ? { runId: "stopped-run", status: "running", workspaceOperationId: null } : null,
|
||||
} : {}),
|
||||
resolutionNote: state === "recorded" ? "The recovered workspace was verified. External actions still require inspection before continuation."
|
||||
: state === "rejected" ? "The previous execution could not be verified stopped. The original workspace remains held." : null,
|
||||
};
|
||||
const response = { active: action.status === "active" ? action : null, actions: [action] };
|
||||
const [client] = useState(() => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
|
||||
queryClient.setQueryData(queryKeys.issues.runs("storybook-recovery"), [
|
||||
{ runId: "stopped-run", agentId: "agent", status: state === "automatic" ? "running" : "failed" },
|
||||
]);
|
||||
queryClient.setQueryData(queryKeys.issues.recoveryActions("storybook-recovery", "recovery", "stopped-run"), response);
|
||||
queryClient.setQueryData(queryKeys.issues.attachments("storybook-recovery"), [
|
||||
{ id: "saved-source", originalFilename: "test-audit.patch", byteSize: 4096 },
|
||||
]);
|
||||
return queryClient;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const originalFetch = window.fetch;
|
||||
const fixtureFetch: typeof window.fetch = async (input, init) => {
|
||||
const rawUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (new URL(rawUrl, window.location.origin).pathname !== "/api/issues/storybook-recovery/recovery-actions/resolve") return originalFetch(input, init);
|
||||
if (response === "conflict") return Response.json({ error: "The original execution is not verified stopped." }, { status: 409 });
|
||||
const body = JSON.parse(String(init?.body));
|
||||
if (body.sourceIssueStatus !== "in_review" || body.executionReconciliation?.runId !== "stopped-run") return Response.json({ error: "Recovery source changed." }, { status: 409 });
|
||||
return Response.json({ outcome: "restored", sourceIssueStatus: "in_review" });
|
||||
const path = new URL(rawUrl, window.location.origin).pathname;
|
||||
if (path === "/api/issues/storybook-recovery/recovery-actions" && (!init?.method || init.method === "GET")) {
|
||||
return state === "unavailable" ? Response.json({ error: "Recovery service unavailable" }, { status: 503 }) : Response.json(response);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
window.fetch = fixtureFetch;
|
||||
return () => { if (window.fetch === fixtureFetch) window.fetch = originalFetch; };
|
||||
}, [response]);
|
||||
return children;
|
||||
if (state === "unavailable") void client.refetchQueries({ queryKey: queryKeys.issues.recoveryActions("storybook-recovery", "recovery", "stopped-run") });
|
||||
return () => { if (window.fetch === fixtureFetch) window.fetch = originalFetch; client.clear(); };
|
||||
}, [client, state]);
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
Reference in new issue
Block a user