Commit Graph
338 Commits
Author SHA1 Message Date
DottaandClaude Opus 5.5 fa3cd6f330 fix(native): end provider startup rejections with Retry instead of an ownership hold (#20808)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Paperclip Runner starts Codex for native runs. On a remote
sandbox such as Daytona, the sandbox is the OS boundary.
> - A Codex planning run on Daytona failed in `session.open`. Codex's
read-only sandbox helper could not set up loopback in the container
(`bwrap: loopback: Failed RTM_NEWADDR`), and Codex loads `AGENTS.md`
through that helper.
> - The server treated that definite provider rejection as an
interrupted transport. Its same-run bootstrap retry went through remote
restart recovery and stopped on `ambiguous_remote_recovery_evidence`.
The task showed "Recovering saved work…" and no Retry, although no
provider work was done.
> - This pull request gives such startup rejections a named, permanent
outcome with a working Retry, and pins ask and standard runs to the
external sandbox policy.
> - The benefit is that a provider startup refusal ends as a clear
failure with Retry, never as an ownership hold.

## Linked Issues or Issue Description

**What happened?**
A Codex planning run on a Daytona sandbox failed in `session.open` with
`error creating thread: failed to load AGENTS.md instructions ... bwrap:
loopback: Failed RTM_NEWADDR: Operation not permitted`. The server
recorded `native_session_interrupted` with recovery mode
`bootstrap_retry`. Thirty seconds later, remote restart recovery blocked
the run with `native_restart_recovery_blocked` /
`ambiguous_remote_recovery_evidence`. No server restart happened. All
default-mode Codex runs on the same image succeeded that day; only the
planning run failed.

**Expected behavior**
A provider startup failure with no provider work must end as a clear
failure with a working Retry, or get a bounded automatic retry that also
ends with Retry. It must never become an ambiguous-ownership hold.

**Steps to reproduce**
1. `cd server && npx vitest run
src/services/native-runtime/native-startup-rejected-retry.integration.test.ts`.
On master the deterministic case is `retryable_failure` /
`native_session_interrupted`.
2. `npx vitest run
src/services/native-runtime/native-session-executor.test.ts -t "provider
itself rejected"`. On master the new code is not produced.

**Paperclip version or commit**
master at 84fc67317e.

## What Changed

- Server (`native-session-executor.ts`): new permanent code
`native_provider_startup_rejected`.
- A definite `session.open` failure maps to it when its startup
`providerFailure` is `requires_action` for `authentication`,
`permission` or `invalid_request`.
- On a remote execution target (`nativeRemoteStartupRejectionCode`), any
definite `session.open` failure with a startup `providerFailure` maps to
it. A remote same-run bootstrap after the runner launched cannot prove
ownership and stops on `ambiguous_remote_recovery_evidence`.
- The run ends as `terminal_failure` with a board owner and a plain next
action. The board offers Retry, which starts one new run with a fresh
provider session.
- On a local target, unknown, transient, rate-limit, quota and model
startup failures keep the bounded same-run retries and end as
`native_session_retry_exhausted` with Retry. That code is unchanged
because the chat-channel failed-run retry depends on it.
- Runner: planning runs keep Codex's
`paperclip-runner-workspace-read-only` profile, also in an external
sandbox. Codex 0.160.0's plan collaboration mode is a prompt ("Plan Mode
(Conversational)") and does not block writes or commands, so it cannot
replace that enforcement.
- Ask mode: an ask task builds `executionMode: "default"`. Its launch
has `readOnly: false`, the external sandbox profile, and no
collaboration mode. Ask mode restricts only Paperclip mutation tools,
not the Codex sandbox.
- Tests:
- `native-startup-rejected-retry.integration.test.ts` (new, embedded
Postgres, Codex provider): a deterministic rejection ends at once with
`canRetry: true`, and Retry starts exactly one new run. Unknown and
transient local failures stay `retryable_failure`. A spent retry budget
ends `terminal_failure` with `canRetry: true`.
  - Unit tests for the deterministic, local-retry and remote rules.
- `runner-provider-launch.test.ts`: ask, standard and planning launches
in an external sandbox.
- The Rust external-sandbox test now fails if a non-planning turn sends
a collaboration mode (`--forbid-plan-collaboration` switch on the fake
app server).
- Docs: `packages/paperclip-runner/docs/codex-driver.md`,
`packages/paperclip-runner/docs/durable-recovery.md`,
`doc/execution-semantics.md`.

## Verification

- Red on master, green here: the commands in "Steps to reproduce".
- `npx vitest run
src/services/native-runtime/native-session-executor.test.ts`: 1364 pass.
- `npx vitest run src/live/runner-provider-launch.test.ts`
(packages/paperclip-runner): 22 pass.
- `cargo test -p paperclip-runner-core --test codex_provider
external_sandbox`: pass. `cargo fmt --check`: clean.
- `npx tsc --noEmit -p server/tsconfig.json`: clean.
- `pnpm verify` returned exit 2 (a global CI input changed). Required CI
supplies the broad coverage.

## Risks

- Codex planning on Daytona still fails at thread creation. With this
change it ends at once with the provider message and Retry instead of an
ownership hold. A follow-up must make Codex's read-only sandbox work in
that container, or add a verified Paperclip read-only guard. This PR
does not relax read-only enforcement.
- A remote startup failure that could clear by itself now needs a Retry
click instead of an automatic same-run retry. Before this change, that
retry ended in an ownership hold, so no working automatic path is lost.
- A false `native_provider_startup_rejected` costs automatic retries and
shows Retry earlier. It never replays provider work, because
`session.open` precedes every prompt.
- Saved state: no persisted shape or launch argument changes. A run that
an older release held on `ambiguous_remote_recovery_evidence` after the
same rejection already offers Retry once its saved workspace is
recovered. No migration is needed.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude (Anthropic) Opus 5.5, model ID `claude-opus-5-5`, run in Claude
Code with tool use (shell, file edit, browser reads) and extended
reasoning.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:48:18 -06:00
DottaandClaude c44a422607 fix(native): retry a follow-up whose own server stopped serving before its remote runner (#20786)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The native runtime runs each agent task through a Runner. On a
graceful shutdown (SIGTERM), the server closes its runtime API listener
first, then drains runs.
> - A run that is still preparing can reach the runtime API lookup after
the listener closed. It then fails with
`local_runtime_api_listener_unavailable` before it reaches its Runner.
> - For a remote (sandbox) Runner, the executor already settles this
failure as a same-run bootstrap retry, so the next server restarts it
with no manual Retry.
> - A follow-up run is different. Admission rebinds the settled
predecessor's provider session into the follow-up's run as its
checkpoint. The recovery evidence saw "a checkpoint exists" and called
the failure `ambiguous_state`. That blocked the task and the user had to
click Retry.
> - This pull request recognizes that carried checkpoint. When this
server's own listener closed, the run has no Runner checkpoint and no
provider event of its own, and a settled predecessor saved that exact
provider session, the failure is a same-run bootstrap retry.
> - The benefit is that a graceful restart during a follow-up's
preparation no longer needs a manual Retry.

## Linked Issues or Issue Description

**What happened?**
In a Daytona restart-chaos wave (codex, remote sandbox Runner), a
follow-up run was created. About 2 s later a graceful SIGTERM drain
began. The draining server kept preparing the run.
`resolveLocalRuntimeApiUrl()` in
`createRunnerdBackendWithinSessionClaim` threw
`LocalRuntimeApiListenerClosedError`. The run failed with
`local_runtime_api_listener_unavailable`. The task went to `blocked`
with a board-owned recovery action, and it needed a manual Retry. The
manual Retry worked.

**Expected behavior**
No provider work started for the run. The next server must pick it up
automatically.

**Steps to reproduce**
1. Finish a remote native run for a task, so that its provider session
is saved.
2. Comment on the task to start a follow-up run. The follow-up carries
the saved provider session as its admission checkpoint.
3. Send SIGTERM while the follow-up is in preparation, before it reaches
its Runner.
4. The follow-up fails with `ambiguous_state` and the task is blocked.

**Paperclip version or commit**
`master` at `62b7733161` (wave) and `eb75c9bb85` (this branch base).

Refs #20514, #20721

## What Changed

- `native-session-executor.ts` (`nativeProviderRecoveryEvidence`): add
one narrow case before the generic checks. When all of these are true,
return `bootstrap_retry` (with `checkpointExists: true` and
`providerSessionEstablished: false`):
- the source failure is `local_runtime_api_listener_unavailable` (this
server stopped serving);
- the target is remote, the run has the current preparation fencing
version, and the run has no Runner controller checkpoint;
  - the run has no provider event;
- the run checkpoint has no cursor, turn, terminal, result, or pending
request of its own;
- a settled earlier run of the same native session saved that exact
provider session.
- `native-restart-recovery.ts`: export the existing
`carriesSettledPredecessorSession` proof, so that the executor uses the
same proof that restart recovery uses for rebound sessions.
- `native-controller-recovery.integration.test.ts`: add a regression
test. It also shows that the verdict stays `ambiguous_state` for a
different failure code, for an unknown provider session, for a
checkpoint with its own turn, and for a run with a provider event.

## Verification

- Red on master: `cd server && npx vitest run
src/services/native-runtime/native-controller-recovery.integration.test.ts
-t "own server stopped serving"` returned `recoveryMode:
"ambiguous_state", providerSessionEstablished: true`.
- Green with the fix: the same file passes (25 tests).
- `npx vitest run
src/services/native-runtime/native-startup-retry.integration.test.ts
src/services/native-runtime/native-runner-restart-preparation.integration.test.ts`:
43 passed (the existing closed-listener bootstrap-retry and ambiguous
cases are unchanged).
- `cd server && pnpm typecheck`: passed.
- Not run: a live restart-chaos wave.

## Risks

- Low risk. The new case applies only to the closed-listener failure on
a remote target. It needs durable proof that the run has no Runner
checkpoint and no provider event, and that the provider session belongs
to a settled predecessor. All other cases keep their earlier verdict.
- If the retry later finds different evidence, the existing restart
recovery and resumption checks re-prove it and fail closed. The task
then keeps a working Retry.
- Upgrade safety: no persisted shape changes. The proof reads only rows
that older servers already write (`sessionCheckpoint`,
`nativePreparationFencingVersion`, Runner checkpoints, run events). A
run that an older server prepared without the fencing version keeps its
earlier verdict.
- No waits are added before `turn.start`. The new queries run only on
the failure path.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 04:22:49 -06:00
Nicky Leach 4d141cdf64 Carry plugin task refusals and batch project reads (#20762) 2026-11-11 00:49:03 -08:00
DottaandClaude f25fee3532 fix(native): hash runner artifacts and run archive tar off the event loop (#20722)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - One server process prepares native runs, renews every controller
lease on timers, and serves every request
> - On a run path, the server hashed the 31 MB runner binary with
`readFileSync` (more than once per run), hashed provider packs and
harness backups synchronously, and ran `tar` with `execFileSync`
(timeouts up to 60 s, one with no timeout)
> - On a slow disk, each of these calls freezes the whole process. A 103
s stall on a hosted instance lapsed every lease. The last event before
that stall was immediately before a synchronous read and hash of the
runner binary (see #20694 and #20709)
> - This pull request moves those reads, hashes and archive commands off
the event loop. It computes the runner binary digest once per boot
> - The benefit is that a slow disk delays only the run that waits for
it. Turns also start faster, because the runner binary is no longer
re-read for every run

## Linked Issues or Issue Description

Refs #20694, #20709

**What happened?**

On a hosted instance, the server process stopped for 103 s without a
restart. Every controller lease lapsed, and two tasks needed a manual
Retry. The last run event before the gap was immediately before
`matchesControllerRunnerArtifact`, which does
`createHash("sha256").update(readFileSync(controllerRunnerBinary))`.
Four workspace restores were running at the same time, and file I/O was
very slow (a 43 s archive hash, 20 s snapshot decrypts). The trigger is
not proven. But each synchronous call below blocks the event loop for as
long as the disk is slow.

**Expected behavior**

File hashing and archive commands on a run path must not block the event
loop. A slow disk must delay only the run that waits for it.

**Steps to reproduce**

1. Make a remote runner binary slow to read (the test uses a FIFO whose
writer waits), or make `tar` slow (the test puts a `tar` wrapper that
sleeps first on `PATH`).
2. Call `resolveRemotePaperclipRunnerBinary`,
`stageRemoteRunnerDirectory`, or `syncRemoteRunnerDirectoryOut` while a
10 ms timer runs.
3. Before this change, the timer stops for the whole slow step (814 ms,
1,764 ms and 3,379 ms). After this change, it keeps firing.

## What Changed

- `server/src/services/native-runtime/off-loop-file-work.ts` (new):
  - `sha256FileStreamed`
- `cachedFileSha256`: a streamed digest, reused while the file's device,
inode, size, mtime and ctime do not change
- `binaryTargetFromHeader`: reads 64 KiB; reads the rest of the file
only when the header cannot answer, so the result equals
`runnerBinaryTarget(readFileSync(path))`
  - `sha256DirectoryTreeStreamed`
- `execFileBuffered`: `execFile` with stdin input, `maxBuffer`,
`timeout` and `env`. It rejects on a non-zero exit, a timeout or a
`maxBuffer` overflow, like `execFileSync`.
- `native-session-executor.ts`:
- `matchesControllerRunnerArtifact` uses `cachedFileSha256`. Each boot
reads the binary once, not once or twice for every run.
  - The remote Claude daemon target is read from the binary header.
- The bundled remote runner binary is checked by
`verifiedBundledRemoteRunnerBinary`: the same checks and errors, with a
cached digest.
- Provider-pack upload verification uses the new
`verifyRemoteProviderPackManifest`. It runs the same checks, in the same
order, with the same errors. One generator drives both the sync and the
streamed readers.
- Harness backup build and verify on run paths use streamed digests
(`buildNativeHarnessBackupManifestStreamed`,
`verifyNativeHarnessBackupStreamed`). The digest format does not change.
- `stageRemoteRunnerDirectory` (3 calls),
`assertSafeRemoteCheckpointArchive` (3 calls) and the checkpoint extract
use `execFileBuffered`, with the same arguments, timeouts and buffer
limits. `stageRemoteRunnerFile` uses `readFile`.
- `packages/paperclip-runner/src/live/runner-binary.ts`: the default
daemon lookup (`resolvePackagedRunnerBinary`, used by the local and
remote resolvers) classifies the daemon with `runnerBinaryFileTarget`.
It reads the 64 KiB header, not the whole file.
- `packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts`:
the release-manifest checks now live in one helper,
`bundledRemoteRunnerRelease`. The sync `bundledRemoteRunnerBinary` and
the server's streamed check both use it.
- `native-codex-runner.ts`:
  - `resolveRemotePaperclipRunnerBinary` reads only the header.
  - The local `runnerDigest` uses `cachedFileSha256`.
  - New `verifiedBundledRemoteRunnerBinary`.

**Ordering and atomicity.** I checked each converted call for code that
relied on the sync call being atomic with respect to other runs:
- **Checkpoint copy-back.** It extracts into a private `mkdtemp`
directory. The replace sequence (`existsSync`, `rename`, `rename`, `rm`)
stays synchronous, so no other run can see a half-installed checkpoint.
This function already awaited a remote command before the extract, so
this change adds no new interleaving with the target.
- **Harness backup verify then restore.** The verify was sync, but the
restore after it was already async. The window between them existed
before. One owner serializes the turns of a session, so no other turn
publishes a backup for that session while a turn verifies it.
- **Staging and provider-pack hashing.** These read build-owned,
immutable trees, or the private staging copies of a run.

## Verification

- Red/green: `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/off-loop-file-work.test.ts`
- With `native-session-executor.ts` and `native-codex-runner.ts` from
`origin/master`: 3 failed. The longest timer gaps were 814 ms (runner
binary target read), 1,764 ms (directory staging tar) and 3,379 ms
(checkpoint list, size check and extract).
- With this change: 6 passed. The gaps stay under 400 ms. The same file
also checks the digest cache, the header fallback, and the
`execFileSync`-like errors (exit code, timeout kill,
`ERR_CHILD_PROCESS_STDIO_MAXBUFFER`).
- `native-session-executor.test.ts`:
- The streamed provider-pack verifier returns the same manifest as the
sync reader, and rejects a changed asset tree with the same error.
  - The streamed harness backup manifest equals the sync one.
- The streamed verify agrees with the sync verify, for a valid backup
and for a corrupt one.
- Run together: `native-session-executor`, `off-loop-file-work`,
`native-codex-runner`, `setup-readiness`, `antigravity-connection-probe`
(1,653 passed). `run-completion.test.ts` passed all 153 tests. Its
`afterAll` embedded-Postgres cleanup timed out locally, which is not
related to this change; CI runs it.
- `pnpm --filter @paperclipai/server exec tsc --noEmit` passes.

## Risks

- **Turn start.** This change adds no wait before `turn.start`; it
removes work. The first run of a boot streams the runner binary once.
Later runs reuse the digest after one `stat`.
- **Cache staleness.** The cache keys on device, inode, size, mtime and
ctime. An in-place rewrite changes mtime and ctime, and a replaced file
changes its inode, so a changed binary is hashed again.
- **stderr.** `execFileSync` wrote a child's stderr to the server's
stderr. `execFileBuffered` captures it. Tar warnings no longer appear in
server logs; errors and exit codes are unchanged.
- **Persisted state.** No persisted shape changes. Old saved state and
new code (and the reverse) read the same manifests and digests.
- **Not converted in this PR:** small metadata syscalls (`existsSync`,
`lstatSync`, `readdirSync`, small JSON reads and writes, renames) in the
native state machines. Some of these rely on running synchronously. Also
not converted: the sync harness backup verify inside the sync
restart-authority checks, and the sync stamp verify in
`native-harness-backup-storage.ts`. Both are listed for a follow-up.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude
Code with tool use, shell, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (code
comments carry the contract)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 00:27:12 -06:00
DottaandClaude 94c7c83ce8 fix(runner): refuse a moved-root same-session checkpoint by name (#20723)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs on a remote sandbox resume a task's provider session
from a saved checkpoint
> - #20712 made a checkpoint that names an abandoned remote workspace
root decline recovery, so a replacement session opens in the new root
> - A checkpoint that requires the same session (an unfinished goal,
`same_session_only`) cannot be replaced. It then failed with the generic
`native_session_recovery_failed`, which recovery treats as a retryable
interruption
> - A retry cannot move a saved session to another root, so the failure
repeats. The upgrade-safe rule requires a permanent, named code for this
case
> - Also, the working-directory errors said "Codex" on Claude and other
ACPX runs, because one app-server driver serves all of them
> - This pull request reports the moved root as provider state that is
incompatible, and makes the errors name the agent generically
> - The benefit is a named, permanent refusal with a working board
Retry, and error text that is correct for every agent

## Linked Issues or Issue Description

Refs #20712

**What happened?**
After #20712, a `same_session_only` checkpoint with a moved remote root
ended with `native_session_recovery_failed: persisted session working
directory is not the assigned remote workspace`. That is not a named
permanent code, so recovery could retry a run that cannot succeed. On
ACPX Claude runs, path errors read "Remote Codex working directory ...".

**Expected behavior**
The run ends with the permanent code
`native_provider_state_incompatible`. Automatic retries stop. A board
Retry continues the task in a fresh provider session in the same
workspace. Errors do not name Codex on other agents.

**Steps to reproduce**
1. Save a native checkpoint with `providerRecoveryPolicy:
same_session_only` under one remote workspace root.
2. Assign the run a different sibling root (the epoch was abandoned).
3. Resume the run.

## What Changed

- `codex-app-server-driver-impl.ts`: the moved-root decline from #20712
now carries `failureClass: provider_state_incompatible`. The runtime
already maps that class:
- checkpoint allows replacement
(`allow_replacement_after_resume_failure`): a replacement session opens
in the assigned root, as before;
- checkpoint requires the same session:
`NativeProviderStateIncompatibleError`, code
`native_provider_state_incompatible`.
- The server already treats `native_provider_state_incompatible` as a
permanent failure (no automatic retry, board action text "Retry the task
to continue in a fresh provider session with the same workspace").
`rebindNativeSessionCheckpoint` returns no checkpoint after that code,
so a board Retry starts a fresh session in the same workspace. No server
change is needed.
- A moved-root checkpoint with an active turn or a pending runtime
request now reports `provider_side_effect_pending`. The runtime never
replaces it under any policy, and the run ends with the permanent code
`native_provider_side_effect_pending` for an operator to reconcile.
Before, it threw the working-directory refusal, which recovery recorded
as the retryable `native_session_interrupted`.
- `harness-driver.ts`: the `PROVIDER_STATE_INCOMPATIBLE` doc now names
the moved-workspace case.
- `codex-boundaries.ts`: working-directory errors say "Agent working
directory", "Remote agent working directory" and "Provider thread
response" instead of "Codex". Tests updated to the new text.
- Tests:
- `native-session-runtime.test.ts`: the real app-server backend with
`remote_runner` authority and a moved root, for each recovery policy.
Same-session policies end with `native_provider_state_incompatible`, the
replacement policy opens a replacement in the assigned root, an
in-flight turn ends with `native_provider_side_effect_pending` under
every policy, and no transport starts.
- `native-backend-factory.test.ts`: the decline carries the failure
class, and an ACPX Claude refusal reads "Remote agent working directory
does not match the assigned workspace".

Previous-release story: no persisted shape changes. Saved checkpoints
are classified at recovery time. A saved same-session checkpoint under
an old root now ends with the named permanent code instead of a generic
one.

## Verification

- Red: without the failure classes, the same-session and in-flight
runtime cases fail (generic `native_session_recovery_failed`). Before
the rename, the ACPX Claude message test fails.
- Green: `cd packages/paperclip-runner && npx vitest run src/backends
src/drivers/codex src/live/runner-prepared-launch-workspace.test.ts
src/native-session-runtime.test.ts` passes (784 tests).
- `npx tsc --noEmit -p packages/paperclip-runner` passes.
- Existing server coverage for the code: `native-session-resume.test.ts`
(no rebind after `native_provider_state_incompatible`) and
`native-session-executor.test.ts` (source-code mapping).

## Risks

- Low. Error message text changes. A search found no code that matches
on the old text outside the updated tests.
- Same-session checkpoints with a moved root now stop for a board Retry
instead of a generic interruption. That is the intended behavior: a
retry cannot succeed.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edit, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 00:14:03 -06:00
DottaandClaude 08ff0828d6 fix(native): offer Retry at once when the Runner refuses a turn start on a closed session (#20729)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native ACPX run (for example Claude) starts each turn with
`turn.start`. When a start is cut off, the Runner reads the sidecar's
dispatch journal (#20675).
> - If the prompt never left, the Runner keeps the session reopenable,
and the same-run resume sends the prompt once. If the prompt may have
left, the Runner closes the session, because a resend could duplicate
it.
> - A closed session cannot reopen for the same run. Every same-run
resume got `ACPX session is not open`. The server treated that as a
generic interruption and spent three attempts (about 95 s) before the
board offered Retry.
> - This pull request gives that refusal a closed class in the Runner
and a permanent, named failure code in the server.
> - The benefit is that the board offers a working Retry on the first
refusal, while every recoverable case keeps recovering by itself.

## Linked Issues or Issue Description

**What happened?**
Restart-chaos wave `w-20261111T011704-s9107`, task t05 (local, claude,
workload C1, fault TM2). A freeze cut off a turn start, and the Runner
saved the session `closed`. Three same-run resumes each failed with `PRP
command turn.start failed: … "message":"ACPX session is not open"`.
Recovery stopped with `native_session_retry_exhausted` at 01:25:14, and
the board then offered Retry. The Retry run succeeded.

**Expected behavior**
When the Runner proves the session is closed for this run, the run stops
at the first refusal with a named failure, and the board offers a Retry
that works. A start that the Runner proved never reached the provider
still recovers by itself.

**Steps to reproduce**
1. `cd server && npx vitest run
src/services/native-runtime/native-closed-session-retry.integration.test.ts`
2. On master, "offers Retry on the first refusal, and Retry starts a new
run" fails: the coordinator is `retryable_failure` /
`native_session_interrupted` (a same-run retry in 30 s).

Refs #20675, #20720

## What Changed

- Runner (`durable/mod.rs`, `acpx_provider_backend.rs`): new closed
class `provider_session_closed`. The Runner sets it on these refusals:
- a turn start that saves the session `closed` (`ACPX turn start failed
closed: …`), but not the rejected-checkpoint case, which stays
`prepared` and reopens;
- `ACPX session is not open` when the saved lifecycle is `closed` for
this run;
- the saved turn-end-restart refusal for this run (asserted in
`acpx_turn_end_restart`).
A session that waits for provider-exit proof can reopen after a restart.
Its refusal gets no class.
- Server (`native-session-executor.ts`): a `turn.start` failure with
that class maps to the new permanent code
`native_provider_session_closed`. The server reads the class from the
authenticated command result, and from the command-failure text if the
error was rewrapped. `nativeSessionFailureDisposition` treats the code
as permanent, so the first refusal ends the run as `terminal_failure`
with a board owner and a plain next action.
- Docs: `packages/paperclip-runner/docs/durable-recovery.md` states the
class and the older-Runner behavior.
- Tests:
- `native-closed-session-retry.integration.test.ts` (new, embedded
Postgres) drives `executePaperclipNativeSession` with the Runner
refusal. On the first refusal, the board shows `canRetry: true`.
`requestTaskRetry` is accepted, and `resumeTaskRetry` starts exactly one
new run with a fresh provider session. A start proved undispatched stays
`retryable_failure`.
- Unit tests in `native-session-executor.test.ts`: both closed refusals,
a rewrapped error, an undispatched start, an unclassified older-Runner
refusal, and a class on another command.
- Rust `acpx_cut_turn_start`: a start cut off before dispatch has no
class. A start cut off after dispatch, and the later refusal, carry
`provider_session_closed`.

## Verification

- Red on master, green here: step 1 above.
- `npx vitest run
src/services/native-runtime/native-session-executor.test.ts`: 1358/1358
pass.
- `cargo test -p paperclip-runner-core --test acpx_park_preparation` (26
pass), `--lib acpx` (94 pass), `--test acpx_provider_turns --test
acpx_provider_session --test acpx_warm_attachment` (all pass), `cargo
fmt --check` clean.
- `npx tsc --noEmit -p server/tsconfig.json`: clean.
- Not run: a chaos wave.

## Risks

- This changes the Rust Runner. Daytona needs a new runner image before
remote runs get the class. Until then, an older Runner sends the refusal
without the class, and the server keeps today's bounded same-run
retries. That path is unchanged and ends with a working Retry. The
server does not trust the text alone, because an older Runner sends the
same text for a session that can still reopen.
- The class is set only where the Runner's saved state proves that no
later start of this run can reopen the session. A false class would cost
one automatic resume and show Retry earlier. It never replays provider
work.
- No persisted shape changes. The class is an optional field of a failed
command result. Old saved state resumes unchanged. Nothing new runs
before `turn.start`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude (Anthropic), model ID `claude-opus-5-5`, run in Claude Code
with tool use (shell, file edit) and extended reasoning.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 00:12:41 -06:00
DottaandClaude 200c6e40e2 fix(native): restart a bootstrap whose Runner suspended before the provider opened (#20691)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - When a native run's first provider start fails before any session
opens, Paperclip retries the bootstrap on the same run. Durable
controller evidence proves that no provider session or provider event
exists.
> - A remote sandbox keeps the Runner state inside the workspace. A
replacement sandbox gets that state back when the saved workspace is
restored.
> - When the provider refused `session.open` (for example an Anthropic
429), the Runner still suspended cleanly. Its state says `suspended`,
but the provider state has no session identity.
> - The same-run restart handled a Runner that never suspended. It did
not handle a Runner that suspended before the provider opened a session.
It failed with `runner_harness_state_mismatch:
provider_identity_incomplete (provider_not_suspended)`, and the task
showed "Recovery needed" without Retry.
> - This pull request lets that proven restart set the unopened session
aside and start the provider again.
> - The benefit is that a rate-limited provider start continues by
itself after the limit resets.

## Linked Issues or Issue Description

Refs #20627 and #20683 (other restore paths). This PR changes only the
same-run bootstrap restart.

**What happened?**

Two ACPX Claude tasks on remote Daytona sandboxes, on staging:

1. The first attempt sent `session.open`. ACPX rejected it with
`classification=provider_rate_limit` (Anthropic 429). The Runner exited
with `lifecycle=suspended`.
2. Paperclip recorded `recoveryMode: bootstrap_retry`,
`providerSessionEstablished: false`, `providerEventsExist: false`, and a
retry after the limit reset.
3. One hour later the retry ran as `bootstrap_incomplete` on the same
run, in a new sandbox with the saved workspace restored.
4. Host preparation inspected the restored Runner state. The Runner was
`suspended`, but the provider had no session identity. The inspection
threw `provider_identity_incomplete (provider_not_suspended)` before the
bootstrap restart branch ran.
5. The run failed. The task stopped at "Recovery needed" with only
inspect actions.

**Expected behavior**

The restart sets the unopened session aside and opens a new provider
session, as it already does for a Runner that never suspended.

**Steps to reproduce**

1. Restart a remote run as `bootstrap_incomplete` with a proven
bootstrap restart.
2. Give the sandbox a Runner state for this run with `lifecycle:
suspended` and a provider state with no session identity.
3. Prepare the host. On `master` it throws
`runner_harness_state_mismatch: provider_identity_incomplete`.

The new `interrupted_bootstrap_unopened` cases of `bootstraps only an
untouched provider session in a $outcome workspace lease` run these
steps for a created and a resumed lease.

**Paperclip version or commit**

`master` at `7435abcc63`.

## What Changed

- During host preparation, a Runner state that is suspended with no
provider session identity returns the new incomplete reason
`provider_unopened`, but only when all of these hold:
  - the run is restarting as `bootstrap_incomplete`;
  - the state belongs to this same run;
- the controller already recorded the bootstrap restart proof (its
durable journal shows no provider turn, and the old Runner is stopped or
absent).
- the provider state is the one saved before any session opened
(`remoteProviderStateNeverOpened`): `prepared`, no session or identity,
no turn in progress, no unconfirmed exit. Only ACPX and Codex-family
states qualify.
- The created-lease and resumed-lease restart branches share one check
(`restartableBootstrapState`) and accept `provider_unopened` exactly
like `not_suspended`. They move the old session aside and claim a new,
untouched session root.
- Every other case still throws `provider_identity_incomplete`: another
run's state, a restart without the proof, a normal resume, and close
checkpointing.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/native-session-executor.test.ts -t
"bootstraps only an untouched provider session"`: 47 passed.
- Red on `master`: the `created` and `resumed`
`interrupted_bootstrap_unopened` cases fail with
`runner_harness_state_mismatch: provider_identity_incomplete
(identity_binding)`. Green with this change.
- Fail-closed cases stay red-proof:
`interrupted_bootstrap_unopened_unproven` (no restart proof) and
`interrupted_bootstrap_unopened_other_run` (another run's state) still
throw.
- Full `native-session-executor.test.ts` and `pnpm --filter
@paperclipai/server typecheck` pass.

## Risks

- Low risk. The new branch needs the same proof that already authorizes
a fresh bootstrap for a Runner that never suspended. The proof comes
from the controller's durable journal, not from the sandbox.
- The normal path is unchanged and has no added wait.
- Not proven live: I did not confirm on staging that the controller
recorded the restart proof for these runs. If it did not, the run still
fails closed as before.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 21:35:38 -06:00
DottaandClaude a46b9ac724 fix(native): wait out an ACP usage limit's reported reset, then Retry the task (#20692)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs (Paperclip Runner with an ACP harness, such as Claude)
can stop because the provider account runs out of its usage allowance
> - The ACP harness reports this as a "terminal limit failure".
Paperclip blocked the task behind a board hold that called the session
state ambiguous, and the only way forward was a manual Retry
> - The provider also says when the limit resets, for example "resets
Nov 12, 6pm (UTC)" for a weekly limit. Paperclip read only clock-only
resets ("resets 2am"), so the agent-level wait fell back to one hour.
Each later wake for the agent then hit the limit again and blocked
another task
> - This pull request reads calendar resets, says on the hold when the
limit resets, and requests the task's Retry itself at that time when the
reset is reported and bounded
> - The benefit is that a usage limit no longer needs a person to click
Retry, and the agent's other tasks wait instead of failing one by one

## Linked Issues or Issue Description

**What happened?**
A native ACP run failed with `native_provider_usage_limit` ("ACP agent
reported a terminal limit failure. You've hit your weekly limit · resets
Nov 12, 6pm (UTC)"). The task went to `blocked` with a board-owned hold.
The hold said "Inspect the original provider failure and explicitly
resolve the ambiguous session state". Nothing resumed the task after the
reset. The agent-level `provider_limit_wait` recorded "no reset time was
reported, so Paperclip waits one hour", so every hour another wake
dispatched, hit the limit, and blocked another task. In one 48-hour
window on a staging instance, this blocked 7 tasks for one agent.

**What did you expect?**
When the provider reports a bounded reset, the task waits and continues
after the reset, like the existing `provider_limit_wait` path. When the
reset is unknown, the task fails with a clear message and a working
Retry.

**Steps to reproduce**
1. Run a native ACP agent until its provider reports a weekly or session
limit with a reset time.
2. Look at the task: it is blocked with an "ambiguous session state"
hold.
3. Wait past the reported reset: the task stays blocked until a person
clicks Retry.

Refs #20666 (other recovery work in the same staging census; not changed
here)

## What Changed

- `provider-quota-reset.ts`: parse calendar resets such as "resets Nov
12, 6pm (UTC)", "resets Jul 17th at 4:30 PM (America/Chicago)" and
"Resets on August 2, 9am (UTC)". A past date rolls into next year. An
impossible date (Nov 31) is refused. Clock-only parsing is unchanged.
This also fixes the agent-level `provider_limit_wait` reset and the
legacy `provider_quota` reset for these messages.
- New `native-runtime/provider-limit-resume.ts`:
- `readNativeUsageLimitReset` reads the reset only from the run's own
durable events. The last turn event must be a provider-observed
`turn.failed` with a `rate_limit` or `quota_exhausted` provider failure,
and the reset must be at most eight days out.
- `requestProviderLimitResumeRetry` asks for the task's Retry
(`requestTaskRetry`, `initiatedBy: "provider_limit_reset"`) on behalf of
the user who requested the run, or the run's accountable user for
automatic wakes. It is idempotent per run and records its outcome on the
hold. At most three automatic Retries follow one another without a
successful run.
- `registerProviderLimitResumeWork` is a scoped deadline owner (shared
work scheduler, no polling). Each active usage-limit hold is due at its
reset. Startup rebuilds it from the holds, and the executor notifies it
in the hold's transaction.
- `native-session-executor.ts`: for a terminal usage limit, the hold and
run failure detail say when the limit resets and whether Paperclip
retries then, instead of "ambiguous session state". The evidence records
`providerLimitResetAt`.
- `task-retry.ts`: accept `initiatedBy: "provider_limit_reset"`. Its
activity is recorded as system actor `provider-limit-reset` on behalf of
the user, like `restart_recovery`.
- `doc/execution-semantics.md`: document the wait and its limits.

Previous-release story: holds saved by the previous release carry
forward. At startup the owner reads each active
`native_provider_usage_limit` hold. It rewrites the hold's message with
the reset when the reset is known and still ahead, and it requests the
Retry at the reset. Holds whose reset is unknown, more than a day in the
past, or that have no accountable user keep their Retry and are recorded
as not requested, so they are not read again.

## Verification

- `npx vitest run server/src/services/provider-quota-reset.test.ts` — 4
tests. Three fail on `origin/master` code (calendar resets return
`null`), all pass with this change.
- `npx vitest run
server/src/services/native-runtime/native-provider-capacity.integration.test.ts`
— 5 tests on embedded Postgres:
- the existing usage-terminal test (both replay modes) still blocks with
a Retry, and no automatic Retry is requested without a reported reset;
- "waits for a reported usage-limit reset and then requests the task's
Retry once": the hold says when the limit resets, an old-release hold is
rewritten, nothing starts before the reset, one Retry receipt is created
for the accountable user at the reset, and a second pass does nothing.
Fails on `origin/master` executor code;
- "starts the Retry from the hold's own deadline": a real delivery
coordinator rebuilds the deadline from the hold and calls Retry dispatch
once, after the reset;
- "leaves a visible Retry when no user is accountable for the run".
Fails on `origin/master` executor code.
- Focused regression suites pass (642 tests): `task-retry`,
`quarantined-task-retry`, `heartbeat-native-restart-suspension`,
`native-startup-retry.integration`, `provider-failure-classification`,
`heartbeat-provider-limit-wait`, `deferred-execution-receipt-work`,
`chat-run-publications`, `native-runner-restart-recovery.integration`,
`native-remote-retry-admission`.
- `pnpm --filter @paperclipai/server exec tsc --noEmit` passes.
- `pnpm verify` selected a broad run (exit 2, because the docs file is a
global CI input). CI supplies the broad coverage.

## Risks

- The automatic Retry uses the accountable user's authority for
automatic (system) wakes. It is the same Retry the hold already offered
that user, and dispatch still checks Retry authority, stop proof, saved
work and action reconciliation. It is never broader than a click.
- A provider that keeps reporting resets that do not restore capacity
gets at most three automatic Retries in a row; then the Retry stays
visible.
- The deadline owner reads all active usage-limit holds at startup
(limit 500 rows). The number of such holds is small.
- Not changed: the agent-level wait still holds new wakes for at most
one day, as before.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, read-only staging API reads). Extended
reasoning enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 20:57:03 -06:00
DottaandClaude a1ec05a1a4 fix(native): read settled harness state again after a dropped sandbox connection (#20690)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native Runner keeps one provider conversation across the per-turn
runs of a task. After each turn, the controller reads the settled Runner
and provider state out of the sandbox and saves it as the harness
backup.
> - A per-turn sandbox is released after the turn. The harness backup is
then the only copy of the provider session.
> - On staging, one dropped sandbox connection (`write ECONNRESET`)
during that read lost the backup. The run itself succeeded, because its
result was already accepted.
> - The next run of the task could not resume the conversation: `PRP
provider resume state is unavailable`. When the task was waiting for a
reply, the run stopped at "Recovery needed" with no Retry. A person had
to send a message to start a new conversation.
> - This pull request reads the settled state again when the sandbox
connection drops during that read.
> - The benefit is that one network reset no longer loses a task's
conversation.

## Linked Issues or Issue Description

Refs #20627 (warm backup restore when the source sandbox is idle) and
#20683 (relaunch of a settled runner). This PR changes a different step:
the backup capture at the end of a turn.

**What happened?**

A per-turn OpenCode task ran on a remote Daytona sandbox with reuse
disabled.

1. Run A accepted its result. The Runner suspended.
2. The controller read the settled state out of the sandbox. The read
failed with `remote checkpoint failed: write ECONNRESET`.
`harness_state.backup.persist` and `session.checkpoint.persist` failed.
3. Run A still finished as `succeeded`, because the result was durable.
The sandbox was released. No harness backup of turn A existed.
4. Run B started in a new sandbox. It failed in 0.2 s with
`native_session_recovery_failed: Error: PRP provider resume state is
unavailable`. When the prior turn yielded for a reply, the checkpoint
policy does not allow a replacement session, so the task showed
"Recovery needed" with only inspect actions.

On staging this happened on 4 tasks in 48 hours. In all 3 predecessor
runs that I could inspect, the backup capture failed. 2 of them show the
`ECONNRESET` in the run log. No deploy happened between run A and run B
in one case, so a deploy is not the cause.

**Expected behavior**

A transient connection drop while reading settled state does not lose
the provider conversation. The read is repeated, and the backup is
saved.

**Steps to reproduce**

1. Run the first durable turn of a remote OpenCode session.
2. Make the sandbox `syncOut` of the backup directories fail once with
`ECONNRESET`.
3. Call the settled checkpoint. On `master` it throws `write
ECONNRESET`, and no backup is saved.

The new test `uses the bundled Linux image authority for remote
opencode-new-durable-reset without controller overrides` runs these
steps.

**Paperclip version or commit**

`master` at `7435abcc63`.

## What Changed

- The settled-state capture (inspect plus `syncOut` of each persistence
directory into a pending directory) is now one step that can repeat. The
step writes nothing durable, so it is safe to repeat.
- A dropped, reset or refused sandbox connection repeats the step, at
most 3 attempts, with 1 s and 2 s delays. Each repeat clears the pending
directory and inspects the live state again, so the copy always matches
the inspected identity.
- Any other failure fails at once, as before: a state mismatch, a
missing directory, an unsafe archive, or a TLS failure.
- The durable backup upload and the local publish run only after a
complete capture. They did not change.
- `isTransientRemoteCheckpointReadFailure` classifies the error with the
existing `classifyRemoteConnectionError`, plus a message match for SDK
errors that lose their `code`.
- The first completeness inspection and its visibility-window re-reads
use the same bounded retry (`retryTransientRemoteCheckpointRead`). A
dropped provider-state read keeps its `provider_state_unreadable`
refusal for every other caller, but the checkpoint retry looks through
it to the dropped read. Undici `fetch failed` and `no response returned`
also count as drops.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/native-session-executor.test.ts`: 1343
passed.
- Red on `master`: the new `opencode-new-durable-reset` case fails with
`Error: write ECONNRESET`. Green with this change.
- The `isTransientRemoteCheckpointReadFailure` test checks that state
mismatches, unsafe archives and TLS failures do not repeat.
- `pnpm --filter @paperclipai/server typecheck` passes.

## Risks

- Low risk. The normal path has no added wait. A repeat adds at most 3
s, and only after a connection failure.
- A repeated read inspects the live state again. If the state changed
between attempts, the existing identity checks still refuse it.
- A connection that stays down for all 3 attempts fails the same way as
before.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 20:46:29 -06:00
DottaandClaude 2eede8b318 fix(native): give a relaunched settled runner its admitted provider pack (#20683)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - After a server restart or a same-run retry, a remote native run
reattaches to its runner in the sandbox (`reattach_remote_runner`).
> - When the controller saved the runner artifact, the executor prepares
that run as an adoption. It skips provider-pack selection, because a
live adopted runner keeps its launched provider.
> - The reattach probe can instead find the runner settled and exited
(`verifyRemoteRunnerOrSettledExit`). Then nothing is adopted, and the
transport relaunches the runner from its durable state. That relaunch
must start the provider again, so it needs the provider-pack launch
inputs.
> - The executor did not give them. OpenCode failed with "OpenCode
executable is missing from the provider pack". ACPX failed with "ACPX
launch profile omitted its provider-pack authority or verified artifact
digests". Both ended the run.
> - This pull request selects the saved (admitted) provider-pack
authority on that settled-exit relaunch and verifies it as an exact
installed identity, as an ordinary reattach does.
> - The benefit is that a restart or a same-run retry can resume an
exited runner with the same provider pack, instead of failing.

## Linked Issues or Issue Description

**What happened?**
On a hosted staging instance, two OpenCode runs and one ACPX (Claude)
run were interrupted by a deploy (SIGTERM) or ended a turn without a
result. The server scheduled a same-run retry that reattached to the
existing remote runner. The runner had exited cleanly
(`runner_remote_process_exited lifecycle=suspended`). The retry then
failed within three seconds with
`runner_remote_provider_artifact_incompatible`.

**Steps to reproduce**
1. Prepare a remote OpenCode or ACPX run with a persisted runner
artifact (`controllerPersistence.runnerArtifact`).
2. Reattach with `restartRecovery.kind = "reattach_remote_runner"`,
where the runner process is not running and its state is `suspended` and
settled.
3. Observe that the transport options have no `adoptExistingRunner`, no
`providerPackAuthorityDigest` and no `opencodeCommand` /
`acpxSidecarSha256`.

**Expected behavior**
The relaunch gets the admitted provider pack's launch inputs, or it
fails with a clear pack-identity error.

## What Changed

- `createRunnerdBackend` (native session executor): when a
persisted-runner reattachment finds no live runner to adopt, select the
saved provider-pack authority (`reattach: true`), so
`expectedProviderPackManifest` is the admitted pack.
- `prepareRemoteRunnerAssets` no longer returns early for that relaunch.
It verifies the installed pack as an exact identity and sets the active
pack root, the same path as a reattach without a persisted artifact.
- A live adopted runner is unchanged: it still skips pack selection.
- The test `uses the bundled Linux image authority for remote
opencode|claude-x64` now also covers the settled-exit relaunch for
OpenCode and ACPX.

## Verification

- RED on `origin/master` (`c444da7d05`): the extended test fails for
`opencode` and `claude-x64` (the relaunch options have no provider-pack
inputs).
- GREEN with this change: `pnpm --filter @paperclipai/server exec vitest
run src/services/native-runtime/native-session-executor.test.ts -t "uses
the bundled Linux image authority"` (14 passed).
- `pnpm --filter @paperclipai/server typecheck` passes.
- `pnpm verify`: see the result below.

## Risks

- Upgrade safety: the relaunch reads the saved authority, so a newer
controller does not select a newer pack for an older session. It
requires an exact installed identity. If the sandbox no longer has that
pack, the relaunch fails with a pack-identity error, and the existing
Retry path applies, as before.
- No change to a live adoption, to fresh runs or to the normal path
before `turn.start`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, read-only staging API reads).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 19:14:43 -06:00
DottaandClaude cec903c6f0 fix(native): retire a finished remote owner whose gone Runner left a delivery unacknowledged (#20677)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native follow-up run on a remote sandbox first proves that the
previous per-turn owner finished. Then it retires that owner's
controller journal and starts a new Runner.
> - The proof requires a settled journal. A command that the Runner did
not acknowledge holds the owner.
> - On staging, a per-turn owner committed its result and its Daytona
sandbox was destroyed after the turn
(`paperclip_runner_destroy_after_turn`, cleanup `success`). Its journal
still held a `run.attach` or a `semantic_tool.result` that the Runner
never acknowledged.
> - The next run failed with `prior_owner_terminal_state_indeterminate;
retirement_refused=journal_commands_unsettled(run.attach.pending)` (or
`semantic_tool.result.pending`). A Retry or a new message gets the same
journal, so it fails the same way. The task stays blocked.
> - Retirement already excuses the Runner's own lifecycle commands when
the prior sandbox lease ended on another sandbox. This pull request
applies the same rule to deliveries that only that gone Runner could
receive.
> - The benefit is that these follow-ups start by themselves. Commands
that carry user intent still hold the owner.

## Linked Issues or Issue Description

**What happened?**
A census of staging failures (48 h, Nov 9–10) found 6 failed runs with
`prior_owner_terminal_state_indeterminate` and
`retirement_refused=journal_commands_unsettled(run.attach.pending |
semantic_tool.result.pending)`. The last one was at 18:56 UTC on Nov 10.
In two examples that I checked:
- The previous run succeeded. Its result was committed and its
coordinator lease was released.
- Its lease policy was `ephemeral`. The lease is `expired`, with
`failureReason=paperclip_runner_destroy_after_turn` and
`cleanupStatus=success`.
- The follow-up got a new sandbox (a different provider lease) and
refused in about 11 s. It started no Runner. The task became `blocked`,
and the next action was "Send a message to continue".

**Expected behavior**
When the record proves that the previous Runner is gone, an undelivered
attachment, tool answer or read cannot run. The follow-up retires the
finished owner and starts a new Runner. Retirement replays no command.

**Steps to reproduce**
1. Finish a per-turn remote native run whose controller journal keeps a
`run.attach` or `semantic_tool.result` command in `pending`.
2. Let the sandbox lease of that run end (`expired` or `released`) on
its own sandbox.
3. Start a follow-up run that gets a new sandbox.
4. Observe
`retirement_refused=journal_commands_unsettled(run.attach.pending)`.

**Paperclip version or commit**
Staging builds of Nov 10. The refusal still reproduces on `master` at
`48c6e996da` (the new tests fail there).

## What Changed

- `native-session-executor.ts` (`readSettledRunnerdProjection`): when
the prior run's sandbox leases ended on another sandbox
(`remoteRunnerGone`, the existing proof), unfinished `run.attach`,
`semantic_tool.result`, `session.snapshot` and `session.goal.get`
commands no longer hold the retirement. These join the lifecycle
commands that the rule already excused.
- In the same case, a journaled `semantic_tool.result` answers its
`semantic_tool.input`. The controller journals the answer after the tool
ran. Only its delivery was outstanding.
- No other proof changes. The prior run must still be terminal, with a
committed result, a released coordinator lease, no recovery state, a
stopped or fenced controller, a delivered `run.terminal`, and no live
work after it. Quarantine copies must pass the same checks.
- `doc/execution-semantics.md`: describe the rule.

## Verification

-
`server/src/services/native-runtime/native-recovered-owner-followup.test.ts`
(real Postgres):
- New: "retires a finished remote owner with an unacknowledged
pending-attach / pending-tool-result when its sandbox lease ended on
another sandbox". It fails on `master` with the exact staging errors
`journal_commands_unsettled(run.attach.pending)` and
`journal_commands_unsettled(semantic_tool.result.pending)`. It passes
with this change, and the log shows the retirement.
- Held cases stay held: a lease that is still retained, a lease on this
run's sandbox, no lease record, and a tool input with no journaled
answer (`journal_tool_input_unanswered`).
- New: "keeps a stopped local owner held by an unacknowledged
run.attach". A local Runner is not gone by record.
  - Whole file: 95 passed.
- Neighbouring suites pass: `explicit-native-continuation.test.ts` and
`native-controller-recovery.integration.test.ts` (460 tests).
- `npx tsc --noEmit -p server/tsconfig.json` passes.
- `pnpm verify` exits 2: a global CI input changed
(`doc/execution-semantics.md`). Required CI supplies the broad coverage.
- Live proof: not done. The redeploy chaos scenario (K15) is not on
`master` yet. Tests only.

## Risks

- The rule applies only when every sandbox lease of the prior run is
`released` or `expired`, has a release time, and names a different
provider lease than this run's. A reusable sandbox that this run resumed
keeps the existing hold.
- A command that carries user intent (`turn.start`, `turn.steer`,
`request.resolve`, `interaction.receipt`, `external_provider.operation`,
`session.goal.set`/`clear`, `session.close`, `run.cancel` and others)
still holds the owner.
- Retirement archives the old journal under `continuity-breaks` and
replays nothing, as before.
- No persisted shape changes. Old saved journals are read by the same
rule.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 18:49:26 -06:00
DottaandClaude c14f2fe45d fix(native): read the scheduled-resume evidence the resume reconciler claimed (#20656)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #20645 lets a local same-run scheduled resume send the one
same-conversation continuation for a turn lost on restore. It applies
only when durable records prove three facts: the previous runner
stopped, the attempt was admitted within the budget, and it is the same
checkpointed conversation.
> - It read the third proof from the coordinator's saved
`failure_detail` while the phase was `retryable_failure`.
> - In production, the resume reconciler
(`claimNativeSessionResumptions`) claims the run first. It moves the
coordinator to `observed` and clears `failure_detail`. So the executor
never saw that evidence, and #20645 refused every real scheduled resume.
> - The restart-chaos L7 wave showed this live. Codex B5 TM2 hit the
exact path (`runner.drain` timeout, scheduled resume,
`provider_turn_lost_on_restore`), sent no continuation, and needed
Retry.
> - This pull request makes the reconciler record that evidence durably
when it claims the run, and makes the executor read it from there.
> - The benefit is that #20645 works on the real path. The proof rule is
unchanged.

## Linked Issues or Issue Description

Refs #20645

**What happened?**
Wave `w-20261110T212307-s9107` (L7, seed 9107, local, master plus
#20645), task codex B5 TM2:
- `native_session_recovery_failed: runner_local_connect_failed: PRP
command runner.drain timed out`;
- `retryable_failure` with `recoveryMode: "exact_checkpoint_resume"`,
`recoverable`, `providerSessionEstablished` and `checkpointExists` all
set;
- `native.local_process_stopped`;
- attempt 2 started;
- `turn.failed` with `provider_turn_lost_on_restore` for the same turn
ID that the checkpoint was running.

No continuation was sent. The run failed with
`native_provider_terminal_failed` and was preserved with
`uncertain_provider_action`.

**Expected behavior**
The proven scheduled resume sends the single continuation.

**Steps to reproduce**
The new integration test below. The live path is timing-dependent (the
L7 command in #20645).

## What Changed

- `native-finalization-reconciler.ts`: when it claims a same-run resume,
the reconciler appends one `same_run_resume` entry to
`recovery_history`. The append is bounded like other history writes, and
it runs in the same update that clears the failure. The entry copies
`recoveryMode`, `recoverable`, `providerSessionEstablished`,
`checkpointExists`, `successorRunId` and `replacementDenied` from the
saved failure, with the controller boot and provider attempt.
- `scheduled-resume-continuation.ts`: the evidence for the third proof
now comes from either of two durable records:
- the saved failure, when the executor claims a `retryable_failure`
coordinator directly (unchanged); or
- a reconciler claim: an `observed` coordinator whose last history entry
is a `same_run_resume` entry naming the executor's own controller boot.
The two other proofs are unchanged: the stop receipt, and an admitted
attempt from 1 to 3.
- `native-session-executor.ts`: passes the coordinator's last history
entry and the controller boot to the decision.
- `doc/execution-semantics.md`: describes the recorded evidence.

Readers of `recovery_history` that look at the last entry do so only for
restart claims, Stop recovery and shutdown suspension. Restart
recovery's previous-claim lookup skips non-claim dispositions, so a
`same_run_resume` entry does not change those paths.

**Still refused** (tested):
- every #20645 case;
- the last history entry is not the resume claim, or there is no
history;
- the resume claim was recorded by another boot;
- the claimed evidence is a bootstrap retry, has no checkpoint, or
records a replacement;
- a reconciler-claimed resume without a stop receipt.

## Verification

- `native-scheduled-resume-continuation.integration.test.ts` now also
runs through the real reconciler claim (`claimNativeSessionResumptions`)
before `executePaperclipNativeSession`. On master the reconciler-claimed
allowed case fails (`resumeInterruptedTurn` is false). With this change
it is true, and the claim admits attempt 2. A reconciler-claimed resume
without a stop receipt still passes false.
- `scheduled-resume-continuation.test.ts`: decision cases for the
reconciler-claim entry.
- `native-session-resumption.test.ts` passes. 54 tests pass across the
three files.
- `cd server && npx tsc --noEmit` passes.
- Live: not yet. The failing wave above is the reproduction. A rerun is
timing-dependent.

## Risks

- One more `recovery_history` entry per scheduled resume, inside the
existing 20-entry bound.
- The proof rule itself is unchanged. This only reads the same evidence
from the record where it now survives.
- No schema change. A run claimed by an earlier build has no
`same_run_resume` entry, so it keeps the existing failure: refused, with
Retry offered as today.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, test runs). Extended reasoning.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 18:21:25 -06:00
DottaandClaude 805531cc71 fix(native): never leave a cleanup-gated task without Retry or an owner (#20639)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs close their provider session at the end of each attempt.
When close cannot prove that the runner stopped, the runner keeps a
cleanup gate (`native_session_cleanup_quarantined`) in server memory
> - #20572 hid Retry for every gated run and said "Paperclip finishes
the cleanup automatically". Only a remote environment release settles
the gate. A local gate that needs an operator never settles, and a
server restart loses it
> - A restart-chaos wave on master found two tasks that rested blocked
with no Retry, no scheduled retry and no recovery owner. A person could
not continue them
> - This pull request makes the run's own recovery retire the gate when
the host proves the old processes stopped. It also offers Retry whenever
nothing settles the gate, and it makes that Retry work
> - The benefit is that a cleanup-gated task either continues by itself
or shows a Retry that starts. It never rests without both

## Linked Issues or Issue Description

Refs #20572, #20578

**What happened?**
Restart-chaos wave `w-20261110T185144-s9107` (local, claude, fault TM2:
the server is paused with SIGSTOP for about 33 s). Tasks t04 and t05
completed their provider turn. The runner checkpoint owner lapsed while
the server was paused (`native_runner_checkpoint_owner_lost`). The
session close then failed with `native_session_close_unrecoverable:
runner did not durably suspend before checkpoint`. The provider had
drained and no tool call was pending. The runner kept an
operator-required cleanup gate. Thirty seconds later the run's own
same-run recovery verified that the runner process and group had stopped
(`native.local_process_stopped`). The gate still refused the recovery,
and the run ended `failed` with `native_session_cleanup_quarantined`.
The task showed `canRetry: false` and a cleanup wait until the deadline.
Nothing owned that wait.

**Expected behavior**
When the old runner and provider processes are proven stopped, the run's
own recovery continues. When nothing settles the gate, the task offers a
Retry that starts.

**Steps to reproduce**
1. Run restart-chaos L7: `--env local --adapters codex,claude
--workloads A1,B5,C1 --faults TM1,TM2,TM3 --time-age-hours 1
--concurrency 3 --tasks 6 --seed 9107`.
2. Observe a claude TM2 task rest `blocked` with `canRetry=false`,
`retryWait=true` and no scheduled retry.

**Paperclip version or commit**
master at ed3ffc3bc7.

## What Changed

- `native-session-executor.ts`: add `provenStoppedOwnNativeSession`. It
returns proof only for a local run with a pid-bound
`native.local_process_stopped` record (no later launch) and with every
recorded provider process gone. An ACPX session open names no provider
PID; its sidecar counts only when it shares the runner's process group,
which the stop record proves empty.
- `native-session-executor.ts`: add
`retireStoppedOwnSessionCleanupGate`. With that proof, it retires the
run's own operator-required gate through the existing
`completeTerminatedLocalNativeSessionCleanup` boundary and records
`native.session_cleanup_gate_retired`.
- `heartbeat.ts`: the native recovery preamble calls it right after it
records the local process stop, before admission. The recovery decision
itself does not change.
- `explicit-native-continuation.ts`: Retry or a new message accepts the
run's own stop proof when the retained-state inventory proof does not
apply. It still starts a fresh session, and it never resumes the old
turn.
- `task-interruption-presentation.ts`: hide Retry for a gated run only
while a live owner settles the gate: an unreleased remote environment,
or a scheduled close in this process. Otherwise offer Retry. The same
rule applies to a Retry held on `cleanup_quarantined`.
- `doc/execution-semantics.md`: describe the new rule.

## Verification

- Red before the fix, green after (5 tests failed on master sources):
- `server/src/services/task-interruption-presentation.test.ts`: "offers
Retry when nothing settles the gate by itself", "holds a Retry on a
cleanup gate only while an owner settles it", and an invariant test: a
failed presentation without Retry must name a wait, and a cleanup wait
needs a settling owner.
- `server/src/__tests__/task-interruption-query.test.ts` (real
Postgres): an unreleased remote lease hides Retry; the released lease
offers it.
- `server/src/services/quarantined-task-retry.test.ts`: "starts a task
Retry for a run's own quarantine proven stopped by its own process
record" (stopped, and provider alive stays held).
- New: "retires a run's own failed-close gate once its runner and
providers are proven stopped" for codex PIDs, an ACPX sidecar in the
runner's group, a sidecar with its own group, a live provider, and no
stop record.
- `npx vitest run` of `quarantined-task-retry`,
`task-interruption-presentation`, `task-interruption-query` and
`explicit-native-continuation`: 575 passed.
- `npx tsc --noEmit -p server` passes.
- Live: LIVE_PROOF_PLACEHOLDER

## Risks

- The gate retirement removes process ownership only. It runs only for a
local run whose exact runner and group and every recorded provider
process are proven gone. A provider without a recorded process identity
keeps the gate.
- Retry on a gated run still runs every existing stop check. If the stop
cannot be proven, the Retry waits and the task keeps a Retry, instead of
a wait that nobody settles.
- No persisted shape changes. Runs saved by earlier releases use the
same fields.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 15:20:42 -06:00
DottaandClaude 37c50f70c2 fix(native): continue a turn lost on restore in a proven same-run resume (#20645)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run keeps its provider conversation when its local runner
dies. When the restored runner finds that the active turn was lost, it
reports `provider_turn_lost_on_restore` and does not invent a result.
> - After a control-plane restart, the admitted restart attempt may send
one continuation in that same conversation (#15297). The agent then
reconciles unfinished work. The task is not resent.
> - A runner can also be lost without a server restart, for example when
`runner.drain` times out. The run then resumes itself after the retry
delay from its exact checkpoint. That path never allowed the
continuation, because the executor passed `resumeInterruptedTurn` only
for a `resume_dead_runner` restart claim.
> - So the lost turn failed with `native_provider_terminal_failed`,
automatic replacement was refused (`uncertain_provider_action`), and the
user had to click Retry.
> - This pull request allows the same single continuation for that
resume, but only when durable records prove the same three facts that a
restart claim proves.
> - The benefit is that a runner lost mid-turn continues in the same
conversation with no Retry. No case that is refused today becomes
allowed without that proof.

## Linked Issues or Issue Description

Refs #15297, #20625

**What happened?**
Restart-chaos wave L7 (seed 9107, local), task codex B5 TM2, run on
master after #20625. A TM3 restart resumed the run (attempt 1). During
the TM2 freeze the TS runtime lost its runner:
`native_session_recovery_failed: runner_local_connect_failed: PRP
command runner.drain timed out`. The executor saved `retryable_failure`
with `recoveryMode: "exact_checkpoint_resume"`. After the delay,
recovery verified the runner process stopped
(`native.local_process_stopped`). The same run then resumed as attempt 2
(`native.process_start_requested`, `providerAttempt: 2`). The restored
runner reported `turn.failed` with `provider_turn_lost_on_restore`
(`recoverable: true`). No continuation was sent. The run failed with
`native_provider_terminal_failed`, recovery stopped with
`uncertain_provider_action` / `preserve_without_replay_v1`, and the task
needed Retry.

**Expected behavior**
The run sends the one same-conversation continuation, exactly as it does
after a restart claim, and finishes without Retry.

**Steps to reproduce**
`node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts
--env local --adapters codex,claude --workloads A1,B5,C1 --faults
TM1,TM2,TM3 --time-age-hours 1 --concurrency 3 --tasks 6 --seed 9107`
(timing-dependent: the drain timeout must land during an active turn).

## What Changed

- New
`server/src/services/native-runtime/scheduled-resume-continuation.ts`.
It decides whether a same-run scheduled resume may continue a lost turn.
Each of the three proofs comes from a durable record:
1. **The previous runner is stopped.** The latest process record of the
run in `heartbeat_run_events` (`native.process_start_requested`,
`native.process_identity_recorded`, `native.local_process_stopped`) must
be `native.local_process_stopped`. Its `processPid` must equal its
`processGroupId`, and neither the pid nor the group may be alive now.
This is the existing `readNativeLocalProcessStop` check, read in the
claim transaction before this attempt records its own
`native.process_start_requested`.
2. **The attempt was admitted under the bounded budget.** The executor's
claim moves `native_run_finalizations` from phase `retryable_failure` to
`observed`. It charges a provider attempt, and the claim refuses any
attempt above 3 (`native_session_retry_exhausted`). The decision also
requires `1 <= attempt <= NATIVE_PROVIDER_RECOVERY_ATTEMPT_LIMIT`.
3. **It is the same provider conversation.** The saved `failure_detail`
must show `recoveryMode: "exact_checkpoint_resume"`, `recoverable:
true`, `providerSessionEstablished: true` and `checkpointExists: true`,
with no `successorRunId` or `replacementDenied`. At run time the
existing runtime guards still apply: the backend must recover the
checkpointed session (`recovered`, not a continuity-break replacement),
and the lost turn must equal the checkpoint's `activeTurnId` and the
snapshot's interrupted turn. The provider kinds stay codex, acpx and
opencode, with no compaction, goal control, held result or pending
runtime request.
- `native-session-executor.ts`: computes that decision in the
coordinator claim. It passes `resumeInterruptedTurn` when either the
restart claim is `resume_dead_runner` (unchanged) or the decision allows
it.
- `doc/execution-semantics.md`: describes the rule and the refused
cases.

**Cases that stay refused** (each has a test in
`scheduled-resume-continuation.test.ts`):
- a restart-recovery claim (it keeps its own existing rule);
- a remote execution target;
- a coordinator that is not `retryable_failure`, including a fresh run;
- `recoveryMode` `bootstrap_retry` or `ambiguous_state`, or no saved
failure detail;
- a failure that is not recoverable;
- no established provider session;
- no checkpoint;
- a recorded replacement (`successorRunId` or `replacementDenied`);
- an attempt outside 1..3;
- no stop receipt, a stop receipt superseded by a newer launch, a
stopped process that is still alive, or a run that never had a process.

The runtime-side refusals are unchanged and already covered by
`native-session-runtime.test.ts`: real provider errors, historical
error-null failures, and no restart permission.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/scheduled-resume-continuation.test.ts`: 22
tests, covering the decision table and the stop receipt in embedded
Postgres.
- `cd server && npx tsc --noEmit` passes.
- Live: not proven. An L7 wave on this branch (seed 9107) did not
reproduce the drain-timeout path, so this pull request has unit and
database tests only. In that wave the same cell (codex B5 TM2) needed
Retry for a different reason: TM2 aged its restart-recovery claim lease
before dispatch, and budget admission refused with `Native recovery no
longer owns provider dispatch`. This pull request does not change that
path. Four other tasks passed. Claude C1 TM2 rested blocked without a
Retry button, which is a separate cleanup-quarantine issue.

## Risks

- This allows one more path to send the same-conversation continuation.
It is limited to local runs. It needs the same proofs as a restart
claim, and it uses the attempt the resume already charged. It does not
change the generation cap or `restartResumeKeepsAttempt`.
- A rolling deploy reads `failure_detail` and stop events written by
earlier releases. Those fields and events already have this shape, so an
older saved failure is judged by the same rule.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, test runs). Extended reasoning.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 15:16:51 -06:00
DottaandClaude Opus 5.5 4398a723c0 fix(native): settle tool callbacks and holds that a server restart orphaned (#20628)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs send provider tool calls to the server as durable
semantic inputs. The server runs the tool and saves the result in the
controller journal
> - Close must prove that every admitted tool call has a saved result.
Otherwise it reports `native_session_settlement_incomplete` and the
runner holds a cleanup gate (`native_session_cleanup_quarantined`)
> - A server restart in the middle of a tool call leaves its input
without a result. The new controller asks the tool authority for a
receipt. Tools without a receipt (assigned app tools,
`connections_search`) return nothing, so the input stays pending forever
> - Runs that already failed this way kept no-replay holds for a turn
that had completed. The task stayed blocked with no Retry and no
continuation
> - Restarts are normal. Work that a restart interrupts must settle by
itself, not wait for a person
> - This pull request settles such a callback as interrupted, with an
unknown outcome, when its owner is provably gone, and settles the holds
that earlier releases left once their controller is gone. It never runs
the tool or the turn again
> - The benefit is that close drains after a restart, and tasks that
earlier releases blocked this way continue on their own

## Linked Issues or Issue Description

Refs #20572 (that PR fixed the presentation of this quarantine. Its
Risks section names this runner drain rule as a separate follow-up.)
Refs #20641 (it settles a resolved no-replay hold when a sibling action
saved the run's files. This PR covers a different case: the hold's own
recovery saved the files, and its run's turn completed. Both use the
same recovery receipt, `completedWorkspaceRecoveryCondition`.)

**What happened?**
A remote native run started an assigned app tool call. The server
restarted 3 seconds later. The new server adopted the runner, and the
provider turn completed about 9 minutes later. The tool call never got a
saved result. Close reported `native_session_settlement_incomplete` with
`runnerSuspended: true`, `providerDrained: false` and one pending
callback. The session quarantined, and the same-run recovery was refused
at admission. A second run showed the same shape with a
`connections_search` callback after a later restart.

The first run then had two resolved-but-blocked holds with
`preserve_without_replay_v1` and an unknown action outcome: the
cleanup-quarantine hold, and a continuation hold whose workspace
recovery saved the files. The task view said "Saved work recovered. Send
a message to continue." with no Retry and no continuation. The task
waits on a child task and stays blocked.

**Expected behavior**
After a restart, a tool call without a saved result settles
automatically. A durable receipt is replayed. Without a receipt, the
agent is told that the outcome is unknown. Close proves the drain, and
no cleanup gate is left. Holds that earlier releases left for a
completed turn settle without a person, and the task continues when its
own wait ends.

**Steps to reproduce**
1. Start a native run and call an assigned app tool.
2. Restart the server while the tool call runs.
3. Let the adopted turn complete, then close the session. Close reports
`native_session_settlement_incomplete` and quarantines. The task stays
blocked by the no-replay holds.

**Paperclip version or commit**
master at fd6e71a7ff.

## What Changed

- Root cause: `DurablePrpControlPlane.#recoverPendingSemanticInputsOnce`
(`packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts`)
keeps an input pending when `recoverSemanticToolInput` returns null.
`PaperclipRunnerToolAuthority.recover` returns null for every tool
without a durable receipt. Nothing else could settle the input, so
`semanticToolResultsSettled()` stayed false and `#closeOnce` in
`runnerd-codex-transport.ts` threw
`NativeSessionCloseUnrecoverableError`.
- The control plane now passes `admission` to
`recoverSemanticToolInput`: `this_controller` when this instance
dispatched the callback live, `earlier_controller` when it restored the
input from an earlier controller's journal. The field is not written to
the result command or the journal. A live dispatch leaves this record
when its result is journaled.
- New
`packages/paperclip-runner/src/control-plane/orphaned-tool-callback.ts`:
- `dispatchToolCallback` records the callbacks that this process runs,
and the ones it saw fail.
- `recoverToolCallback` replays a durable receipt first. Without one, it
settles an `earlier_controller` callback that this process does not run
as `tool_call_interrupted_by_restart` (error result, `outcome:
"unknown"`, `retryable: false`). A `semantic_tool_outcome_unknown`
rejection from the authority settles the same way, only under the same
owner-gone proof.
- `native-session-executor.ts`: `SessionAuthorityEpoch.execute` runs
through `dispatchToolCallback`. `SessionAuthorityEpoch.recover` runs
through `recoverToolCallback`. The three recovery call sites pass
`admission`.
- New ownership kind `native_completed_turn_cleanup_hold`
(`server/src/services/native-runtime/completed-turn-cleanup-hold-settlement.ts`,
registered in `recovery/ownership-registry.ts`). It lists a native run
that failed with `native_session_cleanup_quarantined` after its saved
checkpoint recorded a completed turn, and that has a system-recorded
no-replay hold. After `proveOwnerGone` proves the closing controller
gone (its in-process cleanup gate went with it), it settles each such
hold as `restored`, removes its replay block, and logs
`issue.recovery_action_resolved`. A hold that still protects files not
verified as saved stays for workspace recovery. Operator decisions are
never reopened. It starts no turn. `heartbeat.ts` registers the ordinary
resume path (`resumeRemoteStopComments`) for saved user input.
- Docs: `doc/execution-semantics.md` and
`packages/paperclip-runner/docs/durable-recovery.md`.

## Verification

- Red before the fix, green after:
-
`packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts`
"host-tool callback orphaned by a server restart". It uses the real
control plane and an authenticated runner. A callback starts 3 seconds
before the restart, the old controller retires with no result, the new
controller adopts the runner, the turn completes, and then recovery
runs. `paperclip_call_assigned_tool` and `connections_search` settle as
`tool_call_interrupted_by_restart`, the runner accepts the late result,
`semanticToolResultsSettled()` becomes true (the condition close
requires), and the tool is not dispatched again. Before the fix, 4 of 5
cases failed: recovery received no admission and the callback stayed
pending.
- `server/src/__tests__/completed-turn-cleanup-hold-settle.test.ts` uses
the staging shape of both holds (real Postgres, rows as earlier releases
saved them). Before the fix, the ownership reconciler did not list them
(4 of 4 failed), and the execution blocker was the files hold with
`canRetry: false` and `canContinue: false`. After the fix, both holds
settle as `restored`, the execution blocker is null, no run is created,
the task keeps `blocked`, and the next pass lists nothing.
- Safety cases:
- A callback that this process still runs stays pending. After it
finishes, the next recovery pass settles it.
- A callback that this controller dispatched and saw rejected stays
pending, as before.
  - A durable receipt is replayed and not replaced.
- Holds stay while the closing controller is alive (its gate may still
exist).
- A hold whose files are not saved stays; the run's other hold settles.
  - Holds of a run whose turn did not complete are never listed.
- Focused suites pass: `durable-prp-control-plane.test.ts` and
`runnerd-codex-transport.test.ts` (506 tests); runner adoption,
release-resume, semantic readiness, ACPX reconciliation and external
driver tests; `native-session-executor.test.ts` and
`native-runner-restart-recovery.integration.test.ts` (1496 tests);
`ownership-reconciliation-report.test.ts` and
`interrupted-preparation-hold-settle.test.ts`.
- `tsc --noEmit -p packages/paperclip-runner/tsconfig.test.json` passes.
`pnpm --filter @paperclipai/server typecheck` passes.
- `pnpm verify` returns exit 2 (a global doc input changed), so required
CI supplies the broad coverage.

## Risks

- Previous-release story: no persisted shape changes. `admission` is
computed at recovery and never saved. A journal that an earlier release
saved with a pending callback restores as `earlier_controller`, so that
session settles at its next adoption. Holds that earlier releases saved
settle on the reconciler's first pass after deploy. No migration.
- No double execution: recovery never calls the tool again. It replays a
durable receipt or reports an unknown outcome. The agent is told to
check the current state before it repeats the call. Hold settlement
starts no turn and replays nothing.
- The owner-gone proof for a callback: the new controller holds the
run's checkpoint fence, and the earlier controller retired its journal
writer. If the earlier process is still alive during an overlapping
deploy, it can still finish the tool, but it can never record a result
in this session. The unknown outcome is accurate in that case.
- Hold settlement requires `proveOwnerGone` on the closing controller's
stamp, because the cleanup gate lives in that process. On a server that
has not restarted since the quarantine, the holds stay.
- The in-process failure memory is bounded to 4,096 entries. Eviction
only lets a much older failed callback settle as interrupted later. It
never replays it.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:39:45 -06:00
DottaandClaude Opus 5.5 e3bc4ca798 fix(native): settle warm follow-ups that a restart left behind (#20631)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native warm runs on a remote sandbox continue the saved session of
the previous run. Admission first proves what happened to the previous
Runner.
> - A deploy restarted the staging server while warm Runners were idle.
Three follow-up runs then failed before provider work, each with a
different refusal, and their tasks stayed blocked.
> - The refusals asked for proof that a restart had made impossible. A
restart is a product failure, so recovery must settle it automatically
and keep the strict checks for real uncertainty.
> - This pull request accepts the proof that the restart leaves behind,
in three places: the warm owner on a replaced sandbox, the suspended
Runner with an undelivered suspend receipt, and the restore hold of a
run that saved nothing new.
> - The benefit is that these follow-up runs start, and state saved by
older releases carries forward without a migration.

## Linked Issues or Issue Description

Refs #20567, #20346

**What happened?**
On staging, a deploy replaced the webserver process at about 18:04 UTC
on 2026-11-10 (lease controller boot `348ec0bb` on one deployment, then
boot `3e84c195` on a new deployment that started at 18:04:16). Three
runs of one Codex agent on Daytona failed between 18:08 and 18:15:

1. A warm follow-up got a newly created sandbox. The previous warm
Runner stayed `ready` in its own retained sandbox. Live adoption denied
it with `runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate`
(`remote-warm-session-recovery.ts:127`), because only a resumed sandbox
may be adopted. The per-turn retirement path refused warm owners
(`lifecycle_not_per_turn`).
2. A warm follow-up resumed the same sandbox and directory. The previous
Runner had been suspended by the shutdown, but the controller stopped
before it acknowledged the suspend result. The Runner kept its terminal
fence (`pendingTerminalDelivery` for `runner.suspend`). The read refused
it with `native_controller_recovery_missing_evidence:runner_not_settled`
(`native-session-executor.ts:7032`).
3. A continuation into a fresh workspace failed with
`remote_workspace_recovery_receipt_invalid`
(`remote-workspace-recovery-attachments.ts:60`). Its predecessor had
restored the committed save, failed before provider work, and kept a
restore hold with no recovery receipt. Its saved reference named the
snapshot it restored.

**Expected behavior**
After a restart, the next run continues. It retires a finished owner
that nobody can adopt, reads a finished suspension, and skips a source
that left no unsaved files. Real uncertainty still refuses with a named
reason.

**Steps to reproduce**
1. Run a warm native Codex turn on a reusable Daytona sandbox and let it
commit.
2. Restart the server (deploy) while the Runner is idle.
3. Start the next turn. Get a new sandbox (case 1), or resume the same
sandbox after the shutdown suspended the Runner (case 2).
4. For case 3, continue a task whose previous run restored the last save
and failed before provider work, into a fresh workspace.

**Paperclip version or commit**
Staging before `2e3cbe57` (the runs predate that deploy). The gaps
remain on `master` at `9b21dcbd`.

## What Changed

- `inspectRemoteWarmSessionRecovery` returns `null` when this run's
sandbox is `created` or `replacement`. Such a sandbox holds no Runner to
adopt. The caller then uses ordinary admission.
- `inspectFinishedPerTurnRunnerdOwner` also retires a **warm** owner on
a remote sandbox runner when this run's sandbox is `created` or
`replacement`. It uses all the existing proofs: terminal run, committed
result, released coordinator lease, stopped or fenced controller,
settled journal, and quarantine copies. Local warm Runners and resumed
sandboxes keep the live path.
- `restoreRemoteSuspendedRunnerCheckpoint` accepts a suspended Runner
whose only open fence is its own undelivered `runner.suspend` receipt.
The fence must name `runner.suspend` with lifecycle `suspended`, it must
be the last controller command, and the restored journal must not name
that command differently. A provider cleanup, unsent events, a turn's
terminal fence or a warm transition still refuse with
`runner_not_settled`. A later authorized Runner process reconciles the
receipt, as the Runner already does.
- `attachPreservedRemoteWorkspaceSnapshots` skips a source with a
receipt-less restore hold when its saved reference names the exact
committed snapshot that its own succeeded `remote_workspace_restore`
loaded at the same epoch. Any other saved snapshot still needs the
canonical receipt.
- `doc/execution-semantics.md` describes the three rules.

## Verification

- Red (unfixed code), each in the exact staging shape:
- `native-recovered-owner-followup.test.ts` "retires a finished warm
owner left behind by a restart when this run's sandbox is
created/replacement" failed with `runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate`.
- `native-controller-recovery.integration.test.ts` "defers a remote warm
continuation…" failed: live adoption rejected a created/replacement
sandbox with the same error.
- `native-controller-recovery.integration.test.ts` "reads a resumed
sandbox's suspended Runner with an undelivered suspend receipt" failed
with `native_controller_recovery_missing_evidence:runner_not_settled`.
It reads through a sandbox runner that refuses paths outside the
workspace base.
- `workspace-recovery-preparation.test.ts` "decides a receipt-less
restore hold of a source that restored_unchanged" failed with
`remote_workspace_recovery_receipt_invalid`.
- Green: the same tests pass. Focused files pass:
`native-recovered-owner-followup.test.ts` and
`native-controller-recovery.integration.test.ts` (104 tests),
`workspace-recovery-preparation.test.ts` (85 tests).
- Safety cases (strict checks stay strict):
  - A warm owner on a resumed sandbox is not retired.
- A warm owner with an unsettled journal (pending command, unanswered
tool input) stays held and names `retirement_refused=journal_…`.
  - A warm owner whose finalization lease is still held stays held.
- A turn terminal fence, a provider cleanup, unsent events, a fence
behind a later command, or a journal that names the command differently
still fail with `runner_not_settled` and cache nothing.
- A restore-hold source that saved another snapshot, whose restore did
not succeed, or whose restore was at another epoch still fails with
`remote_workspace_recovery_receipt_invalid`.
- Neighbouring suites pass: `native-session-executor.test.ts`,
`plugin-runner-release-resume.integration.test.ts`,
`explicit-native-continuation.test.ts` and
`native-harness-filesystem-independence-continuation.integration.test.ts`
(1786 tests). The last one asserted the old warm refusal on a created
sandbox. It now shows that the warm follow-up resumes the same provider
session for the new run.
- `npx tsc --noEmit -p server/tsconfig.json` passes.
- `pnpm verify` returns exit 2, because a global doc input changed
(`doc/execution-semantics.md`). Required CI supplies the broad coverage.

## Risks

- Previous-release state: all three rules read state that earlier
releases already save (lease outcome, coordinator rows, Runner state in
the sandbox, journal commands, run results and workspace operations).
Rows saved by older releases carry forward. No migration.
- Case 1 starts a new Runner for a warm session whose old Runner may
still exist, idle, in its retained sandbox. Its controller is gone and
its result is committed, so it can accept no new commands. The provider
session follows the existing resume-or-replace policy.
- Case 2 trusts a suspend fence only in the exact shape that the Runner
writes before it sends the result. Other fences still refuse.
- After case 1, a warm follow-up resumes from its own warm checkpoint.
That checkpoint carries no replacement policy, so a failed provider
resume is still reported as `native_session_recovery_failed` and is not
replaced. This is the existing warm behavior.
- Case 3 skips a source only when it provably saved nothing beyond a
committed snapshot. That snapshot stays in its own workspace.
- The three tasks that already failed stay blocked until their next wake
or a user message. This PR does not add an automatic continuation for a
completed no-replay hold. A follow-up can add that.
- `native-runner-restart-recovery.integration.test.ts` (runs only with a
runner container) no longer probes a replacement sandbox, because that
probe now retires the owner by design.
- Not verified live on Daytona.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, read-only browser access to staging).
Extended reasoning enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:38:30 -06:00
DottaandClaude Opus 5.5 fd6e71a7ff fix(native): restore a warm backup when its source sandbox is idle (#20627)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs on remote sandboxes keep a warm sandbox and a verified
harness backup so the next run continues the same provider session
> - A run that starts while the previous run's workspace export is still
saving gets a new (created) sandbox lease, because reuse skips an
exporting sandbox
> - When that run prepares the harness, it finds a backup but no
recorded reusable lease, and it fails closed with
`runner_harness_state_mismatch: backup_without_reusable_lease`
> - #20278 accepted a destroyed source and a settled restore hold as
retired backup sources, but not an idle warm source that is still
`retained` for reuse
> - This pull request accepts an idle warm source under the same proofs,
claims it so it is never resumed again, and covers the resumed lease
that the failed run leaves behind
> - The benefit is that these recoveries continue the same session
automatically instead of blocking the task

## Linked Issues or Issue Description

Refs #20278

**What happened?**
A monitor wake started about 90 seconds after the previous run on the
same task finished. The previous run's warm sandbox lease was `retained`
with policy `reuse_by_environment`, but its workspace export was still
saving. Reuse skipped it and created a new sandbox. Workspace
realization waited for the export (about 136 s) and restored the saved
files. Then harness preparation found the previous run's backup (its
lease stamp names this exact manifest, and no provider work followed
it). The source lease was not destroyed and was not a restore hold, so
the run failed with `native_session_recovery_failed: Error:
runner_harness_state_mismatch: backup_without_reusable_lease` and the
task was blocked.

**What did you expect?**
The run continues the same provider session. The idle source sandbox
holds exactly the backed-up state, so restoring the backup into the new
sandbox is safe.

**Steps to reproduce**
1. Finish a warm native run on a remote sandbox. Its workspace export is
still saving.
2. Start the next run on the same task before the export settles.
3. Reuse skips the exporting sandbox and creates a new lease. Harness
preparation fails with `backup_without_reusable_lease`.
4. A retry resumes that new lease, which has no harness state, and fails
with `resumed_unavailable`.

## What Changed

- `native-retired-backup-source.ts`: an idle warm source is a third
retired backup source. It must be `released` or `retained` with policy
`reuse_by_environment`, its own run must be terminal and not the current
run, and it must have no cleanup in flight, no restore hold or pending
shutdown settlement, and no pending native workspace export. The backup
stamp, single-owner chain, and later-provider-work checks still apply.
- Before the restore, the idle source is claimed with a compare-and-swap
on the checked row. The claim moves its `reusableSandboxLease` scope
into a `reusableSandboxLeaseSuperseded` receipt. Reuse never resumes
that older state again, and the next acquisition treats it as obsolete
and destroys it. If a run resumed or changed the source first, the claim
matches no row and the backup is not restored.
- `environments.ts`: a cross-run reuse handoff now requires that the
source row has no superseded receipt. The supersede claim and the
handoff compete on the same row, so an acquisition that selected the
source before the claim cannot resume it afterward.
- The proof now rejects a backup whose source is the target allocation
itself (`targetProviderLeaseId`).
- `native-session-executor.ts`: a resumed lease whose sandbox has no
harness state at all (`unavailable`) restores the backup under the same
proof. This recovers the lease that the failed run left behind, so rows
saved by older releases recover after deploy without a migration.
- `doc/execution-semantics.md`: documents the idle-source rule, the
supersede claim, and the resumed stateless lease.

## Verification

- Red before the fix, green after:
- `native-session-executor.test.ts` "restores a warm backup into a
created lease only when an idle warm source finished its run and its
export" failed with `backup_without_reusable_lease`.
- "... a resumed lease never held the session and the warm source is
idle" failed with `resumed_unavailable`.
- `native-retired-backup-source.integration.test.ts` (real Postgres)
"authorizes an idle retained/released warm source and claims it as
superseded" failed (returned false).
- Integration test "refuses a reuse handoff of a source superseded while
its provider resume was pending" fails without the handoff guard and
passes with it.
- Safety rows stay refused: source run still running, workspace export
pending, cleanup in flight, restore into its own allocation, stamp names
another checkpoint, provider work after the backup, two host leases for
one allocation, claim lost to another run, and a resumed lease that lost
its own state. Existing destroyed-source and restore-hold rows are
unchanged.
- `cd server && npx vitest run
src/services/native-runtime/native-session-executor.test.ts
src/services/native-runtime/native-retired-backup-source.integration.test.ts
src/services/native-runtime/remote-warm-session-recovery.test.ts` passes
(1408 tests).
- `npx tsc --noEmit -p server/tsconfig.json` passes.
- `pnpm verify` exits 2 because a global CI input
(`doc/execution-semantics.md`) changed. Required CI supplies the broad
coverage.

## Risks

- Previous-release story: no persisted shape changes. Lease rows saved
by earlier releases carry forward. The new
`reusableSandboxLeaseSuperseded` metadata key is additive.
- The idle source is destroyed at the next acquisition for its
workspace. Its state is the verified backup, which the restored lease
now holds, so no work is lost.
- Not covered: when the source cannot be proven retired or idle (for
example a live owner or later provider work), the run still fails closed
with the same named code. An automatic fresh-session fallback for that
case is not in this PR.
- The root trigger (reuse skips a sandbox while its export saves, then
waits for that export) is unchanged. This PR does not change the reuse
lookup.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, with tool use
and code execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:11:10 -06:00
Nicky LeachandPaperclip 19e938415a fix(runtime): omit Codex refresh handoffs from backups (#20614)
## Thinking Path

> - Managed ACPX Codex retains its final refresh after provider exit so
Core can save it.
> - Shutdown saves a checkpoint before Core removes the live credential
handoff.
> - Checkpoint profiles must exclude the handoff while retaining
resumable session history.

## Linked Issues or Issue Description

Follow-up to #20580 and its credential-backup review finding. Refs
#20457.

## What Changed

- Exclude `auth-refresh.json` and `auth-refresh.json.tmp` from the
shared Codex checkpoint profile, covering native and ACPX homes.
- Extend real checkpoint-copy coverage to ACPX Codex, both handoff
filenames, and native Codex. Keep the live files and session history
intact.

## Verification

- A real ACPX checkpoint-copy test failed before the exclusion fix; Grok
passed.
- Five targeted real-copy/profile tests pass, including after applying
the change to current master.
- `git diff --check` and local secret/PII diff review pass.
- Full regression coverage runs in GitHub CI at the maintainer's
request. No live provider credentials were used.

## Risks

- Two entries added to an existing exclusion list; no runtime lifecycle,
schema, persisted identity, or session format changes. Saved sessions
remain readable. Existing backup restoration uses the same exclusion
profile.
- Provider credential cleanup still follows final writeback; excluding a
backup entry does not delete the live handoff.

## Model Used

- OpenAI GPT-6 through Codex, with tool-assisted implementation and
verification. The runtime does not expose a more specific model ID or
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 18:57:03 +00:00
Nicky Leach 17136a6c25 fix(runtime): persist managed Runner credential refreshes across execution targets (#20580) 2026-11-10 10:15:29 -08:00
DottaandClaude Opus 5.5 be0d100a8b fix(native): settle cleanup-gate refusals after a completed turn without a dead-end Retry (#20572)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs execute a provider turn, then close the provider session
and prove it stopped
> - When close cannot prove that the old runner, provider and tools
stopped, the runner holds a cleanup gate
(`native_session_cleanup_quarantined`) that refuses the next session on
that task
> - A run finished its provider turn and saved its work. A server
restart had left one tool callback without a saved result, so close
could not prove the drain, and the gate refused the run's own same-run
recovery 30 seconds later
> - The task then showed "Couldn't finish this attempt" with a Retry
button, but the retry route refuses this error code with 409. The saved
error text told an operator to act
> - This pull request makes the task projection, the execution blocker,
the executor and the retry route agree, and replaces the operator text
with plain wording
> - The benefit is that finished work no longer looks like a failed
attempt, and the UI never offers a Retry that the server refuses

## Linked Issues or Issue Description

**What happened?**
A remote native run completed its provider turn and saved its comments
and documents. A server restart during the turn left one semantic tool
callback without a saved result. Session close then reported
`native_session_settlement_incomplete` (runner suspended, provider drain
unproven), and the runner kept an operator-required cleanup gate. The
same-run disposition recovery was refused at admission, and the run
ended `failed` with `native_session_cleanup_quarantined`.

**Expected behavior**
The task should show its real state (here, waiting on a child task). It
should not show a failure banner or a Retry that the server refuses. No
text should ask an administrator to act.

**Steps to reproduce**
1. Start a remote native run and restart the server while a tool
callback runs.
2. Let the resumed turn complete without a semantic result, so a
same-run recovery is scheduled.
3. Observe the refusal at admission, the "Couldn't finish this attempt"
banner, and a Retry that returns 409.

**Paperclip version or commit**
master at d0057ee384 (remote native runner, Daytona sandbox).

## What Changed

- Add `server/src/services/native-runtime/completed-turn-cleanup.ts`. It
holds one predicate for "a cleanup-gate refusal after this run's own
provider turn completed" and the plain user wording. It reads only the
saved `errorCode` and `runnerProfileJson.sessionCheckpoint`.
- `task-interruption-presentation.ts`: the projection never sets
`canRetry: true` for a cleanup-quarantined run, which matches
`routes/agents.ts` and `canRetryStoppedRun`. A quarantine after a
completed turn projects as `resolved` only while the task waits on
unresolved blocker tasks, so the task shows that wait. Without such a
wait, the missing task disposition stays visible. Other quarantined runs
show a plain settling message without Retry. The list query computes the
completed-turn fact in SQL.
- `native-session-executor.ts`: the refusal keeps its permanent, named
code, its hold and its ownership, and does not replay provider work. The
next action uses plain wording, and names the saved work after a
completed turn.
- `execution-blocker.ts`: show plain wording for this code instead of
the stored error text, also for rows that earlier releases saved.
- Runner error message and retry-route conflict message use plain
wording.
- `doc/execution-semantics.md`: document how the cleanup gate settles
and how the task shows it.

## Verification

- Red before the fix, green after:
- `server/src/services/task-interruption-presentation.test.ts` "native
cleanup quarantine presentation" (2 failed before: `canRetry: true`,
`state: failed`).
- `server/src/__tests__/task-interruption-query.test.ts` "reads a
quarantine saved after a completed turn as settled work" (real Postgres,
row in the previous release's saved shape; before: `state: failed,
canRetry: true`).
- `server/src/services/native-runtime/native-session-executor.test.ts`
"settles a cleanup refusal after a completed turn" (before: operator
next action and board ownership).
- `server/src/services/explicit-native-continuation.test.ts` "shows
plain wording for a cleanup refusal saved with the earlier operator
text" (before: stored operator text).
- The four files pass in full: 1851 tests.
- `tsc --noEmit` passes for `server` and `packages/paperclip-runner`.
- `pnpm verify` returns exit 2 (a global doc input changed), so required
CI supplies the broad coverage.
- No UI files changed, so token gates and UI tests do not apply.

## Risks

- Previous-release story: no persisted shape changes. Runs that earlier
releases quarantined carry forward. The new rules read existing fields,
so these runs show the new projection without a migration.
- The code stays permanent and named. No automatic provider replay is
added.
- A quarantine whose turn did not complete keeps its board-owned
recovery. Only its text and its Retry visibility change.
- The runner still keeps the in-process cleanup gate until environment
release proves the stop. Existing lease termination receipts
(`acknowledgeRemoteStop`) clear it. This PR does not change the runner
drain rule for a callback that a restart orphaned. That is a separate
follow-up.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:09:59 -06:00
Nicky LeachandPaperclip 2e3cbe57a4 fix(native): preserve parked Runners across controller restarts (#20586)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native execution parks a released sandbox Runner for a later turn.
> - The warm idle timer detaches Core and leaves Runner retention to the
host.
> - Controller shutdown instead stops recently parked Runners through
checkpoint cleanup.
> - This makes reuse depend on whether the idle timer fired before
shutdown.
> - This pull request uses the same detach operation for idle expiry and
shutdown.
> - The benefit is consistent Runner retention without a new retention
or recovery mechanism.

## Linked Issues or Issue Description

Fixes: #20012.
Related: #20005 unified cached and adopted process reuse. Closed #19975
included this shutdown change within a broader retention proposal.

## What Changed

- Detach confirmed released entries during controller shutdown. Keep
busy, unreleased, and close-marked entries on the existing cleanup path.
- Share the detach operation with idle expiry and cached-owner handoff.
- Record that a local handle has started detaching. Later cleanup cannot
fall back to stopping its Runner after a partial detach failure.
- Preserve explicit environment deletion, release proof, admission, and
process-reuse checks.
- Add Codex and Claude restart, detach-failure, and environment-deletion
regressions. Add real Runner adoption cases after the restart sweep,
including managed GitHub rebinding.
- Document the lifetime rule and make the shutdown warning cover both
detach and checkpoint failures.

## Verification

- Final source revision: `e4bb6ecbd7`. Local environment: macOS arm64,
Node 26.12.0.
- New controller-restart and detach-failure tests fail against unchanged
master: 4 expected failures. Old shutdown calls close or reports success
instead of reporting detach failure.
- Focused executor tests: 22 passed.
- Full native session executor suite: 1,328 passed.
- `cd server && node scripts/typecheck-layers.mjs`: passed all source
and test layers.
- `node scripts/check-module-boundaries.mjs` and `git diff --check`:
passed.
- Manual diff review and added-line credential/PII scan: passed.
- `cd server && node ../node_modules/vitest/vitest.mjs run
src/services/native-runtime/plugin-runner-release-resume.integration.test.ts
--bail=1 --reporter=verbose`: 14 passed on this revision. This includes
new Codex and Claude restart cases and managed GitHub rebinding.
- Failure history: an earlier concurrent run had 4 passes and 10
failures. Its first failure was the existing Claude cached case
exceeding 60 seconds. Later cases encountered a leftover prototype spy,
recursive calls, and startup timeouts. No source changed before the
passing full rerun. A clean worktree of unchanged master (`e01fe7f84a`)
passed all 11 original cases. The first timeout's exact cause remains
unknown; the later pass does not establish that cause.
- Standard Greptile review: 5/5 on
`e4bb6ecbd7bd965b5f56507d2c42c580e8a1d547`, with no actionable findings.
- `node scripts/local-verify.mjs --explain` returned exit 2 because the
execution-semantics document requires broad CI. Required CI will supply
broad typecheck, test, and build coverage.
- A hosted deployment and an actual controller process replacement have
not been run. The added integration cases exercise the shutdown sweep
and a new adopting transport against a real Runner.

## Risks

- SIGINT and SIGTERM preserve confirmed parked Runners, including
ordinary service stops. The host still owns eviction and retention.
- Detach failure retains a fenced local owner and reports failure. Core
does not infer permission to stop the Runner from that failure.
- Reuse remains conditional on current admission, process configuration,
and host availability.
- No persisted schema, checkpoint format, launch identity, or migration
changes. State from the previous release uses the existing adoption or
verified restore paths.

## Model Used

- OpenAI Codex, GPT-6, with reasoning, repository inspection, tool use,
and local test execution. The exact deployment identifier and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 16:48:30 +00:00
DottaandClaude Opus 5.5 96ab5e3d38 fix(recovery): read a retained Runner from its own allocation and continue unverifiable no-replay holds from the last save (#20567)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native warm runs on a remote sandbox continue a saved session in the
next run. The next run first reads the retained Runner lifecycle from
the sandbox.
> - #20346 abandons the workspace epoch of a run that died during
preparation. The next run then restores the last save into a new
directory of the same sandbox.
> - The retained-sandbox binding check required the old and the new
directory to be the same. The warm follow-up failed with
`native_controller_recovery_missing_evidence:retained_sandbox_binding`
before provider work.
> - The failed run's no-replay hold then refused its files with
`allocation_ownership_ambiguous`. The task stayed blocked. The text said
that Retry "succeeds only after Paperclip is updated", so no click could
help.
> - This pull request binds the Runner read to the directory that the
retained lease recorded, and lets the existing retained-source fallback
settle these holds from the last save.
> - The benefit is that a moved workspace continues its session, and a
hold that cannot be verified continues automatically instead of
dead-ending.

## Linked Issues or Issue Description

Refs #20346

**What happened?**
On staging, a warm native continuation ran about 10 minutes after a
deploy. The run before it died during preparation (database deadlock),
so its workspace epoch was abandoned. The continuation restored the last
save into a new allocation directory of the same Daytona sandbox. It
then failed with
`native_controller_recovery_missing_evidence:retained_sandbox_binding`,
because the retained lease of the committed source run recorded the old
directory. The failure recorded a no-replay hold
(`native_continuation_requires_reconciliation`, resolved with outcome
`blocked`). Workspace recovery refused the source with
`allocation_ownership_ambiguous`, because the sandbox's reusable-lease
chain had predecessors in another directory and a non-native
preparation-only run. The task stayed blocked with "Retry runs the check
again and succeeds only after Paperclip is updated."

**Expected behavior**
The continuation reads the retained Runner state from the directory
where that Runner wrote it, and continues. When retained files cannot be
verified, the task continues from the last committed save without a
person.

**Steps to reproduce**
1. Run a warm native session on a reusable remote sandbox and let it
commit.
2. Fail the next run during preparation, so that its epoch is abandoned
(#20346).
3. Start the next warm continuation. It restores the last save into a
new allocation directory and fails with `retained_sandbox_binding`.
4. See the hold refuse its source with `allocation_ownership_ambiguous`
and stay blocked.

**Paperclip version or commit**
Staging `c874cfa4dacc` (includes #20346).

## What Changed

- `assertNativeRetainedSandboxBinding` now returns the directory that
the retained lease recorded. It accepts a different target directory
only when both are canonical sibling allocations of the same execution
workspace under the sandbox's `.paperclip-workspaces` (`<workspaceId>`
or `<workspaceId>-<epoch>-<leaseId>`). Same sandbox (`providerLeaseId`),
environment, run, issue and committed owner are still required. Other
directories stay refused.
- `restoreRemoteSuspendedRunnerCheckpoint` reads the Runner state from
that recorded directory. A moved source must be suspended and settled. A
Runner left in the old allocation is never adopted live.
- The retained-source fallback (`continueFromLastSavedWorkspace`) and
its discovery now also cover the native no-replay hold
(`native_continuation_requires_reconciliation`, active or resolved with
outcome `blocked`, replay blocked for the same run). Rows saved by
earlier releases are discovered without migration.
- The fallback owns the Blocked status of such a hold only while the
recorded `nativeFailureBlock` status version still matches the task.
Otherwise it keeps the newer block and starts no continuation.
- The refusal text no longer says "succeeds only after Paperclip is
updated", and it promises no fallback.
- `doc/execution-semantics.md` describes both rules.

## Verification

- Red (unfixed code): `native-controller-recovery.integration.test.ts`
"binds a retained Runner read to the source's directory after the
workspace moved within the same sandbox" failed with
`native_controller_recovery_missing_evidence:retained_sandbox_binding`
at `native-controller-recovery.ts:145`, the staging error.
- Red (unfixed code): `workspace-recovery-fallback.test.ts` "continues a
resolved-but-blocked native no-replay hold from the last save,
discovered by the worker" failed: the worker found no due work for the
exact staging row. The newer-block safety case and the message case in
`system-recovery-state.test.ts` also failed.
- Provider boundary: a new test drives
`restoreRemoteSuspendedRunnerCheckpoint` through a sandbox runner that
refuses paths outside the sandbox workspace base. The suspended case
reads `runner-state.json` from the source directory, never the moved
root. The ready case is refused with `runner_not_settled` and caches
nothing.
- Green: `native-controller-recovery.integration.test.ts`,
`workspace-recovery-fallback.test.ts` and
`system-recovery-state.test.ts` pass (45 tests). The neighbouring suites
`workspace-recovery-retry-copyback`,
`workspace-recovery-preparation-gates` and
`workspace-recovery-stop-proof` pass (135 tests).
- Safety cases: another workspace, another parent, a non-canonical path
or name (`-0-`, trailing text), `/unrelated` and another sandbox stay
refused. A hold whose task moved to a newer Blocked decision keeps the
block and starts no continuation. Exactly one continuation is delivered.
Native replacement is denied for the abandoned source, so no second
executor starts.
- `npx tsc --noEmit -p server/tsconfig.json` passes. `pnpm verify`
returns exit 2 (a global doc input changed), so required CI supplies the
broad coverage.

## Risks

- Previous-release state: rows saved by earlier releases carry forward
without migration. The binding reads existing lease metadata. The
fallback discovers existing resolved-but-blocked holds by their existing
evidence fields.
- The binding now accepts a sibling allocation directory. The read still
binds to the exact sandbox, run, environment and committed owner, and it
reads only the source's recorded directory. A non-suspended Runner in a
moved allocation is refused, not adopted.
- The fallback abandons the unverified epoch and continues from the last
committed save with a fresh session. In the staging shape the run did no
provider work after it restored that save, so no work is lost.
Unverified files are attached to the task when they can be read, as
before.
- Not verified live: a real warm Codex resume after a moved allocation
on Daytona.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, read-only browser access to staging).
Extended reasoning enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:34:44 -06:00
DottaandClaude Opus 5.5 db0d523bc8 fix(recovery): start a Retry after a restart cut off a relaunch whose earlier Runners were proven stopped (#20537)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a run fails, a person presses Retry and expects a new run to
start
> - Before a native Retry starts, Paperclip must prove that the previous
run's Runner and provider stopped, so two providers never work on the
same task
> - When the server is killed after it asked for a Runner launch but
before it recorded the Runner's process, there is no PID to check.
Paperclip then proves the stop from two facts: no Runner process for the
run exists on this host, and the run never saved Runner state
> - A run that already crossed restarts did save Runner state, from its
earlier Runners. Those Runners were proven stopped, with stop receipts.
The proof still refused, because it only asked whether any state
belonged to the run
> - The Retry returned 202, but the task then waited for a board
operator to confirm the stop (`confirm_run_stopped`,
`process_identity_missing`), until the task timed out
> - This pull request lets the proof accept Runner state that only the
earlier, proven-stopped Runners could have written
> - The benefit is that a Retry after a restart-interrupted relaunch
starts right away, with no operator step

## Linked Issues or Issue Description

**Subsystem affected**
Server native-run stop proof for a Retry or a new message
(`server/src/services/native-local-process-stop.ts`,
`unidentifiedLocalRunnerProvenAbsent` in
`server/src/services/native-runtime/native-session-executor.ts`).

**Problem or motivation**
Restart-chaos wave `w-20261110T111245-s701`, task t03 (local, codex, C1,
K1/K2, `--storage s3 --storage-faults ST7`): run `f1ffeed3` crossed
three restarts. Its event history is:

1. Launch request, process identity, stop receipt (first Runner, killed
by K1)
2. Launch request, process identity, stop receipt (second Runner, killed
by K1)
3. Launch request at 11:13:54.999, then a K1 kill before the Runner's
identity was recorded
4. A fourth controller resumed the run, a K2 drain interrupted its
preparation, and the run failed (`adapter_failed`, fixed separately by
#20514)

The person's Retry at 11:14:37 returned 202. Admission reached
`recordUnidentifiedLocalRunnerStop`. No Runner process for the run
existed, but the Runner state root held `control-plane-state.json` with
this run's id, written by Runners 1 and 2. The proof refused, the issue
was saved as `waiting_for_user` / `confirm_run_stopped` /
`process_identity_missing`, and the wake stayed deferred until the task
timed out about 15 minutes later. The harness graded
`R3:retry_no_new_run`.

**Proposed solution**
A Runner persists its command journal (`runner/runner-state.json`)
before any command takes effect, and a provider starts only from a
command. Its `runner/process-lineage/` writer marker is written before
any supervised process starts. So a launch whose Runner never wrote
either file after the launch request cannot own a provider.

- The launch request event now records `requestedAt`, taken on the
launching host just before any backend may spawn.
- `recordUnidentifiedLocalRunnerStop` passes that time on only when the
latest process event before the request is a stop receipt (every earlier
Runner of the run was proven stopped) or there is none.
- `unidentifiedLocalRunnerProvenAbsent` then accepts saved state of this
run only when `runner/runner-state.json` (if present) and every
`runner/process-lineage/` entry were last modified before `requestedAt`.
A file time that falls on a whole second (a coarse-timestamp filesystem)
must predate the request by 2 s. The existing checks stay: no `--run-id`
Runner process on this host, the launching host matches, and no remote
lease.

**Previous-release story**
A launch request saved by the previous release has no `requestedAt`. It
keeps today's proof: it is accepted only without saved Runner state of
the run, otherwise the board shows the operator confirmation. Nothing is
migrated or rewritten.

**Alternatives considered**
- Treating a proven-gone controller as proof that its Runner stopped.
Local Runners outlive a controller restart (graceful drains reattach
them), so a gone controller says nothing about its Runner or provider.
- Running `paperclip-runnerd reap-lineage` for each writer marker. It
needs a Runner PID and binary on the hot Retry path, and a Runner whose
journal write failed has no marker to find.

## What Changed

- `native-session-executor.ts`: the `native.process_start_requested`
payload adds `requestedAt`. `unidentifiedLocalRunnerProvenAbsent` takes
`earlierRunnersStoppedBefore` and accepts this run's saved state only
when the Runner journal and lineage entries predate it
(`runnerStateUnchangedSince`).
- `native-local-process-stop.ts`: `recordUnidentifiedLocalRunnerStop`
reads the last two process events and passes the request time only after
a stop receipt (or no earlier launch).
- `explicit-native-continuation.test.ts`: new embedded-Postgres case. A
native run with a proven-stopped Runner, saved Runner state, and a later
unidentified relaunch: the Retry is admitted and the stop is recorded.
It stays blocked (`process_identity_missing`) when the Runner journal or
a lineage entry changed after the request, when the request has no
`requestedAt` (previous release), when the earlier Runner has no stop
receipt, when a whole-second file time is within 2 s of the request, or
when a `--run-id` Runner process is alive. A whole-second time 3.5 s
before the request is admitted.

## Verification

- Red/green: `cd server && npx vitest run
src/services/explicit-native-continuation.test.ts -t "proven stopped"`.
On master the `proven` case fails (admission returns null); with the fix
all 8 cases pass.
- Related suites: `explicit-native-continuation.test.ts`,
`issue-confirm-run-stopped-routes.test.ts`, and the unidentified-Runner
and stop cases of `native-session-executor.test.ts`.
- Live, the s701 seed with the original wave's flags (`--env local
--adapters codex,claude --workloads A1,C1 --faults K1,K2 --triggers
time,result --concurrency 4 --tasks 4 --seed 701 --storage s3
--storage-faults ST7 --keep`):
- `w-20261110T131243-s701` (final head) and `w-20261110T125259-s701`,
master plus this fix plus #20514 merged locally: 4/4 pass each, every
task crossed a restart, no R3 signature, 0 speed breaches.
- `w-20261110T125616-s701`, master plus this fix without #20514: t01 and
t02 pass. t03 and t04 hit the drain-during-preparation failure that
#20514 fixes (`R3:retry_needed`). The Retry started a new run each time
and both tasks finished (`retry_no_new_run` is gone). New launch
requests carry `requestedAt`.
- The restart timings of these replays did not land in the exact gap
between a launch request and its process identity. The red/green test
covers that window.

## Risks

- Low. The change only widens one fail-closed proof, and only for launch
requests saved by this release, after a stop receipt, with Runner files
older than the request. The proof compares file times with a time taken
on the same host. A backwards wall-clock step between the request and a
Runner's first write could hide that write. The `--run-id` process check
still has to pass, so that Runner must also be gone.
- The Retry path adds one `lstat` per file in a small directory. It adds
no wait and no work before `turn.start`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:08:23 -06:00
DottaandClaude Opus 5.5 c9226de30b fix(heartbeat): leave a preparing run to the next controller after a graceful drain (#20514)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The heartbeat service runs each agent task. A graceful shutdown
(SIGTERM) drains running native runs and hands them to the next server.
> - The drain detaches the native runner session and releases the run's
lease with the recovery state `awaiting_runner_reattach`.
> - An in-process execution can still be in run preparation at that
moment, for example while the run-log journal claim waits on a slow or
unavailable object store.
> - When that execution then lost writer authority
(`run_log_writer_authority_lost`), the generic failure path settled the
run as `adapter_failed`. The next server then saw
`native_continuation_requires_reconciliation`, and replay stayed
blocked.
> - This pull request makes a preparing execution that a restart
detached stop quietly, the same way a detaching native session already
does.
> - The benefit is that a slow preparation step plus a graceful restart
no longer turns into a failed run that needs a manual retry.

## Linked Issues or Issue Description

**What happened?**
In restart-chaos wave `w-20261110T092411-s701` (local, A1/C1, K1/K2, ST7
read-after-write lag), three of four tasks failed with R3
`retry_needed`. For run `218a0425`, the K2 SIGTERM drain wrote
`native.recovery.transition` with `awaiting_runner_reattach` and
released the lease. About 3 s later, the old server's in-process
execution, which was still in `runOutput.begin`, got
`run_log_writer_authority_lost`. `runCompletion.failRun` then settled
the run `adapter_failed` with a durable settlement. The next server
found `native_continuation_requires_reconciliation` and did not replay
the run.

**Expected behavior**
The drain hands the run to the next server. The old execution must not
settle it. A real authority loss outside a drain must still fail the
run.

**Steps to reproduce**
1. Start a native run recovery, and hold it in preparation (slow store,
ST1 outage wait, or ST7 lag).
2. Send SIGTERM so `drainRunningRunsForShutdown` suspends the run.
3. Let preparation continue and check writer authority. The run settles
`adapter_failed`.

**Paperclip version or commit**
`master` at 914e3ae14f.

## What Changed

- `native-session-executor.ts`: add `watchNativeRestartDetach(runId)`.
It records the run's restart-detach marker when the execution starts. It
reports `true` only when a later restart detach for that run happens. A
marker left by an earlier detach does not count.
- `heartbeat.ts`: the execution creates the watcher at start. In the
inner failure path (before `runCompletion.failRun`) and the setup
failure path (before `runCompletion.failRunSetup`), if a restart
detached the run and no provider dispatch started, the execution logs
the handoff and stops without settling. It uses the same teardown flag
(`nativeSessionResumeScheduled`) as
`NativeControllerDetachedForRestartError`.
- `heartbeat-native-restart-suspension.test.ts`: add a
red-before/green-after regression. A recovery execution is drained
during preparation and then checks the real run-log writer authority.
With the drain, the run stays `running` with `awaiting_runner_reattach`,
and the next controller resumes it. Without the drain (the lease only
expired), the run still fails `adapter_failed`.

## Verification

- `cd server && npx vitest run
src/__tests__/heartbeat-native-restart-suspension.test.ts`: 98 passed.
Before the fix, the new drained case failed with `status: failed,
errorCode: adapter_failed`.
- `cd server && pnpm typecheck`: passed.
- Live restart-chaos wave: see the comment below.

## Risks

- Low risk. The new branch applies only when this process's own restart
detach for this run happened after the execution started, and before any
provider dispatch. Typed native outcomes (startup cleanup, ownership
unverified, cancellation pending, resume scheduled) keep their earlier
handling because they are checked first.
- Previous-release story (upgrade-safe run state): no persisted shape,
launch identity or pack identity changes. The marker is process-local.
State saved by the previous release carries forward unchanged. The
deploy-lands-mid-run case is the regression test itself: a drain lands
during recovery preparation and the next controller resumes the same
run.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:00:09 -06:00
DottaandClaude bf45700ca3 fix(native): make Retry work after a turn start cut off once Codex recorded the turn (#20516)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native Codex run sends `turn/start` through its runner. A
control-plane restart can kill the runner and Codex while that request
is in flight
> - #20507 lets the runner resume by itself when Codex's thread history
proves the turn was never created. When Codex did record the turn, the
turn may have done work, so the runner still fails closed and nothing
replays it
> - That branch must still end in a Retry that works. The next run on
the same conversation is refused at the runner-state fence before it
asks for a runner. #20507 let Retry continue after that run by matching
the refusal's error text
> - Driving that branch end to end with the real runner exposed a second
dead end. When the owner's runner dies, its provider-session close
cannot prove suspension, so this server process can keep an
operator-required cleanup quarantine for the task. Retry was then
refused outright ("Send a new message")
> - This pull request replaces the text match with a server-authored
run-log event, lets Retry take the same verified-stop path as a new
message, and adds a real-runner test of the whole branch: the cut-off
owner fails closed, the follow-up is refused with structured proof,
Retry is admitted, and the fresh Retry run completes
> - The benefit is that this restart window cannot strand a task, and
the Retry gate no longer depends on error wording

## Linked Issues or Issue Description

**What happened?**
Follow-up to #20507 (restart-chaos wave `w-20261110T033426-s108`, t03).
#20507 left one item open: prove that a person's Retry produces a
working run when the restart cut off `turn.start` after Codex recorded
the turn. It also matched the fence refusal by its error text.

**Expected behavior**
After that cut-off, the owner stays failed closed and the turn is never
started again by itself. A person's Retry starts a new run that
completes. It never fails with `quarantine_owner_unverified` and never
returns 202 with no run.

**Steps to reproduce**
1. Run `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/native-cut-turn-start-retry.integration.test.ts`.
2. Without the new event, the test fails: the refused follow-up has no
proof that it never launched, so Retry after it would wait on
`process_identity_missing`.

## What Changed

- `native-session-executor.ts`: when the runner-state fence refuses a
run before launch (`prior_owner_*` or `legacy_prior_owner_*`), the
executor records the server-authored event
`native.prior_owner_fence_refused` with the closed refusal `reason`,
then fails the run as before. A failed write leaves the run held (the
safe side).
- `native-local-process-stop.ts`: `nativeRunNeverLaunched` accepts a
failed run that has this event (and still no launch, identity,
`adapter.invoke` or runner-sourced event). It no longer reads the error
text.
- `explicit-native-continuation.ts`: when the task's in-process cleanup
quarantine is operator-required, a Retry no longer returns
`cleanup_quarantined` at once. Like a new message, it must first pass
`verifyStoppedNativeSessionForContinuation`, which releases the
quarantine and records `native.stopped_conversation_verified`. A native
Retry always starts a fresh conversation (`forceFreshSession`), so it
never resumes the interrupted turn. If the proof fails, Retry still
returns `cleanup_quarantined`.
- `verifyStoppedNativeSessionForContinuation`
(`native-session-executor.ts`), three narrow additions:
- a run with `native.prior_owner_fence_refused` may stand in for the
saved state's owner, like a run refused by the cleanup quarantine (both
were refused before launch);
- when the session root is gone, it reads the fence's single
`identity_indeterminate` quarantined copy of the owner's state (several
copies stay ambiguous);
- a provider startup attempt in phase `initialization_failed` whose
direct child exited and whose process and process group are gone no
longer blocks the proof. A same-run restore that fails closed leaves
exactly this record. Those PIDs are also re-checked before the
quarantine is released.
- New real-runnerd integration test
`native-cut-turn-start-retry.integration.test.ts` (real runnerd and fake
Codex with `--hang-turn-start after`):
1. The owner run's `turn/start` hangs after Codex recorded the turn; the
runner and Codex are killed. The owner's provider state keeps the
ambiguous start, and the thread history keeps the turn.
2. The same-run resume restores the runner and fails closed with "cannot
safely recover an ambiguous Codex turn start". Codex saw exactly one
`turn/start`.
3. A follow-up on the same conversation is refused with `prior_owner_*`,
never `quarantine_owner_unverified`. It records
`native.prior_owner_fence_refused`, launches nothing, and
`nativeRunNeverLaunched` is true for it.
4. The owner's operator-required in-process cleanup quarantine is pinned
(whether the real close lands there is timing-dependent; about half of
the local runs did).
5. Retry of the refused follow-up passes
`admitExplicitNativeContinuation` (not a dry run) with no block reason,
releases the quarantine and records
`native.stopped_conversation_verified`.
6. The Retry run, prepared with a fresh conversation as task Retry does
(`forceFreshSession`), uses a different runner-state root and completes
with exit code 0. Codex saw one new `thread/start` and one new
`turn/start`; the cut-off turn was never started again.
- `explicit-native-continuation.test.ts`: the fence rows now insert the
event. A new row shows that the refusal text alone keeps the run held.
The "worker loss (retry)" case now expects a verified Retry to be
admitted like a new message.
- `native-recovered-owner-followup.test.ts`: a fixture event moves to a
free sequence number, because the refused follow-up now writes its own
event.
- Docs: `doc/run-log-events.md` (new event) and
`packages/paperclip-runner/docs/durable-recovery.md`.

## Verification

- Red/green:
- Without the event write, the integration test fails (no fence event,
so the follow-up is not proven unlaunched).
- Without the gate and proof changes, it fails with `expected [
'cleanup_quarantined' ] to deeply equal []`: Retry is refused while the
owner's quarantine is held.
  - With all changes, it passes.
- `vitest run` of `native-recovered-owner-followup`,
`explicit-native-continuation` (427 tests), `quarantined-task-retry`,
`issue-confirm-run-stopped-routes`, `native-finalization-recovery`,
`task-interruption-query`, `execution-projection`,
`chat-run-publications`, `closed-native-receipt-accounting`,
`agent-live-run-routes` and the new integration test pass.
- Server `tsc --noEmit` passes.
- Runner code and the pack `contractDigest` do not change.

## Risks

- Low-medium risk. The new event is written only on the fence refusal
path, which already fails the run. It is local run-log data (no
Telemetry or OpenTelemetry).
- Retry after an operator-required cleanup quarantine is a behaviour
change: it was always refused, and now it is admitted only after the
same exact stop proof a new message needs. That proof checks the owner's
runner stop record, every recorded provider and startup PID and process
group, and the unchanged state fingerprint before it releases the
quarantine.
- A run refused by a build between #20507 and this change has only the
error text. It stays held on `process_identity_missing` until a person
sends a new message or the board confirms the stop. #20507 has not
shipped in a release yet.
- The test models the restart in-process: it kills the runner and Codex
and calls the executor again. It does not drive the HTTP Retry route;
the route's own behaviour (fresh conversation, admission gate) is
covered by existing tests and by `nativeRunNeverLaunched` here.
- Open decision, unchanged: a turn Codex recorded before the restart is
never replayed or adopted. Continuing is a person's decision.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Refs #20507

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 06:16:42 -06:00
DottaandClaude Opus 5.5 c0593b69f1 fix(accounting): close a remote-Runner turn completed across a session restore as a lower bound (#20524)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs settle their charge once the final usage receipt is
complete.
> - #20517 fixed one case for the local Runner. If a restart stops the
provider before a turn's final usage report, and the restored Runner
then reports that turn completed, the turn now closes as a lower bound
instead of waiting 10 minutes.
> - Remote Runners deliver usage through a separate producer-owned
receiver. That receiver dropped `session.resumed`, so the same turn
there still waited for the 10-minute closure timeout.
> - This pull request applies the #20517 rule to that receiver.
> - The benefit is that remote ACPX and OpenCode runs settle promptly
with an honest lower-bound label after a mid-turn restore.

## Linked Issues or Issue Description

Refs #20517.

**What happened?**

After #20517, `session.resumed` reached turn accounting only through the
local Runner's fallback recorder. The remote producer receiver
(`native-usage-delivery.ts`) and the session-authority filter
(`isNativeUsageAccountingEvent`) both dropped it. A remote ACPX or
OpenCode turn that was open across a session restore, and then reported
terminal without a complete report, left the receipt incomplete until
the closure timeout.

**Expected behavior**

Same rule as the local Runner. Such a turn closes as a lower bound right
away. A complete report that arrives after the restore is still used,
and a turn that starts after the restore stays pending until its own
report is complete.

**Steps to reproduce**

`native-usage-delivery.integration.test.ts > a turn restored mid-turn on
a remote Runner > closes the turn as a lower bound, also after the
controller restarts between restore and terminal` fails on master: the
receipt stays `accountingReceiptReady: false`.

## What Changed

- **Receiver (`createNativeUsageReceiver`).** It now accepts
`session.resumed` for turn-reporting providers (ACPX, OpenCode) while a
turn is open. It journals the restore as a receipt like any other
producer event, so turn accounting marks the open turn restored. A
replacement controller rebuilds the same state from that journal.
- **Restores never fail delivery.** A restore from another run or
source, or one with no open turn, is ignored, not rejected. An
unacknowledged lifecycle event would otherwise replay without end.
- **Session-authority filter.** `acceptAccountingEvent` now uses a new
`isNativeUsageDeliveryEvent`: the usage events plus `session.resumed`. A
restore for another run is ignored there too.
- **Recovery routing is unchanged.** `acceptRecoveryAccountingEvent` and
`onRecoveryAccountingEvent` keep the usage-only
`isNativeUsageAccountingEvent`, so a source run's restore never requires
a proven recovery source.
- **Turn accounting** gets a `hasOpenTurn()` helper.
- **Docs:** `doc/SPEC-implementation.md` now covers both Runners.

## Verification

- `native-usage-delivery.integration.test.ts`, describe `a turn restored
mid-turn on a remote Runner` (real PostgreSQL):

  | Test | On master | Here |
  |---|---|---|
| `closes the turn as a lower bound, also after the controller restarts
between restore and terminal`: the restore is journaled by one receiver
and the terminal arrives at a rebuilt one; replaying everything again is
idempotent | fails | passes |
| `keeps the turn's own final report when it arrives after the restore`
(guard) | passes | passes |
| `keeps a turn started after the restore pending, and journals nothing
for a restore with no open turn` (guard) | passes | passes |

- `native-session-executor.test.ts > binds retained warm transport usage
to the successor run authority` covers routing through the
session-authority filter:
  - a current-run restore reaches the successor's capture;
  - a draining-run restore reaches the drained capture;
  - an unrelated-run restore resolves with no capture;
  - recovery routing ignores a source restore.

It fails when the filter is reverted to `isNativeUsageAccountingEvent`.
- Related suites pass: `native-usage-delivery.integration` and
`native-usage-accounting` (46 tests), and the usage, accounting, receipt
and authority cases in `native-session-executor.test.ts` (224 tests).
Server typecheck passes.
- **The live remote path is unproven.** No Daytona wave ran because the
Daytona Runner image has not been rebuilt yet. The local Runner
counterpart (#20517) passed restart-chaos L1, seed 20386, 4/4
(`w-20261110T104332-s20386`).

## Risks

- **Upgrade safety.** Usage state saved by current master has no
journaled restore and rebuilds as before. A receiver that predates this
change replays a journaled restore as an ignored event, which is the
behaviour on master. A restore that an older controller already
acknowledged is not replayed, so that run waits for the closure timeout
as it does today.
- **One extra receipt per mid-turn restore**, only for ACPX and OpenCode
and only while a turn is open. Nothing is added before `turn.start`: a
restore with no open turn returns immediately.
- **Codex and other providers are unaffected.** They do not use turn
accounting.
- No schema or persisted-format change.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with shell, file-edit and code-execution tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:42:52 -06:00
DottaandClaude Opus 5.5 37808f2a4e fix(accounting): close a local-Runner turn completed across a session restore as a lower bound (#20517)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs record usage receipts, and accounting settles a run's
charge once its final receipt is complete.
> - ACPX Claude reports provisional zero usage until a turn ends, then
one final report.
> - A restart can stop the provider after the agent's finish call and
before that final report. The restored Runner then resumes the session
and reports the same turn completed with no complete report.
> - The receipt stayed incomplete, so the accepted run waited for the
10-minute closure timeout and then settled as unpriced. Its spend was
missing from company totals in the meantime.
> - This pull request closes such a turn as a lower bound, the same way
a turn the Runner fails as lost on restore is already closed.
> - The benefit is that an accepted run settles right away with its
observed usage and an honest lower-bound label.

## Linked Issues or Issue Description

Refs #20484 (restart-chaos L1 contention follow-up).

**What happened?**

After #20484 fixed the database contention, restart-chaos L1 (`--seed
20386 --db-pressure 4`, codex and claude, K1) reached 4/4 tasks done.
Each task still failed the T-oracle `company_spend_settled` with
`pending_runs=1`. A replay on master with `--keep`
(`w-20261110T100747-s20386`) reproduced it, and I read the kept
database:

- The pending run (`71f71d10`, ACPX Claude) succeeded and had its result
accepted. Its usage had `accountingReceiptReady: false`, zero tokens,
`costStatus: unpriced`, and no cost event.
- Its only turn started before K1, and every usage report in it was
provisional (`runDeltaComplete: false`). K1 struck right after the
agent's `paperclip_finish` call (source seq 118), before the turn's
final report.
- On restart, recovery verified the local process stopped and resumed
the session (`session.resumed`, seq 119). The restored Runner then
reported the same turn `turn.completed` (seq 124) with no usage report.
- The other Claude run in that wave lost its turn as
`provider_turn_lost_on_restore`, ran a new turn live with full usage,
and settled at once.

**Expected behavior**

An accepted run whose open turn's final report was lost with the
provider process settles promptly. It records its observed usage as a
lower bound, like a turn lost on restore. A complete report that does
arrive after the restore is still used.

**Steps to reproduce**

`server/src/services/native-runtime/native-usage-accounting.test.ts >
closes a turn that completed across a session restore without its final
report, as a lower bound` uses the event sequence from the kept
database. It fails on master because the snapshot stays incomplete.

## What Changed

- `createNativeTurnAccounting` observes `session.resumed` and marks
every open turn as restored. A terminal event for a restored turn
without a complete report marks it lost. The snapshot then closes it as
a lower bound (`accountingUsageLost`), through the existing
lost-on-restore path. A new `turn.started` clears the mark.
- A lost terminal turn keeps its identity in the snapshot, so the
executor's terminal-turn match certifies the final checkpoint as
complete. This came from review.
- The native session executor includes `session.resumed` when it
rebuilds turn state from the run's event log after a controller restart.
- `doc/SPEC-implementation.md` documents the case next to the existing
lost-on-restore rule.

## Verification

- Red on master, green here: `native-usage-accounting.test.ts > closes a
turn that completed across a session restore without its final report,
as a lower bound`. It also checks that replaying the history twice gives
the same snapshot.
- Red on the first revision, green after the review fix:
`native-session-executor.test.ts > certifies a turn completed across a
session restore as a lower bound (history replay: false|true)`. It
drives the restored sequence through executor completion, both live and
from the event history.
- New guards that pass before and after:
- `keeps a restored turn's own final report when it arrives after the
restore`: a complete report after resume is used, not marked lost.
- `keeps an incomplete turn pending when the session resumed before it
started`.
- The existing `keeps an incomplete provider report pending instead of
trusting placeholder zeroes` still passes.
- Related suites pass: `native-usage-accounting`,
`native-usage-delivery.integration`, `closed-native-receipt-accounting`
(107 tests), and the usage, accounting and receipt cases in
`native-session-executor.test.ts` (151 tests). Server typecheck passes.
- Live, restart-chaos L1, seed 20386, `--db-pressure 4`, codex and
claude, K1, head `a6e521f06c`, clean worktree:
`w-20261110T104332-s20386` passed 4/4. R1–R6 and the T-oracle passed for
every task. `company_spend_settled` reported `pending_runs=0` (summary
85.30823 = cost events 85.30823), with zero serialization failures under
about 397k pressure updates. On master the same seed failed every task
on `company_spend_settled pending_runs=1` (`w-20261110T100747-s20386`,
among others).

## Risks

- A restored turn without a complete report now settles at its observed
usage, flagged as a lower bound, instead of after 10 minutes as "closure
timed out". In both cases the final report never arrived. The money
recorded is the same observed usage; only the timing and the provenance
label differ.
- Scope: local Runner only. The executor fallback-recorder path sees
`session.resumed`. The producer-owned delivery path for remote runners
(`native-usage-delivery.ts`, `isNativeUsageAccountingEvent`) still drops
it, so a remote ACPX/OpenCode turn restored mid-turn still waits for the
closure timeout. That is a follow-up.
- No schema or persisted-format change.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with shell, file-edit and code-execution tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:46:29 -06:00
DottaandClaude 8b3348c6ba fix(runner): classify Claude's rejected selected model as an unavailable model (#20513)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runner agents on the Claude (ACPX) provider run with the
model the user saves on the agent
> - When the provider cannot run that model, Paperclip should classify
the run as `native_provider_model_rejected`, ask the user to choose a
model, and let Retry finish the task
> - Claude Code accepts an unknown model at session start and reports it
only on the first turn, as an AIR `request` failure with a fixed title.
The runner reported it as an unknown provider failure
> - So the run failed as `native_provider_terminal_failed`, and the task
stopped with `provider_effect_inventory_unavailable` ("Automatic
recovery stopped. Recorded work is preserved…") without naming the model
> - This pull request classifies that Claude title as an unavailable
model, the category the native runtime already turns into
`native_provider_model_rejected`
> - The benefit is that an unknown Claude model asks for a model choice
and names it, as Codex does after #20488

## Linked Issues or Issue Description

**What happened?**
Config wave `w-20261110T094430-s207-cf` on `master` (82abb3e),
`--adapters claude`: CF3 `model_id` failed both times (t01 K1, t04 K2)
as `CF3/model_id:actionable_message`. The task showed `Stopped`, cause
`provider_effect_inventory_unavailable`, "Automatic recovery stopped.
Recorded work is preserved; actions with unverified outcomes will not be
repeated." The run was `native_provider_terminal_failed`, "ACP agent
reported a terminal request failure."

Root cause, from a direct ACP probe of the bundled
`@agentclientprotocol/claude-agent-acp` 0.73.0 with the AIR
`sessionFailure` capability and the model in
`_meta.claudeCode.options.model` (as ACPX passes it): `session/new`
lists `chaos-missing-model-abc12345` among the model options, so
admission's model check passes. `session/prompt` then returns
`stopReason: "end_turn"` with `_meta.jetbrains.air.sessionFailure` =
`{"category":"request","severity":"error","title":"There's an issue with
the selected model (chaos-missing-model-abc12345). It may not exist or
you may not have access to it. Run --model to pick a different
model.","actions":[]}`. ACPX turns that into "ACP agent reported a
terminal request failure.", and `acpxProviderFailure` classified only
AIR `limit` titles, so the provider failure stayed `unknown`.

(Selecting the same model later through `session/set_config_option`
instead returns JSON-RPC `-32603` "Internal error", `data.details`
"Invalid value for config option model: <id>". The runner does not take
that path for Claude, so this PR does not change it.)

**Expected behavior**
The run is `native_provider_model_rejected`, the task shows
`choose_model` with the rejected model, no recovery incident opens, and
Retry finishes the task after the model is repaired.

**Steps to reproduce**
`node cli/node_modules/tsx/dist/cli.mjs
tests/restart-chaos/config-wave.ts --faults CF3 --adapters claude
--references model_id --tasks 2 --concurrency 2 --restarts K1,K2 --seed
207` on `master`.

**Paperclip version or commit**
`master` at 82abb3e.

## What Changed

- `packages/paperclip-runner/src/drivers/acpx/provider-failure.ts`: for
the `claude` agent, an AIR `request` session failure whose title is
Claude Code's fixed "There's an issue with the selected model (…). It
may not exist or you may not have access to it." is classified
`model_unavailable` (`requires_action`). The native runtime already
marks such a failed turn `native_provider_model_rejected`, and the
server already projects that as `choose_model` with `rejectedModel`.
- Other AIR categories, other agents and other request titles stay
`unknown`.
- `server/src/services/native-runtime/native-session-executor.ts`: a
`native_provider_model_rejected` failure after the provider session
opened now gets a choose-model next action ("The provider rejected the
selected model. Choose a supported model for this agent, then retry the
task. Automatic retries are stopped."). Before, only a rejection before
the session opened got that action; the Claude case fell through to
"Inspect the original provider failure and explicitly resolve the
ambiguous session state; do not open a replacement provider session."
Recovery mode, blocking and retry behavior are unchanged; only the
action text changes.

## Verification

- Red before, green after: `src/drivers/provider-failure.test.ts` —
"classifies Claude's rejection of the selected model as an unavailable
model", with the exact title captured from `claude-agent-acp` 0.73.0,
through both `acpxTerminalResult` and the thrown form, plus negative
cases.
- Red before, green after: `native-startup-retry.integration.test.ts` —
"asks for a model choice when the provider rejects the model on an
opened session's turn (Claude ACPX)": a durable `turn.started` and the
`NativeProviderTerminalFailure` the runtime throws; the coordinator and
the board recovery action name the model choice, not ambiguous state.
- Live: on `master` the wave failed (see above). On this branch:
`w-20261110T103809-s208-cf` on head 3881650 (CF3 `model_id`, claude, K1
and K2): **2/2 pass**. Each task rests as `waiting_for_user` /
`choose_model` with `rejectedModel: "chaos-missing-model-…"`, cause
`native_provider_model_rejected`, next action "The provider rejected the
selected model. Choose a supported model for this agent, then retry the
task. Automatic retries are stopped.", rests again after the restart,
and is done after the model is restored and Retry. An earlier wave on
e552b24 (`w-20261110T100526-s207-cf`, 2/2 pass) still showed the
ambiguous-state text that the second commit replaces.

## Risks

- Low risk. The match is limited to the Claude agent, the AIR `request`
category and Claude Code's fixed title. If Claude Code changes the text,
the run falls back to today's generic failure.
- No profile, digest or pack contract change
(`rules/acpx-profile-identity.mdc`): ordinary Runner source.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, test runs, a direct ACP probe of
`claude-agent-acp`).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 04:41:02 -06:00
DottaandClaude 1402e92d1f fix(native): take back an executor lease that lapsed only because the host slept (#20474)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run holds an execution lease on its
`native_run_finalizations` row. The lease lasts 20 minutes, and the
owner renews it every 5 minutes.
> - When a laptop sleeps for longer than the lease, the wall clock and
the database clock move past `lease_expires_at`. The server's monotonic
timers barely move.
> - After the wake, the next renewal finds its own lease expired. It
interrupts a healthy turn with `native_session_lease_lost`, and the user
must click Retry. Nobody else took the lease.
> - Several background consumers act on an expired lease without
changing its owner. So expiry alone cannot simply be ignored for the
owner.
> - This pull request makes takeover explicit. The same owner can take
back its own lapsed lease only when no other actor has recorded anything
on the row or the run.
> - The benefit is that a run interrupted only by host sleep continues
with no Retry. A real takeover still fences the late owner out.

## Linked Issues or Issue Description

**What happened?**
The restart-chaos time fault TM2 stops the server and runner tree and
ages every database time by one hour, as a stand-in for a laptop that
slept. After the thaw, the next 5-minute renewal of the native execution
lease found the lease expired. It interrupted the turn
(`turn.interrupted` at 300 s, `native_session_lease_lost`). The failure
settlement was then refused with the same code, and the UI showed
"Couldn't finish this attempt" with Retry. The owner, attempt, boot and
pid were unchanged. Evidence: wave `w-20261110T051535-s304` t01 (TM2, 1
h) failed R3. t02 in the same wave (TM1, a 113 s server-only freeze with
no clock jump) passed, so the failure follows the clock jump and not the
pause.

**Is this a harness artifact?**
No. TM2 ages the stored rows, and a real sleep moves the clock forward.
Both produce the same comparison: a stored `lease_expires_at` is earlier
than `clock_timestamp()` after the wake. The server's renewal timer and
local deadline use the monotonic clock. On macOS and Linux that clock
does not count suspended time, so they do not notice the sleep in either
case. The first database check after the wake is the renewal predicate
`lease_expires_at > clock_timestamp()`, and it fails in both cases.

**Expected behavior**
A run whose lease lapsed only because time jumped, and that no other
actor claimed, keeps running and finishes without Retry.

**Steps to reproduce**
`node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts
--env local --adapters codex --workloads B5 --faults TM2
--time-age-hours 1 --concurrency 1 --tasks 2 --seed 304` with the
harness from #20458.

Refs #20458, #20500

## What Changed

- `native-execution-lease.ts`: add `nativeExecutionLeaseHeldCondition`
and `lockNativeExecutionLeaseForReacquire`. A lease is held when it is
live. For a local run, it is also held when it lapsed and is untouched.
"Untouched" means all of these are true:
  - the claimed generation matches;
  - the phase is `observed` and no result is accepted;
- `recovery_state`, `recovery_request_id`, `failure_code` and
`next_attempt_at` are null;
  - `controller_host_hash` is not the board's attested-stopped stamp;
- the run is still `running`, without the ownership-unverified error
code and without a `nativeStartupCleanup` marker.
- The caller's WHERE already matches the owner token, attempt and
controller boot/pid/start.
- The lock helper locks the coordinator row. If the lease lapsed, it
also takes a share lock on the run row. The predicate then reads the
latest committed run, and an in-flight run write cannot slip past it.
- Renewal (`startNativeSessionExecutionLeaseRenewal` /
`renewNativeSessionExecutionLease`) takes back a lapsed lease when the
executor passes its claimed generation. Remote targets never do. A
deadlock (40P01) on that rare path retries at the next tick, as a
statement timeout already does.
- `native-session-executor.ts`: the executor passes its generation to
renewal. Failure settlement and the final release use the same lock and
condition. After a result is accepted, a lapsed lease can only be
relinquished (set to null), never renewed, because workspace
finalization may act on it.
- New `native-execution-lease-reacquire.test.ts` (embedded Postgres, 24
cases).
- `doc/plans/2026-11-09-explicit-native-lease-claim-tokens.md`: step 2,
which adds explicit claim tokens for the consumers this PR does not yet
move.

Consumer inventory (readers of
`native_run_finalizations.lease_expires_at` that can act on a lapsed
lease):
- Restart recovery and takeover (`claimNativeRestartRecoveries`): claims
first. It writes `recovery_state`, `lease_owner` or `controller_*` under
the coordinator lock.
- Finalizer and cleanup claims: claim first (`lease_owner`).
- Failed-run owner release, environment settlement, observed-workspace
preservation, retained-session cleanup: require a terminal run status,
`committed`, or a recovery state. The rule refuses all of these.
- Finalization reconciler, live recovery: require `retryable_failure`, a
field the rule checks.
- Board controller-stopped confirmation: writes `controller_host_hash`,
which the rule checks.
- Startup cleanup promotion: writes the run's `nativeStartupCleanup`
marker, which the rule checks. It also applies only to runner-task
leases.
- Another server's orphan sweep: writes the run's ownership-unverified
code. The rule checks it when the marker lands first. The reverse order
is deferred (see Risks).
- Workspace finalization copyback: acts on a lapsed post-result lease
without a coordinator write. Post-result leases are therefore never
renewed (deferred to step 2).
- Accepted-result and token lease settlement, checkpoints, cancellation
delivery, admission and the other readers: act on live leases only, or
are read-only.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-execution-lease-reacquire.test.ts`
passes (24/24) on this branch. With the source files from
`origin/master`, 5 fail, including "lets the same owner take back a
lease that lapsed untouched" and "renews from the shared timer after the
sleep instead of interrupting the turn".
- (a) The owner takes back its lapsed lease when no other actor claimed
it. Also covered: the shared 5-minute timer, rows saved by the previous
release, and the post-result relinquish.
- (b) 14 cases where a consumer claimed first fence the owner out and
leave the row unchanged. A release over a recorded recovery claim is
refused. A different boot (a deploy mid-run), a different generation, a
missing generation and a remote target are refused.
- (c) Races have exactly one winner: a claimant and the owner lock the
coordinator in each order, an in-flight run status write races the
owner, and 8 simultaneous starts each have one winner.
- Existing suites pass: `native-execution-lease.test.ts`,
`native-session-executor.test.ts`, `native-runner-restart-recovery`,
`native-runner-restart-lease-renewal`,
`native-runner-restart-preparation` and
`native-runner-restart-checkpoint-cost` integration tests.
- Live proof: `B5 × TM2 --time-age-hours 1 --tasks 2 --seed 304` with
`--server-from` this branch. Wave `w-20261110T083444-s304` passed 2/2,
with no Retry and no `native_session_lease_lost`.
- Both runs ran past the 300 s renewal after the one-hour aging, at the
exact point where `origin/master` fails. t01 ran 310 s and t02 ran 319
s. Each was a single run that succeeded.
- The harness used the aging exclusion from #20500 (merged): process
start identity is not aged. Without that exclusion, TM2 also moves
`controller_process_started_at`. The live owner then fails its identity
check, which a real sleep never causes. An earlier wave on the unpatched
harness, `w-20261110T072106-s304`, failed 0/2 for that reason only.

## Risks

- Previous release: no persisted shape changes and no migration. Rows
saved by the previous release have the same columns, and a null
`controller_host_hash` is accepted. A deploy that lands mid-run cannot
take back the old server's lease, because the boot and pid differ.
- On the normal path, a live lease takes the same single coordinator
lock as before. The run share lock is taken only after a lapse, and
nothing is added before `turn.start`.
- Deferred to step 2 (see the plan):
- The orphan sweep of another server process can read the row before the
owner takes the lease back and write after. That run would carry an
ownership-unverified error while it is live.
- Shared-workspace holder detection reads a silent run with lapsed
leases as gone.
  - Post-result leases are not renewed.
  - Remote leases are unchanged.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used in Claude Code
with tool use (shell, file editing, sub-agent code audit).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 02:47:53 -06:00
Nicky LeachandClaude Opus 5.5 232480d18f perf(runner): write preparation span rows to the run log in the background (#20426)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Before a native run starts its provider, the executor copies the
preparation spans (queue, prepare phases, environment) into the run
trace and the run log
> - It records about 25 spans and waits for one run-log insert per span,
one after another
> - On a hosted test deployment, that step
(`native.controller.span_persistence`, added in #20396) took a median of
0.28 s, and every task waited for it before the Runner started
> - The run trace already has a way to write diagnostics without
blocking the caller: `defer`, which `finish` drains before the root span
closes
> - This pull request writes those run-log rows through that path, in
order, while the spans themselves are still recorded at once
> - The benefit is about 0.25 s less before the provider starts, on
every native run, cold or warm

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The time between the end of heartbeat preparation and
`native.session.execute` for native Runner tasks.

**Subsystem affected**
Server: native runtime tracing
(`server/src/services/native-runtime/native-run-trace.ts`) and the
native session executor.

**Current behavior**
`executeNativeSession` calls `trace.record` for each preparation span
and awaits it. `trace.end` awaits `emitCompletedSpanEvent`, which
inserts one `run.performance.span` row through `onEvent`. On a hosted
test deployment, measured with the #20396 spans across 10 runs,
`native.controller.span_persistence` took a median of 0.28 s:
- 0.30 to 0.36 s for runs right after a computer wake
- 0.17 to 0.25 s for runs on an awake computer

It is the second largest step between preparation and the provider start
(the median of that whole gap is 1.0 s).

**Proposed behavior**
The spans are still started and ended at once, with the same parents.
The speed-budget bookkeeping still runs at once too. Only the run-log
row is written later: one row at a time, in the order the spans ended,
before the trace finishes.

**Reason and benefit**
The rows are diagnostics, and they already carry their own start time
(`wallStartedAtMs`, `startOffsetMs`). Readers do not depend on the row
order: the only reader in the repository, `summarizeStartupReuse`, looks
for `runner.process.launch`, which this change does not defer. The
provider can start without waiting for these inserts.

**Breaking changes**
None.

## What Changed

- `native-run-trace.ts`: `end` and `record` take an optional
`deferRunLog`. With it, `end` queues the run-log row on a per-trace
chain registered with `defer`, so rows are written one at a time in
order and `finish` waits for them. A row requested after the trace
finished is dropped, as for other deferred diagnostics.
- `native-session-executor.ts`: the preparation spans and the
`environment.startup` scope use `deferRunLog: true`.
- `doc/run-log-events.md`: note that native preparation span rows are
written late, in order.
- `native-run-trace.test.ts`: two tests.
- Spans are recorded under their parent while every row write is
blocked, and the rows are written in order before
`task.provider_session.measured`.
  - A deferred row requested after `finish` is not written.

## Verification

- `npx vitest run src/services/native-runtime/native-run-trace.test.ts
src/services/native-runtime/native-run-trace-context.test.ts` (34
passed). Both new tests fail without the source change: the first times
out waiting on the blocked write, and the second sees the row written.
- `npx vitest run src/__tests__/heartbeat-preparation-gap-spans.test.ts
src/services/native-runtime/native-session-executor.test.ts` (1321
passed)
- `pnpm --filter @paperclipai/server typecheck`

## Risks

- Low risk. The change is diagnostic only: OpenTelemetry spans and the
speed-budget checks are unchanged.
- The preparation rows can now land in the run log after the first
Runner events. Readers already use the payload times, not the row order.
- If the server process dies between the provider start and `finish`,
the preparation rows of that run can be missing from the run log. The
OpenTelemetry spans are not affected.
- The deferred rows are written while the run continues, as
budget-breach rows already are, so the sink sees concurrent writes from
one run. This was already the case for deferred diagnostics.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, test runs, Honeycomb queries) and
extended thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:50:15 -08:00
Nicky LeachandClaude Opus 5.5 d95fdd8d79 feat(observability): span the unnamed gaps in run preparation (#20396)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Before a task reaches the agent, the server prepares the run: it
reads the task, the workspace and the configuration, and then hands the
run to the native controller
> - With an OTLP endpoint, each preparation step emits a
`heartbeat.prepare.<phase>` or `native.controller.<phase>` span
> - On a hosted test deployment, 0.3 to 0.5 s of each `task.prepare`
sits between those spans, and about 0.5 s more sits between
`execution.dispatch_admission` and `native.coordinator.claim`
> - Nobody can say which step is slow in that time, so latency work in
this area is guesswork
> - This pull request adds spans around the existing awaited work in
those gaps, with the existing timing helpers and naming conventions
> - The benefit is that a trace of a slow preparation now names the step
that used the time

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The OpenTelemetry trace of task preparation. Today it has unnamed gaps
between the named preparation spans.

**Subsystem affected**
Server: heartbeat run preparation and the native controller
(`server/src/services/heartbeat.ts`,
`server/src/services/native-runtime/native-session-executor.ts`).

**Current behavior**
On a hosted test deployment (8 runs per group), `task.prepare` takes 2.6
to 3.4 s. Between `heartbeat.prepare.runtime_state` and
`heartbeat.prepare.run_identity`, about 0.25 s has no span. Between
`execution.dispatch_admission` and `native.coordinator.claim`, about 0.5
s has no span, and in one pair of runs it was 1.4 s. Nobody can tell
which step used that time.

**Proposed behavior**
Spans name the existing awaited reads and writes in those gaps. The
change adds no reads and records no content. The operator endpoint gate
and the no-operation behaviour stay.

**Reason and benefit**
Latency work in this area needs a measurement. With these spans, a trace
of a slow preparation names the step that used the time, so a fix can be
confirmed or rejected after deployment.

**Breaking changes**
None. The change adds span names to the closed list. It changes no API,
schema or behaviour.

**Additional context**
The server uses postgres.js, which the auto-instrumentation does not
cover, so database spans do not fill these gaps.

## What Changed

- `server/src/services/execution-preparation-timing.ts`: add the new
closed phase names.
- `server/src/services/heartbeat.ts`: add `heartbeat.prepare.*` spans
for `chat_retry_admission`, `workspace_selection`,
`dependency_readiness`, `issue_checkout`, `instance_settings`,
`workspace_context`, `project_context`, `plan_wake_routing`,
`routine_environment`, `responsible_user` and `execution_start`. The
re-reads of the issue after a checkout use the existing `issue_context`
name.
- `server/src/services/native-runtime/native-session-executor.ts`: add
`native.controller.*` spans for `attachment_staging`, `warm_restore`,
`span_persistence` (persistence of the preparation spans to the run log)
and `state_migration` before the coordinator claim.
- `doc/observability.md`: list the new spans.
- `server/src/__tests__/heartbeat-preparation-gap-spans.test.ts`: run a
real heartbeat through native preparation with an in-memory span
exporter. Check that each new span is emitted and that the new
preparation spans are children of `task.execution_attempt`.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `npx vitest run src/__tests__/heartbeat-preparation-gap-spans.test.ts`
(fails without the source change, passes with it)
- `npx vitest run
src/services/heartbeat/preparation-timing-context.test.ts
src/services/execution-preparation-timing.test.ts
src/services/native-runtime/native-session-executor.test.ts
src/__tests__/heartbeat-context-compaction-execution.test.ts`

## Risks

- Low risk. The change only wraps existing awaited calls. Values and
errors pass through the timing helper unchanged, and a checkout conflict
still reaches the same `catch`.
- Phases of 50 ms or more inside `heartbeat.prepare.*` are also written
to the run log, as for the existing phases. This adds a few run-log rows
on slow runs.
- Without an OTLP endpoint, the helpers keep their current no-operation
behaviour.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, test runs) and extended thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:27:58 -08:00
DottaandPaperclip 0e325e8d52 fix(runner): settle saved handoffs after verified shutdown (#20336)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - An agent can save a question or approval handoff before its provider
stops.
> - A missing provider terminal notification does not prove that the
saved work failed.
> - Runner already verifies provider exit, event drain, tool settlement
and suspension.
> - This change uses that proof to settle the same saved handoff.
> - Unproven shutdown holds continuation and explains that the work is
saved.
> - Recovery must never repeat the completed work or allow overlapping
execution.

## Linked Issues or Issue Description

Refs #20330. That PR extends the Codex terminal drain window. This
change handles the separate case where no terminal arrives but the owned
shutdown can prove stopped execution.

**What happened?**

A native run saved a human handoff, accepted cancellation, and later
stopped its provider. The missing terminal notification still made the
attempt fail and show “Couldn’t finish this attempt.”

**Expected behavior**

Keep the saved handoff. Accept its yielded result after either an
authoritative provider terminal or verified shutdown of the exact
execution. If shutdown remains uncertain, retain the handoff and explain
the hold without offering a duplicate Retry.

**Steps to reproduce**

Save a governed wait, acknowledge interruption, and omit the provider
terminal. The new regression uses the real Runner binary and a
controlled Codex process. The old runtime rejects the handoff instead of
asking the existing shutdown owner to settle it.

**Deployment mode**

Native Runner with Codex. Local verification used macOS arm64 and Node
26.4.0. Hosted sandbox recovery has not been qualified.

## What Changed

- Add an optional driver shutdown-settlement operation. Runner returns
proof only after its existing stop, drain, tool settlement and
suspension barrier passes.
- Bind the receipt to the run, normalized session and turn. Persist it
with the saved wait and validate it during recovery.
- Complete the same yielded handoff after verified shutdown. Retire the
stopped session from warm reuse. Do not invent a provider terminal or
complete usage.
- Retain a named shutdown-pending condition under the existing bounded
same-run recovery policy. Preserve actual provider failures and
integrity failures.
- Show saved-work shutdown guidance in task and inbox presentation. Keep
genuine approvals authoritative and disable duplicate Retry.
- Document the completion and restart contract. The checkpoint field is
additive; older checkpoints retain the terminal-or-shutdown path.

## Verification

- Red: the new runtime regression failed against the original
implementation because verified shutdown was never requested. Server
presentation and error-classification regressions also failed before the
fix.
- Red: both inbox shutdown states showed generic text before the inbox
change.
- Green: Runner runtime, shutdown and harness-backend suites pass 301
tests. Tests cover missing terminals, failed stop proof, wrong identity,
cancellation, late proof, genuine provider failure, and restart after
receipt persistence. Recovery completes once without another model turn.
- Green: the native executor and task-presentation suites pass 1,371
tests. Execution projection, inbox and blocker presentation pass 125
tests. Two test-authoring mistakes were corrected: an integrity-error
text expectation and an assertion for a nonexistent projection field.
Their initial failures remain in local logs.
- Runner TypeScript and full server layered type checks pass. Token
gates and diff whitespace checks pass.
- Live provider acceptance: real Runner and Codex with gpt-6.1-sol, one
disposable local workspace, one tool call, and one deliberately dropped
terminal notification. The saved file, bound settlement receipt, one
accepted yielded result, one provider turn, confirmed provider exit and
suspended Runner all passed. The tool handler and persistence port were
controlled. This does not claim a full server or hosted journey.
- The full browser question-and-answer cell stopped before execution
because OPENAI_API_KEY was absent. No model work ran in that attempt.
Hosted sandbox recovery and the full browser continuation journey remain
unverified.
- `pnpm verify` returned exit 2 because the execution-spec change
requires broad CI. Required CI will supply that coverage; a full local
repository suite was not repeated.

- Review correction in `8553da244e`: a failed trailing-event write could
hide a provider failure. A new regression reproduced red, then passed
after all ordinary drain errors retained the named pending condition. No
settlement receipt or result can be accepted after that error. The live
missing-terminal check passed again on the corrected runtime.
- The real PostgreSQL control-plane regression and three existing
governed-wait controls pass. The new case persists one accepted yielded
result, a bound shutdown receipt, and the original pending human
question without a provider terminal.
- First CI revision `7c2cb9b1`: test-program typecheck found a missing
`PrpEvent` annotation in the new fixture. The annotation is fixed and
the Runner test-program typecheck passes. Runner support also timed out
in the unchanged `registered roots remain protected even when equal to
genuine public static bytes` case after 30 seconds. One unchanged local
diagnostic passed in 3.69 seconds overall. The hosted timeout cause
remains unknown; no timeout, fixture, or assertion was weakened.
[Original failed support
job](https://github.com/paperclipai/paperclip/actions/runs/42670851821/job/129346900133).

- Rebased onto the merged #20330 and current recovery changes. The
combined branch passes 1,402 executor/presentation tests. Both prompt
and 18-second provider terminals still pass. A warm session with no
terminal passes after the full 30-second drain and verified
stop/suspension. Its initial overly broad assertion rejected a
nonterminal turn event; the corrected assertion rejects every terminal
type. The rebased CI gate was cancelled before any runner or step
started; its cause is unconfirmed and it supplies no validation credit.
Final revision `b49ec5a144` starts fresh CI and review.

- Final-head CI attempt 1 failed before creating jobs. GitHub reports an
internal server error (correlation
`2c576b07-b67f-4179-a69f-74f7d99cd926`), followed by empty
runner-selection outputs for the aggregate jobs. One unchanged-commit
retry was requested; the initial admission failure remains recorded. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/42673159868).

- **Current blocker:** final head
`b49ec5a144e94e41e96e2508a79bd9aec91fe45a` has a fresh 5/5 Greptile
review and zero unresolved threads, but required CI is not complete.
GitHub reports an active [Actions and Checks API
incident](https://www.githubstatus.com/incidents/tp8cghg7tlc4). Attempt
2 ran only part of the full selected graph. Build, canary, Rust, sandbox
providers, admitted browser shards and other completed test jobs passed.
The E2E aggregate correctly failed because `policy` and `e2e_shards` had
`abandoned` results. Server shard 5/12 received a runner shutdown signal
at 18:36:55 UTC and its test step was cancelled; its job still reports
in progress. The shutdown cause is not established. The separate review
workflow had the same pre-execution internal-error class and passed one
bounded retry. No further retry was submitted during the incident.
Resume with the missing/cancelled coverage after GitHub recovers; do not
count the absent typecheck, Runner or support jobs as passes. [Failed
coverage
gate](https://github.com/paperclipai/paperclip/actions/runs/42673159868/job/129354318236),
[interrupted server
shard](https://github.com/paperclipai/paperclip/actions/runs/42673159868/job/129350854174).

## Risks

- A missing terminal can use one additional bounded shutdown interval.
Unknown shutdown still blocks continuation.
- Provider terminal and usage accounting keep their separate meaning.
The receipt proves stopped execution and settled tool outcomes, not
model success.
- Recovery rejects a foreign receipt, a later turn, an active turn with
a settled receipt, and an actual failed provider terminal.
- There is no database migration. Older checkpoint shapes remain
readable. Drivers without this optional proof retain a named pending
state.
- This PR does not deploy the fix or repair an existing hosted task.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools and local
code execution. The exact serving model ID and context window are not
exposed in this session. Live test inference used gpt-6.1-sol.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] 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>
2026-11-09 13:46:16 -06:00
Dotta a3e7e63330 Guard Native recovery with process-space proof and explicit containment (#19813)
## Thinking Path

> - Paperclip manages agents and their work.
> - Recovery must preserve execution ownership and accepted results.
> - A saved PID proves nothing outside its recorded process namespace.
> - Uncertain unfinished work must stay held until its original owners
stop.
> - Operators need a source-bound containment action and a separate
ordinary Retry.
> - Accepted results must use the existing finalizer without another
provider turn.
> - This PR keeps those paths safe, recoverable, and quiet between real
obligations.

## Linked Issues or Issue Description

Refs: #19713

Refs: #19591

Recovery could use a local PID to release or replace a controller from
another process namespace. Connected-chat Retry also rejected successful
containment on a task in `todo`. The new containment action used a
broader permission than the existing owner-stop action. A later review
found that result-less guards also rejected the existing accepted-result
confirmation action.

## What Changed

- Record and verify the controller's kernel/PID namespace. Keep unknown
and foreign unfinished ownership held. Preserve independent durable stop
evidence and the existing remote lease fences.
- Use the shared accepted-result ownership reconciler. Bind settlement
to the observed lease token, generation, boot, PID, namespace and
process birth. Retain finalizer, cleanup, current-controller, live-owner
and remote exclusions.
- Share the remote-owner reader across recovery consumers. Scope runner
leases to the same company, task and run. Do not make local PID probes
for remote accepted-result ownership.
- Gate original-host containment on `runtime:confirm_owner_stop`. Bind
it to the current task, source, assignee, original expiry, lease and
Stop state. Audit separate controller/provider containment and reviewed
action outcomes.
- Keep containment separate from ordinary Retry. Use one canonical
committed receipt and the normal admission, budget, workspace and
lineage checks. Preserve the recorded external-action outcomes.
- Keep the controller-only confirmation actionable for both result-less
work and accepted results. Result-less work gets its next source-bound
attention in the same transaction. Accepted results keep their original
result and use normal shared settlement/finalization.
- Keep identity inspection quiet. Preserve the original foreign-owner
expiry and use the existing scoped finalizer in accepted-result recovery
controls.
- Update the recovery card, validators, execution projection, docs and
access maps. Reuse existing canonical columns; add no migration or
ownership backfill.

## Verification

Current publication head: `71ea76b568de09d6313d8992e803131d9d5c54f3`.

**The current fix has a causal red/green check.** On the test-only
revision, both foreign-local stopped/fenced controller cases failed
because recovery probed saved runner, process-group and provider PIDs in
this host's namespace. This measured the wrong probes; it did not run
duplicate providers. After the fix, the same two cases passed every
no-probe, no-claim, no-controller-stamp, no-stop-receipt and
unchanged-authority assertion. Three existing remote preparation, grace
and fenced-controller progress cases also passed. Total: **5 passed, 0
failed;170 filtered rows excluded**. All three normal affected App
TypeScript programs passed on this head (660,681 and428 roots).

The fix separates a controller wait from execution stop proof.
Independent live/grace evidence still schedules recovery. A
stopped/fenced foreign controller can pass this namespace guard only for
remote execution, which keeps its later checkpoint and never-launched
checks. Foreign local execution stays held before runner or provider PID
inspection.

Earlier source-scoped results remain evidence, not fresh executions on
the current head:

- On `c86dd741`, the complete failed-owner file passed **25/25**, with
no skips or filters. Empty scans wrote no controller stamps. Foreign
ownership kept its original expiry. Normal returned-claim settlement and
attention at the exact deadline passed.
- On `c86dd741`, **15 selected accepted-result/restart controls
passed**. The81 filtered rows are excluded. Both completion cases used
the existing scoped finalizer and kept their accepted result without
another provider turn.
- Trusted CI at synthetic merge `50d072a5` (actual master `1b7fe334`
plus `c86dd741`) passed the complete **17-case maintenance-server
file**. This includes the original90-real-second quiet observation and
two qualified hourly windows. The quiet case took118,918ms. The four
jobs that failed on the earlier publication all passed on that synthetic
merge.
- On `6243b32c`, the complete ownership-report file passed **29/29**.
Native NULL namespace stayed unprovable and generic compatibility
remained. Three selected recovery controls passed;170 filtered rows are
excluded.
- On `f81f2c3c`,356 business cases passed: whole ownership-route221,
recoveryUI70, owner-proof38 and controller-confirmation8; selected
accepted-result authority18; and one real connected-chat containment,
separate authorized Retry, useful Native result and downstream reply
case. The2,024 filtered rows are excluded. A separate earlier
accepted-result causal case kept the original result and completed after
restart without replay.

Normal Shared exports, App programs, UI typecheck, token gates and map
checks passed on their recorded revisions. Unchanged DB, adapter, Eval
and Runner inputs/outputs have explicit carry proofs. The clean normal
merge with fetched master `8fe524f7` preserves all31 exclusive owned
paths and14,827 unrelated master entries. The nine overlapping paths
were reviewed, including the upstream unidentified-run stop action.
Fresh CI must validate that actual merge composition.

PG cases used existing external PG17, fresh owned databases, pools at
most4, one worker and no SQL observer during cases. Registered clients
and recorded process groups closed. Independent final SQL audits found
zero campaign sessions before normal database cleanup. Original failing
fixtures remain retained.

Original failures are preserved. One new test first failed compilation
due to a missing getter import; an import-only correction passed its
full owning programs before the causal red/green check. A local quiet
attempt stopped in the normal prerequisite hook before any business case
and its collector rejected two normal build markers. The later17-case CI
pass establishes quiet recovery; it does not turn that failed local
collector into a pass. An earlier useful case passed before its private
cleanup collector failed on an owned read-only directory; normal cleanup
completed after all owners closed. Earlier runner-shutdown cancellation
and a separate private whole-suite failure remain unexplained.

Current-head checks are complete:57 success,1 neutral and3 skipped,
including the successful aggregate CI gate. The legacy status passed.
Greptile is5/5 and Ponytail gives an explicit5/5 Ship verdict for this
head. All seven review threads are resolved. Full recursive typecheck,
the full test suite and release build were not repeated locally. No
result here proves deployed suspension, a live provider or termination
on another host.

## Risks

- Unknown physical ownership can require an owner/admin action. A false
containment statement can be unsafe.
- New work, reassignment, Stop, renewed authority, accepted results or
pending cleanup can invalidate an action. The operator must refresh.
- Controller confirmation does not prove provider/tool containment.
Completed external actions must not be replayed.
- Generic legacy and child-process compatibility remains separate from
the strict Native controller policy.
- Existing saved state and canonical schema are preserved. Permanent
safety refusals remain held with their normal named recovery path.

## Model Used

OpenAI `gpt-6.1-sol` agents implemented and tested the changes with
reasoning, repository tools and code execution. A GPT-6-based Codex
managing agent reviewed and integrated them. The host does not expose
the manager's exact build or the context-window sizes.

## 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 and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-11-09 12:06:11 -06:00
DottaandClaude Opus 5.5 8fe524f796 fix(issues): let the board confirm an unidentified run stopped instead of an endless Retry wait (#20310)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Before a new attempt can start on a task, Paperclip must prove the
previous run's agent stopped, so two agents never work the same task at
once
> - A local native run can be interrupted after it asked to start the
runner but before it recorded the runner's process. The runner may have
saved state and started a provider, and provider processes can outlive
it, so the existing check (#19694) rightly refuses to assume the run
stopped
> - Today that case has no way out: every Retry is accepted and then
waits forever as `process_identity_missing` (storage chaos ST1, t02/t03)
> - This pull request makes the wait explicit and actionable. The task
explains why it waits, and a board operator who has checked can confirm
the run stopped; the parked Retry then starts at the click
> - A second shape from the Daytona storage baseline (t06) parked a
Retry with no reason at all: a follow-up was interrupted by a graceful
shutdown while it was still restoring its workspace. No provider ran,
but the restore hold kept the same agent held, so the Retry never
started
> - The benefit is that no Retry stays accepted-and-waiting with nothing
anyone can do

## Linked Issues or Issue Description

Refs #19831. Storage chaos wave `w-20261109T145628-s9902` (ST1), tasks
t02 and t03.

**What happened (CHA-3, run `3c3f6241`):**
- The run requested a runner launch (`native.process_start_requested`),
and a K2 restart hit before its process identity was recorded.
- After the restart, the resumed attempt failed on the storage outage.
The task was blocked.
- The board's Retry (202) was parked as `executionWait.reason =
process_identity_missing`: "The previous run has no verified stop
record."
- `recordUnidentifiedLocalRunnerStop` cannot prove the stop because this
run's runner saved control-plane state. Once that exists, a provider may
have started (#19694), and a provider is not guaranteed to carry
`--run-id` or `PAPERCLIP_RUN_ID` (the Codex provider env omits it on
purpose). The shared owner prover needs a PID, which the run never
recorded.
- Nothing could ever clear the wait.

**Daytona storage baseline `w-20261109T154810-s9911`, task t06:**
- The run succeeded. The follow-up was cancelled
(`continuation_task_ownership_changed`), and the next run was
interrupted by a graceful shutdown (`server_shutdown_interrupted`) 3 s
into `preparing`. It was restoring the saved remote workspace and had
launched no provider.
- Restore recovery saved the files (`workspaceRecovery.status:
recovered`, system recovery `complete`). But the
`legacy_execution_requires_reconciliation` restore hold releases only
for a different owner, or for a native Stop fenced before any provider
turn, so it kept the same agent held.
- The UI Retry returned 202 and parked with `executionWait.reason:
execution_recovery`, the generic wait, so the task showed a Retry that
never started.

Automatic proof needs the runner to record its identity before saving
state and to group provider processes under it. That is follow-up runner
work; this PR removes the endless wait now.

## What Changed

- `server/src/services/native-local-process-stop.ts`: new
`confirmUnidentifiedLocalRunnerStopped`. It accepts only a finished
native run of the task with no recorded process and whose latest launch
evidence is an unconfirmed launch on this host in a local environment.
It still refuses while a runner with `--run-id <run>` is running. It
records `native.local_process_stopped` with `basis: "operator_attested"`
and the attesting user. That is the receipt the existing continuation
gate already accepts.
- `server/src/routes/issues.ts`: `POST
/issues/:id/recovery-actions/confirm-run-stopped` (`runId`,
`stopEvidence` of 20 to 12,000 characters). It is board-only and
requires issue read plus `runtime:confirm_owner_stop`, like the other
stop attestations. It logs `issue.run_stop_confirmed` with the evidence,
promotes deferred intents for the issue, and resumes each parked task
Retry, so work starts at the click. Refusals return 409 with a code.
- `packages/shared`: `confirmRunStoppedSchema`, and the presentation
user action `confirm_run_stopped`.
- `server/src/services/task-interruption-presentation.ts`: a Retry
parked as `process_identity_missing` for a native run that recorded no
process now presents as waiting for the user (`canRetry: false`,
`userAction: "confirm_run_stopped"`), with a message explaining why and
who can act. A run with a recorded process keeps the existing
verification wait.
- UI:
- `ExecutionBlockerNotice` shows the explanation and, for users who can
confirm a stop, "Confirm it stopped" with a short evidence form.
- It reuses `PreviousServerStopConfirmation`, now with optional
`action`, explicit `runId` and `formId`, and a custom button label.
  - The Inbox names the action.

- `server/src/services/execution-blocker.ts`:
`interruptedPreparationBeforeProviderWorkCondition`. The restore hold
also releases for the same agent when the stopped run was a
system-interrupted preparation (legacy runtime never resolved, still
`preparing`) with no process identity, no runner or provider events, no
finalization or result, and every lease released with a confirmed stop.
It mirrors the existing interrupted-preparation proof and still requires
the completed workspace-recovery receipt. Auto-replay is unchanged; this
only lets the user's Retry or message start.
- Review fixes:
- `unidentifiedLocalLaunchRefusal` holds the endpoint's launch, host and
lease checks, and the presentation offers `confirm_run_stopped` only
when it passes.
  - The attested stop wakes cost accounting in the same transaction.
  - The confirmation form is keyed by task and run.
  - The route is registered in OpenAPI.

## Verification

Red/green (each new test fails against master and passes with this PR):
- `server/src/__tests__/issue-confirm-run-stopped-routes.test.ts`:
- The parked Retry presents `confirm_run_stopped` with the explanation.
- Confirming records the operator-attested stop and the activity entry,
satisfies the stop check, and resumes the parked Retry: its receipt is
coalesced into a newly started run.
  - It refuses with 409 while a runner for the run is running.
  - Agents and viewers get 403.
-
`ui/src/components/ExecutionBlockerNotice.confirm-run-stopped.test.tsx`:
the notice offers "Confirm it stopped" (and no dead Retry) and posts the
run and evidence. A viewer sees the explanation without the action.

- `server/src/__tests__/interrupted-preparation-restore-retry.test.ts`
(t06 shape):
  - The restore hold no longer blocks.
- A Retry is accepted and resumed into a started run, and the audit hold
keeps `replay: blocked`.
- The hold stays when a provider turn started, when a runner event was
recorded, when an operator stopped the run, or when the sandbox has no
stop receipt.
- `issue-confirm-run-stopped-routes.test.ts`:
- A case per refusal code checks the presentation keeps the generic wait
and the endpoint returns 409.
  - An `alreadyStopped` case.
  - An accounting-wake assertion.
- The UI test switches targets with stale evidence.

Regression and static checks:
- `ExecutionBlockerNotice`, `WorkspaceExportRecovery` and its control
tests: 74 passed.
- `tsc --noEmit` passes for `packages/shared`, `server` and `ui/src`.
`pnpm check:token-gates` is clean.

## Risks

- A board operator can now attest that an unidentified run stopped. The
attestation is permission-gated, requires written evidence, is
activity-logged, and is refused while a runner for the run is visibly
running. A wrong attestation could start a second agent beside an
orphaned provider, the same trust model as the existing previous-server
attestations.
- The confirmation component now accepts a run without a recovery
action. Existing callers pass the action as before.
- No schema migration.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:36:41 -06:00
DottaandClaude Opus 5.5 6459488984 fix(native): open a remote replacement as a new runner after archiving the reattached one (#20242)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a restart, a remote (Daytona) run can resume by reattaching to
its surviving sandbox runner. If provider recovery fails and the
checkpoint allows it, the run opens a replacement provider session
instead.
> - Before a replacement, the executor archives the local controller
state and the remote session root as a continuity break, then opens a
new session.
> - On a reattach backend, the replacement still adopted the archived
runner and asked for its old authority, so it failed. The archive had
already moved the remote state, so the next retry could not verify the
runner and the issue waited 17 minutes on an unconfirmed stop.
> - Review showed that a reattach backend fixes many launch inputs once
at creation (runner binary, provider pack and companions, artifacts,
live-recovery mode, authority, spawn barrier), so resetting them after
the archive does not scale.
> - This pull request builds the replacement on a new backend for the
same run, created under a `bootstrap_incomplete` claim after the
archive, so every launch input resolves through the ordinary fresh path.
> - The benefit is that a failed reattach after a restart falls back to
a working replacement instead of a stuck issue.

## Linked Issues or Issue Description

**What happened?**
In restart-chaos Daytona K13 storm wave seed 9503, task t36 graded R1
(no rest within budget). A K2 drain suspended run a53dd948's sandbox
runner (pid 589) during startup. On restart the run claimed
`reattach_remote_runner`. The adopted runner authenticated and restored,
then its connection closed, and provider recovery failed. The runtime
opened a replacement: `provider.session.archive_before_replacement`
archived the local and remote state, then the replacement failed with
"PRP provider resume state is unavailable". The same-run retry at
10:25:50 then failed with `runner_remote_state_unavailable: exit=1` (the
remote `runner-state.json` had moved), and file preservation deferred on
`agent_stop_unconfirmed` until the wave ended.

**Expected behavior**
After the continuity archive, the replacement opens a new runner for
this run, as it does for every other claim kind.

**Steps to reproduce**
1. Run a remote (Daytona) native run and restart the server so the run
claims `reattach_remote_runner`.
2. Make provider recovery fail on the adopted runner with a checkpoint
that allows replacement.
3. See the replacement open on the adopted runner's authority and fail
after the archive.

**Paperclip version or commit**
master at 465da4227a

## What Changed

- `native-session-executor.ts` `openReplacementSession`, on a
`reattach_remote_runner` backend: archives the continuity state (local
and remote, as before), then calls
`createRunnerdBackendWithinSessionClaim` for the same run with the claim
re-kinded to `bootstrap_incomplete` (minus `remote` and
`stagedAttachment`) and without warm recovery, and opens the replacement
on that backend. Root migration then quarantines the empty archived root
(the #20241 rule) and every launch input resolves fresh.
- A backend that adopts only through warm recovery (no claim to re-kind)
throws `native_runner_replacement_requires_fresh_launch` **before**
archiving, so its saved state stays intact.
- Tests: a classic Daytona reattach backend for `codex` and `acpx`
(Claude). The replacement comes from a second backend: the original
backend's `openSession` is never called, and the new transport has no
adopted runner, uses `turn-<runId>`/`item-<runId>`, has a new
still-pending spawn barrier, and (for `acpx`) carries the packaged
provider paths. The bundled-image replacement test now expects a new
backend and its own pack admission, not the recovered session's cold
admission.

## Verification

- Red before the fix: both new replacement tests fail on master. They
pass with the fix.
- `native-session-executor.test.ts`,
`native-recovered-owner-followup.test.ts`,
`native-harness-filesystem-independence-continuation.integration.test.ts`:
1,398 passed.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` in `server`: passes.

## Risks

- Medium. The replacement now constructs a second backend inside the
same session claim. The new backend's tool-authority epoch supersedes
the reattach backend's, which is being abandoned anyway.
- A warm-recovery-only replacement now fails with a named error instead
of archiving and then failing. Its saved state stays intact for the next
recovery.
- Previous-release story: no saved state or schema changes.
- Not covered: why provider recovery on the suspended, adopted runner
failed in t36 in the first place. This PR makes the fallback work.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:12:24 -06:00
DottaandClaude Opus 5.5 68253e3175 fix(recovery): continue a run refused by an inert cleanup quarantine with no click (#20260)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a native session's cleanup fails unrecoverably, the runtime
quarantines it in process. The next run for that task is refused with
`native_session_cleanup_quarantined`.
> - A board Retry or a new message already recovers from this.
`verifyStoppedNativeSessionForContinuation` proves that the old runner
and providers stopped, releases the quarantine and starts a fresh
session.
> - Without a click nothing recovers. The automatic safe-replacement
sweep proves stopped sessions only with the Codex replacement verifier,
which needs the failed run's own active turn. A run refused before
launch never has one, so the sweep skips it on every pass.
> - Restarts must recover on their own. This pull request lets the sweep
use the same continuation proof for a cleanup-quarantined run.
> - The benefit is that an inert quarantine turns into a fresh-session
continuation with no click, and an unproven one still keeps its board
Retry.

## Linked Issues or Issue Description

**What happened?**
In restart-chaos wave seed 9802 (local, G1, Claude), task t02's run
71f03e16 succeeded, but its runner cleanup could not prove it drained
and was quarantined. Follow-up run 0b4b404f was refused before launch
with `native_session_cleanup_quarantined`, and the task showed
"Automatic recovery stopped" until someone pressed Retry.

**Expected behavior**
When the quarantined session's owner run and every provider process are
proven stopped, recovery continues the task by itself in a fresh
session, without charging an attempt. When that cannot be proven, the
board Retry stays available.

**Steps to reproduce**
1. Have a native run succeed while its runner cleanup is quarantined.
2. Let the next run for the task be refused with
`native_session_cleanup_quarantined` before it launches.
3. Stop the old runner and provider processes, then run the
safe-replacement sweep. No successor is created.

**Paperclip version or commit**
master at 8eb11fa8aa

## What Changed

- `native-session-executor.ts`: new
`verifyStoppedNativeSessionForAutomaticReplacement`. It returns the
existing replacement proof, or, for a
`native_session_cleanup_quarantined` run with no replacement proof, the
continuation proof that the board Retry already uses (Codex and ACPX,
including an inherited owner run).
- `server/src/index.ts`: the safe-replacement sweep uses that verifier.
- The existing sweep then retires the quarantine, records
`native.stopped_text_turn_verified` and queues a successor with
`forceFreshSession: true` at the failed run's attempt number, so no
attempt is charged.
- `quarantined-task-retry.test.ts`: "recovers a quarantined $provider
source that never launched with no click…" for Codex and ACPX (Claude).
A stopped owner makes the sweep queue one fresh-session successor at the
same attempt. A running owner (Codex) or a live ACPX agent process
queues nothing and leaves the board Retry in place.

## Verification

- Red before the fix: with the replacement-only proof, the stopped case
queues no successor. It passes with the fallback, and the running-owner
case passes on both.
- `quarantined-task-retry` (35), `native-safe-replacement`,
`native-replacement-evidence`: pass.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` in `server`: passes.

## Risks

- Low to medium. The fallback applies only to
`native_session_cleanup_quarantined` runs. It uses the stricter
continuation proof that already gates the board Retry: owner run
settled, local leases released, runner and every recorded provider pid
absent, and an unchanged retained state. The sweep keeps every other
gate (task ownership, failure-block receipt, attempt limit, effect
inventory).
- Previous-release story: no schema or saved-state change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:12:07 -06:00
DottaandClaude Opus 5.5 3374ca54f5 fix(runner): record a runner exit mid-turn as runner loss, and stop the lineage sampler panicking (#20259)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The native runner (`runnerd`) runs each provider turn, and the
server records why a run stopped and what the person can do next
> - When `runnerd` exits during a provider turn, the server saved the
run as `native_provider_terminal_failed`: "The provider session is
permanently unusable", with automatic retries stopped
> - The real cause was the runner process, and the transport already
names it (`native_runner_process_exited`) at the start of the
closed-stream message
> - Separately, the process-lineage sampler added in #19727 panicked on
Linux debug builds when a provider tool started its own session, and
that panic text became the run's visible failure
> - This pull request records a runner exit mid-turn as runner loss, and
reads session IDs without the assertion that panicked
> - The benefit is that a person sees the true cause of a stopped run,
and recovery treats a lost runner like every other runner exit

## Linked Issues or Issue Description

Refs #19727

**What happened**

A chat eval killed only the owned `runnerd` process (SIGKILL) while a
provider tool was running. The run failed with `errorCode:
native_provider_terminal_failed` and the next action "The provider
session is permanently unusable". The error text was `Notification
transport failed closed: native_runner_process_exited: runnerd exited
unexpectedly: paperclip-runnerd panic: panicked at
rustix-1.1.4/src/pid.rs:61:9: assertion failed: raw > 0`. The panic came
from the lineage sampler thread before the kill. It called
`rustix::process::getsid`, which asserts a nonzero session. A kernel
thread, or a process whose session leader is outside the PID namespace,
reports session 0.

**Expected behavior**

A runner process exit is recorded as `native_runner_process_exited`, the
same cause the server already uses for a runner exit outside a turn.
Recovery then applies its normal runner-loss rules (checkpoint evidence,
bounded attempts, cleanup quarantine). The lineage sampler does not
panic, and its diagnostics do not replace the real failure text.

**Steps to reproduce**

1. Start a native OpenCode run on Linux with a debug `runnerd`.
2. Let the agent run a shell tool that starts its own session (for
example a long `node -e` command).
3. Send SIGKILL to the `runnerd` PID only.
4. Read `GET /api/heartbeat-runs/<runId>`: `errorCode` is
`native_provider_terminal_failed` and the error names the rustix panic.

## What Changed

- `nativeSessionFailureSourceCode` returns
`native_runner_process_exited` for a `NativeProviderTerminalFailure`
with code `notification_transport_failed` when its message starts with
`Notification transport failed closed: native_runner_process_exited: `.
Only the runner transport writes that prefix. Other closed-stream causes
stay `native_provider_terminal_failed`.
- `paperclip-process-table` has a new `session(pid)` function. Linux
reads the session field of `/proc/<pid>/stat`. macOS calls `getsid`. A
session of 0 or an error gives `None`.
- `process_lineage` reads sessions through
`paperclip_process_table::session` instead of rustix `getsid`.
- Regression tests: the runner-loss classification (attempts 1 and 3,
and three negative shapes), the stat session parser with a kernel-thread
line, and a live read of this process's session.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-session-executor.test.ts -t "runner
process exit that closed|preserves stable provider and runner|model
rejection from a provider terminal|operator-owned through terminal"` (8
passed)
- `cd packages/paperclip-runner/runner && cargo test -p
paperclip-process-table` (macOS: 3 passed; the Linux-only parser tests
run in CI)
- `cd packages/paperclip-runner/runner && cargo test -p
paperclip-runner-core --lib process_lineage` (6 passed)
- End to end: the `recovery.*` chat eval cells that kill `runnerd` are
run against this change on a separate eval branch.

## Risks

- Behavior shift: a runner exit during a turn now follows runner-loss
recovery instead of stopping as a permanent provider failure. Recovery
still decides from durable evidence. Ambiguous state stays terminal, and
an unverified cleanup stays quarantined.
- Previous-release story: no persisted shape changes. Runs saved earlier
with `native_provider_terminal_failed` keep that code. Only new failures
are classified with the new rule.
- The session read on Linux now uses `/proc` instead of a syscall. It
gives the same value, and it reports 0 (now `None`) in the same cases.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with
tool use (shell, file edits).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:38:24 -06:00
DottaandClaude Opus 5.5 3f2a940d5b fix(runner): answer held Paperclip tool calls after a local restart before the next turn (#20227)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a restart interrupts a turn, the Runner keeps each Paperclip
tool call the model made as "held" until Paperclip answers it, and it
refuses to start the next turn while any call is held
> - Paperclip answers held calls after a reattach through its tool
recovery path. That path was wired only for remote runners
> - In restart chaos wave `w-20261109T084023-s9403`, t101 (local codex,
K2 then K1) held a `read_document` call that nothing answered, so the
continuation's `turn.start` failed and the task needed a Retry
> - This pull request wires held-call recovery for local runners too,
and makes `turn.start` answer or wait for held calls first
> - The benefit is that a turn lost to a restart no longer strands the
run behind one unanswered call

## Linked Issues or Issue Description

Refs #19831. Restart chaos wave `w-20261109T084023-s9403` (master
`fc5c79a327`), task t101 (codex C1, K2, local).

**What happened:**
- Run `4b5810d5` called `read_document` (`exec-ac8fa9b4`) around the K2
drain at 09:08:18. A K1 then killed the next server start.
- At 09:08:36 the third controller reattached. The Runner restored the
conversation and reported the old turn lost
(`provider_turn_lost_on_restore`, recoverable). The held call stayed
pending, by design: a held call accepts a late result.
- The controller had the call: the quarantine detail lists
`exec-ac8fa9b4` under `semanticTools.pending`. But
`recoverPendingSemanticInputs()` returned at once: for a local run
`controllerPersistence` is unset, so `recoverSemanticToolInput` was
`undefined`.
- At 09:08:46 the continuation's `turn.start` failed with "cannot
prepare the next provider turn while tool calls are active (pending: 1
[read_document:exec-ac8fa9b4…])". The run ended
`native_session_interrupted`.

## What Changed

- `server/src/services/native-runtime/native-session-executor.ts`: the
runnerd transport now always gets `recoverSemanticToolInput`, not only
when `controllerPersistence` (remote) is set. It uses the same epoch
check and `SessionAuthorityEpoch.recover`. Recovery re-authorizes and
re-runs reads, and replays receipts for mutations; it never repeats an
uncertain mutation.
- `packages/paperclip-runner/src/live/runnerd-codex-transport.ts`
(`#startTurnWork`): when the control plane has unsettled semantic
inputs, it runs `recoverPendingSemanticInputs()` before queueing
`turn.start`, and waits for that pass, bounded by the turn-start
deadline. Recovered results are queued as commands ahead of
`turn.start`, and the Runner applies commands in order. If recovery has
not finished by the deadline, the turn start fails with
`native_turn_start_held_tool_calls_unresolved` instead of sending
`turn.start` behind it. When everything is settled there is no wait, so
a normal turn start is unchanged. Live calls of a turn being replaced
are not waited on; the replaced start still rejects them.
-
`packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts`:
`recoverPendingSemanticInputs()` passes are serialized. A call made
while the startup pass is still answering a held call waits for that
pass, then runs its own, so the turn-start gate cannot finish early by
skipping a call that is already being recovered.

## Verification

Red/green:
- `native-session-executor.test.ts` "answers a local run's held
Paperclip tool calls after a restart (restart chaos t101)". On master
`recoverSemanticToolInput` is `undefined` for a local run; with this PR
it is wired.
- `runnerd-codex-transport.test.ts` "answers a held call before starting
the next turn (restart chaos t101)", against real runnerd and fake
Codex, with a slow startup recovery pass already in flight when
`turn/start` arrives. On master `turn/start` never ran recovery, and
without the serialization `turn.start` is queued before the held call's
result. With this PR the held call is recovered once and its result is
queued before `turn.start`.

Regression and static checks:
- `runnerd-codex-transport.test.ts`,
`durable-prp-control-plane.test.ts`,
`runnerd-codex-transport-adoption.test.ts`: 510 passed.
- `native-session-executor.test.ts` and `native-session-resume.test.ts`:
1368 passed, 1 failed. The failing case, "archives a damaged prior epoch
… with real runnerd", fails identically on master on this Mac (`ENOENT …
authority-epochs`), so CI is its reference.
- `tsc --noEmit` passes for `packages/paperclip-runner` and `server`.

## Risks

- Local runs now recover held calls after a reattach, as remote runs
already did. Reads re-run under current authority. A mutation without a
saved receipt still returns no result and stays held, as before.
- `turn.start` may now wait up to its existing deadline while a recovery
pass runs, then fails clearly if it has not finished. It never waits
when nothing is unsettled, so normal turns get no added latency before
`turn.start`.
- No schema change and no change to persisted run state formats.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:12 -06:00
DottaandClaude Opus 5.5 bd83715a6e fix(recovery): bootstrap a remote follow-up again when a restart cut it off after retiring its finished owner (#20241)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A restart must resume in-progress runs on its own. Restart recovery
classifies each claimed native run and the executor acts on that claim.
> - A C1 follow-up on a remote sandbox first retires the finished owner
of the previous turn (it archives the previous controller state), then
launches a new runner.
> - A restart in that window left the run with an inherited session
checkpoint but no runner for this run. Recovery read the inherited
checkpoint as proof of a live remote runner and claimed reattach. The
executor then found no runner identity and failed with
`durable_identity_unreadable`, so the user had to press Retry.
> - This pull request makes recovery recognize that nothing reached a
runner for this run, and makes the executor bootstrap again over the
empty root.
> - The benefit is that this restart window resumes the follow-up by
itself.

## Linked Issues or Issue Description

**What happened?**
In restart-chaos Daytona K13 storm wave seed 9503, task t03 (codex, C1)
graded R3 retry_needed. Follow-up run 0d8b7258 retired its previous
owner at 10:07:32.988 ("Run 4ee1c19a finished without a saved runner
checkpoint; starting a new runner"). A K1 kill at 10:07:34 cut it off
before any runner start was requested. On restart, recovery claimed
`reattach_remote_runner` and the executor failed with
`runner_state_identity_mismatch: durable_identity_unreadable`. The run
ended with `native_continuation_requires_reconciliation` and a Retry
offer.

**Expected behavior**
Recovery sees that no runner was ever requested for this run, claims
`bootstrap_incomplete`, and the executor starts the new runner the first
attempt was about to start.

**Steps to reproduce**
1. Run a remote (Daytona) C1 follow-up whose previous turn finished.
2. Kill the server after the follow-up retires the finished owner and
before it records `native.process_start_requested`.
3. Restart the server. See the `reattach_remote_runner` claim and the
`durable_identity_unreadable` failure.

**Paperclip version or commit**
master at 3963d38560

## What Changed

- `native-restart-recovery.ts`: the `remote_runner_never_launched` proof
also holds when the run carries only its continued session. That means a
session checkpoint with a matching identity and no projected provider
event, active turn, semantic result, terminal or pending runtime
request, plus the predecessor's `sessionIdBefore`. The proof still
requires no `native.process_start_requested`, no controller checkpoint
for this run, no runner process identity and no `sessionIdAfter`. The
classifier then sees no checkpoint for this run.
- `native-session-executor.ts`: a `bootstrap_incomplete` claim over a
root with no runner identity and no controller state quarantines that
root and bootstraps again for a remote target too. Previously only a
local target with no live runner process could do this. Such a root was
never authenticated by a controller on this host.
- Tests: four classifier cases (restart; a requested runner start, a
pending runtime request and a projected provider event stay held). Two
executor cases (created and replacement sandbox) restart the exact state
the kill left after the retire.

## Verification

- Red before the fix: the classifier test got a non-bootstrap claim. The
executor tests failed with `runner_state_identity_mismatch:
durable_identity_unreadable`, the error from the wave. Both pass after
it.
- `native-runner-restart-recovery.integration.test.ts` and
`native-recovered-owner-followup.test.ts`: 239 passed.
- `native-session-executor.test.ts`: 1,310 passed.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` in `server`: passes.

## Risks

- Low to medium. The proof widens only for a claimed remote run with
none of its own start, checkpoint, runner or provider evidence. A
pending runtime request in the inherited checkpoint still holds the run,
as in #20226.
- The quarantined root keeps the archived previous owner for inspection.
The usual quarantine retention applies.
- Previous-release story: no saved state or schema changes. A run that
an older build left in this state is classified with the new rule at its
next claim.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:17:15 -06:00
DottaandClaude Opus 5.5 fe72bc5f81 fix(native): roll back a warm rotation cut off before its new control plane once its runner is proven dead (#20218)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A warm follow-up rotates authority before it attaches. It archives
the previous epoch (suspended runner state and settled controller) under
`authority-epochs/`, then writes its own control plane
> - #20146 taught recovery to roll back a rotation whose new control
plane held only an undelivered `run.attach`, once its runner was proven
dead
> - A hard kill can land one step earlier, after the archive and before
the new control plane exists. The root then holds only the archive, with
no active control plane and no runner state. The executor read "no
durable identity" and failed with `runner_state_identity_mismatch:
durable_identity_unreadable`
> - This pull request applies the same proven rollback to that earlier
point of the rotation
> - The benefit is that a hard kill anywhere in the rotation window
resumes the follow-up from the previous turn

## Linked Issues or Issue Description

Refs #20146

**What happened?**
Local mixed restart-chaos wave w-20261109T081456-s9901 (master
09e24dd12c), t15 (codex M1):
- Run `4b85497f` started at 08:29:32 as a warm follow-up and requested
its process, then K1 killed the server.
- Recovery stopped the previous run's warm runner (pid 60249) and
claimed `resume_dead_runner`.
- The executor failed with `runner_state_identity_mismatch:
durable_identity_unreadable`.

The quarantined root held:
- `authority-epochs/epoch-c2842347…` with the previous owner `dc7d447b`:
runner `suspended`, every command completed;
- no `control-plane/` state and no `runner/runner-state.json`, only
provider state.

A Retry ran fresh. Phase 3 also saw the same signature on Daytona;
#20214 covers that remote shape.

**Expected behavior**
For a local runner proven dead (`resume_dead_runner`), a root that holds
only the archive of a suspended previous owner of this session restores
that epoch, and the follow-up continues from the previous turn. Without
a dead-runner claim it still fails closed.

**Steps to reproduce**
1. Start a warm follow-up and hard-kill the server after the rotation
archives the previous epoch, before it writes the new control plane.
2. Restart. Before this change, the follow-up fails with
`durable_identity_unreadable`.

**Paperclip version or commit**
09e24dd12c (wave). Branch rebased on current master.

## What Changed

- `native-session-executor.ts`:
- `undeliveredAttachRotationSource` becomes `interruptedRotationSource`,
and the new `latestSuspendedArchivedEpoch` shares the epoch checks:
named by its identity digest, a matching suspended runner state.
- Besides #20146's undelivered-attach shape, it accepts a root with no
`runner-state.json` and no active control plane (absent or empty). In
that case the epoch must belong to this session (`normalizedSessionId`)
and to a different run.
- The rollback moves an active control plane aside only when one exists.
- `native-recovered-owner-followup.test.ts`: #20146's rotation test adds
the "cut off before its new control plane" shape. With a dead-runner
claim it is admitted from the restored previous owner; without the claim
it still fails closed.

## Verification

- Red/green: on master, the new admitted case fails with exactly
`durable_identity_unreadable`. With the change it is admitted. The
fail-closed cases are unchanged.
- `vitest run` of the recovered-owner, restart-recovery and bootstrap
test files and `native-session-executor.test.ts`: 8 files, 1627 tests
passed (14 skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Low risk:
- The rollback still requires a `resume_dead_runner` claim on a local
runner, an absent runner state, and an archived epoch of this session
whose runner is suspended and whose name matches its identity.
- Restoring only moves files back within the root, and nothing is
deleted.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:20:23 -06:00
DottaandClaude Opus 5.5 ad57d5cc12 fix(native): restart an unclaimed remote follow-up that carries only its inherited checkpoint (#20214)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A remote per-turn follow-up inherits the previous run's session
checkpoint at admission, rebound to its own identity. Its session root
still holds the previous owner's remote marker until the follow-up
rotates authority
> - If a restart lands before the follow-up's controller ever claims
execution, recovery should restart its bootstrap: nothing of this run
reached any runner
> - Recovery's unclaimed-bootstrap check required no checkpoint and no
provider evidence. The inherited checkpoint failed both, so recovery
claimed `reattach_remote_runner`
> - Even under a bootstrap claim, the executor skipped the per-turn
projection restore for every restart claim. It therefore never
rehydrated the previous owner's external marker, and failed with
`runner_state_identity_mismatch: durable_identity_unreadable`
> - This pull request does not count the inherited checkpoint as
evidence for an unclaimed remote run, and lets a bootstrap claim restore
the previous owner's projection, as a fresh follow-up start does
> - The benefit is that a follow-up drained in its first second on
Daytona restarts on its own

## Linked Issues or Issue Description

Refs #20153

**What happened?**
Daytona per-turn restart-chaos K13 storm w-20261109T074128-s9503
(revision 9046884811), t26 (codex C1): follow-up run `f1ddc43d` started
at 07:54:18 and was drained by SIGTERM at 07:54:19, during workspace
staging.
- It had not requested a runner process, and its coordinator was never
claimed (provider attempt 0).
- Recovery claimed `reattach_remote_runner` (`hasCheckpoint: true`,
`hasProviderEvidence: true`, both from the inherited checkpoint).
- The executor failed with `runner_state_identity_mismatch:
durable_identity_unreadable`, because the session root held the previous
run's external marker. A Retry succeeded.

**Expected behavior**
An unclaimed remote run whose only checkpoint is the admission-rebound
one, with no durable controller record and no provider events, restarts
its bootstrap (`remote_execution_never_claimed`). The executor
rehydrates the previous owner's projection and starts the follow-up from
it.

**Steps to reproduce**
1. Start a remote per-turn follow-up whose session root holds the
previous owner's external marker. Drain the control plane before the
follow-up's controller claims execution.
2. Restart. Before this change, recovery claims `reattach_remote_runner`
and the executor fails with `durable_identity_unreadable`.

**Paperclip version or commit**
9046884811 (wave). Branch rebased on current master.

## What Changed

- `native-restart-recovery.ts`:
- #20153's admission-rebound checkpoint test moves earlier. A new
`inheritedCheckpointOnly` holds for a remote sandbox run (not a plugin
task) with only that checkpoint, no durable controller record, no staged
attachment and no provider events.
- The unclaimed-bootstrap path and its classification then treat that
checkpoint as neither a checkpoint nor provider evidence.
- `native-inherited-runner-retry.ts` / `native-restart-recovery.ts`: a
warm continuation carries its settled predecessor's runner process
identity until it launches its own runner, and t26's row still held pid
596 at the restart.
- The new `carriesInheritedRunnerIdentity` reuses the same-run retry
path's proof (the exact pid, group and start time a settled predecessor
recorded).
- The restart classifier then ignores that inherited identity on the
unclaimed path, for a run that carries only its inherited checkpoint.
  - A process identity that is the run's own still holds the run.
- `native-session-executor.ts`: `restorePerTurnRunnerdController` now
also runs for a `bootstrap_incomplete` claim, but only when the root's
reference identity belongs to a previous run. A claim on the run's own
root returns early, as before.
- Tests:
- `native-runner-restart-recovery.integration.test.ts`: an unclaimed
remote follow-up with only its inherited checkpoint restarts, with no
process identity and with its predecessor's runner identity. It stays
held with its own runner identity, or when a provider event was
projected;
- `native-recovered-owner-followup.test.ts`: a bootstrap claim over the
previous owner's external marker is admitted, rehydrating the marker,
keeping the backup and quarantining nothing.

## Verification

- Red/green: both new tests fail on master. The classifier test claims
`reattach_remote_runner`, and the executor test fails with exactly
`durable_identity_unreadable`. Both pass with the change, and the held
case holds.
- `vitest run` over the restart-recovery, bootstrap, recovered-owner and
failed-workspace-recovery test files and
`native-session-executor.test.ts`: 9 files, 1715 tests passed (14
skipped). After the inherited-identity change, the restart-recovery,
bootstrap, recovered-owner, failed-workspace-recovery and
inherited-runner test files: 8 files, 407 tests passed (14 skipped).
Without the identity check, the inherited case is not restarted.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Low risk:
- The inherited checkpoint stops counting only when the database shows
no controller record and no provider event for the run, and the existing
unclaimed-bootstrap conditions all still hold.
- The executor change runs the existing fresh-follow-up projection
restore, with all of its own checks, only for a root that still belongs
to the previous run.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:10:23 -06:00
DottaandClaude Opus 5.5 b1049b174e fix(native): retire a finished remote owner whose suspend never finished once its sandbox leases ended (#20211)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Before a per-turn follow-up starts a new runner, it retires the
previous turn's finished owner. Retirement requires the owner's
controller journal to be settled
> - A local runner that is proven stopped can no longer act on its own
lifecycle commands, so an unfinished `runner.suspend` from it does not
hold the follow-up
> - On a remote per-turn sandbox the same thing happens when a restart
or drain lands while the runner is suspending. But the remote path had
no equivalent rule, so the unfinished `runner.suspend` held the
follow-up even after the previous run's sandbox lease had ended
> - This pull request treats a remote owner's lifecycle commands as
settled once every sandbox lease of that run has been released or
expired, and none of them is the sandbox the follow-up now uses
> - The benefit is that a C1 follow-up after a restart on Daytona
continues on its own

## Linked Issues or Issue Description

Refs #20140

**What happened?**
Daytona per-turn restart-chaos K13 storm w-20261109T074128-s9503
(revision 9046884811, image 01246d43): 60 of 65 passed. Three C1
follow-ups failed with `runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate;
retirement_refused=journal_commands_unsettled(runner.suspend.pending)`:
- t04 codex, run `c7446d0c`;
- t16 claude, run `4b2c770f`;
- t39 claude, run `3ef7d934`.

For t04, the previous owner `fc585314` committed its `run.terminal`, and
its journal kept only `runner.suspend` pending. Its per-turn lease
(`5576f27d…`) expired at 07:43:52, and the follow-up ran on a new
sandbox. Each Retry succeeded.

**Expected behavior**
Once the previous run's sandbox leases have all ended, and the follow-up
runs on another sandbox, the previous runner cannot act on its suspend.
The finished owner is retired and the follow-up continues.

The owner stays held while any of these holds:
- a lease is active, retained or pending cleanup;
- the follow-up runs on the same sandbox;
- no lease is recorded.

**Steps to reproduce**
1. Finish a remote per-turn run whose controller journal keeps
`runner.suspend` pending after its committed run terminal, and let its
sandbox lease end.
2. Start a follow-up on a new sandbox. Before this change, retirement
refuses with `journal_commands_unsettled(runner.suspend.pending)`.

**Paperclip version or commit**
9046884811 (wave). Branch rebased on current master.

## What Changed

- `native-session-executor.ts`:
- the new `priorRemoteSandboxLeasesEnded` checks the previous run's
`environment_leases`: at least one lease, every one `released` or
`expired` with `releasedAt`, and none on the current target's provider
lease;
- `inspectFinishedPerTurnRunnerdOwner` passes the result as
`remoteRunnerGone` to `readSettledRunnerdProjection`, for the live root
and for quarantined copies. That function then ignores unfinished runner
lifecycle commands (`runner.suspend`, `runner.drain`,
`runner.shutdown`), exactly as it already does for a stopped local
runner.
- `native-recovered-owner-followup.test.ts`: a red/green test with lease
variants. A lease that ended or was released on another sandbox is
admitted. A retained lease, an ended lease on the same sandbox, or no
lease record stays held.

## Verification

- Red/green: on master, the two admitted cases fail with exactly the
production error; with the change they are admitted. The three held
cases hold on both.
- `vitest run` of the recovered-owner, remote-warm and restart-recovery
test files and `native-session-executor.test.ts`: 7 files, 1634 tests
passed (14 skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Low risk. Only runner lifecycle commands become settled, and only when
the database proves every sandbox of the previous run has ended and the
follow-up runs elsewhere. Unfinished turn, tool or result commands, a
missing run terminal and unanswered tool input still hold.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:40:13 -06:00
DottaandClaude Opus 5.5 6fd46c0025 perf(budgets): judge Paperclip's share of restart recovery; report the provider's session load (#20183)
## Thinking Path

- Speed budgets protect the time to first visible agent activity, and
restart recovery is one of the judged metrics
(`native-speed-budgets.ts`, wave grader).
- In the B1 concurrency-6 chaos wave (`w-20261109T045320-s9807`), Codex
`restart_recovery` was p50 8.6 s and p95 11.2 s, against a budget of 6.5
s and 8.0 s. At concurrency 4 (K13) it was 3.8 s and 5.0 s.
- The preparation spans on a copy of the kept database break it down:
- Paperclip's share (ready → claim → prepare → runtime stage → runner
launch) is a steady 1.2–2.0 s.
- `provider.session.load`, the Codex app-server boot plus
`thread/resume` of long saved sessions, takes 3.0–6.3 s, 65–75% of each
recovery.
- Resumes run concurrently, and the worktree lock and the startup
reconciler are not on the path.
- Judging the full latency therefore mixes our code with provider and
session-size effects. Budget the part we control, so a regression in our
code shows up regardless of session size or concurrency, and report the
rest.

## Linked Issues or Issue Description

**What happened?**
`restart_recovery` breached its budget whenever many long provider
sessions resumed at once, although Paperclip's own recovery work stayed
within about 2 s. The breach did not point at anything Paperclip could
fix, and a real regression in our code could hide inside the
provider-load variance.

**Steps to reproduce**
1. Run a local chaos wave at concurrency 6 with long-running Codex tasks
and K1/K2/K4 faults (for example B1, seed 9807).
2. Compare `restart_recovery` in `speed.json` with
`provider.session.load` in the recovered runs' run logs.

**Expected behavior**
The budget judges Paperclip's share of a recovery. The provider's
session load and the full latency are reported but not judged.

## What Changed

- `server/src/services/native-runtime/native-speed-budgets.ts`:
- New budgeted metric `restart_recovery_control_plane`: from `ready` to
the start of `provider.session.load`, or to the next provider event when
nothing reloads (for example a reattached runner).
- Its budget is 2.5 s / 4.0 s for claude, codex and opencode. Measured:
1.2–2.0 s per run at concurrency 6.
  - The full `restart_recovery` stays in the table for reporting.
  - Breach payloads carry `restartTotalMs` and `providerLoadMs`.
- `server/src/services/native-runtime/native-run-trace.ts`:
- The per-run check records when `provider.session.load` starts, keeps
the recovery measurement, and judges it when the run finishes.
- It judges after the deferred diagnostics drain, so a provider span
committed after the provider event still counts.
  - It emits `restart_recovery_control_plane` breaches only.
- `tests/restart-chaos/ttft-baseline-stats.ts`: each recovery sample
gains `controlPlaneMs` and `providerLoadMs`. The provider-load start is
the span event's write time minus its duration.
- `tests/restart-chaos/wave-speed.ts`:
- Three restart rows per adapter: `restart_recovery` (report-only),
`restart_recovery_control_plane` (budgeted) and `restart_provider_load`
(report-only).
  - All three have outage time removed.
- `tests/restart-chaos/ttft-baseline.ts`: the recovery table adds a
"Paperclip share" column.
- Docs: `doc/run-log-events.md` (metric and payload) and the
speed-budget table in
`doc/plans/2026-11-07-durable-ownership-and-reconciliation.md`.

## Verification

- `server`: `npx vitest run
src/services/native-runtime/native-speed-budgets.test.ts`, 18 passed.
Two new tests:
  - a recovery dominated by provider load does not breach;
- a slow Paperclip share breaches with `restartTotalMs` and
`providerLoadMs`, even when the provider span is committed after the
provider event.

The existing recovery tests moved to the new metric. All six were red
before the trace change.
- `tests/restart-chaos`: `npx vitest run --config
tests/restart-chaos/vitest.config.ts wave-speed.test.ts
ttft-baseline-stats.test.ts`, 29 passed. New tests cover the sample
split and the grader rows (a judged share, a report-only provider load,
zero breaches).
- Server typecheck: no errors in the touched files.

## Risks

- **Lower sensitivity to provider-side slowdowns.** A provider
regression in session load no longer breaches. It stays visible in the
report-only rows and in breach payloads.
- **Runs with no `provider.session.load` span** (reattach, or an adapter
that doesn't emit it) are judged on the full latency against the
control-plane budget. That's conservative.
- **Run-log consumers that filter on `metric: "restart_recovery"`** see
`restart_recovery_control_plane` from now on. The doc notes the change
date.

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool
use and code execution, via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 01:22:28 -06:00
Nicky LeachandClaude Opus 5.5 9046884811 perf(native-runtime): claim a parked Runner while the follow-up run prepares (#20195)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip Runner executes native agent runs. A host can park an idle
Runner (release it and freeze it) between runs, and resume it for the
next run on the same task.
> - A follow-up run that reuses a parked Runner must claim it first. The
claim is a bootstrap ticket, a checkpoint flush, and the plugin resume
that thaws the Runner.
> - Core made the claim only inside `attachRun`, after all of its
per-run preparation. The claim does not depend on that preparation, so
the two ran in series.
> - This pull request starts the claim while Core still prepares the
run. `attachRun` then waits for the claim that is already in progress.
> - The benefit is a shorter start for every follow-up run on a parked
Runner. The model below gives about 285 ms per run at P50.

## Linked Issues or Issue Description

No public issue exists. The issue is described inline.

**What happened?**

A follow-up run that reuses a parked Runner claims the Runner only in
`attachRun`. Core calls `attachRun` after backend setup: runner
environment, assigned MCP tools, tool authority and the plugin task
owner. The claim waits for all of this, but it uses none of it.

**Expected behavior**

The claim runs at the same time as backend setup. Everything that needs
the new run's tool authority stays at attach.

**Steps to reproduce**

1. Run a Codex or Claude task on a host that parks Runners between runs.
2. Let the run finish and the Runner park.
3. Send a follow-up. Before this change, the plugin resume starts only
after backend setup ends.

**Paperclip version or commit**

`master` at 7086019d4b.

**Deployment mode**

Hosted, with parked Runner tasks.

## What Changed

- **Transport** (`runnerd-codex-transport.ts`):
- New `beginRunAttachment({ runId })`. It runs the attach admission
checks, then starts the existing `#claimReleasedRunner` as the held
attachment operation. It returns `false` and does nothing when the
Runner is not released and confirmed, a claim already exists, or
admission fails.
- The admission checks move into `#assertRunAttachmentAdmission`.
`beginRunAttachment` runs them first, and `attachRun` runs them again.
- The next `attachRun` for the same run waits for the early claim. An
`attachRun` for a different run, `release` and `turn/start` still fail
with `native_runner_run_attachment_in_progress` while the claim is held.
- After an early claim, `attachRun` fails with
`native_runner_resume_reconciliation_expired` before it registers if the
claim deadline has passed.
- **Pass-throughs:** `HarnessNativeSession`, `CodexHarnessSession` and
the session, harness and transport contracts get an optional
`beginRunAttachment`. Each layer does the same identity,
protocol-integrity and busy checks that its attach does.
- **Core** (`native-session-executor.ts`):
- The executor records when the in-memory warm entry it reuses was
parked (`released`).
- `createRunnerdBackend` gets `beginRetainedRunAttachment`. Core gives
it only for that parked in-memory warm entry.
- The plugin task owner preparation moves into `preparePluginTaskOwner`.
On the early path, Core prepares and rebinds the retained owner at the
same time as the runner environment and the assigned MCP tools, then
starts the claim.
- Cold, adopted, remote warm recovery and restart paths keep the old
order.

### What stays at attach, and why

- Registration, the quiescence probe, `run.attach` and the authority
rotation stay in `attachRun`. Checkpoint registration needs the new
run's tool authority epoch, and Core creates that epoch during backend
setup.
- The admission checks run again at attach, so a state change during
preparation still blocks the attachment.

## Verification

- `vitest run src/live/runnerd-codex-transport-release-resume.test.ts`
(runner): 21 passed. Five new modes:
- `early claim`: `attachRun` waits for the held claim and does not route
the new run before the claim ends;
- `early claim abandoned`: a close after the claim ends fails the
waiting `attachRun` with `closed`, and the new run is never routed;
- `early claim cancellation`: a close during the claim cancels it, and
the next `attachRun` fails with `closed`;
- `early claim expired`: the deadline passes during the claim, and
attach fails before it registers;
- `early claim stale ticket`: the deadline passes after the claim,
before attach.
- `vitest run plugin-runner-release-resume.integration.test.ts
plugin-runner-eviction.integration.test.ts` (server, with real Runner
processes from `cargo build --workspace --bins`): 2 files, 51 passed,
none skipped. New modes:
- `cached held claim` (Codex and Claude): the plugin resume is held, and
the test proves Core's tool preparation runs while the claim is in
progress;
- `cached cancellation` (Codex): a cancellation after an early claim
retires the Runner and the run stops cleanly.
- `pnpm run typecheck:typescript` (runner) and `pnpm typecheck`
(server): pass.
- Evidence (modeled, not measured live): traces of 8 resumed runs on
parked Runners in a hosted test deployment, on current `master`. The
claim takes about 300 ms, and it fits inside backend setup in 7 of 8
runs. The modeled saving is about 285 ms per follow-up on a parked
Runner at P50 (range 208–331 ms). I did not run this change on a hosted
deployment.
- Not run: the full server suite (CI runs it), smoke tests, and the
hosted park, claim and attach loop with this change.

## Risks

- **New failure mode:** Core now claims the parked Runner before backend
setup ends. If that run then fails preparation, or is cancelled before
attach, the existing retire path closes the claimed session. The Runner
is retired, so that agent's next follow-up starts cold. Before this
change, a preparation failure happened before the claim, while the
Runner was still parked.
- **Ordering:** the retained plugin task owner is rebound to the new run
earlier, before Core creates tool authority. Nothing in the owner
preparation reads that authority. A failure in tool authority setup now
happens after the rebind, and the retire path handles it.
- **Scope:** only an in-memory warm entry that was parked takes the
early path. Cold launches, adopted Runners, remote warm recovery and
restart recovery are unchanged.
- **Deadline:** the claim deadline now starts earlier. A very slow
backend setup can make the claim expire before attach. Attach then fails
with `native_runner_resume_reconciliation_expired` before it registers,
the same failure as a slow claim today.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, git, GitHub CLI) and subagents.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:56:08 -08:00
DottaandClaude Opus 5.5 3964ee1fca fix(native): a governance pause Stop with no turn result stays a cancellation (#20186)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A budget hard stop pauses the company; the pause's lifecycle Stop
cancels the agents' running native turns
> - When a Codex turn is interrupted this way, it ends without a
semantic result
> - The native executor treated that missing result as a retryable
interruption, so it rewrote the cancelled run to `failed` and scheduled
an exact resume
> - The pause held that recovery, and Raise budget and resume continues
only runs that are still cancelled, so the task stayed blocked with no
new run
> - This pull request makes the executor treat an acknowledged
governance pause Stop like a person's Stop: the run stays cancelled
> - The benefit is that Raise budget and resume continues the task
(#20144), with no manual Retry

## Linked Issues or Issue Description

Refs #20144 (resume continues budget-stopped work), #20143 (governance
pause stops are not failures), #20113

**What happened?**
Restart-chaos wave w-20261109T051242-s9802 (H1, seed 9802), task t03
(Codex, fault K2). The company budget crossed its hard limit at
05:19:39.9. The pause's lifecycle Stop ("Agent lifecycle stop
requested") cancelled run 59e976bc at 05:19:40.05, and Codex
acknowledged the interrupt. The turn ended without a semantic result,
and at 05:19:41.27 the executor rewrote the run to `failed` /
`native_session_interrupted`, with an agent-owned `active_run_watchdog`
recovery (exact checkpoint resume). The task went to `blocked`. After
`raise_budget_and_resume` at 05:20:20, no run started for 14 minutes.

**Expected behavior**
A pause's Stop is intentional. The run stays cancelled with its pause
record, and resuming continues the task once.

**Steps to reproduce**
Pause a company for budget while a Codex native turn runs, so the Stop
interrupts the turn before it records a result. Before this change, the
run becomes `failed` with a scheduled exact resume instead of staying
`cancelled`.

## What Changed

- `native-session-executor.ts`: the no-semantic-result branch now also
accepts an acknowledged governance pause Stop (`isGovernancePauseStop`
with an acknowledged native Stop receipt) as an intentional stop. It
settles the coordinator as `native_retry_cancelled` and raises
`NativeCancellationPendingRecoveryError`, as it already does for a
person's Stop and for a reassignment. The run is not rewritten to
`failed`, and no recovery action is created.
- The Stop's cancellation record is written by its fence before
dispatch, so the check does not depend on whether the terminal write has
landed.

## Verification

- `native-session-executor.test.ts`: new cases for "Agent lifecycle stop
requested" with a `pauseStop`, and for "Cancelled due to budget pause".
Each was red before the fix (it reached the retryable-failure write) and
is green now. A control case shows that a system lifecycle Stop without
`pauseStop` (termination) still takes the retry path.
- Full `native-session-executor.test.ts`: 1309 passed. Server typecheck
(`typecheck-layers`, low-memory): no errors.
- Not run: the H1 chaos rerun. Phase 3 runs it after merge.

## Risks

- A governance pause Stop that lands during a turn no longer schedules
an exact resume of that turn. The task continues on resume through a new
Retry (#20144) instead.
- Termination and cleanup use the same lifecycle Stop reason. They are
excluded because only the pausing path records `pauseStop`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, test execution). Extended reasoning
enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:42:25 -06:00
Devin FoleyandPaperclip 9b71d03215 fix: recover interrupted native preparations and unblock upgrades (#20177)
## Thinking Path

> - Paperclip manages AI-agent companies and their work.
> - A native run prepares its workspace before a runner PID or
checkpoint exists.
> - A server crash can interrupt that stage and leave its controller on
another host.
> - Recovery needs a durable owner proof, a delivered recovery wake, and
the saved execution contract.
> - The old paths missed controller lease registration and boot proof,
skipped checkpoint-free preparations, and counted retained work as live
upgrade blockers.
> - This PR connects preparation to the existing controller lease and
recovery paths, and separates retained work from the upgrade census.
> - The result is bounded recovery without a second executor or loss of
saved work.

## Linked Issues or Issue Description

**What happened?**

A native preparation interrupted by a server crash could remain running
indefinitely with no runner PID or checkpoint. Native controller stamps
did not wake the shared instance lease. That lease was disabled on
volume-backed Railway services, and native takeover omitted the boot
binding needed for its cross-host proof. Scoped recovery selected
checkpoint-backed runs but skipped these preparations. The upgrade probe
then counted the retained row as current execution and could block every
later upgrade.

**Expected behavior**

Track the preparation's controller before a runner starts. Recover it
through the normal classifier after proving its owner stopped or fencing
that boot. Keep live or unprovable owners held. Deliver recovery wakes
to preparations without checkpoints. Preserve unresolved historical work
and report it separately from current execution during upgrade
observations.

**Steps to reproduce**

1. Save an observed native preparation with no runner process, result,
provider-start evidence or checkpoint.
2. Replace its controller with a process in another PID space. Let its
owner lease expire and send a scoped recovery wake.
3. Before this fix, recovery skips the checkpoint-free preparation or
cannot prove its foreign controller stopped. Upgrade readiness continues
to count that old row as a live blocker.

Related work: #18037, #19651, #19868, and #20168. The foreign-PID guard
from #19868 remains. The queued first-turn input fix from #20168 is
included through the current base.

## What Changed

- Register the existing instance lease when native execution and restart
claims are written. Keep passive identity reads free of ownership
effects. Keep that lease enabled on volume-backed Railway services
unless explicitly disabled.
- Preserve the existing PID/start-time proof within the same readable
PID space. Bind foreign or unknown-space takeover to its saved boot and
last write. A live lease or grace period keeps the run held with a
scheduled check. Repeated evidence waits preserve their timestamp.
- Include explicitly fenced, checkpoint-free preparations in the
existing finite recovery candidate inventory. Normal launch, checkpoint,
provider evidence and admission checks still decide whether work may
resume.
- Expose the existing board stop-confirmation operation for historical
foreign-host owners. Bind confirmation to the exact run, boot, PID,
process start and host. Reject changed ownership.
- Report narrowly defined old, unowned preparations as
`retainedBlockedRuns` outside the live upgrade census. Local executions,
pending admission, drain state, truncation and positive checkpoint proof
retain their barriers.
- Update the execution contract and add PostgreSQL, real-route, race and
preservation regressions.

## Verification

- Genuine red/green regressions cover the old upgrade census, missing
native lease registration, the volume-backed default, foreign-host
confirmation, and scoped recovery skipping checkpoint-free preparation.
Test bodies ran against real PostgreSQL before their corresponding
production changes.
- Combined verification before the final idle correction (head
`055be6c84d9`): **459 passed, 22 skipped, 11 files passed**. Covers
controller lease/identity, native restart classification and
integration, host confirmation, heartbeat recovery, instance route
authorization, upgrade readiness, and queued first-turn input. The
focused command used `pnpm exec vitest run` with those 11 suites and
`--maxWorkers=1 --no-file-parallelism`. The 22 skipped cases require the
Rust debug fake-Codex fixture, which was not built locally; the
real-process CI coverage must pass.
- `pnpm --filter @paperclipai/server typecheck`: passed on head
`055be6c84d9b058064daad0af71bb5d893ab9529`.
- Actual route proof uses the real heartbeat service and PostgreSQL.
Unauthorized access returns 403. The retained run and owner records
remain unchanged. Recovery integration admits one bootstrap claim across
two replacement controllers, keeps a live owner held, and preserves
local execution.
- `pnpm verify`: exit 2, requests broad CI because the execution
contract changed. Full local suite not run. Current-head CI must supply
broad coverage.
- Local source checkout on macOS ARM64, Node v25.6.1, pnpm 9.15.4. Tests
use isolated PostgreSQL. No live provider restart is claimed by those
fixtures.
- CI found an idle-query regression in the first version of the lease
wiring. Two focused PostgreSQL regressions reproduced the misplaced
identity-read notification and the missing claim notification. The fix
moves registration to actual ownership writes. `env -u npm_execpath pnpm
exec vitest run server/src/__tests__/maintenance-server-routes.test.ts
--maxWorkers=1 --no-file-parallelism`: **17 passed**, including 90 real
seconds and two qualified hourly windows with no extra idle queries. An
initial local setup attempt inherited npm metadata and did not run
tests; clearing that metadata selected the correct pnpm helper. Three
focused controller/native suites also passed **168 tests, 22
fixture-gated skips**. Server typecheck passed after the correction on
head `4a527326dcd5b3a9e286f0e4c3e761d3056af4d9`.
- The older CI server shard also exposed a same-space graceful-restart
regression. The new boot fallback had overridden local process-exit
proof. Seven suspension cases and a focused unit regression reproduced
it; the fix limits boot fallback to foreign or unknown PID spaces. Fresh
verification on `acf1065d98150210c1e227c7f3b05a1eb489730b`: **251
passed, 22 fixture-gated skips, 4 files passed**, including all **40**
graceful-restart suspension tests. Server typecheck passed. The local
regression failed before this correction, and the same-space and
foreign-space paths pass together after it.
- Current-head CI completed successfully on
`acf1065d98150210c1e227c7f3b05a1eb489730b` (run 42568109784). Greptile
reviewed that exact head at 5/5 with no unresolved review threads.
- Secret and private-data scans passed. No workflow, lockfile or
migration changes.

## Risks

- The instance lease now applies to volume-backed Railway services. Its
existing 30-second renewal, quiescence, grace and self-fence behavior
applies while work is held. Idle servers retain the existing no-query
behavior. An explicit `PAPERCLIP_CONTROLLER_INSTANCE_LEASE=off` remains
available.
- A controller lease proves only the server owner's state. It does not
prove an agent process stopped or authorize replay, resource release or
volume removal. The existing classifier and provider/runner checks
remain responsible for those decisions.
- Previous-release records keep their shape and saved state. A missing
boot lease stays unprovable and requires the scoped stop-confirmation
path. A changed confirmation target is rejected.
- The upgrade observation does not recover a held task by itself. Source
tests do not qualify a hosted replacement. Deployment and the affected
task's useful result must be verified separately.
- This repairs recovery after a crash; it does not claim to identify or
fix every source of heap exhaustion.

## Model Used

OpenAI `gpt-6.1-sol`, xhigh reasoning effort, through Codex in T3 Code,
with repository, shell, and GitHub tools. Context window size is not
exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-08 21:53:13 -08:00
DottaandClaude Opus 5.5 b54317d3fb fix(native): give a preparing run's first turn the user's queued messages instead of interrupting it (#20168)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A Paperclip Runner (native) run prepares its workspace and
environment before it starts its first provider turn. This can take
about a minute.
> - A user's message that arrives during that time waits as a deferred
wake behind the run. The Interrupt control then cancelled the run before
any provider turn.
> - The cancel threw away the whole preparation. A new run had to
prepare again, so the user waited longer for the first token.
> - This pull request gives the run's first turn the queued messages.
The run seals its first-turn input immediately before `turn.start`, so
the messages are part of the prompt that the turn starts with.
> - The benefit is that the user sees the message handled in the same
run, with no cancel and no second preparation.

## Linked Issues or Issue Description

**What happened?**
A Paperclip Runner run took about 54 s to prepare (environment startup,
workspace, runtime selection). The user sent a second message 32 s into
that time and clicked Interrupt 21 s later. Interrupt cancelled the run
as `operator_interrupted` ("Interrupted to send queued messages") before
any provider turn. A new run then prepared again from the start. The
runtime-selection transaction that froze the run's input completed about
5 s before the click, and the coordinator claim came 4 s after it, so
the message could not be given to the first turn at any point in the old
code.

**Expected behavior**
When a user message arrives for a task whose current run has not started
its first provider turn, the run does not stop. The message goes into
that run's first turn. A run that is in a turn is interrupted as before.

**Steps to reproduce**
1. Assign a task to a Paperclip Runner agent with a sandbox environment,
so preparation takes a long time.
2. While the run prepares, post a second comment on the task.
3. Click Interrupt on the queued message before the run starts its
provider turn.
4. See that the run is cancelled and a new run prepares again.

**Paperclip version or commit**
Staging revision b43cccbb0.

## What Changed

- `server/src/services/native-runtime/first-turn-queued-input.ts` (new):
`claimFirstTurnQueuedInput` seals a run's first-turn input in one
transaction. It locks the task, the deferred wakes, and the run in the
same order as the queue routes. It takes the responsible user's plain
queued comments in queue order (not interaction answers, interrupt
intents, independent continuations, conversations, or other users'
messages). It renders them with source-trust sanitizing and secret
redaction, then writes a `firstTurnQueuedInput` receipt
(`paperclip.first-turn-queued-input.v1`) into the run context. A second
call for the same run returns the same messages.
`runAcceptsFirstTurnQueuedInput` is true for a running native (or
preparing native) run that has no receipt and no accepted provider turn.
- `packages/paperclip-runner/src/native-session-runtime.ts`: new
`getFirstTurnQueuedInput` option. The runner calls it immediately before
the first `session.startTurn` and appends the result to `task.prompt`
(or to `events` for a resumed-session continuation envelope). It is not
called for the disposition-only recovery turn.
- `native-session-executor.ts`: passes the claim to the runner. A failed
seal commits nothing, so the executor logs it and starts the turn
without the messages. The messages stay queued.
- `queued-comment-delivery.ts`: a message in a run's receipt counts as
delivered only after the provider accepted that run's turn
(`turn.accepted` or the provider's `turn.started`), and only if the
message was not edited after the seal. Release promotion, Send, and
later first turns use this rule. `explicit-native-continuation.ts` uses
the same rule for stopped native runs.
- `routes/issues.ts` Interrupt: when the target run has not sealed its
first turn and has no accepted turn, the route does not record interrupt
intent and does not cancel the run. The response has
`executionWait.reason = "first_turn_pending"`. Other runs are
interrupted as before.
- Queue views (`routes/issues.ts` and the wake-queue adapter) hide
messages that the active run took into its first turn. Queue edits keep
those ids on the wake, so a run that fails before acceptance still
leaves them queued.
- `run-state.ts`: the issue-control run projection includes the receipt,
so the queue poll can hide taken messages.
- `doc/execution-semantics.md`: new section "Queued messages before a
native run's first turn".

## Verification

- Red before the fix, green after:
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/issue-queued-comments-routes.test.ts -t "first
turn|already started"`: Interrupt on a preparing run and on a claimed
native run before `turn.start` does not stop the run, and the first turn
gets both messages in order. Before the fix, both cases cancelled the
run.
- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/native-session-runtime.test.ts -t "first-turn queued input"`: the
claim runs immediately before `turn.start`, and the first turn's prompt
ends with the queued messages. Before the fix, the claim was never
called.
- `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/first-turn-queued-input.test.ts`: a run that
fails before `turn.start` leaves the message queued. The next run
delivers it, and after that no later run or copied retry context takes
it again. Before the delivery rule, the "exactly once" and "accepted
then failed" cases failed.
- Safety cases that pass before and after: Interrupt still cancels a run
whose provider accepted a turn (including a run saved by the previous
release with no receipt) and a run whose first-turn input is already
sealed. The existing "explicit interrupt fallback" cases for preparing
and native runs without a responsible user still pass.
- Speed: the seal is one short transaction before `turn.start`. In the
embedded-Postgres test it took 5.5 ms. It removes a full second
preparation (54 s on staging) from the message-to-first-token time.
- Related suites: `src/modules/wake-queue`,
`explicit-native-continuation`, `issue-queued-comment-queue`,
`issue-queued-comments-routes`, `queued-comment-consumption`,
`heartbeat/run-state` (888 tests), `native-session-executor.test.ts`
(1306 tests), and the full `native-session-runtime.test.ts` (238 tests)
pass. `pnpm --filter @paperclipai/server typecheck` passes.
- Not done: no live run against a real provider or sandbox. Staging was
read only.

## Risks

- Previous-release state: a deferred wake saved by the previous release
has the same shape and is taken normally. A run saved by the previous
release has no receipt. If it is resumed before its first turn, it seals
on the new release. If its provider already accepted a turn, Interrupt
stops it as before. A run saved by the previous release while it was in
`turn.start` but before acceptance counts as unsealed, so in that short
window an Interrupt does not stop it and the message is delivered at the
turn boundary.
- Rollback: if the server rolls back after a run sealed and its turn was
accepted, the old release does not read the receipt and can deliver
those messages a second time.
- A first turn that does not use the fresh-turn path (restart
continuation, disposition-only recovery) does not call the claim. Its
messages are delivered at the turn boundary as before.
- A message that arrives after the seal waits for the turn boundary.
Interrupt stops the run as before.
- No schema migration. The receipt is a key in the run context.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used through
Claude Code with tool use (shell, file edits, test execution, read-only
browser).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:44:18 -06:00
DottaandClaude Opus 5.5 fa7950121d fix(native): move an interrupted bootstrap's session aside on the run's own created sandbox lease (#20154)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When restart recovery proves a remote bootstrap reached no provider
work, it bootstraps the same run again in the same sandbox. Before
relaunching, it stops the interrupted runner (#20103)
> - The relaunch starts a fresh controller record, whose command ids
restart at `command_prp_00000001`. The sandbox still held the
interrupted runner's journal, which had already recorded that command id
with different data
> - The relaunched runnerd rejected it ("commandId was reused with
different command data") and exited while waiting for `run.prepare`.
Every same-run retry collided the same way until the retry budget ran
out and the task blocked
> - The proven interrupted-bootstrap session move (#20103) ran only for
a resumed lease. A same-run retry on a per-turn sandbox reuses the run's
own created lease, so nothing moved the interrupted session aside
> - This pull request applies that same proof-gated session move on the
run's own created lease
> - The benefit is that a same-run bootstrap retry starts its runner on
a clean journal, and the old journal is kept as evidence

## Linked Issues or Issue Description

Refs #20103

**What happened?**
Daytona per-turn restart-chaos M1 rerun w-20261109T013733-s9501 (master
8e3b64a257, image 34bc12bf):
- **t10 (codex M1):** the codex subtask's run `f8a8f483` went through
eleven same-run bootstrap retries between 01:56 and 02:07.
- Each relaunched runnerd logged `paperclip-runnerd: commandId was
reused with different command data` and exited about 12 s later while
waiting for `run.prepare`.
- The run then failed `native_session_retry_exhausted` ("Provider
startup failed on 3 attempts before any provider work began"). The
subtask blocked, and so did the parent.
- **t02:** the same "runnerd exited while waiting for run.prepare"
pattern appeared on retry run `2e13568f`.

**Expected behavior**
A same-run bootstrap retry moves the interrupted session aside, keeping
it as a `.bootstrap-restart-<time>-<uuid>` sibling, and claims a fresh
session root before relaunching. The relaunched runner then cannot see
the interrupted attempt's command ids. This already happened on a
resumed lease and now also happens on the run's own created lease.

**Steps to reproduce**
1. Let a remote bootstrap reach the runner (it journals
`command_prp_00000001`), then restart before any provider work.
2. Recovery bootstraps the same run again in the same sandbox. Before
this change, the relaunched runnerd sees the old journal, rejects the
reused command id and exits, and every retry repeats this.

**Paperclip version or commit**
8e3b64a257 (wave). Branch rebased on current master.

## What Changed

- `native-session-executor.ts`: the created-lease branch of remote
harness preparation now applies the resumed branch's proven restart. It
runs when all of these hold:
  - the session is `not_suspended` and belongs to this run;
  - there is no backup;
  - the claim is `bootstrap_incomplete`;
- the stale-runner stop proved the interrupted runner gone
(`hasRemoteBootstrapRestartProof`).

It then runs `moveInterruptedBootstrapSessionAside` and
`claimUntouchedRemoteSession`. If the move or the claim fails, it throws
`runner_harness_state_mismatch: created_not_suspended` and changes
nothing.
- An earlier revision of this PR archived only the `runner` directory.
Review showed that would leave a session the existing claim cannot
reopen on a resumed lease, so that revision was replaced.
- On a fresh command namespace: moving the session aside removes every
id the relaunched runner could see, so a separate namespace isn't
needed. It would also change the persisted command id format that saved
controller state depends on.
- `native-session-executor.test.ts`: the existing "bootstraps only an
untouched provider session" matrix runs the real move and claim scripts
against a temporary filesystem. It gains two created-lease cases:
- an interrupted bootstrap: the old session is kept as a sibling and a
fresh root is claimed;
  - a symlinked parent: refused, and nothing moves.

## Verification

- Red/green: both created-lease cases fail on master and pass with the
change. The resumed cases from #20103 are unchanged: 42 cases in the
matrix pass.
- `vitest run` over the restart-recovery and bootstrap test files and
`native-session-executor.test.ts`: 7 files, 1540 tests passed (14
skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.
- The collision itself happens in runnerd (the production log shows it
on every retry). The live check is a Daytona per-turn K2/K4 rerun.

## Risks

- Low risk. The move reuses the resumed path's proof gates, its symlink
checks and its whole-session move, which keeps the session and never
deletes it. The new branch acts only when every gate holds; otherwise
preparation continues exactly as before.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:06:26 -06:00
DottaandClaude Opus 5.5 510746c682 fix(native): restart a remote run whose controller never sent its runner a command (#20153)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A follow-up run on a remote sandbox inherits the previous run's
session checkpoint at admission, rebound to the new run's identity
> - A remote runner receives work only through the controller's Postgres
checkpoint, which the controller writes before every command. A run with
no checkpoint rows, no runner events and no recorded runner process
never sent its runner anything
> - Restart recovery still read the rebound admission checkpoint's
provider session id as provider evidence, and classified such a run
`reattach_remote_runner`. The executor then found the root still owned
by the previous run and failed with
`native_remote_recovery_lease_mismatch: stage=runner_verification
field=identity_run_id`
> - This pull request classifies that run as an incomplete bootstrap,
and lets the executor start it from the previous owner's settled root
> - The benefit is that a follow-up interrupted before its runner ever
got a command restarts on its own

## Linked Issues or Issue Description

Refs #20137

**What happened?**
Daytona per-turn restart-chaos M1 rerun w-20261109T013733-s9501 (master
8e3b64a257, image 34bc12bf), t02 (codex M1 parent):
1. Wake run `a867cb18` was admitted at 01:48:31 with a rebound
checkpoint inherited from run `dfeddafa`: identity of the new run,
`cursor: null`, no terminal turns, no active turn.
2. It recorded `native.process_start_requested` at 01:48:36, then a K4
hold froze the control plane.
3. At 01:53:11 recovery classified it `reattach_remote_runner`
(`hasCheckpoint: true`, `hasProviderEvidence: true`, from the rebound
checkpoint), although the run had no `native_runner_checkpoints` rows,
no runner-originated events and no recorded process identity.
4. The executor found the session root still owned by `dfeddafa` and
failed with `native_remote_recovery_lease_mismatch:
stage=runner_verification field=identity_run_id`. The task needed a
Retry.

**Expected behavior**
A remote run whose runner provably never received a command restarts its
bootstrap. The executor treats the previous owner's root as it would for
any follow-up start.

**Steps to reproduce**
1. Claim a remote sandbox follow-up that recorded
`native.process_start_requested` but has no controller checkpoint rows,
no runner events and no `native.process_identity_recorded`, with only
the admission-rebound session checkpoint.
2. Restart. Before this change, recovery claims
`reattach_remote_runner`, and the executor fails with `identity_run_id`.

**Paperclip version or commit**
8e3b64a257 (wave). Branch rebased on current master.

## What Changed

- `native-restart-recovery.ts`: the new
`remoteRunnerNeverReceivedCommands` condition.
- It applies to a remote sandbox run (not a plugin task) with a prepared
remote workspace when all of these hold:
    - no durable controller record, and no staged attachment;
- only the admission-rebound checkpoint (or none), and no provider
process events;
- no runner-originated events, and no
`native.process_identity_recorded`;
    - no run process fields;
- the same claimed and dead previous-controller conditions as
`remoteRunnerNeverLaunched`.
- It yields `bootstrap_incomplete` with the reason
`remote_runner_never_received_commands`, and does not count the rebound
checkpoint as this run's checkpoint.
- `native-session-executor.ts`:
- a `bootstrap_incomplete` claim requires the incomplete-bootstrap proof
only when the root carries this run's own identity;
- a root still owned by the previous run takes the normal previous-owner
path, and may retire a finished previous owner, as any follow-up start
does.
- Policy change, called out for review: a claimed remote run that
recorded only `native.process_start_requested` was held as
`ambiguous_remote_recovery_evidence`. Under the conditions above it now
bootstraps again.
- The controller writes every command to the Postgres checkpoint before
sending it, so with no checkpoint rows no command reached any runner,
and no provider work can exist.
- A runner process that was spawned but never commanded is stopped
before the new bootstrap by the existing stale-runner stop.
- A recorded process identity, an adapter invocation, a runner event,
process fields or a provider session still hold the run.
- Tests:
- `native-runner-restart-recovery.integration.test.ts`: the
classification for a follow-up with an inherited checkpoint and for a
first run without one, plus a held case when a runner process identity
was recorded. The old held expectation for "a runner process start was
requested" moved here;
- `native-recovered-owner-followup.test.ts`: the executor admitting a
`bootstrap_incomplete` follow-up from the previous owner's settled root.

## Verification

- Red/green:
- On master, the classification test claims `reattach_remote_runner`, as
in production. With the change it claims `bootstrap_incomplete` with
`remote_runner_never_received_commands`.
  - A recorded runner process identity keeps the run off this path.
  - The executor test is admitted with the change. On master it fails.
- `vitest run` over the restart-recovery, bootstrap, recovered-owner and
failed-workspace-recovery test files and
`native-session-executor.test.ts`: 9 files, all passing after the policy
test moved; `native-runner-restart-recovery.integration.test.ts` alone:
144 passed (14 skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Low risk:
- The new path requires positive proof that nothing reached a runner: no
controller record, no runner events and no recorded process. Any of
these keeps the existing classification.
- In the executor, a root that carries this run's identity still needs
the full bootstrap proof.
- A root that carries the previous run's identity goes through the same
previous-owner verification as any follow-up start.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:06:21 -06:00
DottaandClaude Opus 5.5 3eea8b87bd fix(native): settle a finished owner whose journal holds a definitively answered command (#20148)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Before a per-turn follow-up starts a new runner, it retires the
previous turn's finished owner. Retirement requires that owner's
controller journal to be settled: every command answered, then a run
terminal
> - A restart can cut off a continuation turn. The recovered runner
answers that `turn.start` with `failed` and the nested code
`execution_indeterminate` ("it will not execute twice"), and the run
continues on a later turn to an accepted result
> - Retirement counted only `completed` commands as answered. A finished
run whose journal held that answered `turn.start` never counted as
settled, so the follow-up failed with
`retirement_refused=journal_commands_unsettled(turn.start.failed)` and
needed a Retry
> - This pull request counts definitive `failed` and `rejected` answers
as settled, except for runner lifecycle commands. An unknown-outcome
`turn.start` counts as settled only once a later `turn.start` completed,
which shows the run moved past it. Every other unknown or pending
command stays unsettled
> - The benefit is that a follow-up after a restart-interrupted, then
finished, run continues on its own

## Linked Issues or Issue Description

Refs #20140

**What happened?**
Local restart-chaos M1 K4/K6 rerun w-20261109T003126-s9701 (master
8e3b64a257), t07 (claude C1, K6):
- The previous run `9cb2c0b3` lost a continuation turn to a restart. The
runner answered that `turn.start` as `failed` with `result.result.code:
"execution_indeterminate"`.
- After the next restore it ran a new continuation to an accepted
result, and the run terminal was committed at 00:48:18.
- A hard kill then landed before the runner suspended, and recovery
finalized the result.
- Follow-up `372991c8` failed at 00:48:37 with
`runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate;
retirement_refused=journal_commands_unsettled(turn.start.failed)`.

**Expected behavior**
A command the runner definitively answered (`failed` or `rejected`)
leaves nothing outstanding, so it counts as settled. A `turn.start` with
an unknown outcome is never executed again by the runner, so once a
later `turn.start` completed, the run has moved past it and it also
counts as settled. The follow-up then retires the finished owner and
continues.

The following still count as unsettled:
- an unknown-outcome `turn.start` with no later completed turn;
- any other unknown-outcome command;
- a `pending` command;
- a failed runner lifecycle command, such as a failed `runner.suspend`
(the runner did not suspend).

**Steps to reproduce**
1. Finish a per-turn run whose controller journal holds a `turn.start`
answered `failed`, followed by a committed run terminal.
2. Start a follow-up. Before this change, retirement refuses with
`journal_commands_unsettled(turn.start.failed)`.

**Paperclip version or commit**
8e3b64a257 (wave). Branch rebased on current master.

## What Changed

- `remote-warm-session-recovery.ts`: the new
`unsettledControllerCommands` decides which commands are settled.
`isSettledRemoteWarmControlState` uses it.
  - `completed` is settled.
- An unknown outcome (raw `indeterminate`, or `failed` with
`execution_indeterminate` at `result.code` or `result.result.code`) is
settled only for a `turn.start` with a later completed `turn.start`.
- Other `failed` or `rejected` answers are settled, except for
`runner.suspend`, `runner.drain` and `runner.shutdown`.
- `native-session-executor.ts`: the retirement refusal message lists
unsettled commands with the same rule.
- `native-recovered-owner-followup.test.ts`: a red/green test whose
journals are in real lifecycle order.
- Admitted: a `turn.start` answered `failed` or `rejected`; a superseded
unknown-outcome `turn.start` in the runner's stored nested shape; a
superseded raw `indeterminate`.
- Held: the same unknown-outcome answer with no later completed turn; a
failed `runner.suspend`.

## Verification

- Red/green: on master, the admitted cases fail with exactly the t07
error. With the change they are admitted. The held cases stay held. If
only `result.code` is checked, the unsuperseded case fails.
- `vitest run` of `native-recovered-owner-followup.test.ts`,
`remote-warm-session-recovery.test.ts` and
`native-session-executor.test.ts`: 3 files, 1403 tests passed.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Low risk. Definitive answers to non-lifecycle commands, and
unknown-outcome turn starts that a completed later turn superseded,
become settled.
- The run terminal requirement, post-terminal live-work checks, and
every lifecycle and process proof are unchanged.
- The same rule now also applies to remote warm recovery's settled
check, where a definitively answered command is equally finished.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, file edits, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 20:47:05 -06:00