Commit Graph
676 Commits
Author SHA1 Message Date
Nicky LeachandClaude Opus 5.5 2d93413085 perf(runner): prepare the next Claude provider generation at park (#20145)
## Thinking Path

> - Paperclip runs agents through Paperclip Runner, and a host can park
an idle Runner between runs and resume it for the next run on the same
task.
> - For Claude (ACPX), a follow-up run on a parked Runner pays about
1.5–2.3 s at `run.attach` before the provider can start the turn. The
attachment closes the Claude sidecar and spawns a new one: initialize,
`session.open` with `session/load`, `run.attach`, then a dormant verify.
> - The respawn is required. The tool-bridge MCP endpoint lasts for the
whole session and can't tell which turn a late callback came from, so a
new turn must not run in a process that ran an earlier one.
> - Nothing requires the respawn to happen at the *start* of the next
run. A turn that ends 2 s later is a better experience than one that
starts 2 s later.
> - This pull request does the respawn while parking. The next run's
`run.attach` then only rebinds the run id on a fresh, never-used
generation.
> - Follow-up Claude runs on a parked Runner reach the provider about
1.5–2 s sooner, and the late-callback guarantees are unchanged.

## Linked Issues or Issue Description

No public issue exists. The issue is described inline.

### What happened?

On a hosted deployment with parked Runners, Claude follow-up runs
reached the provider about 1.8 s later than Codex follow-ups.
`runner.session.resume` medians were 5.2 s for Claude and 3.4 s for
Codex. The extra time is the ACPX respawn inside `run.attach`. Codex
keeps its process in place.

### Expected behavior

The respawn happens while the Runner parks, so the next run starts on a
prepared generation.

### Steps to reproduce

1. Run a Claude (ACPX) task on a host that parks Runners between runs.
2. Let the run finish and the Runner park.
3. Send a follow-up. `run.attach` closes the provider and starts,
initializes, opens and attaches a new sidecar before `turn.start`. The
Claude CLI itself starts at the first prompt on either path.

### Paperclip version or commit

`master` at e194e193.

### Deployment mode

Hosted, with parked Runner tasks.

## What Changed

- **`durable/runner.rs`:**
- New `CommandExecutor::prepare_for_park(deadline)` hook, a no-op by
default. The runner passes a deadline 20 s after the park request; the
host waits 30 s for the reply.
- The Park host-control arm calls it after `host_park_ready()`,
re-proves readiness, and only then replies `parked`.
- A failure records a diagnostic and replies `conflict`, the existing
park-failure path.
- **ACPX, park side** (`acpx_park_preparation.rs`,
`acpx_provider_generation.rs`):
- For Claude, with the session open, nothing pending and nothing queued:
reap the old generation, using the same suspend-and-reap sequence a
second turn uses.
- Start the replacement with the current run attached, and prove it
dormant.
  - Roll the tool receipt epochs and the turn-identity ledger over.
- Record a digest of the session configuration it was opened with,
ignoring the MCP binding id and the aggregate digest.
- Every sidecar request during preparation is capped at the park
deadline. Running out of time fails like any other preparation failure.
- **ACPX, attach side:**
- A prepared, never-used generation whose digest matches the next run is
rebound: the sidecar gets `run.attach` with the new run id, the reducer
and tool bridges are reset, and the snapshot is checked for the new run
id.
- Anything else takes today's cold path: a different tool set, skill,
prompt or launch; a buffered callback; or any rebind error.
- **ACPX, turn start:** the provider restarts when its generation has
run a turn, instead of when the run has settled turns.
`has_settled_turns` is removed.
- **ACPX, polling:** polling skips a prepared generation until it is
rebound or starts a turn, so nothing it emits is attributed to the
parked run.
- **Sidecar** (`acpx-runtime-sidecar.ts`): rejects `run.attach` once a
turn has started in that process.
- **Fake sidecar:** reports live predecessors at process start, and adds
the faults `reject-open`, `late-tool-after-suspend` and `pre-turn-tool`.

## Verification

- `cargo test -p paperclip-runner-core`: 1029 passed (debug).
  - New `tests/acpx_park_preparation.rs` (10 tests). These prove:
- park reaps the old pid before spawning the new one, and acks only
after the dormant verify;
- a late callback from the old generation after park is rejected, and so
is a pre-turn call from the new generation;
- a compatible attach keeps the pid, and the sidecar and snapshot carry
the new run id;
- changed instructions, tools, skills or runtime context take the cold
path;
    - a used generation is never rebound;
- a prepare failure produces a park conflict and a recoverable state;
    - Retire works after a prepared park;
- a replacement whose `session.open` stalls fails at the park deadline,
leaves the session suspended, and the next run attaches and completes a
turn.
- `cargo fmt --check` and `cargo check --workspace --locked`: clean.
- `vitest` `acpx-runtime-sidecar.test.ts` and
`acpx-sidecar-input.test.ts`: 126 passed. The new re-attach guard test
fails if the guard is removed.
- `vitest` `plugin-runner-release-resume.integration.test.ts` and
`plugin-runner-eviction.integration.test.ts` with the branch's Runner:
48 passed. Both now expect the provider process to be replaced at park
instead of at attach.
- Fake-sidecar timing (release build, 3 runs): `run.attach` took 230–260
ms cold and 40–80 ms after a prepared park.
- Real Claude (claude-agent-acp 0.73.0, release build on a hosted test
VM, 3 runs each way plus one with a 180 s cgroup freeze between park and
attach):

  | | before | this PR |
  |---|---|---|
  | `run.attach` | 1691 ms mean | 212 ms mean |
| `run.attach` start → follow-up `turn.started` | 2565 ms mean | 1065 ms
mean |
  | `turn.start` → first provider output | 2261 ms mean | 2232 ms mean |
  | park preparation added to park | — | about 1450 ms |

Session recall, an authorized tool call after the rebind, and the
absence of any tool call or event from the old run all passed in every
run, including after the freeze. The prepared generation holds only the
sidecar: the Claude CLI starts at the follow-up prompt on both paths, so
a parked Runner also holds one node process instead of the whole Claude
tree.
- Hit rate: on a hosted test deployment, all 15 consecutive Claude runs
across two tasks carried identical content-addressed instruction and
skill bundle paths, so the prepared digest would have matched.
- Not run: the hosted park, claim and attach loop with real Claude, a
native MCP binding or skills with real Claude, `cargo test --release`,
smoke tests.
- Pre-existing flake:
`unconfirmed_suspension_state_becomes_recoverable_only_after_cleanup_and_suspend`
fails intermittently on clean `master` when the `hosted acpx durable::`
subset runs in parallel. It passed in both full runs.

## Risks

- **Park takes about 1.5 s longer** before it replies with real Claude.
Preparation is capped at 20 s; the host's park wait is 30 s.
- **Configuration changes:** a change between park and the next run
(registered asset paths, tools, prompt) takes the cold path. That's
safe, but saves nothing.
- **Stale environment:** a prepared process was opened with the previous
run's environment and disk state. If credential or configuration files
change on disk while the Runner is parked, the prepared process won't
see them, whereas the cold path would.
- **Failed prepare:** a park that fails after the reap leaves no live
provider. The next turn or `run.attach` starts Claude cold, as today.
- **Scope:** only ACPX Claude prepares. Other providers park exactly as
before.

## 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
- [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 21:38:57 -08:00
DottaandClaude Opus 5.5 95bf90bd2c fix(chat): post a conversation turn's answer while an approval holds the task (#20179)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People talk to agents in chat. The agent's final message becomes the
chat reply.
> - A chat turn can end while the task stays held. For example, the
agent resubmits a hire approval and the approval is pending.
> - The finalizer keeps the task in review for the approval
(`governed_response_waiting`). That decision is not
`conversation_turn_finished`.
> - Run completion then treats the yielded turn as a control-plane wait.
It records `presentationDecision: none` and posts no comment.
> - The person never sees the agent's answer. The chat shows nothing
after the request.
> - This pull request counts a committed conversation turn as answered
unless its own pending question or confirmation card presents it.
> - The benefit is that the person always gets the reply, whatever state
the task keeps.

## Linked Issues or Issue Description

Refs #19971 (post the full final reply). That fix covers the reply text.
This fix covers replies that were never posted.

**What happened**

In a chat, the person asked the CEO agent to change a proposed hire to a
cheaper model and send it back for approval. The agent resubmitted the
approval correctly. It called `paperclip_finish`, and Paperclip accepted
the report with "task is still waiting for approval". The run succeeded
and the task moved to `in_review`. The run event
`run.presentation.resolved` recorded `commentAction: none` (reason
`yielded_control_plane_wait` on master). The agent's final message never
reached the chat.

**Expected behavior**

A chat turn's accepted final answer is always posted to the
conversation. An approval, an execution stage or a dependency blocker
can hold the task. It must not hide the reply. Only a question or
confirmation card that the turn itself left pending presents the turn
instead of its prose.

**Steps to reproduce**

1. Open an Agent Chat with an agent. Link a hire approval in
`revision_requested` to the chat task.
2. Ask the agent to change the hire and resubmit it.
3. The agent resubmits the approval and ends the turn with a yielded
result and a response wake.
4. See that the chat shows no reply. The run is `succeeded` and its
presentation decision is `none`.

## What Changed

- `server/src/services/native-runtime/native-governance.ts`: move the
pending-card query of `pendingNativeGovernance()` into
`pendingNativeInteraction()`, with no change in behavior. Add
`conversationTurnAnswered()`. It returns false only when a pending
thread interaction (the same predicate as status arbitration) gates the
turn. It checks cards on their own, so a pending execution stage does
not hide a card.
- `server/src/services/heartbeat/run-completion.ts`: a successful
conversation run whose status decision committed now passes
`conversationTurnFinished: true` to the presentation resolver, unless
`conversationTurnAnswered()` is false. The existing
`conversation_turn_finished` path is unchanged.
- `server/src/services/heartbeat/run-completion.test.ts`: add a
regression table with a real database. A pending approval, a
revision-requested approval, a pending execution stage and a dependency
blocker now post the answer. A finished turn still posts. The turn's own
question card still owns the response, also when an execution stage is
pending.
- `doc/execution-semantics.md`: state the rule.

Other finish outcomes I checked:

- `done` or `needs_review` with a gate (`governed_gate_pending`): the
result is not yielded, so the reply was already posted.
- `blocked` (`paperclip_block`, waiting on a person or another task):
not yielded, so the reply was already posted.
- Yielded with a dependency blocker
(`durable_dependency_blocker_bound`), or with
`record_finalization_error` (`prior_status_preserved_no_live_path`,
`monitor_wait_authority_lost`): the reply was also dropped. This fix
covers them, because the rule does not depend on the reason code.

## Verification

- `cd server && npx vitest run
src/services/heartbeat/run-completion.test.ts`: 118 passed. Before the
fix, 4 new cases failed (both approval states, execution stage,
dependency blocker). The finished-turn and question-card cases passed
both before and after.
- `cd server && npx vitest run
src/__tests__/heartbeat-run-summary.test.ts
src/services/heartbeat/run-comment-selection.test.ts
src/__tests__/slack-conversation-lifecycle.test.ts
src/services/native-runtime/agent-chat-response-recovery.test.ts
src/__tests__/chat-run-milestone-governed-work.test.ts
src/__tests__/agent-conversations.test.ts`: all passed.
- `tsc --noEmit -p server`: no errors in the changed files. Unrelated
errors in this worktree come from an unbuilt local Runner package.
- An end-to-end chat eval run on the case branch is in progress. I will
add its result here.

## Risks

- Low risk. The change only affects successful Agent Chat runs with a
committed status decision. Ordinary tasks and external chat (Slack,
GitHub) keep their own paths. External chat returns before this check.
- A turn that yields while an approval is pending now posts its prose.
The approval stays pending and the task status does not change.
- One extra small read (the task row and pending gates) runs after a
conversation turn that is not `conversation_turn_finished`. It runs
after the provider turn ends, so it does not add time before the first
token.
- Previous-release state: no persisted shape changes. The fix reads
`finalizationPhase` and `finalizationReasonCode`, which the previous
release already writes. A run in flight at the deploy completes on the
new code and posts its reply. A run that completed before the deploy
keeps its recorded decision; nothing re-presents it.
- Known gap, unchanged: background presentation recovery for
conversations still requires `conversation_turn_finished` with a `done`
result. If the server restarts between the status commit and the live
presentation, a held yielded turn is not re-posted. That is a separate
follow-up.

> 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

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use (shell,
file edits, test runs) 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-08 23:17:36 -06: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 098f7d370b fix(workspaces): release an unchanged remote writer without a snapshot and wake its waiters (#20171)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A remote (sandbox) task workspace has one writer run at a time. The
writer holds the workspace until its files are saved as a snapshot
> - A run that a user comment interrupts right after its claim did no
work. No provider turn started and no command ran in the sandbox
> - Paperclip still kept its sandbox, then took a full snapshot of the
unchanged workspace. On a 2 GB repository this took 3 minutes
> - The next runs failed setup with `workspace_busy` ("ended, but its
workspace save has not settled"). The retry then waited for the
one-minute settling re-check after the save. The user's message waited
more than 4 minutes
> - This pull request releases such a writer at once, at the saved
version it started from, when durable evidence proves that it changed
nothing. It also wakes parked `workspace_busy` waiters when a hold
settles
> - The benefit is that an interrupted run no longer delays the next
message by a full workspace save

## Linked Issues or Issue Description

**What happened?**
A remote-workspace run was claimed (`native.coordinator.claim`). One
second later a board comment interrupted it (`operator_interrupted`). It
made no provider turn and no tool calls. Cancelled-workspace
preservation still retained its sandbox, and workspace recovery took a
full `remote_workspace_snapshot` of the unchanged workspace (3 minutes
for a 2 GB repository). Two follow-up runs were cancelled with
`workspace_busy`. Their retry was due during the save, and it parked for
the one-minute settling re-check after the save finished.

**Expected behavior**
A writer that provably changed nothing releases the workspace at once
and keeps its existing `snapshotId`. The next run is admitted without
`workspace_busy`. When a save does run, waiters start as soon as it
commits.

**Steps to reproduce**
1. Run a task on a remote sandbox workspace that already has a saved
version.
2. Interrupt the run with a board comment right after the claim, before
the provider turn.
3. See the full snapshot operation on the interrupted run and
`workspace_busy` on the follow-up runs.

**Paperclip version or commit**
Staging served revision b43cccbb0. Reproduced on `master` by the new
regression tests.

## What Changed

- `prepareRemoteWorkspace` records `remoteWorkspaceUnchangedBaseline`
(epoch, writer run, saved version) when preparation started from a clean
saved version and added nothing to it: no recovered attachments, no
repository outside the saved version and no workspace migration.
- New `proveRemoteWorkspaceUnchanged`
(`remote-workspace-unchanged-proof.ts`). It uses only durable evidence.
The writer run must have ended without a result, and its owner fence
must be clear. It must have no process fields, no execution, provider or
tool events (the shared `executionStartedEventPredicate`), no native
result, no runner checkpoint rows (the controller writes one before
every runner command), and no provider turn in a checkpoint that names
this run. It must have no workspace operation other than a restore or a
`workspace_finalize`/`workspace_recovery` receipt, so provisioning and
saves count as writes. Every task repository must be in the saved
version. Any missing or ambiguous evidence refuses.
- New `releaseUnchangedRemoteWorkspaceWriter`. It sets the authority to
clean with no writer, keeps `snapshotId`, marks the run reference
`saved`, logs `workspace.snapshot_skipped_unchanged` and sends the same
notifications as a final save. It never waits on a writer run that
another transaction holds (`skip locked`).
- The release applies in three places: the next run's
`reserveRemoteWorkspace`, cancelled/failed-workspace preservation
(`preserveNativeFailedWorkspace`), and `saveRemoteWorkspace` with the
final boundary. The final save returns the same version on a repeated
call, so a recovery that raced the release finishes without a capture.
- The final-save commit and the unchanged release call
`wakeWorkspaceBusyWaitersInTx`. A parked waiter whose holder was that
writer is due at once, not at the one-minute settling re-check.
- `doc/execution-semantics.md` describes the release and the wake.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/services/remote-workspace-unchanged-release.test.ts` (13 tests,
embedded Postgres, real archive capture):
- Staging shape (claim, then interrupt with no provider work): the next
reservation is admitted. No snapshot operation is recorded. The run
reference is `saved` at the old `snapshotId`. Red on `master`: "The
workspace still has an unfinished writer or save operation."
- A later final save, behind the `workspace_finalize` receipt that
production finalizers publish first, returns the same version with no
capture, a repeat returns it again, and a parked waiter becomes due now.
Red on `master`: a new snapshot is captured.
- Negatives that still do a full save: `turn.started`, `provider.event`,
`tool.execution.started`, `native.process_start_requested`, a
provisioning command that wrote into the workspace, a recorded process,
a live owner fence, and a writer that is still running.
- Upgrade safety: no baseline (a writer prepared by the previous
release), a baseline without its saved version, and a baseline with
another schema each keep the full save.
  - A full final save wakes the parked waiter. Red on `master`.
- `native-cancelled-workspace-preservation.test.ts`: a new case shows
that a cancelled writer with a baseline and no work is released, with no
retained lease and no recovery action. Red on `master` (the
restore-recovery hold was recorded). The other 44 cases pass unchanged.
- I also ran the related remote-workspace, workspace-recovery, native
preservation and run-dispatch suites locally.

## Risks

- A false "unchanged" verdict would drop unsaved files. The proof
requires a positive baseline from preparation and refuses on any write
record. Only `.paperclip-runtime` (excluded from snapshots) is written
by runner staging before a process starts. A deploy that lands mid-run
keeps the full save, because the proof reads only durable rows.
- Previous-release state: a writer prepared without the baseline (or
with an unknown or partial one) keeps today's full save. An older server
ignores the new metadata key.
- Waiters now start sooner after a final save. Arrival order among
waiters is unchanged (the park check still admits the earliest first).
- Other ways a hold can settle (for example a stop receipt that turns an
unused reservation free) still rely on the one-minute re-check.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used in Claude Code
with tool use (shell, file edits and a read-only browser for staging
evidence).

## 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:37:52 -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
DottaandClaude Opus 5.5 cedf335c98 fix(native): a failed terminal followed by an active restart continuation is not a permanent failure (#20149)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a restart loses a native run's active turn, the run records
that turn as failed with `provider_turn_lost_on_restore` and continues
on a same-conversation continuation turn
> - Restart recovery blocks a run as
`provider_checkpoint_permanently_failed` when its checkpoint carries a
failed run terminal and no pending restart interruption
> - While a continuation is running, the checkpoint keeps the lost
turn's failed run terminal and names the continuation as the active
turn. The pending-interruption check returns nothing once a turn is
active
> - So a second restart during the continuation read the run as
permanently failed, blocked replay, and needed the board
> - This pull request treats that failed terminal as the restart loss it
records whenever an active turn follows a restart-lost terminal turn
> - The benefit is that runs survive repeated restarts during their
continuations, including M1 parents and their subtasks

## Linked Issues or Issue Description

Refs #20148

**What happened?**
Local restart-chaos M1 K4/K6 rerun w-20261109T003126-s9701 (master
8e3b64a257), t05 (claude M1 parent):
1. Run `e1e37dab` lost turns to restarts at 00:41:32 and 00:47:53. Each
time it continued, and the third continuation `turn_lab_92b03dc7…` was
active.
2. At 00:48:29 the next restart blocked it:
`native_restart_recovery_blocked` /
`provider_checkpoint_permanently_failed`, with replay blocked.
3. Its saved checkpoint held `terminal.runTerminalState: "failed"` (from
the lost turn), `activeTurnId` set to the continuation, and a last
terminal turn fingerprinted `provider_turn_lost_on_restore`.

Its codex/ACPX subtask's run `f9b10d06` was blocked in the same pass
with the same shape, and its continuation had already proposed a
semantic result. Both stayed blocked. The parent's Retry was held on the
blocked child.

**Expected behavior**
A failed run terminal that records a restart loss, followed by an active
continuation turn, is not a permanent failure. Recovery claims
`resume_dead_runner`. The runtime then reconciles the active turn, and a
held result settles a lost turn as completed. A real provider failure
followed by a later turn still blocks.

**Steps to reproduce**
1. Checkpoint a run whose last terminal turn is a restart loss, whose
`terminal` is failed, and whose `activeTurnId` is a later continuation
(optionally with a semantic result).
2. Claim restart recovery. Before this change it blocks with
`provider_checkpoint_permanently_failed`.

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

## What Changed

- `contracts/native-session-backend.ts` (runner): the new
`nativeRestartContinuationInFlight` is true when all of these hold:
  - the checkpoint has an active turn;
  - its last terminal turn is a different turn;
- that turn's fingerprint is a failed, result-less, recoverable
`provider_turn_lost_on_restore`.
- `native-session-runtime.ts`: recovery clears that stale failed
terminal for a continuation in flight, as it already does for a
checkpointed interruption. Before, it threw
`provider_checkpoint_failed_terminal` before it reconciled the active
turn.
- `native-restart-recovery.ts` (server): `checkpointFailed` now also
requires that no restart continuation be in flight. It uses the same
contract function through the vendor boundary.
- `native-session-runtime.test.ts`: an end-to-end recovery test. A
checkpoint with the stale failed terminal and an active continuation
resolves on the continuation's result without starting a new turn.
- `native-runner-restart-recovery.integration.test.ts`:
  - a codex case of the stale-terminal shape as saved in production;
  - an ACPX case with a held result;
- a held case where an active turn follows a real provider failure
(`model_not_found`).

## Verification

- Red/green: on master, the stale-terminal case is blocked with
`provider_checkpoint_permanently_failed`. With the change it claims
`resume_dead_runner`. The real-failure case stays blocked on both.
- Red/green (runtime): without the runtime change, the end-to-end test
throws `provider_checkpoint_failed_terminal`. With it, the run
completes.
- `vitest run` over the restart-recovery and failed-workspace-recovery
test files in `server/src/services/native-runtime`: 5 files, 305 tests
passed (14 skipped).
- `vitest run` over the runner restart, recovery, continuation and
terminal test files: 9 files, 385 tests passed.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`) and `pnpm
run typecheck:typescript` (`packages/paperclip-runner`): passed.

## Risks

- Low risk. The exception applies only when the recorded failure is
itself a recoverable restart loss and a later turn is in flight. Every
other recovery gate is unchanged: the provider-attempt budget,
controller takeover and process proofs.

## 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:28:12 -06:00
DottaandClaude Opus 5.5 a0b9895bbd fix(native): roll back an undelivered warm attach rotation once its runner is proven dead (#20146)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A follow-up turn can attach to the previous turn's warm local
runner. The transport first rotates authority: it archives the previous
epoch (suspended runner state and settled controller) under
`authority-epochs/` and writes a new control plane that holds only the
`run.attach` command
> - A hard kill in that window, before the runner connects, leaves the
root with the new control plane and no `runner/runner-state.json`.
Restart recovery proves the warm runner dead and classifies the run
`resume_dead_runner`
> - The executor saw a root with the run's own identity and no runner
state, called its authority indeterminate, and quarantined it. The run
failed with `runner_state_identity_mismatch: authority_indeterminate`
and needed a Retry
> - The attach was never delivered, and the runner that would have
received it is proven dead, so it cannot have taken effect. The root's
real authority is still the archived previous epoch
> - This pull request rolls back such an undelivered rotation, so the
follow-up continues from the previous turn
> - The benefit is that a restart in the first second of a warm
follow-up no longer needs a Retry

## Linked Issues or Issue Description

Refs #20140

**What happened?**
Local restart-chaos K13 regression storm w-20261108T235957-s9401 (master
4420a5adcf), task t22 (codex C1):
1. Follow-up run `7b1b1650` started at 00:05:56 as a warm attach to the
previous run's runner (pid 11436).
2. The transport archived the previous epoch (`313ee831`: runner
`suspended`, every command completed) to `authority-epochs/epoch-2c2a…`.
It wrote a new control plane with `run.attach` pending,
`connectionCount` 0, no events, no delivery counts and
`bootstrapHistory.adopted` false.
3. A K1 at 00:05:57 killed the server before the runner connected.
Recovery stopped pid 11436 and claimed `resume_dead_runner`.
4. The executor found `runner/` non-empty (provider state) but without
`runner-state.json`, returned `authority_indeterminate`, and quarantined
the root. A Retry ran fresh.

**Expected behavior**
When a `resume_dead_runner` claim proves the local runner gone and the
run's own root holds only an undelivered `run.attach` over an archived
suspended epoch, recovery restores that epoch and continues. If delivery
is uncertain (any connection, event, delivery count or other command) or
there is no dead-runner claim, it still fails closed.

**Steps to reproduce**
1. Start a warm follow-up and hard-kill the control plane after the
authority rotation, before the runner connects.
2. Restart. Before this change, the follow-up fails with
`runner_state_identity_mismatch: authority_indeterminate`.

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

## What Changed

- `native-session-executor.ts`: the new
`rollBackUndeliveredAttachRotation` runs only for a `resume_dead_runner`
claim on this run with a local runner. It acts only when all of these
hold:
  - the active control plane is this run's own exact identity;
  - its only command is a pending `run.attach`;
- `connectionCount`, `ackedSourceSeq` and the delivery counts are 0,
there are no committed events, and it was never adopted and issued no
credentials;
  - it has no warm, staged or completed transition;
  - there is no `runner/runner-state.json`;
- the latest archived epoch is named by its identity's digest, shares
the runner instance, lease and session, and holds a `suspended` runner
state for that identity.

It moves the undelivered control plane to `continuity-breaks/` as
evidence, then puts the epoch's control plane and runner state back. The
executor then sees the previous owner and takes the normal
suspended-previous-owner path.
- `native-recovered-owner-followup.test.ts`: a red/green test of the
undelivered rotation. A possibly delivered attach (`connectionCount` 1)
and a missing dead-runner claim both still fail closed.

## Verification

- Red/green: on master, the undelivered case fails with exactly the t22
error. With the change, the follow-up is admitted: the previous control
plane is restored, `authority-epochs` is gone, and the undelivered
control plane is kept under `continuity-breaks`. The two fail-closed
cases fail on both.
- `vitest run` of `native-recovered-owner-followup.test.ts`,
`native-session-executor.test.ts`,
`native-runner-restart-recovery.integration.test.ts` and
`native-session-root-entries.test.ts`: 4 files, 1508 tests passed (14
skipped); after rebasing onto #20140,
`native-recovered-owner-followup.test.ts` again: 65 passed.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Medium-low risk. The rollback changes durable state, but only for a
closed shape that proves the attach never reached any runner, after
recovery proved the runner process dead.
- A crash in the middle of the rollback leaves a root that still fails
closed, as before this change.
- The test exercises admission up to tool authority, not a full
real-runnerd turn. A chaos rerun of the K1 warm-follow-up window is the
remaining check.

## 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 19:36:44 -06:00
DottaandClaude Opus 5.5 bd97a3e6f7 fix(native): retire a finished per-turn owner past its own quarantined interrupted bootstrap (#20140)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A follow-up turn on a per-turn session must retire the previous
turn's runner state before it starts a new runner. Retirement proves the
previous owner finished, and it also proves that every quarantined copy
of that session root is settled
> - If restart recovery interrupts a run's own start (for example a hard
kill during `session.open`), recovery proves the half-started bootstrap
held no provider work, quarantines that root, and bootstraps the same
run again. The quarantined copy keeps the run's identity
> - After that run finished, retirement checked the old bootstrap copy
as if it were a finished owner. An unfinished `session.open` (or an
opened session without a turn) is never "settled", so the follow-up
failed with `runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate` and needed a Retry
> - This pull request skips a quarantined copy of the same owner when
the same bootstrap proof that let recovery quarantine it still holds
> - The benefit is that a follow-up after a restart-interrupted start
continues on its own

## Linked Issues or Issue Description

Refs #20137

**What happened?**
Local restart-chaos K13 regression storm w-20261108T235957-s9401 (master
4420a5adcf): 102 of 105 tasks passed. Two of the three failures share
this cause.

- **t58 (codex C1):**
- A K1 at 00:15:42 interrupted run `78dd97f1` during its first
`session.open`. Recovery quarantined that root (`run.prepare` completed,
`session.open` pending, no events) and bootstrapped again.
- The run then completed. A second K1 hit 190 ms after its result was
accepted, and recovery finalized the result.
- Follow-up `def668a6` failed with `runner_state_identity_mismatch:
prior_owner_terminal_state_indeterminate;
retirement_refused=quarantine_journal_commands_unsettled(session.open.pending)`.
- **t67 (claude C1):** the same pattern. The quarantined copy of run
`990936c0` holds a completed `session.open` with only session-open
events and no turn, so the refusal was
`quarantine_journal_terminal_unsettled`.

Both copies are in the kept instance's quarantine directory under the
previous run's own identity. Each Retry succeeded.

**Expected behavior**
A quarantined copy of the finished owner's own earlier bootstrap that
provably held no provider work does not hold the follow-up. Retirement
proceeds and the follow-up starts without a Retry.

**Steps to reproduce**
1. Let a per-turn run's first bootstrap be quarantined after a restart
(the run bootstraps again and finishes).
2. Start a follow-up turn.
3. Before this change, retirement refuses with
`quarantine_journal_commands_unsettled(session.open.pending)` or
`quarantine_journal_terminal_unsettled`.

This is not a code regression. The quarantine-copy check dates from
#19061. Today's recovery fixes let more interrupted starts bootstrap
again within the same run instead of blocking, and each such restart
leaves this kind of copy behind.

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

## What Changed

- `native-session-executor.ts`: the quarantine loop in
`inspectFinishedPerTurnRunnerdOwner` skips a copy when both of these
hold:
  - its durable identity matches the finished owner exactly;
- `runnerdStateProvesIncompleteBootstrap` holds for it. That is the
proof restart recovery uses before it quarantines an interrupted
bootstrap: no provider turn, and for a local runner, its journal and
processes proven gone.
  
  Every other copy still has to pass the settled-projection proof.
- `native-recovered-owner-followup.test.ts`: a red/green test covering
both cases:
  - an interrupted bootstrap copy is retired past;
  - a copy that had already issued a turn still holds the follow-up.

## Verification

- Red/green: on master, the new admitted case fails with exactly the t58
error. With the change it is admitted. The turn-issued copy is refused
on both.
- `vitest run` of `native-recovered-owner-followup.test.ts`,
`native-session-executor.test.ts` and
`native-runner-restart-recovery.integration.test.ts`: 3 files, 1504
tests passed (14 skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Low risk. The skip reuses the existing bootstrap-incomplete proof and
requires an exact identity match, so a foreign or work-holding copy
still blocks. Nothing is deleted; the copy stays in quarantine.
- Not covered: t22 in the same storm, a follow-up whose own unconnected
`run.attach` root was hit by a K1 (`authority_indeterminate`), has a
different shape and is tracked separately.

## 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 19:21:32 -06:00
DottaandClaude Opus 5.5 8e3b64a257 fix(runner): an unknown agent id says which id and how to find the right one (#20135)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents delegate by creating subtasks through Paperclip's tools. A
tool error must tell the agent how to recover, or it improvises
> - Restart chaos t06 ended a delegation with four children instead of
two
> - Evidence showed no replay: the model built an assignee id from two
real agents' halves and got a bare `paperclip_runner_agent_not_found`
four times
> - To debug the error it created "diagnostic test" subtasks without an
assignee. Those succeeded and were left behind as duplicates
> - This pull request makes the error say which id was not found, which
real agents it most resembles, and to call `list_agents`
> - The benefit is that an agent corrects the id instead of creating
throwaway tasks

## Linked Issues or Issue Description

Refs #19831 (durable ownership plan). Restart chaos wave
`w-20261108T225520-s9501`, task t06 (claude, M1, K2 drain).

**Problem:** parent run 2751fe3c created Part 1 correctly. For Part 2 it
passed `assigneeActorId: 0cb62ac2-742d-442c-acc9-d21dd936e716`: Chaos
claude's prefix with Chaos codex's suffix.
- Four `create_task` calls (keys `part2`, `part2` again, `-v2`,
`-backlog`) each returned only `paperclip_runner_agent_not_found`. One
of them crossed the K2 drain and failed the same way as the one before
it.
- The model then created "Part 2 06f119cf test" (`-noassignee`) and
"Part 2 06f119cf" (`-title-test`), both described as "diagnostic test -
will be removed/ignored". With no assignee they defaulted to the caller.
They stayed in backlog.
- Only after `list_agents` did it create the real Part 2.
- Same-key replays were never the issue: `create_task` stores the
`idempotencyKey` as the child's `origin_id`.

## What Changed

-
`server/src/services/native-runtime/paperclip-runner-tool-authority.ts`:
- `create_task` (assignee) and `get_agent` now fail with
`paperclip_runner_agent_not_found: no agent in this company has id
"<id>". Closest ids: <name> (<id>), …. Call list_agents and copy a valid
id exactly; nothing was created or changed.`
- Suggestions are up to three agents of the caller's company whose ids
share at least eight leading or trailing characters with the missing id.
Another company's agents are never named.
- The recoverable-read check matches the error code before the `:`
details, so a recovered `get_agent` read still returns the error as a
tool result.

## Verification

Red/green:
- `paperclip-runner-tool-authority.test.ts` "names a missing agent id
and its closest real agent instead of a bare code (restart chaos t06)".
It uses t06's spliced id, with a second agent "Chaos claude" in the
company.
  - On master: the error is the bare code (red).
- With this PR: both `create_task` and `get_agent` name the id, `Chaos
claude (0cb62ac2-…)` and `list_agents`, and no task is created.
- The file passes (42 tests), including the existing foreign-company
assignee rejection. `tsc --noEmit -p server` is clean.

## Risks

- One extra read of the company's agents, only on the failure path.
- The message names agents of the caller's own company, which
`list_agents` already returns to it.
- No schema or persisted state change.

## Model Used

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

## 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 (#20069, #20108)
- [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 (no
user docs affected)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending)
- [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 18:26:11 -06:00
DottaandClaude Opus 5.5 e194e1930b fix(native): prove a drained remote start had no turn when its controller only read the goal (#20137)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a graceful drain suspends a remote native run during its start,
restart recovery may bootstrap it again only if the run's Postgres
controller checkpoint proves that no provider turn exists
> - That proof accepted `run.prepare`, `session.open` and session-open
events
> - A codex controller reads the session goal (`session.goal.get`, a
read-only query) right after it opens the session and before it sends
the first turn. A drain that lands in that window leaves a third command
in the checkpoint
> - The proof refused any checkpoint with a third command. Recovery
called it `ambiguous_remote_recovery_evidence` and blocked replay, so
the task waited for the board
> - This pull request lets the proof accept the runner maintenance and
read-only session commands that already count as maintenance in the
local proof, and reads every recorded command
> - The benefit is that a run drained in the first seconds of a per-turn
Daytona start bootstraps again on its own

## Linked Issues or Issue Description

Refs #20136

**What happened?**
Daytona per-turn restart-chaos wave w-20261108T225520-s9501 (master
15b987d9b8), task t06, subtask CHA-13, run `687578c9`:
- A K2 graceful drain at 23:11:55.096 suspended the run 4 s into its
start.
- Restart recovery at 23:12:03 blocked it as
`native_restart_recovery_blocked` /
`ambiguous_remote_recovery_evidence`, and replay was blocked as
`preserve_without_replay_v1`.
- The subtask rested `blocked`. Its parent CHA-10 then failed R1.

I decrypted the run's controller checkpoint with the kept instance's
local key:
- **commands:** `run.prepare` (completed), `session.open` (completed),
`session.goal.get` (completed, written 18 ms before the drain);
- **events:** `session.started`, `session.capabilities.updated`,
`session.goal.snapshot` ×2;
- **cursor:** `ackedSourceSeq` 4, `commandCount` 3.

No turn was ever sent. `nativeRunnerCheckpointRemoteBootstrapProof`
rejected the checkpoint because only the parts `command:1` and
`command:2` were allowed, and `runnerdStateHasNoProviderTurn` accepted
only bootstrap commands.

**Expected behavior**
A controller record that holds only a completed bootstrap, read-only
session commands and session-open events proves that no provider turn
exists. The run bootstraps again (`remote_session_opened_without_turn`)
without a Retry.

**Steps to reproduce**
1. Start a codex run on a remote sandbox and drain the control plane
after `session.open` completes and `session.goal.get` is issued, but
before `turn.start`.
2. Restart. Before this change, recovery blocks the run with
`ambiguous_remote_recovery_evidence`.

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

## What Changed

- `native-runner-checkpoint.ts`
(`nativeRunnerCheckpointRemoteBootstrapProof`):
  - accepts up to 8 `command:N` parts and reads them all in order;
- requires the stored command parts to match the cursor's
`commandCount`, so no command can be missing;
  - keeps the 16-event bound.
- `native-bootstrap-proof.ts` (`runnerdStateHasNoProviderTurn`): besides
`run.prepare` and `session.open`, also accepts the existing
`RUNNER_MAINTENANCE_COMMAND_TYPES`:
  - `runner.drain`, `runner.suspend` and `runner.shutdown`;
  - `session.snapshot` and `session.goal.get`.
  
The local opened-session proof already treats these as maintenance; none
of them sends provider input.
- `native-runner-restart-recovery.integration.test.ts`:
- a red/green test with the exact CHA-13 shape, covering both the
recovery claim and the executor's restored-checkpoint proof;
  - a held case where a `session.goal.set` follows the read.

## Verification

- Red/green: on master, the new test is blocked. With the change,
recovery claims `bootstrap_incomplete` with the reason
`remote_session_opened_without_turn`, and the executor's proof agrees.
- Existing held cases still hold: a turn was issued, a turn event was
committed, an acknowledged event is missing, a provider turn event was
projected, and the new `session.goal.set` case.
- `vitest run` over the native bootstrap, restart-recovery and
runner-checkpoint test files and `native-session-executor.test.ts`: 9
files, 1542 tests passed (14 skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Low risk:
- The added commands are the same maintenance and read-only set the
local proof already accepts.
- Any turn command, any non-session-open event, a missing acknowledged
event, or a command count that differs from the stored parts still fails
closed.
- Not changed: when replay was already blocked and the saved work was
later recovered, the task still waits for a board Retry. This fix keeps
that block from happening for this shape.

## 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 18:00:01 -06:00
DottaandClaude Opus 5.5 6451d2bfbf fix(native): verify a staged attachment restart on the claimed sandbox lease (#20136)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A follow-up codex run on a per-turn Daytona sandbox gets a fresh
sandbox, with harness state restored from the previous turn's
checkpoint, and attaches the previous runner session to the new run
through a staged attachment
> - A graceful restart during that start suspends the run. The next
controller reattaches to the surviving remote runner, after proving it
is on the sandbox lease the restart claim names
> - The staged-attachment path required the lease to have been acquired
as `resumed`. A per-turn follow-up's lease is `created`, so every such
restart was refused with
`native_runner_staged_attachment_recovery_unproven`
> - The run then needed a Retry
> - This pull request binds the staged restart to the claimed lease the
same way the ordinary remote reattach does: same provider lease, same
remote directory, and not a replacement sandbox
> - The benefit is that a per-turn follow-up interrupted by a restart
continues on its own

## Linked Issues or Issue Description

Refs #20103

**What happened?**
Daytona per-turn restart-chaos wave w-20261108T225520-s9501 (master
15b987d9b8, image cbbe8224). Two codex C1 follow-up runs, created during
a K2 graceful drain at 23:11:45, were suspended mid-start:
- `4d1d3563` (t07);
- `71b43ea1` (t08).

Two to three seconds after the controller came back, both failed with
`native_runner_staged_attachment_recovery_unproven`, rested in
`native_continuation_requires_reconciliation`, and needed a Retry. The
stack shows the first guard in `verifyStagedAttachmentRestart`. Both
runs' environment leases were acquired with
`sandboxLeaseAcquisition.outcome: "created"`, because per-turn mode
creates a fresh sandbox for each turn. The guard accepted only
`"resumed"`.

**Expected behavior**
When the restart claim's provider lease and remote directory match the
run's own lease, the staged attachment's restart is verified as before,
and the run continues without a Retry. The rest of the staged proof is
unchanged. A replacement sandbox, or a different provider lease or
directory, is still refused.

**Steps to reproduce**
1. Run a codex follow-up on a per-turn Daytona environment, so the lease
is `created` and the start stages an attachment.
2. Gracefully restart the control plane during the start.
3. Before this change, the restarted run fails with
`native_runner_staged_attachment_recovery_unproven`.

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

## What Changed

- `verifyStagedAttachmentRestart` (`native-session-executor.ts`) no
longer requires `sandboxLeaseAcquisition.outcome === "resumed"`. It
requires the same lease binding as `verifyRemoteRunnerReattachment`:
- the target's provider lease equals the claim's
`remote.providerLeaseId`;
  - the target's remote directory equals the claim's `remoteCwd`;
  - the lease is not a `replacement` sandbox.
- A new test case in `native-session-executor.test.ts` covers a created
sandbox (admitted), a replacement sandbox, another provider lease, and
another remote directory (all refused).

## Verification

- Red/green: on master, the created-sandbox case fails with
`native_runner_staged_attachment_recovery_unproven`. With the change it
reaches the staged recheck. The three refusal cases are refused on both.
- `vitest run` of `native-session-executor.test.ts` and
`native-restart-recovery-isolation.test.ts`: 2 files, 1305 tests passed.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Low risk. The check now matches the ordinary remote reattach, which
already admits created leases. Everything after it is unchanged: the
encrypted staged header, the source run and result, the template digest,
and the remote runner identity are all still proven before any RPC.

## 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 17:59:56 -06:00
DottaandClaude Opus 5.5 77168d3367 fix(recovery): settle a dead controller's result lease for a run a pause cancelled (#20113)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A budget hard stop pauses the agent, which cancels its running work
and waits until every execution has provably stopped
> - The shared ownership reconciler (#19933) settles an accepted
result's executor lease when its controller is proven dead, so
finalization does not wait out the 20-minute lease
> - In the restart-chaos H1 wave, the pause cancelled a run 0.36 s after
its result was accepted, and a hard kill then took the controller
> - The settle only acted on runs that were still `running`, so the dead
lease held finalization, the agent stayed in `pausing`, and every later
task for it was stranded
> - This pull request settles that lease for a cancelled run too, and
records the budget pause with its reason
> - The benefit is that a pause crossed by a restart completes at once
and resume continues normally

## Linked Issues or Issue Description

Refs #19933, #20101

**What happened?**
Restart-chaos wave w-20261108T210118-s9802 (scenario H1). An agent-scope
budget hard stop paused agent "Chaos claude". The pause cancelled run
`dfc22298` at 21:02:27.818, 0.36 s after its result was accepted
(finalization phase `workspace_finalizing`, executor lease until
21:21:55, controller PID 78157). A K4 SIGKILL followed. After the
board's `raise_budget_and_resume`, the agent stayed `status=paused`,
`lifecycle_state=pausing`, `lifecycle_holds=[]`, with its lifecycle
operation at `attempts: 90`, `hostComplete: false` and no error. The
pause host step (`stopInvocationsForAgents` →
`agentExecutionsHaveStopped`) returns `pending` while any coordinator
holds an owner lease with an accepted result.
`settleNativeAcceptedResultLease` required `heartbeat_runs.status =
'running'`, so it returned `lease_no_longer_held` for the cancelled run.
The pause also wrote no activity entry.

**Expected behavior**
A proven-dead controller's accepted-result lease is settled whatever
stopped the run, so finalization finishes and the pause completes. The
budget pause is logged with its reason.

**Steps to reproduce**
Pause an agent right after its run's result is accepted, then SIGKILL
the server. After restart, the agent stays in `pausing` until the lease
expires (up to 20 minutes).

## What Changed

- `native-accepted-result-lease-settlement.ts`: settle the lease for a
`running` or `cancelled` run. All other guards are unchanged: exact
owner compare-and-set, owner proven gone by `proveOwnerGone`, remote
copyback excluded, another host never proof.
- `agent-lifecycle.ts` `applyAgentBudgetHoldInTransaction`: when the
budget hold is added or removed, records `agent.paused` /
`agent.resumed` with `reason: "budget"`, the lifecycle state and the
holds, in the transaction that applies the hold. Applying the same hold
again records nothing.
- `budgets-service.test.ts`: its activity-log mock now also stubs
`persistActivity`.

Not changed, by design: resume still waits for an incomplete pause to
finish before resuming. The pause's host step proves the agent's
executions stopped before new work starts. In H1 it was blocked only by
the dead lease, which is now settled at restart.

## Verification

- `heartbeat-native-restart-suspension.test.ts`: new "finalizes an
accepted result whose run a pause cancelled before a hard kill took its
controller". It fails on master (lease still 20 minutes out) and passes
with the fix (lease owner cleared, finalization past
`workspace_finalizing`). Whole file: 41 passed.
- New `agent-budget-hold-activity.test.ts`: the pause and release
entries, with the reason, written once each.
- `budgets-service.test.ts` (19), `budgets-history.test.ts`,
`company-portability-routes.test.ts`,
`agent-lifecycle/__tests__/lifecycle.test.ts`: passed.
- `server` typecheck (`scripts/typecheck-layers.mjs`, low-memory): no
errors.
- Not run: the H1 chaos rerun (Phase 3 schedules it after merge).

## Risks

- A cancelled run's accepted result is now finalized promptly after a
hard kill instead of after the lease term. The finalizer already handles
a run cancelled during finalization; the settle only shortens the wait.
- New activity entries on budget pause and release. Run-log and activity
only; no Telemetry.

> 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-08 17:06:13 -06:00
DottaandClaude Opus 5.5 776f4cb602 fix(native): restart an interrupted remote bootstrap on a resumed reusable lease (t06) (#20103)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run on a remote sandbox must survive a control-plane
restart; a bootstrap interrupted before its first turn is restarted on
the same run
> - With a reusable (warm) sandbox lease, the restart always resumes the
same sandbox. The interrupted runner's state is still on disk there:
never suspended, and with no provider turn
> - The resumed check accepted only a suspended checkpoint or no state
at all, so it failed closed with `resumed_not_suspended`, and the task
needed a Retry (restart chaos t06)
> - The pre-phase had already proven no turn was sent and had stopped
the stale runner, but that proof never reached the resumed check
> - This pull request carries that proof forward, for this run only, and
starts the bootstrap again on the resumed sandbox
> - The benefit is that a warm task interrupted during startup continues
on its own after the restart

## Linked Issues or Issue Description

**What happened?**
In restart-chaos wave w-20261108T192459-s9501 (Daytona warm, claude C1),
run 9bf86b59's runner reached transport-ready at 19:37:42 UTC. The
control plane was then down (another task's K4, then this task's K1).
Restart recovery classified the run `bootstrap_incomplete` and restarted
it on the same run at 19:41:51. The reusable lease resumed the same
sandbox. `inspectRemoteHarnessState` found this run's runner state with
`lifecycle` other than `suspended`, and the run failed at 19:41:59 with
`runner_ingress_unavailable: runnerd process failed:
runner_harness_state_mismatch: resumed_not_suspended`. The task needed a
manual Retry. Per-turn and non-reusable leases do not resume, so they
take another branch and were unaffected.

**Expected behavior**
A same-run restart of a bootstrap that provably sent no turn starts the
bootstrap again on the resumed sandbox, with no Retry. It must never
take over a live provider session or another run's warm session.

**Steps to reproduce**
1. Run a task on a reusable (warm) Daytona lease.
2. Kill the control plane after the runner connected and before the
first turn, so the run is `bootstrap_incomplete`.
3. Before this change, the restart fails with `resumed_not_suspended` on
the resumed lease.

**Paperclip version or commit**
master at d007afbf86 (the wave ran on c5a172a183).

## What Changed

- `native-session-executor.ts`:
- **Pre-phase:** the `bootstrap_incomplete` remote-sandbox pre-phase
clears any earlier proof for the run, then records a per-run proof
(`recordRemoteBootstrapRestartProof`). It records one only for a
confirmed stop:
    - `absent_confirmed`: verification reported `process_not_running`;
    - `stopped` or `killed`: the exit was confirmed after the signal.

An uncertain probe or a runner that outlives SIGKILL records nothing
(`stopStaleRemoteRunnerBeforeBootstrap` now distinguishes these
outcomes).
- **Inspection:** `inspectRemoteHarnessState` also returns the `runId`
of a not-suspended runner state (`notSuspendedRunId`). Its other fields
are unchanged.
- **Resumed check:** on a `bootstrap_incomplete` restart, a
not-suspended state with no backup is restarted only when all of these
hold:
    - the on-disk state is this run's own;
    - the proof is present;
- the interrupted session folder moved aside by rename, kept as evidence
and never deleted. The runtime root, the sessions parent and the folder
must each be a real, non-symlink directory.

    The proof is consumed only after the move succeeds.

It then claims a fresh session through the existing
`claimUntouchedRemoteSession`, which still requires an unconnected
controller with matching identity.
- Every other case keeps `runner_harness_state_mismatch:
resumed_not_suspended`.

## Verification

- Red/green: a new resumed-lease scenario, `interrupted_bootstrap`, in
"bootstraps only an untouched provider session in a $outcome workspace
lease". It fails without the change and passes with it. It checks that
the old session folder is kept beside the new claim and that the claim
runs exactly once.
- Counter-cases that still fail closed, with nothing moved aside:
`interrupted_bootstrap_other_run` (a previous warm run's state on disk),
`interrupted_bootstrap_unproven` (no pre-phase proof) and
`interrupted_bootstrap_symlink_parent` (a symlinked sessions parent).
The existing warm "no-proof" case keeps `resumed_not_suspended`.
- Stale-runner stop: "does not confirm absence when the process probe is
uncertain", "reports a runner that outlives SIGKILL as not stopped", and
"records a bootstrap restart proof only for a confirmed stop" (one case
per outcome).
- `pnpm exec vitest run
src/services/native-runtime/native-session-executor.test.ts
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts`
(server): 2 files, 1440 tests passed (14 skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (server): passed.

## Risks

- **Taking over a live session:** this is the risk to watch. It is
bounded by every gate together: the `bootstrap_incomplete` claim, the
pre-phase proof from the restored controller record (no turn sent) with
the stale runner stopped, this run's own on-disk state, no backup, the
folder move, and the existing unconnected-controller claim. A failure at
any step keeps the current fail-closed error.
- **Upgrade safety:** the old runner's session folder is kept by rename,
never deleted. The change applies only to state a restart left behind,
so old-saved state takes the same gated path.

## Model Used

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

## 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 15:01:30 -06:00
DottaandClaude Opus 5.5 d007afbf86 fix(native): let a follow-up wait out the previous turn's warm release instead of failing busy (#20068)
## Thinking Path

- With a warm lifecycle, one session serves consecutive turns on a task.
A follow-up comment wakes the next run, which claims the warm session's
instruction copy and reuses the session.
- #20048 makes warm the default on remote sandboxes. Its merge condition
includes "no restart or follow-up ever leaves a task needing Retry".
- In the #20048 Daytona chaos wave, task t12's follow-up run was created
43 ms after the previous run settled. The previous run was still
releasing its warm session, so `claimWarmNativeInstructionCopy` saw it
busy and failed the run with `native_session_supervisor_busy`. The task
then needed a Retry.
- The release takes seconds and always finishes, so a follow-up should
wait for it rather than fail. A session busy because another run is
preparing or executing a turn is a genuine conflict and must still fail
at once, as existing tests require.

## Linked Issues or Issue Description

Refs #20048

**What happened?**
Wave w-20261108T142906-s9501, t12 (claude, warm): run 5b839da4 succeeded
at 14:46:36.224. The follow-up run f2bf2f05 was created at 36.267 and
failed immediately with `adapter_failed` /
`native_session_supervisor_busy`, from `claimWarmNativeInstructionCopy`
called by `instruction-working-copy.prepare`. Its automatic retry also
failed, and only a user Retry completed the task.

**Steps to reproduce**
1. Run a warm native session turn to completion.
2. While that run is still releasing its warm session (post-turn
instruction check, release, hand-back), admit the next run on the same
session.
3. The next run's instruction-copy claim throws
`native_session_supervisor_busy`.

**Expected behavior**
The follow-up waits for the previous run's release (bounded) and reuses
the session. A session held by a run that is still preparing or
executing fails admission at once, as before.

## What Changed

- `server/src/services/native-runtime/native-session-executor.ts`:
- When the provider turn ends, the executor records the session scope as
settling (`settlingNativeSessionScopes`). The outer `finally` clears the
mark once the run has handed every scope back.
- `claimWarmNativeInstructionCopy` waits, polling every 25 ms for at
most 30 s (`warmSessionReleaseWait`), while the warm entry is busy and
its scope is settling. It then re-reads the entry and runs the existing
checks. If the entry is still busy at the deadline, or busy for any
other reason, it throws `native_session_supervisor_busy` as before.
- No wait happens on the normal path (entry idle), so turn admission
adds no latency.
- `server/src/services/native-runtime/native-session-executor.test.ts`:
two cases.
- A follow-up claimed while the previous turn's post-turn check is held:
it waits, then succeeds once the release finishes. It fails on master
with the wave's error.
  - The release outlasts the bound: it fails busy.

**Previous-release state:** the change touches only in-memory admission
state, so no saved run or session state changes.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-session-executor.test.ts
src/services/heartbeat/instruction-working-copy.test.ts`: 1301/1301
pass.
- With the wait removed (behaviour on master), the "release finishes"
case fails with `native_session_supervisor_busy`.
- The existing concurrency tests ("fences owned instruction preparation:
concurrent", and the settled-release ownership tests) still fail
admission at once.
- Not yet covered: the Daytona warm wave rerun for #20048.

## Risks

- **A wait that never ends.** The wait is bounded at 30 s and happens
only while the owner is past its turn, which always reaches the outer
`finally`. Every caller of the inner executor runs inside that `try`,
and attachment staging keeps the same session scope.
- **Hiding a real conflict.** Only a settling owner is waited for. A
preparing or executing owner fails at once, as the existing tests
assert.
- **Scope.** I tried a similar wait at execution admission, but it
conflicted with deliberate fences that reject a run during a settled
release (the existing "releases settled codex only under its current
owner" tests). It is not part of this PR.

## 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-08 13:57:32 -06:00
DottaandClaude Opus 5.5 b32ef9f111 perf(budgets): judge time to first visible agent activity; keep time to first text as a reported metric (#20077)
## Thinking Path

- The native speed budgets (plan invariant 8) judge how fast an agent
responds. They judged `task.time_to_first_token`: time to the first
deliverable **text**.
- In the clean local restart storm (w-20261108T153612-s9401), Claude
breached its TTFT budget (cold p50/p95 17.4/34.7 s against 16.5/30). The
per-run spans showed this was not a control-plane wait: in all 49 cold
runs (and 76% of warm), Claude started a tool call before writing any
text. Its first visible activity came at 7.4 s p50 from run creation;
first text came at 19.6 s. Codex never calls a tool first.
- So first text charges model tool work as latency for tool-first
adapters. What the user experiences as "the agent responded" is the
first visible activity: a text delta, a tool-call start, or a thinking
indicator.
- The coordinator decided: budgets judge time to first visible agent
activity from run creation, with the budget numbers unchanged; first
text stays a reported metric with no budget for tool-first adapters.

## Linked Issues or Issue Description

**What happened?**
Local storm s9401: the Claude TTFT budget breached on every aggregate,
while the control-plane steps (queue 0.05 s, prepare 0.3 s, runner
startup ~4 s, first provider event 1.3–1.8 s) were well within budget.
The rest of the first-text wait was Claude streaming tool input (about
5.5 s per call) and running tools before it wrote text.

**Steps to reproduce**
1. Run a Claude task whose first action is a tool call (most real
tasks).
2. Read the run log: `provider.time_to_first_agent_event` ends at the
tool call; `task.time_to_first_token` ends many seconds later at the
first text.
3. The speed budget judges the later one and reports a breach.

**Expected behavior**
The budget judges the first visible agent activity from run creation
(`task.time_to_first_agent_activity`). Time to first text is still
recorded and reported.

## What Changed

- `server/src/services/native-runtime/native-session-executor.ts`: when
the first meaningful agent event commits (the existing
`firstMeaningfulAgentEventKind`: text delta, tool-call start, or
reasoning), record `task.time_to_first_agent_activity`. It uses the same
detached anchor resolution as TTFT (accepted comment, answered question,
durable wake request, or run creation), so it adds nothing to the turn
path.
- `server/src/services/native-runtime/native-speed-budgets.ts`: the
metric is now named `time_to_first_activity` (was `ttft`), with
identical budget numbers. The type comment documents the definition and
why first text is unbudgeted.
- `server/src/services/native-runtime/native-run-trace.ts`: the budget
check judges `task.time_to_first_agent_activity` from run creation,
keeping the existing input-wait handling. `task.time_to_first_token` is
no longer budgeted.
- `tests/restart-chaos/ttft-baseline-stats.ts` and `ttft-baseline.ts`:
report first activity from run creation (the budgeted value) next to
TTFT.
- Docs: `doc/observability.md` (new span) and
`doc/plans/2026-11-07-ttft-baseline.md` (metric table).
- Tests:
- A tool-first turn records first activity at the tool call (5 s) while
TTFT records first text (16 s).
- A tool-first trace with slow first text but fast first activity is
within budget.
  - The existing budget tests now judge the activity span.

Observability path (AGENTS.md section 5.7): the new run-log span uses
only the existing `eventKind` and `anchor` attributes. Its name survives
run-log redaction intact; checked with `redactEventPayload`. The
operator OTLP gate is unchanged. It is not Telemetry.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-speed-budgets.test.ts
src/services/native-runtime/native-run-trace.test.ts`: 44/44 pass.
- `cd server && npx vitest run
src/services/native-runtime/native-session-executor.test.ts -t "first
visible activity|first assistant text once|first-token timing"`: 7/7
pass.
- `npx vitest run --root tests/restart-chaos`: 159/159 pass; `tsc` on
`tests/restart-chaos` is clean.

## Risks

- **A metric rename.** Consumers of `run.performance.budget_exceeded`
see `metric: "time_to_first_activity"` instead of `"ttft"`. In-repo
there are none besides the tests; Phase 3's chaos speed grade switches
to the new span.
- **A looser budget for slow first text.** Intentional: first text is
reported, not judged. A regression in control-plane latency still shows
up in first activity.

## 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-08 12:32:42 -06:00
DottaandClaude Opus 5.5 a9cf4ee731 fix(outbox): deliver a user comment whose wake was lost on a task its agent then closed (#20066)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A user's comment on a task with a live run must reach the agent: it
is steered into the run or delivered in a follow-up run. Per-comment
exactly-once delivery is plan invariant 4
> - A comment commits before its wake is written. A control-plane
restart in between loses the wake, and the reconciler's lost-wake rule
(chaos s9401/t69) saves the missing outbox entry
> - That rule only looked at `todo` and `in_progress` tasks. In the
restart chaos Daytona wave s9501, task t06 got a comment 10 ms before a
K1 kill. The live run then finished, its agent closed the task, and the
comment was never read
> - This pull request extends the rule to a task its agent closed, and
reopens that task for the user's message, as the release drain already
does
> - The benefit is that a message sent while the agent works is never
silently dropped by a restart

## Linked Issues or Issue Description

Refs #19831 (plan, section E: work-intent outbox, invariant 4), and
#19992 (the s9401/t69 and t72 rules this extends). Found by the restart
chaos campaign, wave w-20261108T142906-s9501, task t06:

- The comment was saved at 14:40:17.048.
- The K1 kill came at .058.
- No wake request ever carried the comment, and no run event shows it
steered in.
- The run that started before the comment finished at 14:44:22 and
closed the task.

## What Changed

- `recovery/ownership-invariant-report.ts`
(`checkUserCommentsWithoutWake`): also reports a comment on a `done`
task its agent closed (`taskClosedByAgentSql`), when no wake carries it,
no run started after it, and no run of the task logged it as a steered
`userMessage` item.
- `work-intent-outbox.ts` (`saveUndeliveredUserComments`): a new
`reopenAgentClosedDone` option. In the same transaction that locks the
issue, re-checks the agent close and saves the receipt, it reopens the
task to `todo` through the issues service. It also logs `issue.updated`
(reopened from done, source `undelivered_comment_backstop`).
- `native-finalization-reconciler.ts` (`applyOwnershipReportBackstops`):
the lost-wake backstop allows `done` and passes `reopenAgentClosedDone`.
The receipt keeps its `wake_lost` / `reconciler_backstop` wait, so the
existing release path delivers it once.

## Verification

- `heartbeat-held-comment-wake.test.ts`, new test "delivers once a
comment whose wake a restart lost mid-run on a task the run then
closed". It fails on `master` and passes here. The violation is found,
the backstop saves and reopens, the comment is delivered exactly once,
and a second pass and backstop deliver nothing more. All 25 tests in the
file pass.
- `ownership-reconciliation-report.test.ts`, new test "finds a user
comment that a restart left without a wake on a task its agent then
closed". It fails on `master` and passes here: only the lost comment is
reported, not one steered into the run and not one on a task a person
closed. All 26 pass.
- The server package typecheck passes.

## Risks

- **Reopening a done task.** This only happens when the latest recorded
close was not made by a person, the comment is a direct human comment,
and no run received it or had it steered in. That's the same rule the
t72 stale-continuation path and the release drain already apply.
- **The steer check** joins the task's runs to their `item.started`
events by comment ID. It runs only for candidate comments within the
lookback window.

## 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-08 10:08:00 -06:00
DottaandClaude Opus 5.5 c910651115 fix(native): start each rebound run with a fresh restart-continuation marker (#20053)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When the control plane restarts during an agent turn, the runner
restores the same conversation. A Codex, Claude (ACPX) or OpenCode turn
lost in the restart is marked `provider_turn_lost_on_restore`, and the
run continues it once in the same conversation
> - That one continuation is guarded by a disposition-only recovery
marker in the session checkpoint. It is run state: it names a turn of
the run that used it
> - A follow-up run (a user comment on the same task) reuses the
provider session by re-binding the previous run's checkpoint to the new
run. Re-binding clears the run-scoped turn, result and request state,
but it kept the marker
> - The chaos K13 storm (wave w-20261108T134237-s9401) hit this three
times (t24 and t45 on Claude, t77 on Codex). The task's first run had
already used its restart continuation. The follow-up run inherited a
spent marker naming a turn it never had, so when a restart lost its own
turn, the driver kept the session closed. The run failed, and the user
had to click Retry
> - This pull request resets the marker at both re-bind sites, as for
every other run-scoped field
> - The benefit is that a restart during a follow-up turn continues on
its own instead of putting a Retry click on the user

## Linked Issues or Issue Description

Refs #19831 (durable ownership plan, restart continuation). Found by the
restart chaos campaign. In all three failed follow-up runs:

- After `resume_dead_runner`, the run emitted `turn.failed {code:
provider_turn_lost_on_restore, recoverable: true}` for its own active
turn.
- The checkpoint still carried `dispositionOnlyRecoveryConsumed: true`,
with `dispositionOnlyRecoveryTurnId` set to the previous run's
continuation turn.

## What Changed

- `native-session-resume.ts` (`rebindNativeSessionCheckpoint`) and
`native-session-executor.ts` (warm checkpoint rebind): a rebound run
starts with `dispositionOnlyRecoveryConsumed: false` and
`dispositionOnlyRecoveryTurnId: null`.
- Nothing changes inside a run: the driver's reopen rule
(`mapTerminalTurn`) and `nativeRestartInterruptedTurnId` are untouched.
The first commit changed the helper; review found the driver keeps its
own copy of the rule, and the second commit reverts the helper in favour
of this fix.

## Verification

- `native-session-resume.test.ts` "retains provider identity but clears
prior turn and event state" now seeds a spent marker on the previous
run. It fails on `master` and passes here.
- `native-session-executor.test.ts` "starts a new run's usage from the
prior run's total when a warm checkpoint resumes" (3 cases) seeds the
first run's checkpoint with a spent marker and asserts that the second
run starts with it reset. It fails on `master` and passes here.
- The other `native-session-resume.test.ts` tests pass. One test,
"archives a damaged prior epoch before completing guarded same-task
replacement with real runnerd", fails locally identically with and
without this change (ENOENT under `authority-epochs`, from the
real-runnerd environment). It's left to CI.
- Next: a targeted chaos rerun (Codex and Claude C1 under repeated K2)
on this branch. I'll add the result here.

## Risks

- **Previous-release story (upgrade-safe run state):** no persisted
shape changes. A run re-bound by this build gets a fresh marker. A run
re-bound before the deploy, still in flight with an inherited spent
marker, behaves as before: a lost turn falls back to Retry, once.
- Inside a run, nothing changes: the marker still bounds same-run
continuations, together with the provider-attempt restart budget.

## 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-08 08:49:38 -06:00
DottaandClaude Opus 5.5 c8822ffd4b fix(runner): deliver a lead's queued requirement to a busy task's next turn (#20028)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - In chat, a lead agent routes new requirements to tasks that other
agents own
> - The lead uses the native `comment_on_task` tool for this
> - The tool description only says the comment does not reopen or
interrupt the task
> - A lead read this as "the busy assignee never receives it" and tried
other routes, such as the assignee's workspace files
> - The comment API also keeps another agent's comment as a note unless
it sends `resume: true`, and the tool could not send it, so the queued
change was dropped when the worker completed the task first
> - This pull request states the real queued-delivery behavior and adds
an optional `resume` flag to the tool
> - The benefit is that the lead routes the change once, through the
product, and reports it honestly as saved for later

## Linked Issues or Issue Description

**What happened**

The person asked the chat lead to change the headline of an existing
website task. The website assignee was in an active run. The lead read
`comment_on_task` ("without reopening or interrupting it") and concluded
in its reasoning that the running worker would not see a comment. It
then listed the assignee's workspace directory and considered writing
the worker's input file directly. It used its full reply window without
routing the change.

When the lead did post the change with `comment_on_task`, Paperclip
queued it behind the active run. The worker then completed the task. The
wake queue cancelled the deferred wake ("no longer applies to a terminal
task") because an agent comment carries follow-up intent only with
`resume: true`, and `comment_on_task` sent only `{ body }`. The
requirement never reached the worker.

**Expected behavior**

The lead posts the change as a comment on the existing task with
follow-up intent. Paperclip queues it and delivers it to the assignee's
next turn, also when the assignee completes the task first. The lead
tells the person that the change is saved for that next turn, not
already applied.

**Steps to reproduce**

1. Start a task for a worker agent and keep the worker in an active run.
2. In the lead's chat, ask for a change to that task's requirements.
3. Read the lead's reasoning: it cannot tell from the tool description
that a comment reaches a busy assignee later.
4. When the lead comments, let the worker finish the task as done: no
continuation run starts for the queued comment.

## What Changed

- `server/src/services/native-runtime/runner-bridge-tools.ts`: the
`comment_on_task` description now says to use it for a new or changed
requirement on an existing task, instead of a duplicate task or the
task's workspace files. It says that a comment on a running task is
queued and delivered at the start of the assignee's next turn on that
task, and that the agent reports it as saved, not applied.
- The same tool now accepts an optional `resume` boolean. The
description tells agents to set it for a requirement change, and
explains that without it a comment on finished work is only a note.
-
`server/src/services/native-runtime/paperclip-runner-tool-authority.ts`:
`taskBridgeApiRequest` forwards `resume: true` to `POST
/api/issues/{id}/comments` and omits it otherwise. The existing
comment-route permission check for follow-up intent on another agent's
task still applies.
- `server/src/services/native-runtime/runner-bridge-tools.test.ts`:
regression tests assert the advertised description and schema, and the
forwarded request body.

## Verification

- `cd server && pnpm exec vitest run
src/services/native-runtime/runner-bridge-tools.test.ts` passes (11
passed, 6 platform-skipped).
- The wake-queue rule that keeps a resume-intent comment alive after
completion is already covered in
`server/src/modules/wake-queue/application/use-cases.test.ts`
(`done_no_resume` against resume).
- End to end: the `agent-chat.multiple-handoffs` chat eval, run with
this commit cherry-picked onto its case branch. Results are added as a
comment.

## Risks

- Low risk. No route or permission changes. The new `resume` field uses
the existing comment API flag and its existing authorization (assignee,
manager override, or open-write access).
- An agent that sets `resume: true` on a comment to finished work
reopens that work. This is the existing API behavior; the description
limits it to requirement changes.
- A runner with an active session goal can steer a comment into the live
turn. The description describes the normal queued path. It does not
promise the live path.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), agentic coding 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-08 06:06:56 -06:00
DottaandClaude Opus 5.5 82a1fda9b1 feat(recovery): settle observed-workspace preservation fences on the shared path (#20022)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a native run on a remote sandbox fails mid-turn with unsaved
files, Paperclip pins an observed-workspace preservation fence. The
sandbox is stopped and retained, never deleted, and the run is settled
only after the provider confirms the stop
> - The fence is held by the run's expired coordinator lease. Its
release proof is the provider's stop receipt, and only the
environment-settlement worker acted on it
> - The ownership reconciler could not see these fences at all: they sit
on the preserved-workspace recovery action, which is resolved (no
replay) while the fence still waits
> - The durable ownership plan has every in-progress record visible to
the one reconciler, and settled from it once its owner is proven gone
> - This pull request makes the fence visible, proves it by the provider
stop, and settles it through the same function the worker already uses
> - The benefit is that a fence whose sandbox stopped can no longer sit
unseen, and expiry alone never releases one

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory:
"Observed-run workspace preservation fence").

**What happened?**
`recoveryActionKind` lists only unresolved recovery actions, so
`observed_workspace_preservation_fence` reported nothing: its fence
lives on a resolved action. The kind registered no settle, and its fence
owner never carried the provider proof.

**Expected behavior**
Every `stop_pending` fence is listed. The fence is `gone`
(`provider_stopped`) once its run's one remote lease has a termination
receipt and a confirmed stop-and-retain. The reconciler then settles it
with `settleStoppedObservedWorkspace`, which re-checks every fence field
under its own locks. An expired coordinator lease without that receipt
is `unprovable` and is never released.

## What Changed

- `recovery/ownership-registry.ts`:
- `recoveryActionKind` takes `includeResolved`, which only the observed
fence uses.
- `resolveFenceHolders` adds `provider: "stopped"` for observed fences
whose run lease carries the stop proof.
- The kind registers a settle that calls
`settleStoppedObservedWorkspace`. A source-busy lock conflict is kept
for a later pass.
- `native-observed-workspace-preservation.ts`: pinning a fence wakes the
ownership worker in the same transaction.
- The settling-kinds list in `ownership-reconciliation-report.test.ts`
is updated.

## Verification

- `heartbeat-observed-workspace-preservation.test.ts`: 29 pass. The new
test "settles the fence from the shared reconciler once the provider
stop is proven, never before" fails on `master` and passes here:
- a pinned fence with an expired lease is
`fence_expired_without_provider_proof` and is not settled;
- after the provider stop, a read-only pass sees `provider_stopped` and
changes nothing;
- the settle moves the fence to `stopped` and the coordinator to
`terminal_failure`, and the saved files are untouched.

All 15 existing "keeps unsafe observed source held" cases and the
stale-evidence cases still pass.
- `ownership-reconciliation-report.test.ts` (25) passes.
- The server package typecheck passes.

## Risks

- The kind's listing now includes resolved recovery actions that carry a
`stop_pending` fence. Like every recovery-action kind, it is an
evidence-predicate scan; no new index is added.
- The settle is the existing worker's function with all its fence
checks. The reconciler is a backstop for that worker, not a new release
rule.

## 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-08 05:56:08 -06:00
DottaandClaude Opus 5.5 484170d13b feat(recovery): prove copyback owners on the shared path by their PID space (#20018)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a server restarts in the middle of copying a native run's
workspace back, the run keeps a receipt that names the controller doing
the copyback, so a second controller never writes the same workspace
> - A successor may take over only after proving that controller and its
copyback children stopped. Three places did this with their own hostname
check and then judged the PID on this host: the finalization takeover,
the local stop proof, and the board confirmation
> - A hostname is not a PID space: two containers can share one, so a
dead PID here says nothing about a controller there
> - The durable ownership plan proves owners by identity through one
shared prover, keyed by PID space
> - This pull request records the PID space on the receipt and routes
all three checks through one shared prover. It also judges the RC-50
copyback-owner hold on the shared path, and a proven-gone owner wakes
its waiting recheck
> - The benefit is that a takeover can never be authorized by the wrong
host's PIDs, and a hold whose owner is provably gone is rechecked now
rather than after its backoff

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory:
"Copyback-owner hold (RC-50)", "Native copyback owner receipt", and the
"One fact, many proofs" row `proveLocalWorkspaceOwnerStopped`). This
follows #19997, which left `proveLocalWorkspaceOwnerStopped` alone.

**What happened?**
The receipt recorded `hostname` but no PID space, so every check
compared hostnames and then probed the PID in this PID space. The
registry could not judge the RC-50 hold (`processSpace: null`), so a
hold whose owner was dead stayed on its backoff.

**Expected behavior**
Receipts carry `hostHash`. `proveCopybackOwnerGone` judges the owner in
that space; another space is unprovable (`foreign_host` / board
attestation). Receipts written before this change keep the previous
same-hostname rule. The hold's owner is judged by the shared prover, and
`gone` makes its waiting recheck due now.

## What Changed

- `native-workspace-owner-stop-proof.ts`: new `copybackOwnerStamp`,
`proveCopybackOwnerGone` and `copybackOwnerOnAnotherHost`.
`proveLocalWorkspaceOwnerStopped` uses the shared prover. Its
process-group orphan check is kept unchanged, because copyback children
are a separate proof.
- `native-workspace-finalization-ownership.ts`: the receipt records
`hostHash`, and the takeover uses `proveCopybackOwnerGone` in place of
the hostname compare plus `evaluateNativeControllerTakeover`.
- `native-workspace-owner-recovery.ts`: the board confirmation refuses a
live or locally unverifiable owner. An owner in another PID space stays
the board's to attest, and `hostHash` must match the recorded receipt.
- `workspace-recovery.ts`: new `wakeCopybackOwnerRecheck`. It makes a
waiting `copyback_stop_unconfirmed` recheck due now (respecting provider
cooldown and the deadline), grants no attempt, and only applies while
the hold still names the proven owner.
- `recovery/ownership-registry.ts`: `copyback_owner_hold` uses the
shared stamp and registers a settle that wakes the recheck. It never
resolves the hold itself.
- The settling-kinds list in `ownership-reconciliation-report.test.ts`
is updated.

## Verification

- `native-workspace-owner-restart-recovery.test.ts`: 18 pass. Three new
tests fail on `master` and pass here:
  - the receipt records its PID space;
- a same-hostname receipt from another PID space is never judged by
local PIDs: the takeover and the stop proof both refuse, and the board
can still attest;
- the RC-50 hold is judged `pid_dead` and its recheck is woken with no
attempt spent, while a receipt with no PID space stays unprovable.

The guard "a stamped owner in this PID space proven gone resumes
copyback" passes on both.
- `native-workspace-finalizer.test.ts` (65),
`workspace-recovery-preparation-gates.test.ts` (47),
`native-workspace-finalization-work.test.ts` (25) and
`ownership-reconciliation-report.test.ts` (25) pass.
- The server package typecheck passes.

## Risks

- Receipts written before this change behave as before: the
same-hostname rule, judged in this PID space.
- The board confirmation path is unchanged for owners on another host.
For an owner on the same hostname but in another PID space, it now asks
the board instead of trusting local PIDs.
- The hold settle only reschedules an existing recheck. The worker still
proves the copyback process group before taking ownership.

## 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-08 05:32:49 -06:00
DottaandClaude Opus 5.5 beec70ad9f fix(native): judge sync-retention run processes with the shared run-process prover (#20025)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run syncs its workspace into a local folder. A retention
job deletes old folders, but never one whose run's adapter process may
still write it
> - That job had its own liveness check. It probed the recorded PID in
this PID space, whatever host recorded it, and compared process start
times to the millisecond
> - So a dead PID here could delete the synced files of a process still
running on another host. And a live process whose recorded start
differed by under a second (`ps lstart` has 1 s precision) counted as a
recycled PID, so its folder could be deleted too
> - The durable ownership plan proves owners by identity with one shared
prover. The lost-process reapers already use it for exactly this run
identity (#20004)
> - This pull request makes retention use that prover, so anything but a
proof the process is gone keeps the folder
> - The benefit is that preserved files are no longer deleted on a
wrong-host or sub-second-skew judgment

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2), #19838 (inventory: "One fact, many
proofs"). Phase 2's native agent flagged `runProcessMayBeLive` in
`native-workspace-sync-retention.ts` as still bespoke after #19997.

**What happened?**
`runProcessMayBeLive` ignored `process_host_hash` and required an exact
start-time match.

**Expected behavior**
The same verdict as the lost-process reapers (`proveRunProcessGone`):
- the PID is judged only in its recorded PID space;
- rows saved before `process_host_hash` keep the one-release local
fallback on single-host servers;
- a reused PID needs a definitely later start;
- EPERM is alive.

## What Changed

- `native-workspace-sync-retention.ts`: `runProcessMayBeLive` calls
`proveRunProcessGone` (reading `process_host_hash`). The injectable
retention probe is adapted to the shared probe for tests. Remote-only
runs keep their existing shortcut.

## Verification

- `native-workspace-sync-retention.test.ts`: 59 pass. Two new tests fail
on `master` and pass here:
- a run recorded in another PID space is kept, even if that PID is dead
here;
  - a live PID whose start time is within the 1 s precision is kept.

The new "deletes a terminal run whose process, recorded in this PID
space, has exited" and all existing retention cases (recycled PID, dead
process, destroyed sandbox, live group) pass.
- The server package typecheck passes.

## Risks

- Folders of runs recorded on another host are now kept until their
other owners settle, rather than deleted on a local PID probe. That is
the safe direction; the folders are bounded by the existing retention
limits.

## 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-08 05:32:46 -06:00
Nicky Leach e7e870cfc4 fix(native): decide warm and adopted Runner process reuse from one key (#20005) 2026-11-08 01:02:50 -08:00
DottaandClaude Opus 5.5 a4dbde6181 refactor(recovery): judge native owner processes with the shared prover (#19997)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs record the server and adapter processes that own them,
and recovery must prove an owner gone before it takes over or releases
its work
> - Phase 2 of the durable ownership plan gave the repository one
prover, `proveOwnerGone`. The native runtime still had four private
proofs of the same fact
> - The private proofs disagreed with the shared rules in edge cases:
some judged a PID from another PID space, and one accepted an
inconsistent start time as proof of reuse
> - This pull request moves those four proofs onto `proveOwnerGone` and
deletes the private liveness code
> - The benefit is one rule for "the owner is gone" across native
recovery, with the stricter shared behaviour where they differed

## Linked Issues or Issue Description

Refs #19933, #19950, #19953 (the shared ownership path and earlier Phase
2 kinds).

**What is the problem?**
Native recovery proved dead owners with four private checks instead of
the shared prover. The checks used different rules for PID spaces,
reused PIDs and unreadable start times.

**What should happen?**
Every native owner proof uses `proveOwnerGone`. Only a `gone` verdict
releases or takes over. The remote fence and the one-release legacy
fallback stay as they are.

**Why does it matter?**
Recovery decisions about the same process must agree. A dead PID in
another PID space must never be used as proof.

## What Changed

- `native-restart-recovery.ts` `evaluateNativeControllerTakeover`: the
local-process branch now calls `proveOwnerGone`. The remote fenced
branch (#19830), the `operator_attested` sentinel and the null-host
fallback (`processSpaceUnrecorded` with `legacyHostFallback`, recorded
by callers as `legacy_host_unknown_dead_pid_proof`) are kept. Reasons
are unchanged, plus a new `controller_pid_start_inconsistent`. Owners
built from receipts with no PID space are judged in this PID space:
their callers check the recorded host first.
- `native-failed-run-owner-release.ts`: `recordedProcessMayBeLive` is
deleted. `runProcessMayBeLive` and `controllerMayBeLive` release only on
a `gone` verdict. Each use of the legacy fallback is recorded as
`legacy_host_unknown_dead_pid_proof` when the release commits.
- `native-session-executor.ts`: `verifiedRecoveryProcessIsAlive` is
replaced by `recoveryProcessMayBeAlive`, which is false only when the
prover proves the runner gone. Signalling a recovered runner requires a
proven same process.
- `native-local-process-stop.ts` `recordedLocalExecutionStopped`: the
bound identity (launch event, host, PID, group, start time) is judged by
the prover. It uses `process_host_hash` when the row has one.
- New `native-runtime/native-owner-stamps.ts`: controller and
adapter-process stamp builders, shared with the ownership registry.

Not changed: `proveLocalWorkspaceOwnerStopped` (workspaces),
`evaluateNativeProviderProcesses`, the stop observation in
`recordNativeLocalProcessStop`, and `runProcessMayBeLive` in
`native-workspace-sync-retention.ts`.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-restart-recovery.test.ts
src/services/controller-process-identity.test.ts`: 51 passed.
- `cd server && npx vitest run
src/services/native-runtime/native-failed-run-owner-release.test.ts`: 14
passed.
- `cd server && npx vitest run
src/services/native-runtime/native-controller-host-recovery.test.ts`: 2
passed.
- `cd server && npx vitest run
src/services/quarantined-task-retry.test.ts -t "keeps quarantine"`: 18
passed. `explicit-native-continuation.test.ts`: 414 passed.
`ownership-reconciliation-report.test.ts`: 23 passed.
- Red with the master versions of the two modules: 3 new tests fail
("accepts a reused PID only when the live process started definitely
later", "never releases on a dead PID recorded in another PID space",
"keeps the one-release fallback for a run process saved before its PID
space, and records it"). The tests were written after the code.
- Two fixtures changed to match the shared rules:
- `controller-process-identity.test.ts`: the "recycled" case recorded a
start after the live process, which is an inconsistent stamp. It now
records an earlier start (a real reuse), and the inconsistent case is
asserted as alive.
- `quarantined-task-retry.test.ts` `worker_alive`: the live worker now
records this process's real start time. The old fixture was a reused
PID, which the prover correctly proves gone.
- `cd server && npx vitest run
src/services/native-local-process-stop.test.ts`: 5 passed (review round
1). With the master module, 2 fail: the other-PID-space case and the
reused-PID case.
- Not run locally: typecheck (CI runs it) and the wide suites.

## Risks

Behaviour changes, all from the shared rules:
- A live PID observed to start before the recorded controller is no
longer accepted as recycled (`controller_pid_start_inconsistent`; the
run waits for evidence).
- A failed-run owner whose recorded adapter process or controller is in
another PID space, or is a remote sandbox PID, is no longer released by
a local probe. It keeps the lease path.
- An unreadable start time is no longer exit evidence for a recovered
local runner.
- A local execution whose PID was reused by a later process now counts
as stopped. Before, any live PID blocked the proof.
- A local execution whose row records another PID space is no longer
counted as stopped by a dead local PID.
- A PID space that cannot be read here makes a hostless receipt owner
unprovable.

No persisted state changes. Rows saved by the previous release keep the
one-release null-host fallback, as before.

## Model Used

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

## 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 02:35:15 -06:00
DottaandClaude Opus 5.5 8475826d2d fix(wake-queue): deliver user comments whose wake a restart lost or a stale continuation dropped (#19992)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A user's follow-up comment on a task must reach the agent exactly
once, through restarts (invariant 4)
> - The final unmasked local chaos storm (96 tasks, 31 restarts) passed
94 tasks. The 2 failures were both lost user comments
> - In t69, a restart killed the server between saving the comment and
writing its wake. In t72, a comment's own run was cancelled as stale,
because the earlier run completed the task first without reading the
comment
> - This pull request adds a reconciler rule that gives a lost comment
its wake, and saves the comment before a stale cancellation
> - The benefit is that a user's message is never silently dropped when
a restart or a completion race hits at the wrong moment

## Linked Issues or Issue Description

Refs #19956 (work-intent outbox), #19944, #19831 (plan, invariant 4).

**What happened?**
In chaos wave `w-20261108T055444-s9401` (local, unmasked, codex and
claude, 30-min K13 storm, master `46bf913fb9`), 94 of 96 tasks passed.
Two failed:
- **t69, R1 `rested_not_done`.** The user's comment was saved at
06:14:59.358 and reopened the task to `todo`. A K1 SIGKILL fired 28 ms
later, before the wake was written. No wake row exists. Recovery skips a
`todo` task whose last run succeeded, so the task sat with no run until
the wave ended.
- **t72, R4 `concurrent_runs`.** A comment posted during a run queued
its own run. That run was claimed at 06:16:04.874. The first run's
completion marked the task `done` 2 ms later. The comment's run was then
cancelled with `continuation_task_ownership_changed` before dispatch,
and the comment was lost.

**Expected behavior**
Each accepted user comment is delivered once, unless the task is
cancelled or reassigned, or a person closes it.

**Steps to reproduce**
`node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts
--replay w-20261108T055444-s9401/t69` (or `.../t72`).

## What Changed

- `recovery/ownership-invariant-report.ts`: a new reconciler-owned I4
rule, `user_comment_without_wake`. It finds a user comment that meets
all of these conditions:
  - it is newer than every run of the task;
- the task is assigned, is not a conversation, has no live run or lock,
and has at least one earlier run;
  - no wake request carries the comment.

A comment younger than 30 s is not reported yet, because it may still
get its normal wake. Its end-of-grace time is returned as `nextDueAt`.
- `native-runtime/native-finalization-reconciler.ts`: the backstop step
is now exported as `applyOwnershipReportBackstops`. For each such
comment it saves the missing outbox receipt (`executionWait: wake_lost`)
and promotes it.
- `work-intent-outbox.ts`: new `saveUndeliveredUserComments`, which the
reconciler rule and the stale-run path both use. It saves only live user
comments that no completed run received. It writes nothing for a task
that is reassigned, is a conversation, or has a status the caller does
not allow.
- `heartbeat/queue.ts`: the saved-receipt pass that already sends
pause-saved messages also sends `wake_lost` receipts. It uses the same
per-comment consumption check and the same chat-input exclusion.
- `heartbeat/stale-comment-continuation.ts` and `heartbeat.ts`: before a
stale continuation is cancelled, its undelivered user comments are saved
as a deferred receipt. The release drain then reopens the `done` task
and delivers them once. This applies only when an agent run that started
before the comment completed the task after it. A task that a person
marks done is left closed.
- `tests/restart-chaos/grader.ts`: R4 ignores runs cancelled before
dispatch (`continuation_task_ownership_changed`) when it looks for
overlapping execution. Those runs never executed. Comment consumption
still counts them.

## Verification

Each focused file ran alone in one process (`--maxWorkers=1`):
- `heartbeat-held-comment-wake.test.ts` (22 pass). This includes two new
cases:
- t69 shape: the reconciler rule fires, the backstop delivers the
comment once, and a second pass does nothing;
- t72 shape: the stale run is cancelled, the task reopens, and the
comment is delivered once.

  Both fail with the rule or the save disabled, and pass with them.
- `ownership-reconciliation-report.test.ts` (23 pass). It includes the
rule's boundaries: a carried comment, a too-young comment (deadline
only) and an already-read comment.
- `tests/restart-chaos/grader.test.ts` (17 pass). The new R4 case fails
with the old grader. A cancelled run that is not a pre-dispatch
cancellation still fails R4.
- Unchanged and passing: comment wake batching (27), refused assignment
wake (3), wake-queue postgres adapter (103), stale queue invalidation
(68), deferred receipt work (45). The first version also reopened a task
that a person had marked done, which broke one deferred-receipt test.
That case is now excluded.
- The server package typecheck and the restart-chaos harness typecheck
pass.
- Same-session chaos context on master: K6 crash loop
`w-20261108T063326-s9402`: 6 of 6 pass. The K4 long-hold wave is still
running. After merge, I'll replay only the targeted fault shapes (t69
and t72).

## Risks

- The reconciler rule runs on each report pass. It is bounded by the
1-hour lookback and the per-kind row cap, and it excludes conversation
tasks, so chat input keeps its own authorization.
- A comment younger than 30 s waits up to the end of its grace period
before the backstop acts.
- The stale-run save reopens a `done` task only for a comment that the
completing run could not have read.

## 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-08 02:14:45 -06:00
DottaandClaude Opus 5.5 ccce55ab11 fix(issues): tell callers when a task list page is capped (#19972)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents read company tasks through `GET
/api/companies/{companyId}/issues`, often through the runner `call_api`
tool
> - The route caps each page at `limit` (500 by default, 1000 at most)
and returns a bare array
> - A capped page and a complete list look the same, so an agent cannot
tell that rows are missing
> - In an eval with 501 open tasks, the agent got 500 rows and reported
498 tasks as the complete list
> - This pull request adds a "more results exist" signal and the
next-page position, and surfaces it to agents
> - The benefit is that agents page until the list is complete instead
of presenting a partial list

## Linked Issues or Issue Description

**What happened**

The chat eval `tasks.organize-open-work` seeds 501 open Launch tasks.
The agent called `GET /api/companies/{companyId}/issues` through
`call_api`. The route returned exactly 500 rows (the default limit) and
gave no signal that more rows existed. The agent noted that exactly 500
rows was suspicious and that some tasks were missing. It still reported
the partial list as complete.

**Expected behavior**

A capped list response tells the caller that more results exist and how
to get the next page. The agent-facing tool result shows the same signal
next to the data.

**Steps to reproduce**

1. Create 501 tasks with status `todo` in one company.
2. Call `GET /api/companies/{companyId}/issues?status=todo`.
3. See that the response has 500 rows and no header or field that says
more rows exist.

## What Changed

- `server/src/routes/issues.ts`: the company issue list fetches one row
past the page. It then sets `X-Paperclip-Has-More` (`true`/`false`) and
`X-Paperclip-Limit` on every page. A capped page also sets
`X-Paperclip-Next-Offset`, and `X-Paperclip-Next-After-Id` for
`sortField=id&sortDir=asc` cursor paging. Pagination is reported only to
actors who can read the whole company's task list; actors whose rows are
filtered after the query (such as low-trust agents) get no pagination
headers, so the raw page cannot reveal tasks they cannot read.
`oneChildPerParent` lists are ordered by parent first, so they page by
offset and never advertise an id cursor. The body is unchanged (still an
array). The Mine inbox path does not change.
- `server/src/services/native-runtime/runner-api-client.ts`: `call_api`
results for successful responses that carry these headers include
`pagination: { hasMore, truncated, limit, nextOffset, nextAfterId?,
guidance? }`. The guidance tells the agent to repeat the call with the
next `offset`/`afterId` until `hasMore` is false. The field is also
present when the body is saved as an artifact. Routes without the
headers get no new field.
- `server/src/routes/openapi.ts`: the API catalog entry documents
`limit`, `offset`, `sortField`, `sortDir`, `afterId`, the headers and
the `call_api` pagination field.
- Tests for capped, cursor, last, exact-size and default-limit (501
rows) pages in all list projections, for offset-only paging of
`oneChildPerParent` lists, for a low-trust agent receiving no pagination
headers, and for the `call_api` pagination field.

## Verification

- `cd server && npx vitest run
src/__tests__/issues-list-query-parsing.test.ts
src/__tests__/low-trust-red-team-routes.test.ts` — 31 passed.
- `cd server && npx vitest run
src/__tests__/issues-list-query-parsing.test.ts
src/services/native-runtime/runner-api.test.ts` — passes. Without the
source change, 8 of the new tests fail.
- `cd server && npx vitest run src/__tests__/inbox-mine-routes.test.ts
src/__tests__/issue-list-assignee-filter-routes.test.ts
src/__tests__/issue-list-updatedsince-filter-routes.test.ts
src/__tests__/openapi-routes.test.ts
src/services/native-runtime/runner-api*` — 2237 passed, 2 skipped.
- `tsc --noEmit -p server/tsconfig.json` shows no errors in the changed
files.

## Risks

- Low risk. The response body shape does not change. Existing clients
ignore the new headers.
- Each list query reads one more row. The extra row is enriched and then
dropped. The cost is small.
- `call_api` results gain an optional `pagination` field. Consumers that
do not know the field ignore it.
- Agents whose task reads are filtered after the query (low-trust
agents) still get no truncation signal, as before; a raw-page signal
would leak hidden tasks. Pushing their boundary into the list query
would let them have one later.
- Other capped list routes do not set the headers yet. They can adopt
the same headers later.

> 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

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

## 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-07 23:50:08 -06:00
DottaandClaude Opus 5.5 d33b3ac263 fix(wake-queue): save temporarily refused work intents and promote them through one path (#19956)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Users send work to agents as assignments, comments and Retries. The
wake queue delivers each one as a run
> - When an admission check refused a wake for a temporary reason, the
wake often ended as a terminal skip, so the work was lost
> - Restart-chaos waves found dropped comments and Retries (RC-37,
s7532) and double delivery (RC-53). Each fix covered one hold type
> - The durable ownership plan (section E) asks for one outbox rule: a
closed set of terminal outcomes, a saved deferral for every temporary
refusal, and one promotion path
> - This pull request adds that rule. It saves messages refused by a
pause, promotes deferred intents from every hold-release event through
one function, and adds a reconciler backstop
> - The benefit is that an accepted message is delivered exactly once
after a pause lifts, and the reconciler reports any intent that is stuck
or dropped

## Linked Issues or Issue Description

Refs #19831 (plan, section E), #19838 (inventory, wake-queue rows and
section 1b), #19927 (Phase 2 data: the I4 false positive), #19702,
#19741, #19829, #19826. Builds on #19944 (section D, merged).

**What happened?**
- A user commented while the agent was paused, or while the company was
paused. Admission refused the wake. Nothing saved the message as
pending, so when the pause lifted, nothing delivered it.
- Hold-release events woke different subsets of workers. A saved intent
then waited out a 30-second refusal backoff after its hold cleared.
- The I4 reconciler check matched free text with a regular expression.
Phase 2 found a false positive: a delivered retry whose error said
"retrying once".

**Expected behavior**
An accepted work intent ends only as delivered, consumed by a later run,
task cancelled, or hard stop. Every temporary refusal saves the intent
as `deferred` with its reason and its release trigger. Every
hold-release event promotes deferred intents through one function
(invariant 4).

**Steps to reproduce**
Pause an agent. Post a user comment on its task. Resume the agent. On
`master` the comment is never delivered as a wake.

## What Changed

- New `server/src/services/work-intent-outbox.ts`:
- `WORK_INTENT_TERMINAL_OUTCOMES`: the closed set of terminal outcomes.
- `TEMPORARY_REFUSAL_TRIGGERS`: maps each temporary refusal code to its
release trigger.
- `promoteDeferredWorkIntents`: the one promotion function. It removes
the 30-second refusal backoff from the matching deferred intents. It
then wakes run dispatch, the saved-receipt worker and stranded-work
recovery, and hints each saved intent of the issue.
- `heartbeat/queue.ts`: the #19829 save-as-deferred block is now a
shared helper. A user's message refused by a paused agent, a paused
company, or a budget pause that is not a reached hard stop is saved with
`executionWait: { reason, trigger, message }`. The caller still gets a
409. The message is saved only when the task's latest run is terminal,
because the release drain promotes a saved message behind that run. A
task that never ran keeps the message in the task, and stranded-work
recovery starts the task once the pause lifts. A `pending_approval`
agent and a task-tree hold are not changed here: the release drain still
fails a promoted wake for a pending approval, and tree holds have their
own resume path.
- Hold-release events call `promoteDeferredWorkIntents`:
  - accounting settled (`run-cost-accounting.ts`);
  - environment lease released (`environments.ts`, two sites);
  - native run projected terminal (`native-run-projection.ts`);
  - budget pause lifted (`budgets.ts`);
  - company reactivated (`companies.ts`);
  - task-tree hold released (`issue-tree-control.ts`);
  - agent lifecycle ready (`agent-lifecycle-driver.ts`).
- Reconciler (`recovery/ownership-registry.ts`,
`ownership-invariant-report.ts`, `native-finalization-reconciler.ts`):
- registers three new kinds: `wake_request_deferred`, `queued_run` and
`scheduled_retry_run`;
- I4 now reads the closed refusal code (the wake `reason`) of accepted
intents, not free text;
- a new I4 check reports a deferred pause refusal whose pause has lifted
for more than 5 minutes. The worker then promotes it as a backstop;
- a lifted pause inside its 5-minute grace window returns its deadline
through `nextDueAt`, and the worker also wakes on agent-lifecycle
signals (company and agent pause changes). So a missed release event is
still checked.
- `doc/SPEC-implementation.md`: adds the rule "Accepted work intents are
delivered exactly once".

## Verification

Each focused file ran alone in one vitest process (`--maxWorkers=1`) on
embedded Postgres. Results at `2875c85fd2`:

| Question | Loop | Elapsed | Result |
|---|---|---|---|
| Is a comment refused by a pause saved, then delivered once after the
pause lifts? | `heartbeat-held-comment-wake.test.ts -t "pause lifts"` |
8 s | 2 pass. With `queue.ts` from `master`, both fail: no intent is
saved |
| Does I4 use the closed code, and does it catch a stuck deferral? |
`ownership-reconciliation-report.test.ts` | 45 s | 20 pass. This
includes the Phase 2 "retrying once" false positive and the case
"paused, then lifted" |
| No regression on the paths that changed | held-comment (15),
archived-company guard (8), undispatched execution wait (32), deferred
receipt work (45), recovery pause durability (16), native run projection
work (55), tree-control routes and services (19, 1, 7) | 8–334 s each |
All pass |

- Exactly-once: each pause test runs two promotions (a release event,
then the reconciler backstop) and many dispatch passes. It asserts one
delivery. The existing consumption tests (RC-53) still pass.
- Server package typecheck passes.
- Speed: nothing new runs before `turn.start`. The new admission work
happens only on a refusal. Promotion runs in the release transaction and
in the background reconciler.
- Not run here: chaos waves. The coordinating session schedules them on
the shared harness.

## Risks

- `promoteDeferredWorkIntents` moves `updated_at` back for deferred
intents that were refused in the last 30 seconds. A busy company with
many release events can retry refused intents more often. Each retry
re-runs normal admission, and the refusal resets the backoff.
- More release sites now wake run dispatch and stranded-work recovery.
These notifications are coalesced per company.
- Lifting a pause is now the operator's explicit authorization to send
what the pause refused, as Send is after a Stop. When the pause stopped
the task's run, the lifted pause issues the task's next explicit wake,
which adopts the saved message once. Other messages queued behind an
acknowledged Stop keep the existing rule. If Stop recovery still holds
the task, the message waits for that recovery.
- An agent pause's stop sweep keeps the messages that the pause deferred
(`preservePauseDeferred`). Terminate and cleanup stops still end them.
- The backstop check reads only pause refusals (`company.inactive`,
`agent.not_invokable`), because those holds can be checked exactly.
Other deferred kinds keep their existing promotion paths.

## 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-07 23:43:17 -06:00
DottaandClaude Opus 5.5 f755361ffe fix(recovery): deliver a steered message the restart lost before the model read it (#19962)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A user can steer a running Codex or Pi turn: a comment goes into the
live turn instead of waiting for the next run
> - The provider acknowledges the steer at once, but the model reads it
only at its next step, after the in-flight tool call
> - A control-plane restart in that window kills the provider with the
message in memory. Restart recovery starts a continuation turn that does
not carry the message. The message is lost and no error shows
> - The restart chaos harness showed this in 6 of 6 steered runs (Codex,
K1, K2 and K6, 2 samples each): steer acknowledged, run succeeded, no
reply to the steered message
> - This pull request makes restart recovery deliver every acknowledged
steer that the model did not read, exactly once
> - The benefit is that manual Steer no longer loses messages on
restart, and the planned automatic steering can ship on top of it

## Linked Issues or Issue Description

**What happened?**
A comment steered into a running Codex turn got no reply when a
control-plane restart (SIGKILL, SIGTERM or crash loop) happened during
the agent's tool call. The run succeeded, and the steered comment had no
effect.

**Steps to reproduce**
1. Start a Codex task that runs a 40-second command.
2. During the command, post a comment and click Steer. The provider
acknowledges it.
3. Restart the control plane before the command ends.
4. Recovery resumes the turn with a continuation. The agent finishes the
task and ignores the comment.

**Expected behavior**
The agent reads and handles the steered comment once, after recovery.

**Root cause**
The provider keeps acknowledged steering input in memory until its next
model step. The restart continuation
(`paperclip.native-continuation.v1`) only told the model to continue the
current request, so the acknowledged input was gone.

## What Changed

- `unreadSteeredMessageIds` (runner package): derives
acknowledged-but-unread steers from run-log events in order. A
`steering_acknowledgement` item adds the message id (once per id). A
`userMessage` item in the same turn reads the oldest unread steer. Codex
records each steered input as one `userMessage` item when it enters the
conversation. A `userMessage` item in a later turn reads all earlier
steers, because the continuation carries them. `turn.completed` reads
all steers.
- `readUnreadSteeredMessages` (server): reads those facts from
`heartbeat_run_events` (only transport-validated events with a
`sourceEventId`). It also reads the comment or interaction text for the
same company and issue. The run log is the durable record of both
"acknowledged" and "read", so no new column is needed.
- The restart continuation now includes `steeredMessages` and one
instruction to handle them, oldest first. The runtime reads them through
a lazy `getUnreadSteeredMessages` option, only when a restart
continuation starts. Native recovery wires it for every run.
- `queuedInteractionSteeringBody` is extracted, so recovery rebuilds a
steered interaction response with the same text the steer sent.
- Fake Codex gets `--consume-steer`, which emits the `userMessage` item
for an accepted steer, like real Codex.

## Verification

- Red/green live test with real runnerd and fake Codex
(`runnerd-restart-continuation.test.ts`, "delivers a steered message the
restart lost exactly once"): start a held turn, steer it, SIGKILL runner
and provider, restore and recover.
- "unread": the continuation carries the message exactly once. Without
the runtime change this test fails.
- "read" (`--consume-steer`): the continuation does not carry it again.
- Both: after the continuation completes, a later restart has nothing to
deliver.
- `steering-delivery.test.ts`: ordering, dedupe, initial-input,
later-turn and completed-turn rules.
- `unread-steered-messages.test.ts` (embedded Postgres): ignores events
without a source id and messages from other issues. It reads interaction
text, ignores model-output items as read evidence, and returns nothing
after the continuation input.
- `pnpm vitest run src/steering-delivery.test.ts
src/live/runnerd-restart-continuation.test.ts` (runner) and `pnpm vitest
run src/services/native-runtime/unread-steered-messages.test.ts`
(server): pass on the rebased revision.
- `issue-queued-comments-routes.test.ts` and
`native-session-runtime.test.ts -t restart`: pass.
`native-session-resume.test.ts` "archives a damaged prior epoch…" fails
locally on plain `origin/master` too (local runnerd binary); CI runs it
with a fresh build.
- Restart chaos (Codex steer mode, K1/K2/K6) runs after this lands in
the harness slot. Before: 0 of 6 steered messages answered.

## Risks

- Duplicate delivery: a provider that records steered input without a
`userMessage` item, or a `userMessage` event lost in the crash window
before persistence, makes recovery send the message again. The model
then sees it twice. That is safer than losing it, and the instruction
asks the model to handle each one once.
- Pi (ACPX) steering has no `userMessage` read evidence today, so a Pi
steer that the model already read before a restart may arrive again in
the continuation.
- Only the restart continuation carries the messages. Other recovery
paths start after a completed turn, which already read every steer.
- No schema or migration change. A session saved by an earlier release
resumes normally; its run log already holds the acknowledgement events.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file editing, 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-07 22:22:52 -06:00
DottaandClaude Opus 5.5 bf3a067076 feat(recovery): settle the finalizer and cleanup leases on the shared ownership path (#19950)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs keep their ownership in `native_run_finalizations`, and
the shared ownership reconciler now settles proven-gone owners (Phase 2
of the durable ownership plan)
> - The finalizer lease (`native-finalizer:*`, 5 min) and the
retained-session cleanup lease (`native-cleanup:*`, 60 s) recorded only
a random token. They named no holder process
> - A server killed while it finalized or cleaned up kept the run
waiting for the whole lease. The lease was released by age alone. A
graceful shutdown also keeps an in-progress finalizer lease for the next
boot to wait out
> - This pull request records the holder process with each claim and
moves both kinds onto the shared prover and a compare-and-swap settle
> - The benefit is that a dead holder's run continues in the first pass
after boot, and a live or unprovable holder is never released

## Linked Issues or Issue Description

Refs #19933 (the settle hook and the first moved kind, merged).

**What happened?**
A finalizer or cleanup lease whose server process died stayed held until
the lease expired (5 min or 60 s). The run stayed `running` and its
follow-up work waited.

**What did you expect?**
The next boot proves the dead holder gone and frees the lease at once. A
live holder, or one that cannot be proven gone, keeps its lease.

**Steps to reproduce**
1. Accept a native result and let the finalizer claim it.
2. Kill the server (SIGKILL) before the finalizer commits.
3. Start the server. The run stays `running` for up to 5 minutes.

## What Changed

- `native_run_finalizations.lease_holder` (jsonb, migration 0446):
`{token, bootId, pid, hostHash, processStartedAt}` of the process that
took a token-only lease. It names the holder only while `token` equals
`lease_owner`, so renewals keep it and a later lease never inherits it.
The executor's `controller_*` columns are not changed.
- The finalizer claim (`native-run-finalizer.ts`) and the cleanup claim
(`native-session-executor.ts`) write it in the same UPDATE as the lease,
from `currentControllerProcessStamp()` (no I/O).
- New `native-token-lease-settlement.ts`: compare-and-swap settle that
expires exactly the proven-gone holder's outstanding lease, after it
notifies the finalization or cleanup work that the expiry frees.
- Both claims also wake the ownership worker queue in their transaction,
so a quiet worker watches the new holder (Greptile round 1).
- `ownership-registry.ts`: `native_finalizer_lease` and
`native_cleanup_lease` register `settle`. Their owner is the holder
process; rows without a holder keep the token fence. Listings cover
outstanding leases only.
- `heartbeat/recovery.ts`: restart recovery settles both kinds before it
finalizes and schedules cleanup. A failure is logged and the reconciler
pass retries it.
- Two sparse partial indexes on `lease_expires_at` for the two listings,
so the settle watch re-check stays indexed.

Bespoke code deleted: none. These kinds had no liveness check; their
only release was lease age (the takeover in the claim), which stays as
the fence for unprovable holders.

## Verification

- `cd server && npx vitest run
src/__tests__/heartbeat-native-restart-suspension.test.ts -t
"finalizer|cleanup|holder|exact lease"`: 12 passed (rerun after the
rebase on master). The crash-point test also asserts the ownership wake
for its run; it fails without the signal.
- `cd server && npx vitest run
src/__tests__/ownership-reconciliation-report.test.ts`: 17 passed.
- Red on the base branch (server code at `bea3bfb20b`, tests and schema
from this branch): 7 new tests fail, 5 pass (the 5 are existing tests).
Red: the crash-point restart test, the two reconciler settle tests, the
three live/unprovable counter-cases (owner was a token fence), and the
exact-lease CAS test. The tests were written after the code.
- New tests:
- crash point: a finalizer claim was written and its holder killed
before commit; the next restart finalizes the run, and this boot's claim
stamps itself as holder;
- a dead finalizer or cleanup holder is settled by a recording pass,
never by a read-only pass (`record: false`); the accepted result and
phase are untouched;
- live counter-cases: a live earlier-boot PID (`pid_alive`), the current
boot (`current_process`), a dead PID in another PID space
(`different_process_space`, unprovable) keep the lease through a pass
and a restart;
- a holder written for another token, and a holderless row of the
previous release, stay token fences, also with
`PAPERCLIP_EPHEMERAL_SERVER=true`;
- settle refuses a lease taken over after the proof
(`owner_changed_since_proof`).
- Not run locally: typecheck (CI runs it) and the wide suites.

## Risks

- Previous-release story (rules/upgrade-safe-run-state.mdc): rows saved
by the previous release have no `lease_holder`. They stay token fences
and the existing claim takes them over at expiry. Neither lease outlives
5 min, so this covers at most 5 min after the upgrade. A rollback
ignores the new column.
- Unprovable holders (other host or PID space, unknown space): no
operator action. The lease is short and self-expiring; at expiry the
next claim takes it over and the old holder's next token
compare-and-swap fails. That fence is the handoff. A different host is
never proof.
- The settle only expires a lease. Finalization and cleanup still run
under their own checks (cleanup keeps its maintenance-history proofs).
Settling ownership does not settle the accepted result, workspace
preservation, or accounting.
- Migration adds a nullable column and two partial indexes on a sparse
predicate. Low risk.

## Model Used

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

## 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-07 22:16:34 -06:00
DottaandClaude Opus 5.5 ca34bed257 test(native): exited-remote-runner budget case follows provider-work attempt charging (#19963)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Two restart-recovery PRs merged independently: #19951 lets a remote
runner that exited before provider work bootstrap again, and #19948
charges a restart attempt only when the provider accepted a turn.
> - Together they change one of #19951's test expectations: at the last
attempt, the exited runner is no longer charged, so it bootstraps again
instead of hitting the budget.
> - That test now fails on master in `Verify Paperclip Runner (vitest
native server)`, which blocks other PRs.
> - This pull request updates the test to the combined behavior. Product
code does not change.
> - The benefit is a green master, with the generation-cap bound on
repeated exits still under test.

## Linked Issues or Issue Description

Refs #19951, #19948, #19950 (blocked by this failure).

**What happened?**
On master, the test "keeps an exited remote runner's retry held when the
bootstrap budget is spent" receives `bootstrap_incomplete` with
`chargesProviderAttempt: false` instead of `blocked /
execution_recovery_budget_exhausted`.

**What did you expect?**
The test should match the combined behavior: uncharged at the last
attempt, and blocked once the controller generation cap is reached.

**Steps to reproduce**
1. `cd server && npx vitest run
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
-t "exited remote runner"` on master fails.

## What Changed

- Removed the stale budget row from the exited-runner counter-cases.
- Added two cases. An exited runner at attempt 3 bootstraps without
being charged. At `NATIVE_PROGRESS_CREDIT_GENERATION_LIMIT` it blocks
with `execution_recovery_budget_exhausted`. These mirror #19948's own
cases.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
-t "exited remote runner|relaunched remote runner|generation cap"`: 11
pass.

## Risks

- Low. Test-only change. Repeated runner exits before provider work stay
bounded by the generation cap, which the new case covers.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in 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
- [ ] 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-07 21:52:54 -06:00
DottaandClaude Opus 5.5 f6cb3e43f4 fix(admission): admit new work independent of other runs' cleanup and accounting (#19944)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Budget admission decides if new agent work can start in a company
> - Five admission gates waited on the cleanup or accounting of other
runs
> - The restart-chaos campaign found that these gates froze companies
for up to 10 minutes, caused false budget pauses, denied in-run writes,
and dropped wakes, comments and Retries
> - The durable ownership plan (Decision 1) removes the company-wide
accounting gate. It does not narrow it again
> - This pull request removes the five gates. Unfinished accounting now
adds its bounded estimate to exposure
> - The benefit is that one run's unsettled accounting never stops the
other work in its company

## Linked Issues or Issue Description

Refs #19831 (plan, section D), #19838 (inventory, finding 3), #19927
(Phase 2 reconciler data), #19497, #19724, #19826.

**What happened?**
Admission refused new work in these cases, with no reached hard stop and
no pause:
- a terminal run awaited accounting (`waitingRunCount`);
- a native run recovered its accounting (`recoveringRunCount`);
- a budget-owned scope hold awaited reconciliation, after the hard stop
no longer applied;
- a budget input was older than 15 s (`budget_evaluation_work`);
- the predecessor run row of a task was missing (`hasPendingRunCleanup`
failed closed).

**Expected behavior**
Admission never waits on the cleanup or accounting of another run. Only
a reached hard stop, an explicit pause or an inactive company refuses
new work (invariant 3).

**Steps to reproduce**
Make a company with an active budget policy. Leave one failed run with
`cost_accounting_pending = true`. Request work for another agent. On
`master` admission refuses with "Company cannot start work while
completed runs await accounting."

## What Changed

- `budgets.ts`: admission refuses only for a reached hard stop or an
explicit pause. The code removes `observedBlocks`, the
`recoveringRunCount` check, the stale `budgetHeld` refusal and the 15 s
input-age gate. It also removes the unused
`inspectInvocationEligibility`. `reconcileScope` no longer returns
accounting holds.
- `budget-exposure.ts`: removes the `waiting` and `recovering`
aggregates and the own-task subquery. The admission query is smaller.
Unfinished accounting stays exposure: the larger of the held reservation
and the observed cost.
- `budget-evaluation-work.ts`: removes `oldestBudgetInputAt`, which
nothing reads now.
- `execution-blocker.ts`: `hasPendingRunCleanup` returns false for a
missing run row. A run row that is deleted removes its finalization
coordinator and nulls its lease references, so the row owns nothing. A
predecessor that owns a lease or a coordinator still holds the successor
of its own task.
- `recovery/ownership-invariant-report.ts`: the I3 check no longer
mirrors the removed gate. It reports a wake request or a run that was
refused with an accounting-hold reason. Any such report is a regression.
- `doc/SPEC-implementation.md`: adds the product rule "Admission is
independent of cleanup and accounting". `doc/execution-semantics.md` is
aligned.

## Verification

- New `server/src/__tests__/admission-independent-of-cleanup.test.ts`,
on embedded Postgres. It has one case per gate. All six cases fail on
`master` and pass here. A seventh case (an explicit pause still refuses)
passes on both. One case also asserts invariant 3 together with
exposure: two failed runs that each hold a 400-cent reservation fill a
1000-cent budget, and a hard stop still refuses.
- Existing tests that asserted the old gates now assert admission
(invariants 3 and 4): `closed-native-receipt-accounting`,
`budgets-service`, `budget-evaluation-work`,
`heartbeat-held-comment-wake`, `ownership-reconciliation-report`. In
`heartbeat-held-comment-wake`, each saved comment, Retry, assignment and
continuation is delivered exactly once while another run's accounting is
pending. Settling accounting later does not deliver it again.
- All six files pass locally, one file per process. The server package
typecheck passes.
- Speed: admission is on the turn path. This change only removes work
from it: two correlated aggregates, one own-task subquery and one
`budget_evaluation_work` read per policy. No wait or I/O is added before
`turn.start`.
- Not run here: the chaos waves (K13 storm, K6 crash loop, K4 long
holds). The coordinating session schedules them on the shared harness.

## Risks

- Behavior change: work starts while accounting for other runs settles.
Spend stays bounded by the reservation capacity check and the hard stop,
which read the ledger plus observed cost. A run that has no reservation
and no observed cost adds zero exposure until its charge commits. This
is the gap that Decision 1 accepts. Budget enforcement was already best
effort (SPEC 11.6).
- A budget-owned pause with no reached hard stop no longer refuses work
in budget admission. The paused company or agent is still refused by the
existing pause checks until the budget worker lifts the pause.

## 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-07 21:46:39 -06:00
DottaandClaude Opus 5.5 a4a3e342ec fix(recovery): charge a restart attempt only when the provider accepted a turn and made no progress (#19948)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A run has an execution recovery budget: three provider attempts for
native runs, and two failure retries for legacy runs
> - In the restart chaos campaign, control-plane restarts used up that
budget for runs that were healthy (RC-22, 22b, 22c, 40 and 60)
> - #19720 and #19747 fixed two variants with watermark heuristics, and
legacy interrupted preparation still spent the failure budget
> - The durable-ownership plan (section F, invariant 5) asks for one
rule: charge an attempt only when a recorded outcome proves that
provider work started and no progress followed
> - This pull request adds that rule and moves the native restart charge
and the legacy preparation retry onto it
> - The benefit is that a restart never spends budget for work the
provider never started, while real failure loops and crash loops stay
bounded

## Linked Issues or Issue Description

Refs #19720, #19747, #19856

**What happened?**
A control-plane restart charges a provider attempt by heuristics. The
first crash restart always charges. A later restart charges unless the
previous resume had no start evidence (#19747) or completed work
(#19720). A runner that spawned but whose provider never accepted a turn
counted as started, so a K6 crash loop could still spend the budget.
Legacy runs whose preparation a restart interrupted
(`providerWorkStarted: false`) spent `failureRetries`, the same counter
as real failures (`execution-recovery-attempt.ts`).

**Expected behavior**
An attempt is charged only when the provider accepted a turn
(`turn.accepted` or `turn.started`) and no accepted turn then completed
work. A startup before spawn, a runner that spawned but was never
admitted, and a `turn.start` the provider did not accept are not
charged. The generation-12 cap still bounds uncharged restarts.

**Steps to reproduce**
Start a native run at its last attempt. Kill the controller after the
runner spawns but before the provider accepts a turn. Before this
change, the next restart claim blocks the run with
`execution_recovery_budget_exhausted`.

## What Changed

- New `server/src/services/native-runtime/provider-attempt-outcome.ts`:
the one rule. `readProviderAttemptOutcome()` returns
`provider_not_started`, `provider_started_without_progress` or
`provider_progressed` from run events. `providerAttemptChargesBudget()`
charges only the second. `providerAttemptWindowStart()` starts the
attempt window at the latest `native.process_start_requested`, so
earlier work never counts for a later attempt.
`PROVIDER_ATTEMPT_CREDIT_GENERATION_LIMIT` (12) is the shared cap.
- `restartResumeKeepsAttempt()` (`native-restart-recovery.ts`) now
applies that rule to the attempt the restart interrupted, including the
first restart. It keeps the unstarted-resume carry from #19747, the
reattachment rule (an adopted provider counts as started), the drain
exemption from #19625, and the generation-12 cap. A history entry from
an older release without a recorded position still charges.
- Legacy runs: `interrupted_preparation` is now an uncharged retry lane
(`execution-recovery-attempt.ts`). It keeps its own counter, bounded at
the same cap (12), and never changes `failureRetries`. `retries.ts` uses
the longest transient delay after the second lane attempt.
- `doc/execution-semantics.md` states the rule.
- Accounting has its own "provider work started" readers (inventory
Summary finding 5). Phase 3 owns those; this PR exposes the rule for
them to adopt and does not change them.

## Verification

- `native-runner-restart-recovery.integration.test.ts` (embedded
Postgres, real `claimNativeRestartRecoveries`): 129 passed. New cases,
red on master:
- not charged at the last attempt for a claimed remote bootstrap, a
remote bootstrap retry, and a claimed unlaunched remote run;
- not charged when the runner spawned but was never admitted, and when
the provider did not accept `turn.start`;
- not charged on the first restart when the original attempt already
made progress;
- a normal retry that spawned but was never admitted is not charged, and
earlier work still does not count for it.
- Counter-cases, green on master and with the change: an accepted
`turn.accepted` or `turn.started` with no progress is charged; the
generation cap blocks at the last attempt; crash loops with and without
progress stay bounded; the #19720 and #19747 cases still pass.
- `execution-recovery-attempt.test.ts`: new lane tests (red on master).
- `workspace-recovery-preparation-dispatch.test.ts` (25 passed),
`heartbeat-process-recovery.test.ts` (interrupted-run cases),
`run-dispatch/adapters/postgres.test.ts` (interrupted cases).
- `server` typecheck (`scripts/typecheck-layers.mjs`, low-memory mode):
no errors.
- Not run: chaos waves (the coordinator schedules them on this host),
and the full server suite (CI covers it).

## Risks

- Behavior change: the first crash restart no longer charges when no
accepted turn is recorded. If a crash lands before the controller
projects the provider's `turn.accepted`, that attempt is not charged.
This fails toward more retries, and the generation-12 cap bounds it.
- A legacy preparation crash loop now gets up to 12 retries instead of
2. Each retry still passes the normal issue gates and keeps the
transient delay.
- The new queries use the `(run_id, seq)` index, once per restart claim.
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 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-07 21:20:05 -06:00
DottaandClaude Opus 5.5 c6e4ed1547 feat(recovery): settle a claimed wake whose run finished without settling it (#19934)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A wake request is claimed by the run that handles it, and that run
marks it finished.
> - In a K4 chaos wave (#19927), native restart recovery blocked a run
and ended it, but left its assignment wake `claimed`. Nothing ever
released it.
> - The durable-ownership plan settles such records on one shared path,
by proof, never by age.
> - This pull request registers a settle for `wake_request_claimed`. It
is Phase 2's second kind and is stacked on the accepted-result lease PR.
> - The benefit is that no run-ending path can strand a claimed wake.

## Linked Issues or Issue Description

Refs #19831 (plan), #19861 (registry), #19927 (the data that found
this). Stacked on #19933 (it adds the `settle` hook).

**What happened?**
Run `7c45860b…` failed with `native_restart_recovery_blocked`
(`execution_recovery_budget_exhausted`) at 23:19:41. Its wake
`6b3c4cce…` stayed `claimed` until the end of the wave (23:44 and
later). Rule I2 flagged it.

**What did you expect?**
A claimed wake ends when its run ends, with the run's outcome.

**Steps to reproduce**
1. `node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts
--env local --faults K4 --k4-hold-max-seconds 600 --tasks 6
--concurrency 3 --seed 9203 --keep`
2. Look for `agent_wakeup_requests` rows with status `claimed` whose run
is terminal.

## What Changed

- New `recovery/wake-request-claimed-settlement.ts`: when the owner run
is terminal, and for a native run its coordinator is `committed` or
`terminal_failure`, the claim gets the run's outcome. That is
`completed` for a succeeded run and otherwise the run's status, as run
completion and native terminal projection already do. It is
compare-and-swap on the claim (id, run, status `claimed`).
- `ownership-registry.ts`: `wake_request_claimed` registers that settle.
- `native-finalization-reconciler.ts`: the pass log line includes the
settled count per kind.

## Verification

- `cd server && npx vitest run
src/__tests__/ownership-reconciliation-report.test.ts`: 17 pass,
including the new case. It covers a blocked native run, a native run
whose projection is still pending (kept), a succeeded legacy run, a live
run (kept), and a read-only pass (no change).
- Red/green (test written after the code): the new test fails on master
and passes here.
- Server typecheck passes.

## Risks

- No persisted shape changes, so there is no previous-release story
beyond this: claims saved by an older release are settled the same way.
- Race with the run's own settle: it waits for a native coordinator to
be final. For a legacy run, both write the same status, so the race is
idempotent.
- No change adds work before `turn.start`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in 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
- [ ] 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-07 21:18:22 -06:00
DottaandClaude Opus 5.5 83dec71a8f fix(runner): return the edited agent's link and effect in instruction save receipts (#19940)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - In chat, a person can ask the CEO agent to change another agent's
saved instructions
> - The native instruction tools save the change, but their receipts
contain no link to the edited agent
> - Completion feedback tells the agent to use only hrefs that tools
returned and not to guess routes
> - So the agent can not link the agent it changed, and it has no
grounded fact about when the change applies
> - This pull request adds the edited agent's real profile href and the
save's effect to the save and restore receipts
> - The benefit is a chat reply that lets the person open the changed
agent and know which runs use the new rule

## Linked Issues or Issue Description

Refs #19344

**What happened**
A person asked in chat: "Add a rule for Morgan to run the relevant tests
before handing work back." The agent saved the rule correctly and kept
concurrent human guidance. The reply did not link Morgan and did not say
when the rule applies. The run's tool events show the cause:
`update_agent_instructions` returned the revision but no href, and the
accepted `paperclip_finish` feedback said "Do not guess routes or invent
links when no resource href was returned."

**Expected behavior**
After an instruction save or restore, the agent has a real profile href
for the edited agent and a statement of when the saved instructions take
effect. The reply links the agent and explains that later runs use the
change.

**Steps to reproduce**
1. In a chat with the CEO agent, ask it to add a rule to another agent's
instructions.
2. Let the agent call `update_agent_instructions`.
3. Read the tool receipt. It contains no href for the edited agent and
no effect statement.

## What Changed

- `update_agent_instructions` and `restore_agent_instructions` receipts
now include `resourceLinks` with the edited agent's company-scoped
profile href (`/<prefix>/agents/<agentId>`) and name.
- The same receipts include `appliesTo`. It states that runs that start
after the save use the new instructions and that runs in progress keep
their snapshot. This matches `materializeInstructionBundle`, which reads
the committed head when it builds the run context.
- The link uses the agent id, which is unique. It does not use the
name-derived URL key.

## Verification

- `cd server && pnpm exec vitest run
src/services/native-runtime/agent-instruction-tools.integration.test.ts
src/services/native-runtime/agent-instruction-tools.test.ts`: 14 passed.
- Without the source change, the new assertions fail in 3 tests (update
receipt and both lost-acknowledgement recovery receipts).
- The recovery test confirms that the persisted receipt, which recovery
replays, also carries the link.
- The end-to-end chat eval `agents.edit-instructions` runs this change
on its case branch.

## Risks

- Low risk. The change adds fields to a tool receipt. Existing fields do
not change.
- Receipts that were saved before this change replay without the new
fields. That is acceptable: the agent then has no link, as before.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), 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-07 21:11:18 -06:00
DottaandClaude Opus 5.5 196108acb9 fix(native): bootstrap again when a relaunched remote runner exited before provider work (#19951)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Restart recovery decides whether an interrupted native run can
resume, bootstrap again, or must wait for a person.
> - A remote run is blocked as "ambiguous" when recovery cannot prove
what reached the sandbox runner.
> - In a Daytona chaos wave (#19947), two runs were blocked even though
the controller had recorded that the relaunched runner exited before any
provider work.
> - This pull request accepts that recorded runner exit as proof and
bootstraps the run again, under the existing attempt budget.
> - The benefit is that a restart no longer turns a run with no provider
work into a failed, blocked run.

## Linked Issues or Issue Description

Refs #19947 (Daytona data), #19831 (durable-ownership plan), #19934 (the
stuck claimed wake from the same runs).

**What happened?**
Wave `w-20261108T012041-s9301`, runs `450f277d…` and `9b93d40c…`, went
through this sequence:
1. Restart recovery proved the remote bootstrap incomplete and
relaunched it.
2. The new runner exited: "runnerd exited while waiting for
run.prepare". The executor settled `bootstrap_retry` with
`providerSessionEstablished: false`, `providerEventsExist: false` and
`checkpointExists: false`.
3. The next restart recovery saw the recorded runner PID and blocked the
run as `ambiguous_remote_recovery_evidence`.

**What did you expect?**
Bootstrap again, because the runner is proven gone and no provider work
happened.

**Steps to reproduce**
1. `cd server && npx vitest run
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
-t "relaunched remote runner"` fails on master with `blocked /
ambiguous_remote_recovery_evidence`.

## What Changed

- `native-restart-recovery.ts`: new proof
`remoteRunnerExitedBeforeProviderWork`. It applies to a remote sandbox
with a prepared workspace and a settled `bootstrap_retry` failure whose
original code is `native_runner_process_exited`, with no provider
session, events or checkpoint and no lease or successor. Such a run is
classified as `bootstrap_incomplete`
(`remote_runner_exited_before_provider_work`). It passes the same retry
admission check as `remote_runner_never_launched`, and the same
three-attempt budget.
- Tests: the s9301-shaped retry plus five counter-cases.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
-t "exited remote runner|relaunched remote runner|remote bootstrap
retry|never requested a runner|exact-checkpoint retry"`: 14 pass.
- Red/green: the new bootstrap test and the budget counter-case failed
on master (both returned `blocked / ambiguous_remote_recovery_evidence`,
the same as the s9301 rows) and pass here. The other four counter-cases
(not a runner exit, session established, provider events, checkpoint)
block on both.
- Loop: focused vitest with one embedded Postgres, 26–50 s.
- Not run locally: the server typecheck and the full suite (memory
limits on this host). Required CI covers them.

## Risks

- Fail-open risk: the proof needs the executor's own settled record of a
runner exit with no provider work, plus a fresh check that no checkpoint
or provider evidence exists. A recorded PID without the exit record
still blocks, as before.
- Previous-release story (rules/upgrade-safe-run-state.mdc): no
persisted shape changes. Rows without these failure fields keep today's
outcome.
- Nothing runs before `turn.start`. This is restart recovery only.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in 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
- [ ] 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-07 21:08:35 -06:00
DottaandClaude Opus 5.5 f5400e2594 feat(recovery): settle the accepted-result lease on the shared ownership path (#19933)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A control-plane restart can kill the controller after an agent's
result is accepted. The result then stays leased to a dead process.
> - Today a private sweep (`native-dead-controller-result-lease.ts`,
#19713) proves that controller dead with its own liveness rules.
> - The durable-ownership plan (#19831) replaces private proofs with one
prover (`proveOwnerGone`, #19853) and one registry (#19861).
> - This pull request moves the accepted-result executor lease to that
shared path and deletes the private sweep. It is Phase 2's first kind.
> - The benefit is one tested rule for when a dead owner's result lease
is released, and the reconciler now repairs a lease that restart
recovery missed.

## Linked Issues or Issue Description

Refs #19831 (plan), #19853 (prover), #19861 (registry), #19713 (old
sweep), #19883 (legacy host fallback), #19927 (Phase 2 data).

**What happened?**
The accepted-result lease had its own liveness proof, separate from the
shared prover. The reconciler could only report a dead owner, not settle
it. When restart recovery missed a lease, it waited out the 20-minute
lease.

**What did you expect?**
One prover decides, and both restart recovery and the periodic
reconciler pass settle a lease whose controller is proven gone.

**Steps to reproduce**
1. Accept a native run result.
2. Kill the controller (SIGKILL) and start a new server.
3. If the restart sweep does not see the row, the result stays leased
until the lease expires.

## What Changed

- `ownership-registry.ts`: a kind may register `settle` and
`onUnprovable`. `native_accepted_result_lease` registers both. Other
kinds stay report-only.
- New `native-accepted-result-lease-settlement.ts`: a compare-and-swap
settle that expires exactly the proven owner's lease. It keeps the old
guards: the run must still be `running`, remote copyback stays fenced,
and a lease taken over since the proof is left alone.
- `ownership-invariant-report.ts`: the pass calls `settle` on `gone` and
`onUnprovable` on `unprovable`. A read-only pass (`record: false`) never
settles. New `settleOwnableKind()` for restart recovery.
- `heartbeat/recovery.ts`: restart recovery calls
`settleOwnableKind("native_accepted_result_lease")` instead of the old
sweep.
- Deleted `native-dead-controller-result-lease.ts`.
- `owner-proof.ts`: accepts a board attestation (`operator_attested`).
It also keeps the one-release local fallback for rows saved before their
PID space was recorded, on single-host servers only, and flags each use
so it is logged (`legacy_host_unknown_dead_pid_proof`, #19883). An
ephemeral server returns `unprovable` (`process_space_unrecorded`) and
the existing "confirm the previous server stopped" board action is
raised.

## Verification

- `cd server && npx vitest run
src/__tests__/heartbeat-native-restart-suspension.test.ts`: 27 pass, 7
of them new.
- `npx vitest run src/services/owner-proof.test.ts
src/__tests__/ownership-reconciliation-report.test.ts`: pass.
- Red/green (tests written after the code): 5 new behaviour tests fail
on master and pass here. They cover the ephemeral board action, the
reconciler-pass settle, the live-owner counter-case in that pass, the
legacy marker, and operator attestation. The stamped-dead-controller and
foreign-PID-space cases pass on both, as the old path behaved the same.
- Server typecheck passes (`npx tsc --noEmit -p server/tsconfig.json`).
- Not run locally: the full server suite (`pnpm verify` asks for broad
coverage because a file was deleted). Required CI covers it.

## Risks

- Previous-release story (rules/upgrade-safe-run-state.mdc): NRF rows
saved before #19868 have no `controller_host_hash`. They keep the same
one-release local proof as before on single-host servers, and each use
is logged. On ephemeral servers they stay unprovable and ask the board,
as before. Rows on another PID space keep their lease (the fence), as
before.
- Fail-open risk: settle is compare-and-swap on the lease token, boot,
PID, host hash and start time of the proven owner. An `unprovable` owner
is never released. Live-owner and foreign-space cases are tested.
- No change adds work before `turn.start`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in 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
- [ ] 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-07 20:58:04 -06:00
DottaandClaude Opus 5.5 18644a3263 feat(recovery): instrument 20 durable write boundaries with crash points (#19938)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A control-plane restart between two durable writes leaves an
in-progress record owned by a dead process
> - #19937 added `crashPoint(name)` and a registry of 20 named
boundaries from the Phase 0 inventory
> - A name without a call site cannot fire, so the crash-point suite
cannot test it
> - This pull request puts one call at each registered boundary and adds
a test that keeps the registry and the call sites in step
> - The benefit is that the on-demand suite (next pull request) can kill
the control plane at every known write-ahead gap

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 5). Refs #19846 (inventory, "Write-ahead
coverage"). Stacked on #19937 (helper and registry). Refs #19861
(ownable kind ids).

**Problem**
The registered crash points have no call sites.

**Expected behavior**
Each registered point is called at its gap: after the first durable
write or outside effect returns, before the next one starts. Disarmed
(always in production) each call is one comparison.

**Scope**
Call sites and a coverage test only. The suite runner and CI workflow
follow.

## What Changed

- Server call sites (`@paperclipai/shared/crash-points`):
- `sandbox-creation-operations.ts`: around the provider `create` call
(intent → provider → lease).
- `native-session-executor.ts`: after `native.process_start_requested`,
before spawn.
- `paperclip-control-plane-port.ts`: after the result-accept
transaction, before finalization starts (only when a result was
inserted).
- `native-run-finalizer.ts`: after `commitNativeStatusDecision`, before
the run projection.
- `workspace-operations.ts`: `workspace_finalize` only — after the
running row, and after `settle()` before the done row.
- `environment-runtime.ts`: provider release → lease row; teardown claim
→ destroy; reusable destroy → lease release; stop-and-retain claim →
stop.
- `heartbeat.ts`: lease release → runtime services; budget reservation
hold → dispatch.
- `heartbeat/queue.ts`: run claimed `running` → wake request `claimed`.
- `run-cost-accounting.ts`, `usage-receipts.ts`: accounting commit →
spool unlink; receipt spool → DB row → unlink.
- `packages/paperclip-runner/src/crash-point.ts`: a small copy of the
helper for controller code in this package, which does not depend on
`@paperclipai/shared` (adding that dependency would change the runner
content ID). It arms only `runner.*` names. runnerd never sees the
variable: its environment is an allowlist.
- Runner call sites: after the runnerd spawn, before its identity is
published (both spawn paths); `turn.start` result received → controller
journal saved → next send.
- `server/src/__tests__/crash-point-call-sites.test.ts`: every
registered point has a call site, every call names a registered point,
and every `kinds` entry is an ownable kind id of the Phase 1 registry.

## Verification

- `cd server && npx vitest run
src/__tests__/crash-point-call-sites.test.ts` → 3 passed.
- `pnpm --filter @paperclipai/server typecheck` → clean. `pnpm --filter
@paperclipai/paperclip-runner exec tsc --noEmit -p .` → clean.
- Behavior with nothing armed is unchanged: each call returns at its
first comparison (measured in #19937: about 3.7 ns).

## Risks

- Low risk. No call changes control flow unless `PAPERCLIP_CRASH_POINT`
names it and `NODE_ENV` is not `production`.
- Points on the path before `turn.start` (process start, runner spawn)
cost nanoseconds, within plan invariant 8.
- `usage-receipts.ts`: the persist-then-unlink one-liner became a block
with the same statements in the same order.
- The runner package change gives the runner a new content ID, as any
runner source change does. No persisted run state changes
(rules/upgrade-safe-run-state.mdc).

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits) and a read-only exploration subagent that
mapped the boundaries.

## 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-07 20:56:33 -06:00
DottaandClaude Opus 5.5 f35eb1e5ef fix(observability): judge the TTFT budget from run creation; report the wait behind a live turn (#19941)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Invariant 8 of the durable-ownership plan says turns must start
fast. #19863 added per-run speed budgets that write
`run.performance.budget_exceeded` to the run log.
> - The Phase 2 chaos data (#19927) showed follow-up TTFT about 3x over
the claude budget.
> - The cause is measurement scope. A follow-up comment posted during a
live turn waits for that turn to end, and TTFT is anchored at the
comment. The budgets came from idle follow-ups.
> - This pull request judges the TTFT budget from run creation and
reports the wait separately.
> - The benefit is that budget breaches point at real start-up slowness,
not at time spent behind a turn the user is already watching.

## Linked Issues or Issue Description

Refs #19863 (speed budgets), #19927 (Phase 2 data), #19852 (startup
sub-spans), #19888 (warm default).

**What happened?**
In two local storm waves, C1 follow-up runs breached the claude warm
TTFT budget (p50 27.6–29.9 s against 9.5 s). Their spans show
`comment.to_run_created` at 6–62 s (waiting for the live turn), then
session resume at 1.4–3.8 s and submit-to-first-event at 2–5 s. Codex
without the wait was about 4.8 s, the same as the warm-follow-up
baseline.

**What did you expect?**
The budget should judge start-up latency, which is what its baseline
measured. The wait behind a live turn should be visible but not judged.

**Steps to reproduce**
1. Run a local chaos storm with C1 tasks.
2. Read `run.performance.budget_exceeded` rows with metric `ttft` for
`issue_commented` runs, and compare them with each run's
`comment.to_run_created` span.

## What Changed

- `native-run-trace.ts`: the TTFT budget check measures from run
creation (start of `heartbeat.queue`, or end of the ingress span). The
TTFT span itself does not change.
- `native-speed-budgets.ts`: breaches can carry `inputWaitMs` (TTFT
only).
- `tests/restart-chaos/ttft-baseline-stats.ts` and `ttft-baseline.ts`:
the wait behind a live turn is its own measured step (`inputWait`), next
to the full TTFT and the TTFT from run creation. The TTFT table gains a
"Wait behind live turn" column. The full comment-to-first-token time
stays the user-visible metric.
- `doc/run-log-events.md` and the plan's speed-budget section describe
the rule.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-speed-budgets.test.ts
src/services/native-runtime/native-run-trace.test.ts`: 43 pass, 2 new.
`pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts
tests/restart-chaos/ttft-baseline-stats.test.ts`: 12 pass, 1 new. They
cover a follow-up under budget after a 60 s wait (no breach), a
follow-up over budget (breach with `inputWaitMs`), and queueing after
run creation still judged.
- Server typecheck passes.

## Risks

- Low. This is a run-log-only change (AGENTS.md §5.7): no Telemetry and
no span attributes change.
- A slow wait before run creation is no longer a TTFT budget breach. The
TTFT span still records it, and the breach payload names it. Admission
holds after run creation are still judged.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in 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
- [ ] 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-07 20:27:41 -06:00
Nicky Leach 35cbd15175 fix(native): recover a stopped Runner whose journal stayed ready at a settled cut (#19907) 2026-11-07 16:01:47 -08:00
Nicky Leach 94718ce168 fix(native): continue on a replacement when a retained Runner retires before claim (#19897) 2026-11-07 14:51:32 -08:00
DottaandClaude Opus 5.5 2d89486c57 fix(native): record OpenCode's rejected model as a model rejection (#19865)
## Thinking Path

- Paperclip runs agents through native providers; when a provider
rejects the selected model, the person needs to see "Model unavailable"
and choose another model.
- #19548 made the OpenCode driver report OpenRouter's invalid model ID
as a closed `model_unavailable` provider failure. The task banner now
shows it.
- The server's terminal classifier, however, recognized a model
rejection only from Codex's exact ChatGPT error envelope. OpenCode's
structured failure was stored for the banner but the run was recorded as
`adapter_failed`.
- So chat showed a generic failure, and the task was not blocked for a
model choice. The `runs-adapters.inspect-failed-run` chat eval caught
this.
- This change maps an action-required `model_unavailable` failure to
`native_provider_model_rejected` for any provider.

## Linked Issues or Issue Description

Refs #19548. An OpenCode run whose model OpenRouter rejects (`... is not
a valid model ID`, HTTP 400) finishes with `errorCode: adapter_failed`.
Expected: `native_provider_model_rejected`, as for Codex.

## What Changed

- `native-provider-failure.ts`: `forTerminal` returns
`native_provider_model_rejected` when the failed turn's provider failure
has category `model_unavailable` and retryability `requires_action`.
Transient or unknown retryability still returns null. The diagnostic is
the closed turn diagnostic; no provider text or model name is copied.
- `NATIVE_MODEL_REJECTION_MESSAGE` no longer names a ChatGPT connection,
since it is now the blocker text for any provider.
- Tests for both providers, plus negative cases for transient/unknown
retryability.
- `wake-queue/adapters/postgres.ts`: a native run that failed for a
rejected model or incomplete configuration, on a task the arbiter
already blocked, is released without opening a
`native_continuation_requires_reconciliation` incident. That incident
put the run in `recovery_needed`, which hid the run page's Retry, though
the provider refused the request and nothing ran to reconcile. Queued
messages stay deferred.

## Verification

- `pnpm exec vitest run
src/services/native-runtime/native-provider-failure.test.ts
src/services/native-runtime/status-arbiter.test.ts
src/services/native-runtime/native-provider-failure.integration.test.ts`
(server): 98 passed.
- `pnpm exec vitest run src/modules/wake-queue` (server): passes,
including a new test that fails on the old code (it opened the
incident).
- Server `tsc --noEmit`: clean.
- End to end: the `runs-adapters.inspect-failed-run` chat eval will be
rerun on the chat-eval candidate with this change.

## Risks

- Codex model-rejection blocker wording changes from "current ChatGPT
connection" to "current AI connection".
- Any provider driver that reports `model_unavailable` with
`requires_action` now blocks the task for a model choice instead of the
generic failure path, which disables automatic adapter-failure retries
for that run. That is the intended behavior for a rejected model.
- Configuration-failure native runs on a blocked task no longer get a
board reconciliation incident. If such a run had already produced side
effects in earlier turns, they are not flagged for reconciliation; a
provider model rejection happens before the turn does work.

## Model Used

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

## 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-07 16:28:07 -06:00
DottaandClaude Opus 5.5 cbf811cd3b fix(recovery): prove a replaced deployment's copyback owner gone automatically (#19913)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs save their workspace after the result is accepted, under
a copyback owner receipt
> - A deploy can restart the server while that save is in progress
> - The new server runs in a new container, so it cannot see the old
process and fails closed
> - The run then waits on a board action that asks a person to verify
the previous controller
> - Recoveries must continue without a human step, and copy must not ask
anyone to inspect a server
> - This pull request adds a deployment-level rule to the shared
owner-liveness primitive and uses it in workspace finalization
> - The benefit is that a save interrupted by a deploy resumes on its
own, with no second writer

## Linked Issues or Issue Description

**What happened?**
A deploy restarted the server while three runs were saving their
accepted results. Each run kept a copyback owner receipt that names the
old Railway deployment on another container. The restarted server
reported `native_workspace_finalization_owner_unverified`, owned by the
board, with the next action "Verify the previous controller and its
workspace-copyback processes have stopped...". The cloud harness stop
receipt did not arrive, so the runs stayed in "Recovery needed" for
about an hour, until a person confirmed the stop by hand.

**Expected behavior**
The restarted server proves that the previous controller is gone,
resumes the save, and settles the run with no human step. No user-facing
text asks a person to verify a server or controller.

**Steps to reproduce**
1. Start a native run on a Railway service with a volume.
2. Let the result be accepted and the workspace save start.
3. Deploy, so that the server restarts during the save.
4. See that the run waits for a board confirmation.

**Paperclip version or commit**
Observed on e2d490c61. Reproduced on master before this change. The
owner-proof work in #19848, #19851, #19853, #19868, #19871 and #19883
does not cover this shape: `proveOwnerGone` returns `unprovable`
(`different_host`) for an owner on another container.

## What Changed

- `owner-proof.ts`: `LocalOwnerStamp` can carry the platform `worker`
(deployment identity). `ProcessProbe` can carry this process's
deployment and whether the platform gives it an exclusive volume. New
verdict reason `deployment_replaced`.
- New rule in `proveOwnerGone`, after the current-boot check: the owner
is gone when this server runs with a Railway volume
(`RAILWAY_VOLUME_ID`, `RAILWAY_VOLUME_MOUNT_PATH`) and the stamp names
the same project, environment and service with a different deployment
ID. Railway stops the previous deployment before it mounts the volume
into the new one, and a service with a volume has no replicas. So every
process of the old deployment, including orphaned copyback children, has
exited.
- `native-workspace-finalization-ownership.ts`: evaluate that proof
before the cloud harness request. On `deployment_replaced`, claim the
exact owner receipt under the existing advisory lock and row-lock
revalidation, log `issue.workspace_owner_stop_verified` with
`deploymentProof`, mark the recovery `resuming`, and continue the save
with no provider turn. Other cases keep the harness request and the
existing fences.
- New owner receipts also record the mounted volume ID (`volumeId`).
When a receipt has it, the proof requires this server to mount the same
volume. Both `RAILWAY_VOLUME_ID` and `RAILWAY_VOLUME_MOUNT_PATH` must be
set. The finalizer reaches this check only while it holds the run's
copyback advisory lock, so the old owner's lock session has also ended.
- Copy: the hold's next action, the route conflict and the two Retry
wait messages now describe the wait. They no longer ask a person to
verify a server or controller. The hold's text still names the Resume
saved result action for platforms where neither this rule nor the
harness can confirm the stop.
- `doc/native-workspace-finalization-recovery.md`: describe the rule.

Previous-release story: receipts written by earlier releases already
record `worker.deploymentId`, so they carry forward and take this path
after the upgrade. No schema change.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-workspace-owner-restart-recovery.test.ts
src/services/owner-proof.test.ts`
- Before the fix: "resumes the interrupted save automatically, with no
board action" fails (`acquired: false`).
  - After the fix: all pass.
- The restart test seeds the staging receipt shape (old deployment,
other hostname, PID 3, existing `operator_required` hold). It checks
that the save resumes once, the hold becomes `resuming`, the task
presentation no longer waits for a user, the receipt clears, and no
agent wake is created.
- Negative cases stay fenced: same deployment, a service without a
volume, a half-configured volume, another service, another environment,
and a receipt that names a different volume. Unit tests cover the same
rule in `proveOwnerGone`, including that the current boot stays alive.
- Related suites pass: finalizer, queued-wake recovery, route ownership,
workspace recovery gates and dispatch, explicit continuation, recovery
services (1005 tests).
- `pnpm --filter @paperclipai/server typecheck` passes.

## Risks

- The rule depends on Railway volume exclusivity. It applies only when
this server has a volume mounted and the receipt names the same service
and environment. Without a volume, the existing harness and board paths
still apply.
- A receipt from a deployment that ran before the volume was attached
could, in theory, overlap during that one redeploy. Volumes are attached
at stack creation, so this is not a practical case.
- Copy changes are text only. Error codes are unchanged.

## 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
- [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-07 16:12:23 -06:00
DottaandClaude Opus 5.5 99529f9d71 feat(observability): local speed budgets and a run-log budget check (#19863)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When someone messages an agent, the reply must start as fast as
possible, and restart-safety work must not slow it
> - The durable ownership plan makes this invariant 8 ("fast turns") and
asks Phase 1 to turn the Phase 0 baseline into p50/p95 budgets per
adapter
> - Without budgets, a regression in time to first token, agent boot or
restart recovery shows up only when someone reruns the baseline by hand
> - This pull request records the budgets and adds a per-run check that
writes a run-log row when a run exceeds its p95 budget
> - The benefit is that every later phase can show "no regression
against the budgets" from the run log, without new waits on the turn
path

## Linked Issues or Issue Description

Refs #19831 (plan, "Performance constraint", rule 4, and invariant 8).
Refs #19846 (the Phase 0 baseline these budgets come from). Stacked on
#19861.

**Problem**
The plan requires p50/p95 budgets per adapter for TTFT, agent boot and
restart recovery latency, and a check against them. Neither exists.

**Expected behavior**
Budgets are recorded in the plan and in code. A run that exceeds a p95
budget gets one run-log row that names the metric, the measurement and
the budget.

**Impact**
Phase 2–5 exit criteria can cite budget breaches from chaos waves and
staging. The check adds no wait before `turn.start`.

## What Changed

- New `server/src/services/native-runtime/native-speed-budgets.ts`: the
local budget table (codex, claude, opencode) for TTFT and agent boot
(cold and warm) and restart recovery latency. Each budget is 1.2× the
baseline, rounded up to 0.5 s. `evaluateSpeedBudget` judges one run
against p95. An unknown start kind uses the looser budget. Remote runs
and adapters without a baseline are not judged.
- `native-run-trace.ts`: the trace takes an optional `budget` subject.
It checks `task.time_to_first_token`, agent boot (end of
`heartbeat.queue` to the end of the first `runner.turn.submit` or
`runner.session.resume`) and restart recovery once each. It writes
`run.performance.budget_exceeded` through `defer`, so the write is
detached from the event path. `finish` now drains diagnostics that defer
more work.
- `native-session-executor.ts`: passes the adapter and environment to
the trace, labels cold or warm at the existing `provider_session` reuse
receipts, and checks restart recovery at a recovered run's first
provider event, measured from the startup recovery `ready` time.
- Docs: budget tables in
`doc/plans/2026-11-07-durable-ownership-and-reconciliation.md` ("Speed
budgets (Phase 1)"), and the new event in `doc/run-log-events.md`.

## Verification

- `npx vitest run
src/services/native-runtime/native-speed-budgets.test.ts
src/services/native-runtime/native-run-trace.test.ts` (in `server/`): 34
tests pass. They cover:
  - every budget has p50 ≤ p95;
  - the provider-to-adapter mapping;
  - p95 judgement, and the looser budget when the start kind is unknown;
- one breach for a slow boot, and none for a second submit or a fast
TTFT;
  - a deferred TTFT breach is written before the trace closes;
  - restart recovery is checked once;
  - remote runs and runs with no budget subject write nothing.
- `npx tsc --noEmit -p server`: no errors.

## Risks

- Low risk. This is a run-log change only (AGENTS.md §5.7, no extra
review). The OTLP and Telemetry paths are unchanged.
- The baseline samples are small (2–12 per cell) on a shared host.
Budgets carry 20% headroom, and the codex restart budget (8.0 s p95) is
tight. Expect some breach rows on a loaded machine. They are diagnostics
and change no behavior.
- Restart-recovery budgets use run 4 of the baseline, as merged in
#19846.
- Restart recovery is measured from the startup recovery `ready` time to
the first provider event, which matches the baseline definition.
- Previous-release story: no persisted run state changes. The new event
is additive.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in 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-07 15:27:42 -06:00
DottaandClaude Opus 5.5 177f642da7 fix(recovery): continue native runs that failed before their first claim (#19911)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs go through a coordinator claim before any provider work
starts
> - A run can fail before that claim, for example while it restores the
previous harness backup
> - The failure path then records a no-replay hold, as if the run had
acted
> - Workspace recovery saves the files, but the task stays blocked and
asks a person to send a message
> - Recoveries must continue without a human step when no side effect is
possible
> - This pull request lets the existing safe-replacement worker continue
such runs once, after their files are saved
> - The benefit is that these tasks resume automatically, and runs that
may have acted keep today's reconciliation

## Linked Issues or Issue Description

**What happened?**
Native runs that failed before their first coordinator claim were never
continued automatically. They had no claim, no provider process and no
provider or tool events. Their result recorded `executionRecovery: {
kind: "bootstrap", providerWorkStarted: false }`. The failure path still
moved the coordinator to `terminal_failure` with
`native_continuation_requires_reconciliation` and recorded a resolved
`preserve_without_replay_v1` hold (replay blocked, board owner).
Workspace recovery then saved the files, but the next action stayed
"Saved work recovered. Send a message to continue."
`nativeReplacementPending` accepted only a few failure codes, and the
interrupted-preparation path covers only legacy runs.

**Expected behavior**
When a native run provably failed before its first claim, Paperclip
continues it once, automatically, after its files are saved (or when it
had no workspace to save). Runs with a claim, a process, a provider or
tool event, or recorded provider work keep the existing reconciliation.

**Steps to reproduce**
1. Start a native run with a remote workspace.
2. Make it fail before `native.coordinator.claim` (for example, abort
the harness-backup restore).
3. Let workspace recovery save the files.
4. See that the task stays blocked and asks for a message.

**Paperclip version or commit**
Observed on e2d490c61. Reproduced on master before this change.

Refs #19906 (fixes the restore deadline that caused the observed
failures).

## What Changed

- `native-safe-replacement.ts`: add one class-level candidate, "failed
before its first claim". It requires the
`native_continuation_requires_reconciliation` coordinator at attempt 0
and controller generation 0, with no lease, assessment, decision or
result.
- The candidate also requires no process identity, no `sessionIdAfter`,
no provider/turn/item/tool/`run.result` events, no tool invocations,
`providerWorkStarted` not `true`, no Stop or cancellation, no explicit
user continuation, and no later run for the task.
- A no-replay hold must have completed file recovery (the existing
`completedWorkspaceRecoveryCondition`). A blocked task must carry this
failure's block receipt at the current status version.
- Discovery checks saved-file evidence (snapshot, stopped sandbox) or a
workspace-less run before it selects the candidate.
- The reconciler skips the stopped-session probe for this class. It
feeds the decision with known failure meaning and a complete effect
inventory, since no provider ran. It rechecks the candidate under the
task and coordinator locks before it inserts the successor.
- The successor is the existing fresh-session `native_safe_replacement`.
Its context carries `nativePreClaimContinuationOf`, so a failed
successor is not continued again. The existing transaction resolves the
hold (`replay: verified_safe_replacement`) and restores the task block.
- `doc/execution-semantics.md`: describe the rule.

Previous-release story: holds recorded by earlier releases (resolved,
`replay: blocked`) carry forward. The test seeds that exact shape. No
schema or persisted identity changes.

## Verification

- `cd server && npx vitest run
src/services/native-preclaim-continuation.test.ts`
- Before the fix: "continues automatically exactly once after its saved
work is recovered" fails (no successor).
  - After the fix: all 9 tests pass.
- The tests cover: the staging shape with an old-release hold continues
exactly once under concurrent and repeated sweeps; it waits for saved
files; it does not chain from its own successor; and it keeps
reconciliation when the coordinator claimed, a provider event exists, a
tool call started, provider work is recorded, a process started, or a
later run owns the task.
- `npx vitest run
src/services/native-runtime/native-safe-replacement.test.ts` passes (62
tests).
- `pnpm --filter @paperclipai/server typecheck` passes.

## Risks

- The successor uses a fresh provider session, like the other safe
replacements. History comes from `retryOfRunId`.
- The new predicate adds one indexed `not exists` on `heartbeat_runs
(company_id, context_snapshot->>'issueId', created_at)` per candidate.
It runs only for the narrow pre-claim failure code.
- A run whose prepared workspace uses the older `nativeWorkspaceSync`
descriptor without remote-workspace evidence stays held, as today.

## 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
- [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-07 15:12:36 -06:00
DottaandClaude Opus 5.5 76994c5027 fix(native): bound failover backup restores by inactivity, not total time (#19906)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native per-turn session that continues an earlier conversation
first restores its failover backup from durable storage. This happens
before the coordinator claim, so no provider has started.
> - That restore ran under a fixed 300 s total deadline
(`AbortSignal.timeout(300_000)`). The deadline covers the download and
the local decrypt, digest, extraction and provider-artifact steps.
> - On staging, restore time for resumed sessions grew during the day.
It reached 300 s, and then every resumed run of one review agent failed
with the generic "The operation was aborted". Fresh sessions were not
affected.
> - A failure at this point also enters the saved-work reconciliation
path, so the user request stopped.
> - This pull request bounds each durable read by inactivity instead of
total time. A slow transfer continues while bytes arrive. A read that
stops sending bytes fails with a named code.
> - The benefit is that resumed sessions no longer fail only because
their backup is large or the store is slow.

## Linked Issues or Issue Description

Refs #19894 (adds the same idle-deadline rule for workspace snapshot
reads; this PR covers the separate native failover-backup reads).

**What happened?**

On staging, a PR-review agent had 17 runs fail in about 80 minutes. Each
failure had the message "The operation was aborted" and the code
`native_continuation_requires_reconciliation`. Every failing run resumed
an existing session (`sessionIdBefore` set, per-turn lifecycle). Every
fresh-session run in the same window succeeded. In each failing run, the
error came 304–309 s after the `environment.startup` span, and no
`native.coordinator.claim` span was recorded. The run result had
`executionRecovery: { kind: "bootstrap", providerWorkStarted: false }`.
Earlier resumed runs of the same agent show the time from
`environment.startup` to `native.coordinator.claim` growing from about
30 s to about 200 s before the failures started. The only fixed 300 s
deadline on that path is the failover-backup restore in
`restoreDurableHarnessForExecution`.

**Expected behavior**

A resumed session restores its backup when the store keeps sending
bytes, even when the total time is more than five minutes. A read that
stalls fails with a named, diagnosable code. A failed restore publishes
no partial backup and keeps the saved backup.

**Steps to reproduce**

1. Persist a native failover backup with provider artifacts.
2. Restore it through a storage reader that sends bytes slowly, so the
full restore takes longer than the deadline.
3. Before this change, the restore fails with "The operation was
aborted". A reader that stops sending bytes, with no caller deadline,
waits indefinitely.

## What Changed

- `native-harness-backup-storage.ts`: add `downloadIdleTimeoutMs` to
`restoreDurableNativeHarnessBackup` and
`verifyDurableNativeHarnessBackup`. When set, each durable `getObject`
(the backup object and the provider artifacts) runs under an idle timer.
Each received chunk renews the timer. The timer is active only while a
read is in flight, so local decrypt, digest, extraction and copy are
never timed. On expiry the whole restore aborts and throws
`native_harness_storage_download_stalled`. Caller cancellation keeps its
own error.
- `verifyRecoverableNativeHarnessBackup` and the executor's
`restoreDurableHarnessForExecution` use the five-minute idle limit
(`NATIVE_HARNESS_DOWNLOAD_IDLE_TIMEOUT_MS`) instead of
`AbortSignal.timeout(300_000)`.
- The whole restore or verification keeps a 30-minute outer limit. It
fails with `native_harness_storage_restore_deadline_exceeded`, so a hung
local step stays bounded and named.
- The executor passes the startup Stop signal
(`providerPackSourceAbort`) into the restore, so Stop (also a Stop that
came before the restore began) ends it. Only a startup that already
completed does not reuse its signal. The restore checks the signal after
the local copy and before it publishes `current`.
- The wrapped storage is a `Proxy` that replaces only `getObject` and
forwards every other member to the real service.
- `doc/DEVELOPING.md`: document the idle limit and the named code.

Previous-release story (`rules/upgrade-safe-run-state.mdc`): no
persisted shape changes. Backups, artifact rows, receipts and
checkpoints saved by the previous release restore unchanged. A deploy
that lands during a restore stops the process. The new process starts
the restore again from the saved durable backup under the new limit. The
existing hash, size, encryption and atomic publication checks are
unchanged.

## Verification

Run from `server/`:

- `pnpm exec vitest run
src/services/native-runtime/native-harness-backup-download-deadline.integration.test.ts`
— 4 tests passed (embedded PostgreSQL, local-disk storage, an OpenCode
backup with provider artifacts). The tests are in a focused suite, so
the existing 990-line storage suite does not grow.
- `pnpm exec vitest run
src/services/native-runtime/native-harness-backup-storage.integration.test.ts`
— 68 existing tests passed.
- New: "keeps restoring a backup whose download outlasts the idle window
while bytes keep arriving". With the former total-deadline policy, the
same slow transfer fails with "The operation was aborted" and publishes
nothing (the staging message). With the idle limit, the restore
completes after more than twice the window, restores the session and the
OpenCode launch artifacts, and verification also completes.
- New: "names a download that stops sending bytes and publishes no
partial backup". It checks restore and verify, checks that no `current`
backup is published, and then restores the same saved backup
successfully.
- New: "stops at the outer bound during local copy and publishes
nothing".
- New: "lets Stop end a progressing download with its own reason,
including a Stop before it starts".
- RED: with the production files reverted to `origin/master`, the stall
test does not finish and times out after 10 s, because no stall is
detected. The other two tests pass on the old code, because the total
deadline was the executor's caller policy. The first test reproduces
that policy directly.
- `pnpm exec vitest run` on `native-harness-online-preservation`,
`native-harness-filesystem-independence` and
`native-controller-migration` integration suites passed.
`native-warm-checkpoint-ownership.integration.test.ts` fails locally on
master as well, because the local Rust `paperclip-runnerd` binary is not
built.
- `node scripts/typecheck-layers.mjs` passed for all layers.

Not verified: a live resumed run on staging with this build. The staging
evidence shows where the time goes (before the coordinator claim, on
resumed sessions only) but does not show which restore step was slowest.
The fix removes the total deadline for every step on that path.

## Risks

- A restore that progresses very slowly can now take longer than five
minutes. Before, it failed. The run still stops on caller cancellation.
- A local step that hangs (for example a stalled disk) is now cut off by
the 30-minute outer limit instead of 5 minutes. A stalled local writer
also pauses the read through backpressure, so it can be reported as
`native_harness_storage_download_stalled`.
- A failure before the coordinator claim still enters the existing
saved-work reconciliation path. This PR does not change that
classification. It removes the cause of these failures.

## Model Used

- Provider: Anthropic. Model: Claude Opus 5.5 (`claude-opus-5-5`), with
tool use (shell, file edits, read-only browser for API inspection) 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-07 14:34:57 -06:00
DottaandClaude Opus 5.5 988a4b6749 feat(recovery): ownable-kind registry and report-only ownership reconciler (#19861)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A control-plane restart leaves in-progress records owned by dead
processes, and about 31 per-subsystem proofs decide when to release them
> - The durable ownership plan (section C) asks for one registry of
ownable record kinds and one reconciler
> - Phase 1 must measure first: how many in-progress records exist, who
owns them, and which invariants they break, with no behavior change
> - This pull request registers 46 kinds from the Phase 0 inventory's
section C and adds a report-only pass to the existing native
finalization reconciler
> - The benefit is per-kind invariant-violation counts from chaos waves
and staging. That data unlocks Phase 2 (moving kinds onto one settle
path) and the removal of the company-wide admission gate

## Linked Issues or Issue Description

Refs #19831 (plan, section C and Phase 1). Refs #19846 (Phase 0
inventory, section C). Stacked on #19853 (the prover) and #19851 (one
process identity).

**Problem**
No single place lists the control plane's in-progress records or checks
the plan's invariants. Each subsystem releases its own records by its
own rules, so nobody can count how many records break "owned", "settled"
or "visible next action".

**Expected behavior**
One report-only pass lists every registered kind, classifies each owner
as gone, alive or unprovable, and records each invariant violation once.
It changes no record and stays quiet when nothing is in progress.

**Impact**
Phase 2 moves kinds onto the shared settle path in the order these
counts show. Phase 3 removes the admission gate once the I3 data exists.

## What Changed

- New `server/src/services/recovery/ownership-registry.ts`: 46
`OwnableKind` entries (`id`, `obligation`, `storage`, `listInProgress`,
`settle: null`, `invariantChecks`). Each record names its owner as a
process stamp, a fence token, a run, an operator, or none. The
skill-cache lock is registered with a named coverage gap because its
lock files live in per-skill roots.
- New `server/src/services/recovery/ownership-invariant-report.ts`: one
pass classifies every owner with `proveOwnerGone` (or the owning run's
status), then runs:
  - I1 owned: an in-progress record with no owner;
- I2 settled: an owner proven gone on two consecutive passes while the
record is still in progress;
- I3 admission independence: terminal runs whose accounting outlives the
10 min closure, which hold company admission;
- I4 exactly-once intents: wake requests ended `skipped`/`failed` on a
temporary refusal (heuristic until section E);
- I7 visible next action: an agent-assigned `in_progress` issue with no
live run, no pending wake and no open recovery action for 5 min.
- I5, I6 and I8 are reported as named coverage gaps; I8 comes in the
speed-budget PR.
- Violations are written once per process to the activity log as
`recovery.invariant_violation` (`actorId: ownership-reconciler`,
`details.mode: report_only`), insert-only with no live-event or plugin
fan-out. Nothing goes to Telemetry (AGENTS.md §5.7).
- `native-finalization-reconciler.ts`:
`registerOwnershipReconciliationWork` runs the pass in the shared
delivery-work scheduler (new queue `ownership-invariant-report`). There
is no second loop.
- `index.ts` registers it after residual recovery and does not await its
first pass, so readiness is not delayed.

### Sleep-aware trigger (rules/sleep-aware-background-work.mdc)

- **Trigger:** one pass at registration (startup reconstruction from
durable truth). After that, a settled `active-execution-recovery`
signal, which run lifecycle, controller renewal and process-evidence
writes already emit. Signals are coalesced to at most one pass per 30 s.
No producer gained a write.
- **Pending predicate and next deadline:** a 30 s follow-up only while
the last pass saw a changing in-progress set, or an owner proven gone
that needs the second I2 pass.
- **Quiet:** an empty set or the same stuck set twice sets no deadline.
The test asserts `nextWakeAt() === null` after an idle pass.
- **Turn path:** the pass never runs before `turn.start`. Stamps read
only cached identity.

## Verification

- `npx vitest run src/__tests__/ownership-reconciliation-report.test.ts`
(embedded Postgres, in `server/`): 4 tests pass:
  - every kind is registered once with no settle path;
- verdicts per class: alive (this process's claim), gone (lease of a
failed run), unprovable (no owner; another PID space);
- each rule fires on its fixture, and I2 fires only on the second pass;
- every touched table is byte-identical before and after (report-only);
- activity rows are written once per violation, and none are written
with `record: false`;
- the worker is quiet when idle and schedules a follow-up only while
records change.
- `npx vitest run src/__tests__/delivery-work-coordinator.test.ts
src/services/owner-proof.test.ts
src/__tests__/server-startup-feedback-export.test.ts`: pass.
- `npx tsc --noEmit -p server`: no errors.

## Risks

- No record changes, so no settle risk. The risk is load: one pass runs
about 46 small indexed or `limit 201` queries, at most once per 30 s
while runs are active, and none when idle.
- I4 matches refusal text with a heuristic. It can over- or under-count
until section E gives wake requests closed terminal reasons. It is
labelled as such in the code.
- Violations dedupe per process, so a restart writes a still-open
violation again (once per boot). Chaos reports count distinct `(rule,
entityId)`.
- Previous-release story (`rules/upgrade-safe-run-state.mdc`): no
persisted run state changes. The pass reads rows of any age, and a
missing field classifies as unprovable.
- `heartbeat_runs` and `native_run_finalizations` record no host or PID
space, so foreign-boot owners there classify as unprovable
(`missing_identity`). This is the inventory's finding 2, and the report
makes it countable.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in 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-07 13:51:28 -06:00
DottaandClaude Opus 5.5 f439150cb3 fix(recovery): limit the null-host owner fallback to single-host servers and record each use (#19883)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #19868 and #19871 made restart recovery judge a recorded pid only in
the PID space that recorded it
> - Rows saved before those host columns existed have no recorded host.
They kept the previous local dead-pid proof, so in-flight native runs
would not be stranded at upgrade
> - On an ephemeral (multi-host) server that fallback is unsafe: the row
may have been written on another host
> - This pull request limits the fallback to single-host servers,
records every use, and gives ephemeral servers a working board action
> - The benefit is that single-host upgrades still recover on their own,
multi-host servers never accept a cross-host proof, and the next release
can remove the fallback with evidence

## Linked Issues or Issue Description

**Bug description**
After #19868 and #19871, a `native_run_finalizations` row with
`controller_host_hash = null`, or a run with `process_host_hash = null`,
still accepted "the pid is dead here" as proof on every server. That
includes an ephemeral server, where the row may come from another host.
Nothing recorded how often the fallback was used, so it had no safe
removal path.

**Expected behavior**
- Single-host server: the fallback applies for one release, and each use
writes a `legacy_host_unknown_dead_pid_proof` activity-log entry.
- Ephemeral server (`PAPERCLIP_EPHEMERAL_SERVER=true`): the row is
unprovable, and the board has an action to confirm the previous server
stopped.
- A mismatched host is always unprovable.

Refs #19868, #19871, #19846

## What Changed

- `native-restart-recovery.ts` `evaluateNativeControllerTakeover`:
- A coordinator row with `controllerHostHash: null` returns
`controller_host_unrecorded` when `ephemeralServerEnabled()` is true.
- Otherwise a pid proof from that row carries `legacyHostFallback:
true`.
- `operator_attested` is accepted as proof
(`operator_attested_controller_stopped`).
  - Receipt-built owners, which have no host key, are unchanged.
- New `recordLegacyHostFallbackProof` writes the activity-log entry. It
is not a run-log event: run events are progress evidence for restart
recovery's crash-loop credit, and the record must not change any
recovery decision. It is called from restart recovery, the
dead-controller result-lease release, native retirement, and remote
warm-session recovery.
- New `native-controller-host-recovery.ts`:
- `requestNativeControllerStopConfirmation` (board-owned
`native_controller_host_unverified` recovery action, idempotent, never
displaces another active action).
- `confirmNativeControllerStopped`. It verifies the exact recorded
controller and stamps `operator_attested` with compare-and-set. It then
resolves the action, wakes live-run recovery, and logs
`issue.native_controller_stop_confirmed`.
- Route `POST /issues/:id/recovery-actions/confirm-controller-stopped`.
It is board-only and requires `runtime:confirm_owner_stop`. Generic
`restored`/`false_positive` resolution of this cause is refused. A
shared `confirmNativeControllerStoppedSchema` validates the body.
- UI: `WorkspaceExportRecovery` reuses the "Confirm the previous server
stopped" control for this cause, and the generic recovery card hides it.
Adds the `issuesApi.confirmControllerStopped` client call.
- `recovery/service.ts` orphan reaper: a null `process_host_hash` is
judged only when not ephemeral. A successful use records the entry
best-effort, after the terminal write. On an ephemeral server the run is
left to its existing Stop control.
- `ephemeralServerEnabled` moves to a leaf `ephemeral-server-mode.ts`
(re-exported by `ephemeral-server-contract.ts`). Importing the contract
module from recovery code created an import cycle in the runner child
process.
- The new route is registered in OpenAPI.
- `doc/execution-semantics.md`: documents the host rule and the
one-release sunset.

## Verification

- `native-restart-recovery.test.ts`: null host on a single-host server
is released with the flag. Null host on an ephemeral server is
unprovable. A mismatched host is unprovable in both modes. An attested
controller is accepted. Receipt owners are unchanged.
- New `native-controller-host-recovery.test.ts` (embedded Postgres):
  - The fallback writes the activity-log entry and no run event.
  - Ephemeral mode creates exactly one board action.
  - A wrong run id is refused.
- Confirmation stamps the attestation, after which takeover is allowed.
The action resolves and the activity is logged. A second confirmation is
refused.
- `recovery-stale-issue-lock-sweep.test.ts`: on a single-host server a
null host is terminalized and records one event. On an ephemeral server
it is left alone with no event. The existing audit-failure case still
clears the lock.
- `WorkspaceExportRecovery.control.test.tsx`: the controller
confirmation posts to `confirmControllerStopped` and never to the
copyback endpoint.
- Locally: these suites plus `remote-warm-session-recovery`,
`native-controller-recovery.integration` and
`native-startup-cleanup-recovery` (136 passed), and after the CI fixes
`native-runner-restart-preparation` and `native-runner-restart-recovery`
integration, `openapi-routes` and the suites above (256 passed); the UI
recovery suites (52 passed); `node scripts/typecheck-layers.mjs` with no
errors in the touched files; `pnpm check:token-gates` clean.

## Risks

- Previous-release story: no schema change. Null-host rows saved by
earlier releases recover as before on single-host servers, now with an
event, and wait for the board on ephemeral servers. The next release
removes the fallback; the event count shows whether any rows remain.
- On an ephemeral server, a null-host orphaned run process is not reaped
automatically. The operator uses Stop. That is the intended "unprovable"
outcome.
- A board confirmation is an attestation. It applies only to the exact
recorded controller (boot id + pid) and only while the host is still
unrecorded.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used 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-07 13:25:47 -06:00
DottaandClaude Opus 5.5 0189fedffc fix(native): bound an idle warm local runner whose controller died (#19879)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A warm runner (`lifecycleMode: warm`) keeps runnerd alive between
runs so a follow-up turn starts fast. The follow-up default flip (next
PR) makes warm the default for local Codex and OpenCode agents.
> - The controller owns a warm runner's idle window. Runnerd ignores the
idle timeout itself.
> - A graceful controller shutdown suspends idle warm sessions. A
controller that dies (SIGKILL, crash) does not. Local runners launch
with no process reconnect grace, so the idle runner kept running until
its 1-hour connection lease ran out, owned by nobody.
> - This pull request gives warm local runners the 7-minute reconnect
grace that remote runners already use.
> - The benefit: idle runners are reaped by proof within a bounded time,
never left orphaned, before warm becomes the default.

## Linked Issues or Issue Description

Refs #19874 (warm completion contract), #19846 (TTFT baseline), #19852.

**What existing behavior does this improve?**
Ownership of an idle warm local runner after its controller dies.

**Current behavior**
`runnerProcessReconnectGraceMs` is `undefined` for local targets, so
runnerd launches without `--reconnect-grace-ms`. An idle warm runner
whose controller was killed stays alive until its connection lease (one
hour) expires. The restart-chaos harness cannot show this: its K1 kills
the controller's whole process tree, runner included.

**Proposed behavior**
`localRunnerProcessReconnectGraceMs(policy)` returns the remote grace (7
minutes) for a warm policy and `undefined` for per-turn. After the
grace, runnerd records `transport_reconnect_grace_exceeded`, stops its
provider, keeps durable state and exits. The next run resumes cold.

**Reason and benefit**
The restart plan requires idle runners to be reaped by proof, never
orphaned. Seven minutes is far longer than a controller restart (13–20 s
locally) and the long-outage fault holds (3–5 minutes), so a live turn
still reattaches.

## What Changed

- `native-runner-recovery-contract.ts`:
`localRunnerProcessReconnectGraceMs`.
- `native-session-executor.ts`: the local runnerd transport passes it as
`runnerProcessReconnectGraceMs`. Remote targets keep `null`
(lease-owned).
- `runnerd-warm-idle-orphan.test.ts` (new): real runnerd and fake Codex.
An idle warm runner, after its turn completed, whose controller
detaches.
- `doc/DEVELOPING.md`: the grace and its bound.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/live/runnerd-warm-idle-orphan.test.ts`: with a process grace, the
idle runner exits with `transport_reconnect_grace_exceeded` and the next
transport resumes the same provider session cold. With no grace (`null`,
today's local launch), the runner is still alive after the controller
left.
- `cd server && npx vitest run
src/services/native-runtime/native-runner-recovery-contract.test.ts`:
warm gets the remote grace; per-turn keeps none; the grace outlasts a
controller restart and a long outage.
- `tsc --noEmit` passes for `server` and `packages/paperclip-runner`.
- Local restart measurement with warm runners (K1/K2, codex, claude,
opencode, n = 3 each): every interrupted run recovered, with recovery
latency equal or lower than per-turn. The chaos waves with this change
are in the default-flip PR.

## Risks

- A warm local runner whose controller stays down for more than 7
minutes exits. A turn in progress then resumes through
`resume_dead_runner`, the same path as a controller crash that kills the
runner. Remote runners already behave this way.
- Previous-release story (`rules/upgrade-safe-run-state.mdc`): no
persisted state changes. A warm runner launched by the previous release
keeps the launch arguments it started with (no grace) until it exits.

> 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

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in 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
- [ ] 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-07 13:11:18 -06:00
DottaandClaude Opus 5.5 00ad9503fe feat(commentary): agents read only their own commentary unless granted commentary:view (#19842)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can submit complaints and suggestions as commentary, and
people with commentary access read them on the Commentary page
> - When a person asks an agent in chat what agents have been
complaining about, the agent cannot read commentary and gets "Board
access required"
> - That response does not say where a person can see it, so the agent
can name the feed but cannot link it
> - Product rule: an agent cannot read other agents' commentary unless
it holds `commentary:view`, which is off by default; it may read its own
> - This pull request implements that rule and adds a remediation to the
denial naming the human page for the agent's own company
> - The benefit is a clear permission boundary and a working link for
the person instead of a dead end

## Linked Issues or Issue Description

**What happened?**
In the `activity.agent-feedback` chat eval, the agent correctly said it
could not read agent commentary and that only the person could open it,
but it gave no link, because the denial named no page.

**Expected behavior**
An agent denied a commentary read learns which page a person with
commentary access can open.

**Steps to reproduce**
1. As an agent, call `GET /api/companies/{companyId}/agent-commentary`.
2. Before this change, the 403 body is only `{ "error": "Board access
required" }`.

## What Changed

- `server/src/routes/agent-commentary.ts`:
- An agent with the `commentary:view` grant reads all commentary its
task access allows. The grant is not among the default agent grants.
- Without the grant, an agent may read its own entries: `?agentId=<its
own id>` on the list, or its own entry by ID.
- Without the grant, listing all commentary, filtering by another agent,
or reading another agent's entry returns 403 "Board access required".
The response's `remediation` names `/<issuePrefix>/activity/commentary`
for the agent's own company.
- A request for another company still fails on company access first,
with no page path.
- Board reads are unchanged: `commentary:view` for the user, plus task
read conditions.
- `agent-commentary.integration.test.ts`: one test covers the full-list
and other-agent denials with the page path, own-list and own-entry
reads, the cross-company denial, the grant allowing a full list, and
that `commentary:view` is not a default agent grant.

## Verification

- `npx vitest run src/__tests__/agent-commentary.integration.test.ts` in
`server`: 21 tests passed.
- The new test fails against the previous route.
- Negative control: with the previous route, the new test fails.
- Related commentary tests: `agent-commentary-reader`,
`agent-commentary-helper`, `commentary-permission-migration` passed.
`agent-commentary-skills` fails the same way with and without this
change (a missing skill file in a temporary directory locally).
- The chat eval rerun with this change is recorded in #19614.

## Risks

- Agents can now read their own commentary when they ask for it
explicitly; before, every agent read was denied.
- The denial's status code and message are unchanged; a `remediation`
string is added for same-company agents.
- There is no UI yet for granting `commentary:view` to an agent.
- An agent learns the company's issue prefix, which it already sees in
task identifiers.

> 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 Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:04:46 -06:00