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:
DottaandPaperclip authored and GitHub committed 2026-10-18 21:43:15 -05:00
1 parent ad9f5edd49
commit 3858d2341d
40 files changed
+851 -327

No files matched your search

+1
View File
@@ -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
+10
View File
@@ -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.
+6 -5
View File
@@ -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
+5 -3
View File
@@ -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
+11 -8
View File
@@ -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.
+2 -2
View File
@@ -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],
};
@@ -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" } };
+22 -22
View File
@@ -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>;
}