Commit Graph
2305 Commits
Author SHA1 Message Date
DottaandClaude Opus 5.5 a88e23fa34 fix(runner): read an uncommitted turn start as empty history when the event window is complete (#20107)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run must survive repeated control-plane restarts; each
restore reconciles the turn its checkpoint names as active
> - That reconciliation reads the turn's history. When the provider has
no paginated history, the runnerd transport rebuilds it from the
committed runner events
> - After back-to-back hard restarts, the checkpoint can name an
accepted turn whose start was never committed. The runner was killed
after journaling `turn.start` and before the provider emitted anything
> - The rebuild treated every missing start as lost history and failed
with `codex_history_incomplete`, so the task needed a Retry
> - This pull request treats a missing start as empty history when the
committed window is provably complete
> - The benefit is that a run survives back-to-back restores, as it
already survives one

## Linked Issues or Issue Description

Refs #20074

**What happened?**
In restart-chaos wave w-20261108T200620-s9701 (local, K4/K6 + M1,
back-to-back K4 restores), three runs failed on the second restore. Two
were Claude ACPX C1 tasks; the third was an M1 parent with its subtask.
They failed with `native_session_recovery_failed:
HarnessReconciliationError: codex_history_read_failed: thread/items/list
requires supported paginated history; Error: codex_history_incomplete:
requested turn start is outside the retained runner event window`, and
each needed a Retry.

The sequence:
1. The first restore lost the in-flight turn and started a continuation
turn. ACPX echoes the requested turn id, so the checkpoint named it as
active.
2. Six seconds later a second K4 SIGKILL killed the runner. The runner
had journaled that `turn.start` (now `execution_indeterminate`), but the
provider had emitted nothing, so no `turn.started` was committed and the
runner outbox was empty.
3. On the second restore, `thread/items/list` for that turn hit the
guard, although the committed window was complete: source sequences
1–25, nothing evicted.

**Expected behavior**
The second restore treats the never-started turn like the first restore
treated its lost turn. The runner reports it lost on restore, and
recovery starts a continuation with no Retry.

**Steps to reproduce**
1. Have a checkpoint name an accepted turn whose `turn.started` was
never committed. For example, kill the runner after `turn.start` and
before the provider emits anything.
2. Restore the session. Before this change, recovery fails with
`codex_history_incomplete`.

**Paperclip version or commit**
master at 88835e624a plus #20094 (the wave). Branch rebased on current
master.

## What Changed

- `runnerd-codex-transport.ts`: a new
`committedEventWindowComplete(state)`. It is true only when the
committed events are exactly source sequences 1 to `ackedSourceSeq`:
none missing, none duplicated, none evicted.
- In the `thread/items/list` fallback, a turn with no committed start
returns empty history when the window is complete. Otherwise it still
throws `codex_history_incomplete`.

## Verification

- Red/green connected test in `runnerd-restart-continuation.test.ts`:
"starts a new turn when the checkpoint names an accepted turn whose
start was never committed". It uses the real runnerd and the fake Codex
server, and holds the provider start with `--turn-started-gate`.
  - Without the change, it fails with the exact production error.
  - With the change, the run completes on a continuation turn.
- Unit test: `committedEventWindowComplete` is false for a gap, a
missing first event, duplicates and no state.
- `npx vitest run` over every Codex, restart and recovery test file in
`packages/paperclip-runner`: 45 files, 1158 tests passed.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck`
(`packages/paperclip-runner`): passed.

## Risks

- Low risk. The empty history applies only when every acknowledged event
is still retained, so no event of that turn can have existed and been
dropped. Any evicted or gapped window keeps failing closed.
- Known gap, recorded as a follow-up: if eviction did remove events of a
turn the checkpoint still names, recovery still fails closed. The
planned fix is to never evict events of an unresolved checkpointed turn.

## 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 15:37:11 -06:00
DottaandClaude Opus 5.5 3bf51913ef fix(daytona): gzip transport compression for sandbox images without zstd (#20104)
## Thinking Path

- Per-turn remote sandboxes are the default, so every remote turn
uploads the workspace into a fresh Daytona sandbox.
- The Daytona plugin compresses large uploads with zstd, but only when
the sandbox has a `zstd` binary. Without one, the upload goes raw: 220
MB for the paperclip seed.
- The default Paperclip runner image has zstd, so it is already covered.
A sandbox image without zstd is not: for example a user-supplied
`config.snapshot` built on plain `debian:bookworm`. Such an image still
has gzip.
- This adds gzip as a fallback codec for those images. It is a
robustness fallback, not a speedup for the default image.

## Linked Issues or Issue Description

Refs #20100

**What happened?**
On a Daytona sandbox image without a `zstd` binary, every remote turn
uploads the workspace seed uncompressed. Measured live with the
paperclip seed: raw takes 18.6–28.8 s, against 6.1–16.0 s compressed.

**Steps to reproduce**
1. Use a Daytona snapshot whose image has gzip but no zstd.
2. Run a remote task on a large repository and inspect the `transfer`
span: there is no `transfer.compression.codec` attribute, and the full
raw size is uploaded.

**Expected behavior**
Large workspace tarballs are compressed in transit whenever the sandbox
can decompress them.

## What Changed

- `packages/plugins/sandbox-providers/daytona/src/file-sync.ts`:
- The existing availability probe (the same round trip as `mkdir`) also
reports `gzip`.
- Codec choice: zstd when the sandbox has it and Node supports it
(unchanged), otherwise gzip, otherwise raw (unchanged).
- gzip runs at level 1. On the 220 MB paperclip seed it produces 69.6 MB
in 1.26 s on the host; zstd -3 produces 57.6 MB in 0.54 s.
- In the sandbox, gzip decompresses with `gzip -dc -- <scratch> > <raw
scratch>`. It reuses the existing explicit `chmod`, atomic `mv -f`,
failure sweep, private host temp directory, and minimum-saving fallback.
- A failed or partial decompress exits 49, the raw scratch is never
promoted, and every scratch file is swept. The workspace sentinel and
`baselineSha256` checks still run before `turn.start`.
- The `transfer.compression.codec` span attribute is now `zstd` or
`gzip` (still a closed set).
- Tests in `file-sync.test.ts` (red on master):
- A real shell promotion on a sandbox that reports no zstd: the upload
is `.gz` and under half the size, `gzip -dc` runs, and the target is
byte-identical with mode 0600 and no scratch left.
- A truncated gzip upload fails the sync, never creates the target, and
leaves no scratch.
- Codec preference: zstd when both are present, gzip when only gzip is,
raw when neither is.

## Verification

- `npx vitest run` in `packages/plugins/sandbox-providers/daytona`: all
pass. The existing zstd suite runs on this host because it has zstd.
- `tsc --noEmit -p packages/plugins/sandbox-providers/daytona`: clean.
- Live Daytona run on the runner image, 2 vCPU, 220 MB seed, through the
real `performSyncIn` with each codec forced through the probe. The
sha256 matched every time.

  | Codec | Times |
  |---|---|
  | raw | 28.8 s, 18.6 s, 20.2 s, 22.8 s |
  | zstd | 6.9 s, 6.1 s, 16.0 s, 8.5 s |
  | gzip | 9.8 s, 16.8 s |

Network variance is large. Both codecs roughly halve the raw upload or
better.

## Risks

- **Host CPU:** about 1.3 s of streamed gzip per large upload, on the
libuv thread pool, and only when the sandbox lacks zstd. Files under 8
MB, or that save less than 10%, still go raw.
- **No compressor at all:** a sandbox without gzip or zstd uploads raw,
as today.
- **Probe caching:** the probe runs on every sync, on the `mkdir` round
trip it already makes, so it adds no latency. It stays uncached,
following the existing design's rule that one principal's observation is
never reused for another.
- **Observability:** one new value, `gzip`, in an existing closed-set
span attribute. The endpoint gate is unchanged.

## 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 14:52:56 -06:00
DottaandClaude Opus 5.5 015d3d0a7c fix(environments): tear down retain-on-failure sandboxes when their environment is deleted (#20085)
## Thinking Path

- Remote sandboxes cost money while they exist, so every sandbox
Paperclip keeps must have an owner and an end.
- Two lease policies keep a sandbox after its run.
`reuse_by_environment` keeps a warm sandbox for the next turn.
`retain_on_failure` keeps a failed run's sandbox, stopped, as a
workspace-restore source.
- Environment deletion is the last chance to end both: with the
operator's consent it destroys the environment's kept sandboxes,
otherwise it refuses.
- It only looked at `reuse_by_environment`. A `retain_on_failure`
sandbox neither blocked the delete nor got destroyed, so it was orphaned
the moment its environment was deleted.

## Linked Issues or Issue Description

Refs #20048

**What happened?**
In Daytona warm wave w-20261108T170423-s9501, the harness deleted the
environment after grading. Three stopped sandboxes (4e20c8b3, abf0980b,
5e49c561) remained afterwards and had to be deleted by hand through the
Daytona API. All three belonged to `retain_on_failure` leases: failed
runs' sandboxes kept as workspace-restore sources.

**This leaks on master today, with per-turn sandboxes too.** The four
leases in that wave carried `workspaceRestoreRecovery` from
`legacy-workspace-restore-recovery.ts` (no `nativeSourceRetention`
marker). That writer turns any failed run's lease with a
workspace-restore failure into a `retain_on_failure` restore source,
whether the environment is warm or per-turn. Every such sandbox has been
surviving environment deletion and costing money until deleted by hand.

**Steps to reproduce**
1. A run in a sandbox environment fails, and its lease is released as a
`retain_on_failure` restore source (sandbox stopped, not destroyed).
2. Delete the environment with `destroyReusableSandboxLeases=true`.
3. The delete succeeds; the restore-source sandbox is never destroyed
and has no lease or environment left to find it by.

**Expected behavior**
The delete blast radius counts kept restore-source sandboxes like
reusable ones. Without consent, the delete is refused; with consent,
they are destroyed through the same claimed teardown before the
environment row is removed.

## What Changed

- `server/src/services/environments.ts`: `getDeleteBlastRadius` counts
leases with policy `reuse_by_environment` **or `retain_on_failure`**
(status `active`, `released`, `retained` or `failed`) as kept-sandbox
holders. The blocked reason is still `reusable_sandbox_lease`.
- `server/src/services/environment-runtime.ts`:
`destroyReusableSandboxLeasesForEnvironment` selects both policies. It
claims through `destroyClaimedSandboxLease` with `anyLeasePolicy` plus a
scope condition limited to those two policies, keeping its existing
guards: live-run skip, pending export, legacy shutdown protection, and
the claim compare-and-swap.
- `server/src/__tests__/environment-runtime.test.ts`: a released
`retain_on_failure` lease on a failed run now blocks the delete and is
destroyed by the consented teardown (`expired`, `environment_deleted`).
This fails on master.

## Verification

- `cd server && npx vitest run src/__tests__/environment-runtime.test.ts
src/__tests__/environment-service.test.ts
src/__tests__/environment-routes.test.ts`: 347/347 pass.
- The new test fails on master: the blast radius reports no kept
sandbox.

## Risks

- **A delete now refused that used to succeed.** An environment holding
restore-source sandboxes is refused until the operator consents to
destroying them. That is the intended behaviour; it was silently leaking
them before.
- **Losing restore files.** The consented delete destroys restore
sources, which is what the operator agreed to by deleting the
environment. Leases with a pending export or legacy shutdown protection
are still skipped and keep blocking.

## 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:38 -06:00
DottaandClaude Opus 5.5 a9ae1b69c3 fix(runner): let a finish through after a restart lost the provider turn (#20088)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Runner relays an agent's Paperclip tool calls and keeps them
across control-plane restarts
> - #20069 refuses a `paperclip_finish` or `paperclip_block` decided
before an earlier returned result reached the agent
> - The guard filters by turn id, but each committed event carries the
run's one identity turn id, so provider turns are not separated
> - When a restart loses a provider turn, a call of that turn gets its
result but never its provider completion
> - The guard then refuses every later finish of the run, so the run
exhausts its retries and the user must click Retry
> - This pull request makes a new provider turn end the pending calls of
the previous turn
> - The benefit is that an agent can finish after a restart that lost
its turn

## Linked Issues or Issue Description

Refs #20069

**What happened?**
A restart chaos storm (local, Codex, A1/C1, K2 drains for 30 minutes,
116 tasks) had one failure. Task t110 needed a user Retry. Its run was
drained twice. The first drain lost the provider turn while a
`read_agent_instructions` call was pending. After that, all four
`paperclip_finish` calls failed with `terminal_call_before_results` and
named `read_agent_instructions`. The run ended with
`native_session_retry_exhausted`.

**Steps to reproduce**
1. Start a Codex run that calls a Paperclip tool.
2. Drain the control plane while that call is pending, so the Runner
restores the session and reports `provider_turn_lost_on_restore`.
3. Let the agent continue in the new turn and call `paperclip_finish`.

**Expected behavior**
The finish is recorded. The lost call belongs to a turn that the agent
will not see again.

**Actual behavior**
The Runner refuses every finish with `terminal_call_before_results`. The
run fails and needs a user Retry.

## What Changed

- `unseenReturnedToolCalls` forgets the calls it tracked when a
`turn.started` event begins a new provider turn.
- A new test reproduces the t110 event order: a call in a lost turn,
then a new turn that reads, sees its result, and finishes.

## Verification

- `npx vitest run src/live/terminal-call-freshness.test.ts` in
`packages/paperclip-runner`: the new test fails before the change and
passes after it (7/7).
- `npx vitest run src/live/runnerd-codex-transport.test.ts -t terminal`:
10 passed.
- `tsc --noEmit -p packages/paperclip-runner`: clean.
- Evidence: the kept Runner control-plane state of t110 shows the call
`exec-0f3d1b90` (generation 1) with a `semantic_tool.result` command but
no `tool.execution.completed`, followed by `turn.failed
provider_turn_lost_on_restore` and new `turn.started` events.

## Risks

- The t03 case of #20069 stays covered: the stale block and the earlier
calls are in the same provider turn, with no `turn.started` between
them. The existing t03 tests still pass.
- If a driver emits `turn.started` inside one provider turn, the guard
can miss a stale call of that turn. No current driver does that.

## 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 12:29:38 -06:00
DottaandClaude Opus 5.5 4f7eee10c2 feat(recovery): let the board confirm an unrecorded owner stopped for every settling process kind (#20084)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Recovery releases in-progress records (leases, claims, intents) only
when it can prove their owner is gone
> - A record saved before its server process was fully recorded is
`unprovable`, and it is never released on its own
> - #20067 added a board-only action to confirm that such an owner
stopped, but only the environment run lease used it
> - Other settling kinds (for example the legacy controller lease, which
shows `missing_identity` rows in the Phase 2 reconciler data) stay stuck
with no way forward
> - This pull request opens the same confirmation to every settling kind
whose record names a partly recorded server process
> - The benefit is that an operator can release these stuck records
safely, and recovery settles them at once

## Linked Issues or Issue Description

Refs #20067, #20054

**What happened?**
The Phase 2 reconciler data shows `legacy_controller_lease` records that
stay `unprovable` with `missing_identity`: they were saved before
Paperclip stored the controller's PID space. Recovery never releases an
unprovable owner, and only the environment run lease offered the board's
"confirm the previous server stopped" action. These records, and records
of other settling kinds saved the same way, had no path forward.

Also, a board confirmation did not wake the reconciler. Unprovable
records are not watched, so the release waited for some unrelated pass.

**Expected behavior**
Recovery asks the board on the record's task. After the board confirms,
recovery releases the record through the kind's own compare-and-swap
settle, at once. A confirmation never releases a record that changed
since the board was asked.

**Steps to reproduce**
1. Have a legacy run whose `controller_process` was recorded without a
host hash (an older server).
2. Run the ownership reconciliation pass. The record is `unprovable`
(`missing_identity`) and no board action appears.

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

## What Changed

- `ownership-registry.ts`: a new `ownerGoneAttestable` helper opts a
settling kind in. The kind asks the board (cause
`ownership_owner_unprovable`) only for a process-owned record that is
unprovable for a legacy reason and has a version. Opted in:
`legacy_controller_lease`, `environment_teardown_claim`,
`sandbox_creation_intent`, `workspace_runtime_control_operation`,
`native_workspace_finalize_attempt`, `native_finalizer_lease`,
`native_cleanup_lease`.
- `owner-gone-attestation.ts`:
- The request records the record version (`recordUpdatedAt`), and each
pass refreshes an open request to the current version.
- The board submits the version it was shown (`recordVersion`, a new
optional field in `confirmOwnerGoneSchema`). The confirmation is refused
with 409 unless that equals the request's current version and the locked
record's version.
- `recordVersionAttestations` reads back every resolved confirmation.
Any one that names the record's current version applies.
- `confirmOwnerGone` now wakes the ownership reconciler in its
transaction.
- Each opted-in kind has a version lock. For an `operator_attested`
proof, its settle runs in a transaction that locks the record row and
re-checks the confirmed version before the kind's own compare-and-swap.
- `ownership-invariant-report.ts`: in the periodic pass and in one-shot
settles, a matching confirmation turns a legacy `unprovable` verdict
into `gone` (`operator_attested`). A live or provable owner always wins.
The extra read runs only when a kind has such a record.
- `WorkspaceExportRecovery.tsx`: the board's confirmation names the
stuck record by kind (for example "This task's earlier run"), with a
generic fallback, and sends the version it showed.
- `doc/execution-semantics.md`:
- It describes the action, the version binding and which kinds are
offered.
- It updates the controller-lease section to match #20054: the holdings
list, and that the lease is off only on Railway deployments with an
exclusive volume.

## Verification

- `pnpm exec vitest run src/__tests__/owner-gone-attestation.test.ts`
(server): 10/10. The new legacy-controller cases fail without the
change:
  - the board is asked, and nothing is released before the confirmation;
- the confirmation wakes the reconciler, and the next pass settles the
record as `operator_attested`;
- a record that changed after the confirmation is not released, and a
fresh request is raised;
- a record of these kinds with no owner stamp is not offered, because
its settle could never release it;
- a confirmation for a version the record no longer has is refused, and
the refreshed request then applies;
- a write between the reconciler's listing and its settle stops the
release inside the settle's transaction.
- The 10 server ownership suites (registry, report, owner proof,
controller lease, legacy controller, teardown, finalize, token leases):
332/332.
- `pnpm exec vitest run
src/components/WorkspaceExportRecovery.control.test.tsx` (ui): 48/48.
- `pnpm check:token-gates`: all gates clean.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` in `server` and
`ui`: passed.

## Risks

- A wrong release would be serious. The safeguards:
- The confirmation applies only while the record's last-write time
equals the one the board was asked about, and only while the prover
still finds the recorded process unprovable for a legacy reason.
- Each kind's settle still compare-and-swaps on the exact recorded
process.
- A record of these kinds with no owner stamp at all is not offered.
Those settles cannot release it, so it keeps its existing recovery path.
- Kinds that are unowned by design (queued pending cleanup, accounting
debt) are not offered.

## 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 12:16:07 -06:00
DottaandClaude Opus 5.5 c55af26821 fix(runner): treat a replayed start of a turn lost on restore as history (t48) (#20074)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native Codex run must survive a control-plane restart and continue
its work
> - #20057 made recovery adopt an in-flight turn, or start a new turn
when the restored runner reports the old turn lost
> - One case was not covered. A graceful drain can detach the controller
after the runner accepted `turn.start` but before the provider started
the turn
> - The runner then records the provider's `turn.started` while no
controller is attached. After a restart it replays that start, and the
Codex session rejects it as an unexpected turn
> - This pull request treats that replayed start as history from the
earlier generation
> - The benefit is that the run starts its continuation turn instead of
stopping with `native_event_replay_conflict`

## Linked Issues or Issue Description

Refs #20057

**What happened?**
A restart chaos wave ended with one failed task (t48, Codex). A graceful
SIGTERM suspended the controller 300 ms after the runner accepted
`turn.start`. The provider's `turn.started` came after the controller
detached. Recovery classified the run as `resume_dead_runner`. The
restored runner reported the turn as `provider_turn_lost_on_restore`,
which is correct. But it also replayed the undelivered `turn.started`.
The Codex session failed with "Provider started an unexpected turn."
(`turn_binding_mismatch`), and recovery stopped with
`native_event_replay_conflict`.

**Expected behavior**
Recovery starts a new continuation turn. The replayed start of the lost
turn is recorded as history, and the run completes.

**Steps to reproduce**
1. Start a Codex native run.
2. Detach the controller gracefully after the runner accepts
`turn.start` and before the provider emits `turn/started`.
3. Let the runner record `turn.started` while it is unattached, then
kill the runner and the provider.
4. Restore the run. Before this change, the session fails with
`turn_binding_mismatch`.

**Paperclip version or commit**
master at a9cf4ee731 (the wave ran on 66a2cbab98).

## What Changed

- `codex-app-server-driver-impl.ts`: recovery identifies the turn that
this run's latest `turn.start` had the runner accept, when recovery did
not adopt it and provider history does not show it live. Recovery passes
it to the session as `lostAcceptedTurnId`.
- `codex-session-state.ts`: the session keeps `lostAcceptedTurnId` as an
unconfirmed lost turn. The stale-event rule treats its replayed start
and items as history.
- `codex-session-notifications.ts`: `turn/started` applies the
stale-event rule to earlier-generation and unconfirmed lost turns. The
runner's restart-loss terminal confirms the lost turn. Any other
terminal, or a semantic result, from that turn fails closed, so a real
outcome is never dropped. A start for a turn that this generation
settled, or for a turn the session never saw, still fails closed.
- `runnerd-restart-continuation.test.ts`: a new connected test with the
real runner and the fake Codex server reproduces the t48 shape. A new
fake option, `--turn-started-gate`, holds the provider's start until the
test detaches the controller, so the test does not depend on timing.
- `codex-app-server-driver.terminal-replay.test.ts`: driver tests for
the replayed start (history), and for an ordinary failure or a result
from the lost turn (fail closed).

## Verification

- Red: before the fix, the new test failed with
`NativeProviderTerminalFailure: Provider started an unexpected turn.` at
`codex-harness-session.ts:211`. This is the same error and stack as the
chaos run.
- Green: `npx vitest run src/drivers/codex
src/live/runnerd-restart-continuation.test.ts` (in
`packages/paperclip-runner`) passed 392 of 392.
- The wider Codex, restart and recovery test files (45 files, 1153
tests) passed on the final revision. The two fail-closed driver tests
fail on the first revision, where the lost turn's outcome was dropped as
history. The one failure in an earlier run ("still rejects history for a
turn that became terminal in the live session") showed that the first
version was too broad. The rule now applies only to earlier-generation
turns, and that test passes.
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` in
`packages/paperclip-runner` passed.

## Risks

- Low risk. The change only applies after a restore, to a turn that the
runner accepted for this run's latest `turn.start`, and only when
recovery did not adopt that turn and provider history does not show it
in progress.
- A turn that the session never saw still fails closed. A re-start of a
turn this generation settled still fails closed. A real failure or a
result from the lost turn fails closed instead of being dropped.

## 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 11:14:24 -06:00
DottaandClaude Opus 5.5 e8a3f1f6f2 feat(recovery): prove a replaced container's records gone through a fenced controller epoch (#20054)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a restart, the control plane settles in-progress records
(leases, claims, intents) only once it proves their owner process is
gone
> - The proof compares host and PID space, so a stamp from a replaced
container or a rebooted machine stays unprovable forever, and its
records never settle
> - Self-hosted users on Docker, Kubernetes and Fly hit this on every
redeploy; a Railway deployment with an exclusive volume is covered by
`deployment_replaced` (#20026)
> - This pull request adds a fenced controller epoch: each boot holds a
database lease, and an expired boot is fenced so its records can be
proven gone
> - The benefit is that work stranded by a replaced container is
recovered, without keeping an idle database awake

## Linked Issues or Issue Description

Follow-up to #20026 and the Phase 2 durable-ownership data. Related:
#19965 (owner stamps), #19883 (process space). No duplicate PR was
found.

**What happened?**

A record stamped by a server process in another container reports
`different_process_space`. A dead PID elsewhere is never proof, so the
record stays `unprovable` and is never settled. Every redeploy of a
self-hosted container leaves such records behind.

**Expected behavior**

A record owned by a server boot that was replaced (stopped cleanly, or
lost its lease and was fenced) is proven gone and settled exactly once.
A live boot's records stay with it. An idle server issues no recurring
queries.

**Steps to reproduce**

1. Run Paperclip in a container and start work, so an environment lease
is stamped by that boot.
2. Replace the container. The new container has a new PID namespace.
3. The ownership reconciler reports `different_process_space`
(unprovable) for the old lease, forever.

**Paperclip version or commit**

`master` before this PR.

## What Changed

- New `controller_instances` table (migration 0449): boot id, epoch
(bigserial), process space, pid, a lease on the database clock,
`fenced_at` / `fenced_by_epoch`, `stopped_at`.
- `controller-instance-lease.ts`, registered with the shared
delivery-work coordinator (`controller-instance-lease` queue, runs
during drain):
- **Holds work → renews; idle → silent.** One upsert creates the row on
first use and extends the lease only if the boot holds work: a stamp
within the last 90 s, or a stamp whose ownership write may still be
pending (held until the server's tracked work settles, capped at 10
minutes; a renewal that sees some other held record does not clear it,
so a slow ownership write cannot be outlived), or an in-progress record
held by its boot id. Those records are active environment leases,
in-flight cleanup claims, sandbox creation intents, workspace finalize
attempts, agent lifecycle steps, native finalizer and cleanup leases
(`lease_holder` bound to the token), open native coordinator claims, and
running legacy runs. Renewal runs every 30 s while the boot holds work.
When it finds none, the lease lapses and the worker sleeps until the
next stamp (`onControllerStamp`).
- **No idle trailing statements.** When the server's last tracked work
settles, the worker re-checks holdings at once, at most every 5 s (the
rate limit also absorbs the worker's own run). So the last check happens
with the work, not 30 s later.
- **Quiesce, don't crash.** Each renewal has a 10 s bound. A failed or
timed-out renewal applies an internal task drain, so no new work is
dispatched. A recovered renewal resumes. The boot exits only after 60 s
of failure (counted from the first failed attempt's start) while it
still holds work, or at once when it finds itself fenced.
- **Fence on demand, by a newer epoch.** For a stamp from another host
or PID space, the prover reads that boot's row:
    - `stopped` or `fenced`: gone.
    - live lease: alive.
- otherwise, the boot's last sign of life is the later of its lease
expiry and the record's last write plus the TTL. Within 30 s of that,
the owner stays `alive` (`controller_lease_grace`), so the settle watch
keeps rechecking. After that, the prover fences the boot, but only with
a lease row of its own (created on demand) and only an older epoch.
  - A graceful shutdown records `stopped_at`.
- A reconciliation pass that settles nothing (`record: false` or
`settle: false`) never fences and never creates the boot's row.
- `owner-proof.ts`: these proofs apply only to server-held records. A
stamp with a recorded process group stays unprovable, because agent
processes can outlive their server. The reconciler passes each record's
`updatedAt` to the proof.
- `task-admission.ts`:
- the quiesce drain never replaces an operator's drain, and has no idle
owner, so HTTP stays open;
- a `subscribeIdleWorkSettled` hook fires when the last tracked work
finishes.
- Off on a Railway deployment with an exclusive volume (it has its own
replacement proof). `PAPERCLIP_CONTROLLER_INSTANCE_LEASE=on|off`
overrides this. A stateless Railway service keeps the lease. A server
without its own lease row never fences.
- `maintenance-server-routes.test.ts`: in the post-run quiet window, the
lease may check once more that the run's records were released. The test
now excludes those statements, then asserts that the lease issues
nothing in a further window.
- Docs: `doc/execution-semantics.md`; the Phase 2 data doc records the
two known gaps.

## Verification

- `controller-instance-lease.test.ts` (15 cases):
- a replaced container with all Railway variables empty is fenced and
settled exactly once over two passes;
  - a live lease is alive;
- an owner in grace, and work written after an idle lapse, stay alive
and watched;
  - an older epoch never fences a newer one;
- no fencing without its own lease row (off or unregistered), and an
idle prover creates its row on demand;
  - a stopped boot is gone;
  - process-group stamps stay unprovable;
- idle issues zero statements, and one upsert renews only while records
are held;
  - **a 45 s outage:** quiesced, no exit, resumes;
- **a renewal that never answers:** bounded, and the boot exits on time;
- **a 70 s outage:** exits at 60–65 s, and the successor recovers the
work exactly once;
  - fenced: stops;
  - the settle re-check;
- graceful stop; off on a volume-exclusive Railway deployment, on for a
stateless one, and the on/off override;
  - a running legacy run keeps the lease live.
- Red, with the proof's instance check removed: the replaced-container,
live, stopped and successor cases fail.
- Wider: task-drain, idle-sleep, idle-local-work, owner-proof and
ownership suites, plus the maintenance quiet-window tests. Server
low-memory typecheck is clean.

## Risks

- **Clock skew.** The proof compares a record's `updatedAt` (often the
writer's clock) with the lease (database clock). The 30 s grace absorbs
ordinary skew.
- **A new exit path exists.** A server whose database is unreachable for
more than 60 s while it holds work now exits, after first quiescing.
This is the intended safety backstop.
- **Bounded trailing activity.** A lease that holds work issues one
statement every 30 s, plus at most one check when its work settles. An
idle server issues none.
- **Boots from before this release** have no row, and their records stay
unprovable. The board "mark owner gone" action for those follows in a
separate PR.

## Model Used

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

## 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 11:06:32 -06:00
DottaandClaude Opus 5.5 f679cf81dc fix(runner): refuse a finish or block decided before earlier results reached the agent (#20069)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Restarts must be invisible to a task: an agent that was working
keeps working after the control plane comes back
> - Restart chaos t03 (Daytona warm wave, K4 holds) ended a task as
blocked even though its document was saved
> - The cause is in how a terminal decision (paperclip_block or
paperclip_finish) is handled after a long controller outage, not in how
the Runner journals tool calls
> - This pull request refuses a terminal call that the agent made before
it had seen results that already returned, so the agent continues with
those results
> - The benefit is that a controller outage no longer turns into a false
"blocked" task

## Linked Issues or Issue Description

Restart chaos t03, wave `w-20261108T142906-s9501` (Daytona, codex, A1,
three back-to-back K4 holds of 136 s, 252 s and 152 s). Refs #19831
(durable ownership plan).

**What happened**

The kept instance's run log shows the order of events:
1. The turn is accepted at 14:29:57. The runner then delivers nothing to
the controller until 14:39:27. The holds keep the controller down, and
the Runner keeps Codex's `item/tool/call` requests until it is
reattached.
2. During that outage Codex keeps running. Its `write_document` call
stays pending. The model is sampled again about once a minute and says
"The save request is also waiting on Paperclip". It then calls
`paperclip_block`, with the reason `tool_unavailable`.
3. After the reattach the Runner delivers the backlog in order.
`write_document` runs and returns (the document is created: sourceSeq
152). Then the stale block arrives (sourceSeq 247) before Codex has
acknowledged the write result: there is no `tool.execution.completed`
for it before the block. The task ends blocked.

**Codex behavior, checked directly** against the pinned Codex 0.160
app-server. A local probe sent one dynamic tool call and left it
unanswered:
- Codex does not time out. It re-samples the model at about 60 s and 127
s ("The save call is still waiting to return").
- A reply sent 130 s later is accepted (`durationMs` 130003), and the
model finishes ("DONE").

So the reply is not lost. The agent's terminal decision was made without
it.

**Expected behavior**
- A terminal call decided while earlier Paperclip results were still
unseen is refused with a retryable error.
- The agent continues with those results. In t03 it would see that the
document was saved, and finish.

## What Changed

- `packages/paperclip-runner/src/live/terminal-call-freshness.ts`:
`unseenReturnedToolCalls` reads the turn's committed run events up to
the terminal call. It returns each dynamic call that has a returned
`tool_result` but no provider `tool.execution.completed`.
`staleTerminalCallResult` builds the retryable
`terminal_call_before_results` error, which names those calls.
- `runnerd-codex-transport.ts` `#handleSemanticToolInput`: when such
calls exist, `paperclip_finish` and `paperclip_block` get that error
instead of being dispatched. The recovery path after a restart uses the
same handler. A long-running call with no result yet does not count.
- Crash point `runner.semantic_tool.after_input_before_dispatch`, just
before the controller dispatches a dynamic tool call. A crash-point hold
(`--hold-ms`, `--crash-point-hold-ms`; a K4-style receipt) keeps the
controller down after the self-kill. Together they reproduce t03 on
Daytona.

## Verification

- Red/green: `cd packages/paperclip-runner && npx vitest run
src/live/runnerd-codex-transport.test.ts -t "refuses a terminal call
decided"`.
- The case `seen: false` failed on master: the handler was called and
the stale block was dispatched. It passes with the fix.
- The case `seen: true` (the agent saw the result first) dispatches
normally, both before and after.
- `npx vitest run src/live/runnerd-codex-transport.test.ts`: 289 passed.
`src/live/terminal-call-freshness.test.ts`: 5 passed.
- `npx tsc --noEmit -p packages/paperclip-runner` passes, and `node
scripts/typecheck-test-projects.mjs tests/restart-chaos` passes.
- Shared registry: 15 passed. Call-site test: 3 passed. Chaos runner
tests: 12 passed.
- Codex probe (local, about 2.5 min per run): reproduced the "still
waiting" re-sampling and the accepted late reply described above.
- Not yet run: the Daytona crash-point repro with a hold. It needs the
shared harness slot, and I'll add the result here. The fix is controller
TypeScript, which is not an input of the Daytona image, so the current
image still matches.

## Risks

- A refused terminal call costs the agent one more turn step. It applies
only when a result returned that the agent had not seen, which in
practice happens only after a controller outage.
- The check reads committed run events. If the store evicted the earlier
call's events, it finds nothing and keeps today's behavior.
- Upgrade-safe run state: no persisted shape changes. The check reads
events the Runner already commits. A run saved by the previous release
resumes with the same history and the same answer.
- ACPX and other providers emit no dynamic `tool.execution.*` events, so
the check is a no-op for them.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:43:21 -06:00
DottaandClaude Opus 5.5 0746d1203d feat(recovery): let the board confirm that an unrecorded owner stopped (#20067)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a restart, recovery releases an in-progress record only once
its owner is proven gone, never by age
> - A run lease saved before owners were stamped has no owner Paperclip
can judge on an ephemeral (multi-host) server, so it stays stuck and
holds its run's sandbox forever
> - Only a person can tell that the previous server stopped (for
example, its container was replaced)
> - This pull request asks the board on the stuck task and lets an owner
confirm the stop, with an activity-log entry; recovery then releases the
record on its normal path
> - The benefit is that legacy stuck records have a way out that never
guesses

## Linked Issues or Issue Description

Follow-up to the Phase 2 durable-ownership work: #20054 (controller
instance lease) and #19883 (process-space recording). It generalizes the
existing "confirm the previous server stopped" action for native
controllers (`native_controller_host_unverified`). No duplicate PR was
found.

**What happened?**

On an ephemeral server, an active run lease of a finished run that was
saved before holders were stamped has no owner stamp. The ownership
reconciler reports it `unprovable (no_owner_stamp)` and never settles
it. No operator action exists, so the lease and its sandbox stay
reserved.

**Expected behavior**

The task shows the stuck resource, and a board owner can confirm that
the previous server stopped. Recovery then releases the lease. Agents
and operators without the owner-stop permission cannot.

**Steps to reproduce**

1. Run with `PAPERCLIP_EPHEMERAL_SERVER=true`.
2. Have an active environment lease with no `leaseController` stamp,
whose run is terminal.
3. Run an ownership reconciliation pass. The lease stays `unprovable`
with no action on the task.

**Paperclip version or commit**

`master` before this PR.

## What Changed

- `packages/shared`: `confirmOwnerGoneSchema` (`actionId`, `recordId`,
`stopEvidence` of 20 to 12000 characters).
- `recovery/owner-gone-attestation.ts`:
- `requestOwnerGoneConfirmation(kind)` is an `onUnprovable` hook. For a
legacy unprovable owner (`no_owner_stamp`, `process_space_unrecorded`,
`missing_identity`) on a record that has a task, it opens one idempotent
board recovery action on that task (cause `ownership_owner_unprovable`).
- `confirmOwnerGone(...)` verifies the exact action and record. It asks
the kind to write the attestation (compare-and-swap), resolves the
action, and writes an `issue.ownership_owner_gone_confirmed`
activity-log entry with the actor and the stated evidence.
- Ownership registry:
  - `OwnableKind.attestOwnerGone` is a new optional hook.
- `environment_run_lease_active` implements it: only an unstamped active
lease of a terminal run, with the notification for the cleanup path.
- The lease's owner then reads `operator_attested`, which proves gone,
so the existing settle releases it. A holder stamped since the board was
asked owns the lease, and its stamp decides.
- Route: `POST /api/issues/<built-in function
id>/recovery-actions/confirm-owner-gone`. It uses `assertBoard` plus
`runtime:confirm_owner_stop`, the same board-only permission as
confirm-controller-stopped. It is registered in OpenAPI.
- UI: `issuesApi.confirmOwnerGone`. The task's recovery card shows the
existing `PreviousServerStopConfirmation` for this cause, with
resource-specific wording; owners confirm and operators see the wait.
`isPreviousServerStopCause` includes the cause, so the generic card does
not offer other resolutions.

## Verification

- `owner-gone-attestation.test.ts`:
  - the stuck lease opens one board action on the task;
- confirmation resolves the action, writes the activity log, and the
next pass settles the lease (`operator_attested`) exactly once;
  - a mismatched record is refused with 409;
- a holder stamped after the ask is refused and the lease stays active;
  - no action for a judgeable owner or for a record with no task.
- Red, with the `onUnprovable` hook removed: 2 of 3 fail.
- UI: `WorkspaceExportRecovery.control.test.tsx` checks that the owner
confirms with the right payload and that an operator sees the wait
without the control (47/47).
- Server suites: openapi-routes, ownership and recovery suites, plus the
existing native-controller-host recovery. Low-memory server typecheck is
clean; `check:token-gates` is clean.

## Risks

- The board can attest a server stopped that is in fact running. The
action and the UI copy ask for the evidence, and the activity log
records who attested what. This is the same trust model as
confirm-controller-stopped.
- Only run leases opt in so far. Records without a task (login/probe
leases) keep the legacy idle sweep.

## Model Used

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

## 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 09:46:36 -06:00
DottaandClaude Opus 5.5 66a2cbab98 fix(runner): adopt the in-flight Codex turn after a restart lands just after turn.start (#20057)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native Codex run survives a server restart by resuming its durable
provider session and adopting the turn that was in flight
> - The adoption rule (RC-59) only adopts a turn named by the latest
`turn.start` command's accepted result
> - Two gaps made it fail. A restart just after `turn.start` leaves that
command `execution_indeterminate` (the runner died after journaling it).
Separately, the rule demanded that Codex echo the requested turn id,
which it never does.
> - The restored session then rejected Codex's own in-flight turn as
unexpected, and the user's Retry failed on the first click
> - This pull request adopts exactly the turn the runner reconciled for
that ambiguous start, and accepts Codex's own turn ids
> - The benefit is that a restart landing right after a turn starts no
longer costs the user a second Retry

## Linked Issues or Issue Description

Found by the restart-chaos campaign: wave `w-20261108T134237-s9401`,
task t77 (Codex C1), Retry run `5358f1cb`. Related: #19852 (fast
follow-up turns), RC-59 adoption. No duplicate PR was found.

**What happened?**

The Retry run submitted its first turn at 14:05:25.171. A K2 SIGTERM
followed at .690 (graceful drain, then `resume_dead_runner`).
- The runner had journaled the `turn.start` and then died, so on restore
it settled the command as `failed / execution_indeterminate`.
- The runner's restored Codex session reconciled the ambiguous start to
the live replacement turn (`session.reconciled`,
`activeProviderTurnId`).
- Codex then sent `turn/started` for that turn. The restored driver had
no active or pending turn, because `controllerAcceptedTurnId()` was
null, and failed with `turn_binding_mismatch`.
- The run ended `native_event_replay_conflict`, and the user needed a
second Retry.

**Expected behavior**

The restored session adopts the runner-reconciled in-flight turn and
finishes it. It does not treat it as a replay conflict.

**Steps to reproduce**

1. Start a native Codex run.
2. Send SIGTERM to the server about 0.5 s after the first `turn.start`,
before runnerd confirms it.
3. Let the server restart and resume the run (`resume_dead_runner`).
4. Before this PR, the run fails with "Provider started an unexpected
turn."

**Paperclip version or commit**

`master` before this PR.

## What Changed

- `controllerAcceptedTurnStartId(commands, options)`
(runnerd-codex-transport.ts):
- **Codex and OpenCode results.** These providers assign their own turn
ids, so an accepted result never echoes the requested `turn_lab_` id,
and RC-59 adoption never matched a real Codex result. For them, the
accepted turn is now adopted only while it is also the runner's live
active turn in its authenticated `session.snapshot`. A turn the restored
runner reports lost has no live turn, so it keeps the established
restart-continuation path. ACPX still requires the echoed id.
- **Indeterminate start.** When the latest `turn.start` (after the
latest attach) failed with `execution_indeterminate`, it adopts the
runner's live active turn. Runnerd reports one only after it reconciles
an ambiguous start to a distinct replacement turn.
- The transport records the snapshot's active turn at `thread/read`,
which `recover()` reads before it evaluates adoption.
- The driver's existing RC-59 guards still apply: the turn must be in
provider history, be the only active turn, and not already be terminal.

## Verification

- Connected restart test, `runnerd-restart-continuation.test.ts` "adopts
the in-flight turn when a restart lands just after turn.start (t77)". It
uses the real runnerd and fake Codex:
  - the runner and provider die after journaling a started turn;
  - runnerd's own restore settles the command `execution_indeterminate`;
  - recovery adopts `provider-turn-1` with no second `turn/start`.
  - It fails without the fix: nothing is adopted.
- Unit cases for `controllerAcceptedTurnStartId`:
  - a Codex-assigned id is adopted only while it is the live turn;
  - an indeterminate start is adopted with the reconciled turn;
- it fails closed with no live turn, an ordinary failed start, a later
attachment, or an ACPX id mismatch.
- Regression guarded: "starts a new turn when the restored runner
reports a lost turn the checkpoint never bound" still passes.
- `runnerd-codex-transport`, `codex-app-server-driver.recovery`,
`runnerd-codex-transport-adoption`,
`runnerd-codex-transport-cold-restore`, `runnerd-restart-continuation`
and `runnerd-controller-outage`: 383/383. Runner TypeScript typecheck is
clean.
- Live: the chaos campaign will run the `runner.turn_start.*` crash
points (Codex A1/C1) and a Codex C1 K2 storm, and post the result here.

## Risks

- **Previous-release story.** No persisted shape changes. Commands
journaled by the previous release carry forward and are read with the
new rule. An indeterminate start that the runner did not reconcile to an
active turn still fails closed, as before.
- **Wider Codex adoption.** Codex adoption now actually fires. If
runnerd ever returned an accepted result that names a turn other than
the one this command started, the driver would adopt it. Runnerd binds
the result to the exact command id, so this is the same trust the rule
already placed in ACPX.

## Model Used

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

## 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 09:26:58 -06:00
Nicky Leach 17ad23aafe fix(workspaces): back off failed terminal cleanup and retire abandoned reservations through remove (#20020) 2026-11-08 07:12:48 -08:00
DottaandClaude Opus 5.5 96badeaa42 fix(lifecycle): never repeat a passed harness verification; settle a dead verifier's lease at once (#20030)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A new or reconfigured agent runs a harness verification (a hello
probe in a test sandbox) before it is ready, and its runs wait until it
passes
> - In the final Daytona restart chaos wave, a Codex agent's runs waited
about 417 s for their first sandbox, while boot took only about 32 s
> - Each control-plane restart killed the verifier. The next process
waited out the 120 s lifecycle lease and then booted a fresh sandbox.
Nothing recorded a configuration that had already passed.
> - This pull request records a passed verification by what makes it
valid and reuses it, and settles a dead verifier's lease on the shared
ownership path at once
> - The benefit is that a restart never repeats a passed verification,
and never leaves runs waiting out a dead process's lease

## Linked Issues or Issue Description

**What happened?**
In the final Daytona chaos wave, the Codex agent was created at 09:55:46
UTC and its three runs were queued. Verification ran four times, in four
sandboxes, because faults K1, K4 and K2 each killed the verifying
server. After K2 the server was ready at 10:00:20, but verification
restarted only at 10:02:13: it waited out the 120 s lifecycle lease. The
first run sandboxes were created at 10:02:43, 417 s after launch.

**Steps to reproduce**
1. Create a Codex agent and assign it a task at once, so the task waits
for harness verification.
2. Kill the control plane (SIGKILL) while the agent is verifying.
3. The restarted server waits out the dead verifier's 120 s lifecycle
lease, then verifies again in a new test target. The task's first token
arrives about 130 s after the fault.

**Expected behavior**
- A passed verification of the same effective configuration in the same
runner image is never repeated.
- A verification is never restarted from scratch while its holder
process is alive. When the holder is proven gone, its lease is settled
at once.
- Runs gate only on a configuration that has never passed.

**Root cause**
`verify()` (`server/src/services/agent-harness-verification.ts`) leased
a fresh sandbox on every attempt and recorded nothing on a pass. The
lifecycle lease (`modules/agent-lifecycle/adapters/postgres.ts`) named
no holder process, so a restart could only wait for it to expire.

## What Changed

- Restart chaos TTFT driver: a `verify` phase, reported in its own rows,
and a `--verify-fault K1|K2|K6` option. The fault fires 1 s after
`verifying` is observed, so it interrupts a held lease. The seed keeps
each agent's creation body, so the driver can hire an identical agent.

- **Persisted pass:** new columns `agents.harness_verified_digest` and
`harness_verified_at` (migration 0448). The digest is an HMAC, keyed
with the instance's decision-signing secret, over:
- the fully resolved effective configuration: every value the test used,
including `cwd`, resolved credentials, and agent-over-environment
precedence;
- the selected AI account and its credential generation (connection and
grant ids and `updatedAt`, never the credential);
  - the environment and the runner image.

The key means the stored value cannot confirm a guessed credential
offline. For a sandbox, the runner image is an immutable image digest or
a qualified Daytona snapshot ID (`sandboxBinding`, now exported). For
local runs it is the server version. A mutable image or snapshot name,
or a local environment redirected to the managed sandbox, never reuses a
pass.
- **Server-only:** `normalizeAgentBaseRow` and
`redactAgentRowForResponse` strip the digest and its timestamp from
agent API responses.
- `verify()` still runs every account, policy and secret check, then
returns complete on a matching digest without booting a test target. It
records the digest on a pass before the lifecycle step completes, so a
restart in between reuses it.
- **Lease holder:** the lifecycle `claim()` stamps the claiming
controller process on the lease (`lifecycleOperation.leaseHolder`,
supplied through the lifecycle effects port) and clears the stamp with
the lease. The compare-and-swap release lives in the lifecycle module
(`releaseAgentLifecycleLeaseOfHolderInTransaction`).
- **Shared ownership path:** a new `agent_lifecycle_lease` kind with
`settleAgentLifecycleLease`.
- When the reconciler proves the holder gone, settle clears exactly that
lease (compare-and-swap on the holder), notifies lifecycle work, and
logs `agent.lifecycle_lease_reconciled`.
- A live holder is never taken over. The lifecycle state, operation and
progress are untouched.

## Verification

- `restart-verification.test.ts` (embedded Postgres, real lifecycle
store and worker, real reconciler pass):
- A verification held by a dead process is claimed and completed at once
after the startup pass, with the agent ready in under 10 s instead of
waiting out the 120 s lease. It fails without the new kind.
  - A live holder is never taken over.
  - The claim stamps the holder, and `defer` clears it.
- `agent-harness-verification-preflight.test.ts`:
- A second `verify()` of the same configuration allocates no target. A
revoked account still fails before reuse. A changed model, `cwd` or
literal credential tests again. It fails without the reuse.
  - A failed verification records nothing.
- Ownership report, all lifecycle module tests, remote, provider-API and
unit harness verification tests, and crash-point call sites: 13 files,
220 tests pass.
- Targeted local run: the TTFT driver's new `verify` phase (`--phases
verify --verify-fault K1`, Codex, 2 samples) hires a new agent, assigns
it a task at once, kills the control plane (K1) while it verifies, and
times fault to first agent token:
- Before (master server code): 129.8 s and 129.8 s. The restarted server
waited out the dead verifier's 120 s lease. Run-log TTFT from run
creation was only 4.1 s.
- After (this branch): 15.8 s and 17.2 s. The startup reconciler pass
settled `agent_lifecycle_lease` and verification re-ran at once.

## Risks

- **A stale pass:** the digest covers every resolved value, the selected
account generation, the environment and an immutable runner image, so
any change tests again. Rotating the instance signing secret invalidates
every digest, so each agent verifies once more.
- **Migration:** two nullable columns, no backfill. Existing agents
verify once more, then reuse.
- **Settle:** it runs only on a `gone` proof, compare-and-swap on the
exact holder. It never changes the lifecycle state.

## 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-08 08:10:40 -06:00
DottaandClaude Opus 5.5 bfa9002a3c test(restart-chaos): reach the stop-and-retain crash point with --daytona-reuse-lease (#20049)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart-chaos crash-point suite kills the server at named
points, then checks that recovery restores every invariant
> - On Daytona, `environment.stop_retain.after_claim_before_stop` was
never reached, because no workload produced a stop-and-retain release
> - A per-turn run on a reusable sandbox lease ends every turn with
`stop_and_reuse`, which releases through exactly that stop-only claim
> - This pull request adds `--daytona-reuse-lease`, so the chaos Daytona
environment keeps reusable sandboxes and the point is reachable
> - The benefit is live coverage of the stop-and-retain crash window

## Linked Issues or Issue Description

This is a follow-up from the Phase 2 durable-ownership data
(`doc/plans/2026-11-07-phase2-reconciler-data.md`, "Not covered").
Related: #19937 (crash-point suite) and #20042 (stop-only claim fence
renewal). No duplicate PR was found.

**What happened?**

The Daytona crash-point run `cp-20261108T104102` reported
`environment.stop_retain.after_claim_before_stop` as `not_reached`. The
point needs a stop-and-retain intent, and the chaos Daytona environment
is created with ephemeral leases.

**Expected behavior**

A flag lets a Daytona wave or crash-point run use reusable leases. A
per-turn run then stops and keeps its sandbox, and the point is reached
and graded.

**Steps to reproduce**

1. Run `node scripts/crash-points.mjs --env daytona --daytona-image
<image> --points environment.stop_retain.after_claim_before_stop
--adapters codex --workloads A1`.
2. Before this PR the point is `not_reached`.
3. With `--daytona-reuse-lease --lifecycle per_turn` it is reached.

**Paperclip version or commit**

`master` at `8475826d2d` (the signed image `c24059f1…` revision).

## What Changed

- `--daytona-reuse-lease` (wave and crash-point suite, Daytona only)
creates the chaos environment with `reuseLease: true`. The crash-point
suite passes the flag through.
- The R6 sandbox sweep counts a **stopped** sandbox behind a **released
`reuse_by_environment`** lease as kept for reuse, not a leak. A later
run on the same workspace resumes it, because reuse accepts released and
retained leases. A running sandbox, or a stopped one behind an ephemeral
lease, is still a leak.
- The crash-point registry prerequisite and the README name the flag.

## Verification

- Live, Daytona, codex A1, from the image revision `8475826d2d`:
- `cp-20261108T132149`: the point was **reached** and settled with no
invariant violations. R6 flagged the stopped reusable sandbox as a leak,
which led to the sweep fix.
- `cp-20261108T133351`, with the sweep fix: **pass**. 0 orphaned
sandboxes, 0 held leases, the environment deleted, and nothing left
running.
- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`
passes `daytona-sweep`, `scheduler` and `crash-points` (52/52). `tsc -p
tests/restart-chaos/tsconfig.json` is clean.

## Risks

- Test harness only; the registry change is prerequisite text. Without
the flag, waves behave as before.

## Model Used

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

## 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:07:19 -06:00
DottaandClaude Opus 5.5 46f32ed33f fix(sentry): stop treating short host settings as credentials in run-failure redaction (#20013)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When an agent run fails, Paperclip sends a redacted failure report
to Sentry so the team can fix the cause
> - Redaction treats the server's own environment values as possible
secrets and removes them wherever they appear in the report
> - Since #19461, every value under an environment name the allowlist
does not know counts as a secret, even very short ones such as `vi`,
`RUN`, `off` or `1`
> - On a host with such settings, the report text is damaged: `service`
becomes `ser***REDACTED***ce`, and a `1` anywhere can vanish. One server
test failed on every such host, while CI (which has no such values)
stayed green
> - This pull request keeps the fail-closed rule for an agent's
configured environment, and requires a plausible credential in the
server's own environment
> - The benefit is that Sentry reports stay readable on every host, and
real credentials stay private

## Linked Issues or Issue Description

Refs #19461 (introduced the catch-all), #14585 (added the failing test).

**What happened?**
`heartbeat-process-recovery.test.ts` › "redacts opaque environment-bound
credentials from Sentry diagnostics: result" failed locally on `master`:
`expected 'ser***REDACTED***ce' to be 'service'`. Instrumentation showed
that the server's own `process.env` contributed these redaction values:
`["false","67591","true","0x0","RUN","off","vi","64","1","0","8"]`. They
came from a desktop agent session's environment.

**Expected behavior**
Short host settings are not credentials. Declared, secret-named and
credential-shaped values stay private. An agent's configured environment
keeps its fail-closed rule.

**Steps to reproduce**
Run that test on a machine whose environment contains
`SOME_UNKNOWN_NAME=vi`.

## What Changed

- `packages/adapter-utils/src/command-redaction.ts`:
`collectDiagnosticEnvironmentSecrets` gets an option, `{ hostProcess:
true }`. With it, an undeclared value under an unrecognized name is
collected only in these cases:
- it has at least 8 characters and is not a bare number, hex number or
boolean-like word; or
- its name has a credential segment: `TOKEN`, `SECRET`, `PASSWORD`,
`BEARER` or `PRIVATE`, or a final `KEY` or `KEY_ID`.

  Without the option, behavior is unchanged.
- `server/src/services/run-failure-report.ts`: the report collects the
server's own `process.env` with `hostProcess: true`. The agent's
configured environment (`heartbeat.ts`) and the provider environment
(`terminal-session-failure.ts`) keep the default.

## Verification

- `command-redaction.test.ts` (42 pass). The new test fails on `master`
and passes here. It checks three things: the host's short values are not
collected; a short value under `AWS_ACCESS_KEY_ID` and a long opaque
value stay private; and an agent binding `CUSTOM_BINDING: "1"` stays
private.
- `run-failure-diagnostics.test.ts` (135 pass, unchanged). These tests
pin the agent-environment fail-closed rule.
- `heartbeat-process-recovery.test.ts` › "redacts opaque
environment-bound credentials…": both modes pass here. The `result` mode
failed on unchanged `master` on this host.
- The adapter-utils and server typechecks pass.

## Risks

- The server's own environment now leaves out a non-credential-named
value that is shorter than 8 characters or is a bare number or boolean,
for example `MY_PIN=1234`. Values under credential names, declared
secrets and credential-shaped values are still redacted.
- No Telemetry path changes; this is the Sentry run-failure report.

## 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:50:30 -06:00
DottaandClaude Opus 5.5 946c098bf3 fix(opencode): bind the attached run's contract after a restored launch (#20001)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native OpenCode agents run through runnerd and a small OpenCode
proxy. The proxy checks each `paperclip_finish` claim against the run's
completion contract.
> - Each follow-up run has its own contract revision. #19874 lets
runnerd keep the OpenCode process across a contract change, and sends
the new contract on `turn/start`.
> - When runnerd starts again for a follow-up, it restores the saved
session first. The restored proxy launches with the saved run's
contract, but its environment already names the new run.
> - The proxy applied the `turn/start` contract only when the run id
changed. Here the run id did not change, so the proxy kept the saved
contract and refused every correct finish claim.
> - This pull request makes the proxy rebind whenever the turn's
contract differs from the one its session holds. The stale-revision
error also names the expected revision.
> - The benefit: a queued-comment follow-up finishes on its first try,
and an agent that sends a stale revision can repair it.

## Linked Issues or Issue Description

Refs #19874 (warm runs carry their own completion contract).

**What happened**

A user sent a follow-up comment while an OpenCode agent was working.
After the first run finished, Paperclip started a continuation run for
the queued comment. The agent updated the plan and called
`paperclip_finish` with the current revision `2`. The tool refused it
twice with "Semantic result completion contract revision does not
match". The error did not name the expected revision. The agent searched
for it for 3 minutes, and cleanup cancelled the run.

Root cause: runnerd restores a saved `session_open` session before it
applies `run.attach` (`provider_backend.rs`: "execute() restores the
durable provider before dispatching run.attach"). The restore launches
the OpenCode proxy with the saved contract (revision `1`). Runnerd's
environment already names the new run, so the proxy's `PAPERCLIP_RUN_ID`
is the new run. `attach_run_in_place` keeps this provider for a changed
contract (#19874) and sends `paperclipCompletionContract` revision `2`
on `turn/start`. The proxy compared only the run id. The run id matched,
so the proxy skipped `attachRun` and kept revision `1`.

**Expected behavior**

The continuation's first `paperclip_finish` with the current revision is
accepted. If a claim has a stale revision, the error names the expected
revision.

**Steps to reproduce**

1. Use a native OpenCode agent with warm follow-ups (the default).
2. Start a task. While the agent works, post a follow-up comment, so
that it queues.
3. Let the first run finish. Paperclip starts a continuation run with
completion revision `2` and restores the saved OpenCode session.
4. The agent calls `paperclip_finish` with `contractRevision: "2"`.
5. Before this fix, the tool refuses it with "Semantic result completion
contract revision does not match".

The chat eval `steering.discard-queued-message` (runner-opencode) shows
this in run events: `session.resumed` carries revision `2`, and both
`paperclip_finish` results fail with the mismatch.

## What Changed

- `opencode-app-server-proxy.ts`: record the contract that the session
opened with. On `turn/start`, call `attachRun` when the run id changes
or when the supplied `paperclipCompletionContract` differs from the
session's contract.
- `opencode-proxy-task-envelope.ts`: add
`sameOpenCodeProxyCompletionContract`.
- `opencode-server-driver.ts`: the stale-revision error now ends with
`Use contractRevision "<expected>".`, like the criteria error.
- Tests: a real-proxy regression for the restored launch, a driver test
for the new error text, and a `repair-revision` mode in the fake
OpenCode fixture.

## Verification

- `pnpm exec vitest run src/cli/opencode-proxy-input.test.ts
src/drivers/opencode/opencode-server-driver.test.ts
src/cli/opencode-app-server-proxy.test.ts
src/cli/opencode-proxy-thread-read.test.ts` in
`packages/paperclip-runner`: 88 passed.
- Red/green: without the source changes, both new tests fail. `binds the
current contract when a restored launch already names the attached run`
never sees the finish call reach the controller. `names the expected
revision in a repairable stale-revision tool error` does not find the
expected revision in the error.
- `npx tsc --noEmit -p packages/paperclip-runner`: no errors in the
changed files. (My worktree is missing a link for
`@paperclipai/paperclip-eval-kernel`, which causes unrelated errors in
`src/eval/workflow-harness.ts`.)
- End-to-end: the chat eval `steering.discard-queued-message`
(runner-opencode) with this fix cherry-picked. The run link goes in a
comment below.

## Risks

- Previous-release story (`rules/upgrade-safe-run-state.mdc`): this
change does not change a persisted shape. The regression test uses the
saved-state shape: launch parameters from the saved run, environment and
`turn/start` from the new run. A runnerd that does not send
`paperclipCompletionContract` behaves as before, so the proxy keeps its
launch contract. The proxy and runnerd ship in the same runner artifact.
- The proxy can now call `attachRun` for the same run id when the
contract changes. `attachRun` resets the per-run result state. This
occurs only at `turn/start`, when no turn is active, and only when the
contract is different. A result for the earlier contract is not valid
for the new contract.
- The error text changed. No code parses it.

> 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-08 03:35:52 -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 68d5fb5a6e feat(chaos): check I6 and I8 per crash point; ready the Daytona points (#19984)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The on-demand crash-point suite (#19946) kills the control plane at
named durable write boundaries and checks the plan's invariants
> - Its first full run measured I1–I4 and I7, but I5, I6 and I8 were
reported as not measured
> - #19960 (generation stamping) and #19863 (speed budgets) now record
what I6 and I8 need in the run log
> - The six Daytona-only points have not run yet, and three of them
cannot be reached by a plain task
> - This pull request checks I6 and I8 per point, names the I5 gap, and
readies the Daytona points for the coming Daytona wave
> - The benefit is that every invariant is either asserted or a named
gap, and a Daytona run explains any point it cannot reach

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 5). Follows #19946 (the suite). Uses #19960
(event generations) and #19863 (speed budgets).

**Problem**
- The suite reported I5, I6 and I8 as "not measured".
- A Daytona run would report three points as `not_reached` with no
reason.
- A Daytona run without its key or image failed only after a 3-minute
boot.

**Expected behavior**
- I6 and I8 are checked at each point.
- I5 is a named gap with its reason.
- Daytona points say what they need, and a Daytona run checks its inputs
before it boots.

## What Changed

- `tests/restart-chaos/invariants.ts`: `runLogViolations` checks two
invariants from the run log, scoped to the wave's company:
- I6: a run that failed with an error that names a stale or
earlier-generation event;
- I8: each `run.performance.budget_exceeded` event, with the budget
evidence (metric, measured, p95, adapter, start kind).
Both are keyed by run or event id, so the per-point baseline diff
charges each one to the point that produced it. I5 is reported as
`notMeasured` with a reason (`I5_GAP`).
- `packages/shared/src/crash-point-registry.ts`: an optional
`prerequisite` on the three Daytona points a plain task cannot reach.
These are the teardown claim (needs a failed release), the reusable
destroy (needs a `reuse_by_environment` lease), and stop-and-retain.
- `tests/restart-chaos/crash-points.ts`:
- `--list` prints each point's `needs:`, and a `not_reached` result
names it too;
- `--env daytona` refuses to start without `DAYTONA_API_KEY` and an
image.
- Docs: the README and the plan describe the I5/I6/I8 coverage and the
Daytona points.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts
tests/restart-chaos/crash-points.test.ts`: 12 passed.
- `cd packages/shared && npx vitest run src/crash-points.test.ts`: 15
passed.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos` and
`npx tsc --noEmit -p packages/shared` both pass.
- I ran `runLogViolations` against a throwaway Postgres 18 with seeded
rows, then stopped and deleted it. It returned:
  - I8 for the budget breach, with its evidence;
- I6 for the run of this company that failed on
`stale_generation_event`.
It excluded the other company's stale run, the `process_lost` run and a
non-budget event.
- `node scripts/crash-points.mjs --list --env daytona` lists 19 points,
with `needs:` on the three. `--env daytona` with no key or image fails
at once with the missing names.
- No harness run: the harness slot is held for other work. The next
suite run exercises the new checks.

## Risks

- Test harness only; no product code.
- I6 is a proxy: it matches the run's error text and code. A failure
that names a generation for another reason would be reported, with its
evidence, for a person to judge.
- I8 can flag breaches on a loaded host. Each breach carries its
measured and budget values, so a load-caused breach is visible as one.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:06:01 -06:00
DottaandClaude Opus 5.5 65f41593d1 fix(runner): tell agents a skill found in a directory is imported, not re-created (#19983)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can add skills to the company library in two ways: author new
content with `create_skill`, or import an existing skill so its source
is kept
> - #18717 told agents in the `create_skill` contract to import "an
existing skill" instead of copying it
> - An agent read "existing" as "already in the company library"
> - It searched the library, found no match, and saved a managed copy of
a SKILL.md from a vetted local directory
> - The copy has no source locator, so update, audit and reset flows
cannot follow the source
> - This pull request says in the same contract that a SKILL.md in a
local directory, repository, URL or catalog is an existing skill even
when the library has no match
> - The benefit is that agents import such skills and keep their
provenance

## Linked Issues or Issue Description

Refs #18717

**What happened**

A person asked the CEO agent: "Find a useful accessibility-review skill
and add it to our company." The conversation pointed to a vetted local
directory with a SKILL.md. The agent called `search_skills`, found no
match, and wrote in its reasoning "No existing accessibility skill. Now
create it via paperclip_create_skill". It saved a `managed` skill with
`sourceLocator: null`. It did not call the import API that the
`create_skill` description recommends.

**Expected behavior**

The agent imports the skill from its directory through `POST
/api/companies/{companyId}/skills/import`. The saved skill is
`local_path` with the directory as its source locator.

**Steps to reproduce**

1. Put a SKILL.md in a directory that a company project workspace
allows.
2. Give the directory path to the agent in chat, and ask it to add the
skill to the company.
3. Look at the saved skill. Before this change, the agent can save a
`managed` copy without a source locator.

## What Changed

- Add one sentence to the `create_skill` description in
`packages/paperclip-runner/src/protocol-actions/create-skill.ts`: a
SKILL.md in a local directory, repository, URL or catalog is an existing
skill, even when the company library has no match, so import it instead
of copying its content.
- Regenerate the semantic action catalog, the semantic tool contracts,
the protocol manifest digest and the seeded catalog digest.
- Extend the existing contract test so that the discovered, live and
scenario descriptions all contain the new rule.

## Verification

- `pnpm exec vitest run
src/semantic-tools/paperclip-semantic-tools.test.ts -t "distinguishes
authored skills"` in `packages/paperclip-runner` passes. The new
assertion fails on the parent, because the parent description does not
contain the sentence.
- `node scripts/generate-semantic-action-catalog.mjs --check`, `node
scripts/generate-protocol-manifest.mjs --check` and `node
scripts/generate-semantic-contracts.mjs --check` pass.
- End-to-end chat eval `skills.find-and-install` (runner-opencode,
local), with this commit cherry-picked onto the eval branch: before, run
42448994428 failed because the agent saved a managed copy. After, run
42450246200 passed. The agent quoted the new sentence in its reasoning,
imported the directory through the import API, and the saved skill is
`local_path` with the vetted directory as its source locator.

## Risks

- Low risk. The change is model-facing text only. It does not change
permissions, routes or tool inputs.
- Agents that author truly new skills still use `create_skill`. The
sentence applies only when a SKILL.md already exists somewhere.

## Model Used

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

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] 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:47:30 -06:00
DottaandClaude Opus 5.5 051834bdc8 feat(chaos): add the on-demand crash-point suite and its manual CI workflow (#19946)
## 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
> - Random restart-chaos waves found these 20 ms–1 s windows only by
luck
> - #19937 added `crashPoint(name)`, and #19938 put it at 20 durable
write boundaries
> - Nothing yet runs a task, kills the server at a chosen point,
restarts it, and checks the plan's invariants
> - This pull request adds that on-demand suite, as a local command and
as a manual CI workflow (not a required check, per Decision 2)
> - The benefit is a repeatable work list: which boundary breaks which
invariant, for Phases 2–4

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 5 and Decision 2). Refs #19861 (report-only
reconciler used for I1–I7). Stacked on #19938 (call sites), which is
stacked on #19937 (helper).

**Problem**
The named crash points cannot be exercised end to end. No command arms
one point, runs real agent tasks across it, restarts, and grades the
result.

**Expected behavior**
`node scripts/crash-points.mjs --points <glob> --kinds <id> --adapters
<list>` runs one wave per selected point and reports each point as pass,
fail (with failing R rules and violated invariants), not reached, or
harness error. A `workflow_dispatch` workflow runs the same thing on
demand.

## What Changed

- **Runtime arming.** `PAPERCLIP_CRASH_POINT_CONTROL=<file>`, in the
shared helper and the runner mirror. Each hit reads `<token>
<name>[:<nth>]`, and a new token resets the count. It is test mode only:
`NODE_ENV=production` ignores it, and the disarmed path is still one
comparison.
- **`tests/restart-chaos/main.ts --crash-point
<a>:<nth>,<b>:<nth>,...`** (config, types, ledger):
  - one booted server and no Vite UI;
- per point: take an invariant baseline, launch the point's batch, arm
the point, then on rest disarm it and check invariants as violations
that are new since the baseline;
  - every task is graded R1–R6 and T;
  - `crash-point.json` (v2) holds one entry per point.
- **`tests/restart-chaos/supervisor.ts`:**
  - every spawn gets the control file;
- a SIGKILL whose receipt names the child's pid is restarted like K1:
the control file is emptied first, the owned tree is killed, and a
replacement starts. A failed tree cleanup fails the fault;
- the isolated instance pins `PAPERCLIP_IN_WORKTREE=false` (a
`.paperclip/.env` above the checkout otherwise suppressed all
scheduling);
  - `RESTART_CHAOS_HEALTH_TIMEOUT_MS`.
- **`tests/restart-chaos/invariants.ts`:**
- the Phase 1 report-only pass with `record: false`: two real-clock
passes for I1 and I2, and one pass 15 minutes ahead for I3, I4 and I7;
  - spool kinds are read from the wave's instance;
  - an unfinished pass is a harness error;
  - each violation carries its wake and run state as evidence.
- **`tests/restart-chaos/crash-points.ts` and
`scripts/crash-points.mjs`:**
- select points by glob, kind and environment, and run them in one wave;
  - a per-point outcome: pass, fail, not_reached or harness_error;
  - `SUMMARY.md` with a violation evidence column.
- **`.github/workflows/crash-points.yml`**, manual only, under the
paid-workflow contract:
  - a credential-free `authorize` job;
  - a `suite` job in `runner-e2e-paid` whose first step reauthorizes;
  - a frozen install with `--ignore-scripts` and no shared cache;
  - only the selected adapters' keys;
  - the workflow is added to both security-test allowlists.
- **Docs:** "Crash-point suite" in `tests/restart-chaos/README.md`, and
"Built (Phase 5)" plus the first work list in the plan.

## Verification

- Focused tests:
- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts
tests/restart-chaos/crash-points.test.ts`;
- `cd packages/shared && npx vitest run src/crash-points.test.ts`: 15
tests, including runtime arming in a real child process;
- `node --test scripts/__tests__/release-verify-workflow.test.mjs`: 72
tests;
  - `tests/runner-e2e/workflow-security.test.ts`: 19 tests.
- Typecheck: `node scripts/typecheck-test-projects.mjs
tests/restart-chaos` passes.
- Loop line: question "how long is one crash-point loop?" | loop: `node
scripts/crash-points.mjs --points <one>` (codex A1) | elapsed 2.7 min
end to end: boot 9 s, crash 9 s after arming, replacement ready in 6 s,
rest about 2 min | result: one point alone takes about 3 min. Before
this change it took about 15 min (one cold boot per point, Vite UI, host
at load 77).
- Full local run, 14 points, codex A1, 11.8 min in all:
- every point was reached (the server killed itself at each one, and the
replacement came back);
  - 11 pass;
- `workspace.finalize.after_start_before_copyback` and
`workspace.finalize.after_copyback_before_done` fail I2 and R6: a
`workspace_finalize` operation stays running after the run succeeded;
- `wake.claim.after_run_running_before_wakeup_claimed` reports I4. Its
evidence shows the wake failed with "…retrying once", and the retry
delivered the work, so this is the known refusal-regex false positive.
- The 6 Daytona-only points were not run.

## Risks

- Test harness and an on-demand workflow only. No product code changes.
- `.github/**` needs a CODEOWNER review. The workflow uses provider
keys, so it refuses non-default branches and actors that are not on the
allowlist, like `runner-full-stack-e2e.yml`.
- `PAPERCLIP_IN_WORKTREE=false` is pinned in every chaos wave, not only
crash-point waves. A chaos instance is never a worktree dev instance, so
scheduling must not be suppressed.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:41:15 -06:00
DottaandClaude Opus 5.5 576ba29fff feat(recovery): settle the legacy controller lease on the shared ownership path (#19953)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Legacy (non-native) runs hold a controller lease on `heartbeat_runs`
while the server prepares and dispatches the adapter. The shared
ownership reconciler now settles proven-gone owners (Phase 2 of the
durable ownership plan)
> - The lease stamped only a boot id. The reconciler could never prove
its owner gone (`missing_identity` in the Phase 2 data), and recovery
released it by age: lease expiry (60 s), then a 5-minute age guard on
dispatched runs
> - A hard-killed server's legacy run therefore stayed `running` for up
to 6 minutes after the next boot
> - This pull request records the controller process with the claim,
registers a compare-and-swap settle, and makes live-run recovery use the
shared prover instead of the age guard when the controller is proven
gone
> - The benefit is that a dead controller's run is settled at once,
while a live, unprovable, or previous-release controller keeps today's
behavior

## Linked Issues or Issue Description

Refs #19933 (the settle hook, merged). Stacked on #19950 (migration
0446, registry) and targets its branch until it merges; then it
retargets to master.

**What happened?**
After a hard kill, a dispatched legacy run stayed `running` until its
controller lease expired and its last update was 5 minutes old.

**What did you expect?**
The next boot proves the old controller gone and fails the run as a lost
process at once.

**Steps to reproduce**
1. Start a legacy (non-native) run and let it dispatch the adapter.
2. Kill the server (SIGKILL).
3. Start the server. The run stays `running` for up to 6 minutes.

## What Changed

- `heartbeat_runs.controller_process` (jsonb, migration 0447): `{bootId,
pid, hostHash, processStartedAt}` of the claiming server process. It
names the controller only while its `bootId` equals
`controller_boot_id`. Revocation swaps the boot id, so a revoked lease
never keeps it.
- `legacyControllerClaim` writes it in the same UPDATE (from
`currentControllerProcessStamp()`, no I/O).
- `legacy-controller-lease.ts`: `legacyControllerOwnerStamp`,
`legacyControllerProvenGone` (calls `proveOwnerGone`), and
`settleLegacyControllerLease` (compare-and-swap expiry of the proven
controller's outstanding lease after it wakes live-run recovery).
- `ownership-registry.ts`: `legacy_controller_lease` registers `settle`
and `listForRuns`. The owner is the bound process; other rows are
boot-id fences. The listing covers outstanding leases only.
- `heartbeat/recovery.ts` (3 small hunks): a controller of another boot
that the shared prover proves gone has its live lease settled through
`settleOwnableKind` and skips the age guard. The local child-process
check after it is unchanged, so a live child still keeps the run
running.
- Migration 0447 builds the partial index with `CREATE INDEX
CONCURRENTLY` (heartbeat_runs is a known-large table) and is added to
`MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS`.

Bespoke code deleted: none. The kind had no liveness proof; the age
guard stays for controllers that are not proven gone.

## Verification

- `cd server && npx vitest run
src/services/legacy-controller-lease.test.ts`: 33 passed.
- `cd server && npx vitest run
src/__tests__/ownership-reconciliation-report.test.ts`: 17 passed.
- Red with `recovery.ts` and `ownership-registry.ts` from the base
commit (tests, schema and lease module from this branch): 4 failed, 29
passed. Red: the crash-point reaper test, the expired-lease age-guard
test, the reconciler settle test, the exact-controller CAS test. The
tests were written after the code.
- New tests:
  - the claim stamps this process, and a changed boot id unbinds it;
- crash point: a controller dispatched the adapter and was killed with
its lease live; the startup reaper (default 5-minute age guard) fails
the run as `process_lost` at once;
  - a dead controller's expired lease skips the age guard;
- counter-cases keep the run and lease through the reaper and a
reconciler pass: a live earlier-boot PID, a dead PID in another PID
space, and a previous-release row with no `controller_process`; an
expired lease of a live controller stays behind the age guard;
- a read-only pass (`record: false`) never settles; a recording pass
expires the lease and leaves the run to live-run recovery;
  - settle refuses a controller that changed after the 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 `controller_process`. They stay boot-id
fences with today's release (expiry, revocation, age guard). A rollback
ignores the column.
- Unprovable controllers (another host or PID space, unknown space, a
live PID with an unreadable start time) never release early. Their
handoff stays the lease expiry plus revocation, and the age guard. A
different host is never proof.
- Behavior change: a dispatched run whose controller is proven gone is
failed as `process_lost` without the 5-minute wait. The retry decision
is unchanged.
- The settle expires the lease only. Live-run recovery still settles the
run under its own checks. Accounting and workspace obligations are not
touched.
- The index build is concurrent. A cancelled build is retried by the
migrator's invalid-index handling.

## 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 23:27:54 -06:00
DottaandClaude Opus 5.5 c0c26188db fix(runner): stamp event generations and record an earlier generation's events as history (#19960)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs execute through the Paperclip Runner, which turns
provider output (Codex, OpenCode, Claude through ACPX) into durable
runner events
> - In the restart chaos campaign, events from an earlier turn or runner
generation failed the current turn about ten times (RC-15, 20, 20b, 20c,
33, 33b, 59 and 61)
> - #19718, #19722, #19772 and #19783 each special-cased one variant,
and other variants still fail the run
> - The durable-ownership plan (section G, invariant 6) asks for one
rule: every event carries its generation and turn, and an earlier
generation's events are recorded as history and never fail the current
turn
> - This pull request stamps the runner generation on every event and
applies one stale-event rule in the ACPX provider state, the Codex
driver (which Runnerd also uses for OpenCode) and the pending
`turn.start` check
> - The benefit is that a late report about a settled or restored turn
no longer stops a healthy turn, while events for a turn the runner never
saw still fail closed

## Linked Issues or Issue Description

Refs #19718, #19722, #19772, #19783

**What happened?**
A runner or provider can report a turn after that turn settled: a
resumed provider reports the lost turn's usage or terminal, an ACPX
sidecar emits a last delta after the turn terminal, or a held failure of
the previous turn sits in front of a new `turn.start`. Several of these
paths fail the current turn: ACPX `AcpxEventScope` rejects any event for
a non-active turn ("named a stale turn"), the Codex driver fails
`turn_binding_mismatch` for a settled turn's late item between turns,
and `runnerdPendingTurnStartFailure` reports any held non-recoverable
failure, whatever turn it names. Runner events carried no generation.

**Expected behavior**
An event for a turn that is not active, and that an earlier generation
accepted or this generation already settled, is recorded as history and
never fails the current turn. An event for a turn the runner never saw
still fails closed.

**Steps to reproduce**
ACPX: settle turn 1, start turn 2, and deliver a late `runtime.event`
for turn 1. Before this change, the provider state returns an error and
the run fails.

## What Changed

- Runner (`durable/state.rs`, `durable/runner.rs`): every event payload
carries `generation`, the runner process generation that emitted it. A
runner process that recovers state starts the next generation
(`begin_recovered_generation`); it is saved with the next normal write,
so recovery adds no write before the first turn. Loading stays pure. Old
saved state loads as generation 0. Outbox events keep their original
generation.
- PRP event schemas v1–v3 declare the optional `generation` property;
generated validators and the manifest are regenerated.
- ACPX (`acpx_event_scope.rs`, `acpx_provider_state.rs`): an event that
names a settled turn of this run becomes a bounded
`stale_generation_event` diagnostic (32 per state) and changes no state.
Unknown turns still fail.
- Codex driver (`codex-session-state.ts`,
`codex-session-notifications.ts`): one predicate, `isHistoryTurn()`: the
turn is not active, and it was restored from the checkpoint (an earlier
generation) or settled in this generation. The classifier's stale-turn
branch, the resume-usage branch, restored-terminal replay and the main
fail point (`notificationNamesActiveTurn`) all use it. A terminal for an
earlier generation's turn is history even when it disagrees with the
checkpoint. A terminal that contradicts a turn this same generation
settled is still a `conflicting_turn_terminal` integrity failure, and a
provider that starts a settled turn again is still rejected.
- Transport (`runnerd-codex-transport.ts`): a pending `turn.start`
ignores a held failure that names another provider turn.
- `DURABLE_TRANSPORT.md` documents the stamp and the rule.

## Verification

- `cargo test -p paperclip-runner-core`: new
`records_late_events_for_a_settled_turn_as_history` (red before),
`stamps_every_event_with_the_runner_process_generation`; two existing
ACPX tests now assert "recorded as history, no state change" instead of
"rejected". Durable tests: 112 passed. `cargo fmt` clean.
- `codex-app-server-driver.stale-generation.test.ts` (new, 9 tests):
late item, delta, terminal and workspace change for a settled turn
during and before the next turn, an earlier generation's conflicting
terminal after recovery, and an unknown turn that still fails. Red on
master: 8 of 9 (the during-turn cases on master already dropped the
event, with a different diagnostic; the between-turn and recovery cases
failed the session).
- `runnerd-pending-turn-start-failure.test.ts` (new): an earlier turn's
held failure no longer fails the pending start; the pending turn's own
failure and a failed run terminal still do.
- `vitest run src/drivers/codex src/live/runnerd-codex-transport`: 787
passed; 2 Codex executable-resolution tests timed out under host load
and pass when run alone.
- `tsc -p tsconfig.json` for the runner package, `check:protocol-types`,
`check:replay-goldens`: clean.
- Not run: chaos waves (the coordinator schedules them on this host).

## Risks

- A late report about a settled turn is now dropped with a diagnostic
instead of failing. A provider that keeps working on a settled turn
could have its output ignored silently; a provider that starts a settled
turn again, or names a turn the runner never saw, still fails closed.
- `generation` is a new optional field. Consumers that do not know it
ignore it, as the PRP contract requires.
- The generation counter is written with the next normal state save, so
there is no new write 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 23:16:00 -06:00
DottaandClaude Opus 5.5 fda58d8cc1 fix(chat): post the complete final reply, not a 4,000-character run-log preview (#19971)
## 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.
> - The runner cuts every completed agent message to a 4,000-character
run-log preview. The server then posts that preview as the reply.
> - A long answer, for example a list of 501 open tasks, reaches the
person cut off with "…[truncated]".
> - PR #19959 fixed only the case where the accepted summary starts with
the exact preview text. It missed runs where the final prose differs
from the summary.
> - This pull request keeps the complete final answer in the runner, and
makes the server never prefer a preview over a complete source.
> - The benefit is that the person receives the agent's complete reply.

## Linked Issues or Issue Description

Supersedes #19959.

**What happened**

A chat agent wrote a final answer of about 4,985 characters (all 501
tasks). The posted chat reply was a 4,000-character preview that ended
with "…[truncated]".

**Expected behavior**

The person receives the agent's complete final message. A preview of a
message is never posted when a complete text exists.

**Steps to reproduce**

1. Ask a chat agent (OpenCode or Codex) to list a large set of open
work, so that its final answer is longer than 4,000 characters.
2. Let the run finish.
3. Read the chat reply. It ends with "…[truncated]" at 4,000 characters.

Root cause:

-
`packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs`:
the codex/OpenCode facade projection of `item/completed` and the ACPX
`AssistantMessage` projection cut the final answer with
`bounded_text(text, MAX_TEXT_CHARS)` (4,000 characters).
- `durable/state.rs` `sanitize_value` caps every string again at 4,096
bytes before the event goes to the server.
- `server/src/services/heartbeat-run-summary.ts`
`resolveHeartbeatRunResponse` gives an explicit `final` agent message
top precedence, so it posts the preview.

## What Changed

- Runner (`provider_events.rs`): a completed `final_answer` agent
message keeps its complete redacted text, up to a new 32,000-character
`MAX_FINAL_AGENT_MESSAGE_CHARS` bound. Progress prose and in-flight
items keep the 4,000-character preview.
- Runner (`durable/state.rs`): `preserve_final_agent_message_text` keeps
the complete redacted text of a completed final agent message when it
fits in half of the transport frame. Otherwise the marked preview stays,
so a large message never fails the frame check.
- Server (`heartbeat-run-summary.ts`):
`readCompletedAssistantMessageCandidate` marks a stored preview as
`bounded`. The resolver never posts a bounded preview while a complete
source exists. It posts a marked adapter final response or the accepted
semantic summary instead
(`bounded_final_agent_message_replaced_by_semantic_summary`). It posts
the marked preview only when no complete text exists
(`bounded_final_agent_message_without_complete_source`). This also fixes
replies from runners that are already deployed.

## Verification

- `cd server && npx vitest run
src/__tests__/heartbeat-run-summary.test.ts`: 86 passed. Six new tests:
a complete final message longer than 4,000 characters is posted in full;
the old exact-prefix case posts the complete accepted answer; a preview
whose prose differs from the summary posts the complete summary (also
with chat precedence); a marked adapter final response wins over a
preview; the marked preview is posted only when nothing complete exists;
a stored preview candidate is marked `bounded`. Five of the six fail
against the `master` resolver.
- `cargo test -p paperclip-runner-core --lib -- final_answer
complete_final`: 3 passed. Both new runner tests fail when the fix is
reverted.
- `cargo test -p paperclip-runner-core --lib`: 581 passed, 3 failed. The
failures are `task_memory::tests::monitor_*` (OOM-kill monitor timing
tests). They do not touch the changed code.
- `cargo test -p paperclip-runner-core --test acpx_event_projection
--test acpx_rich_events --test local_integrity_boundary_golden --test
acpx_provider_events`: all passed. `cargo fmt --check` passes.
- Evidence from the failed eval run: the `item.completed` final
`agentMessage` text was exactly 4,000 characters (3,988 retained plus
the marker). The `run.result.proposed` summary was complete at 4,985
characters.

## Risks

- Larger durable events: a final answer can now be up to 32,000
characters (at most about 128 KB). The outbox keeps the preview when the
text exceeds half of the frame size, so the frame check does not fail.
- Redaction stays the same. The complete text goes through
`redact_sensitive_text_values`, which the preview also used.
- The server treats any final message that ends with "…[truncated]" as a
preview. If an agent writes that text itself, the server posts the
complete accepted summary instead. That summary is also the agent's own
complete answer.
- The runner part reaches users only after a runner release. The server
part fixes replies at once when an accepted summary exists.

> 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`), agentic coding 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:01:00 -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 779913333a fix(documents): say whether an exact revision read is still current (#19939)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Task documents keep a revision history, and agents read and restore
revisions through the API
> - The exact-revision read returns one revision, but it does not say if
that revision is still current
> - An agent read the revision that it restored earlier by its ID and
reported it as "latest", but another person had saved a newer edit
> - This pull request adds the document's current revision to the
exact-revision read
> - The benefit is that a pinned read cannot pass as a read of the
current document

## Linked Issues or Issue Description

**What happened**

In a chat eval, the agent restored a plan revision. Then another person
saved a new revision. The user asked the agent to read the current plan.
The agent called `GET
/api/issues/{id}/documents/plan/revisions/{revisionId}` with the ID of
its own restored revision. The response did not show that a newer
revision existed. The agent reported the old revision as the latest and
said that the read confirmed this.

**Expected behavior**

A read of one exact revision must show if that revision is still
current, and which revision is current. Then the reader can see a newer
edit and read the current document.

**Steps to reproduce**

1. Save two revisions of a task plan.
2. Call `GET
/api/issues/{id}/documents/plan/revisions/{firstRevisionId}`.
3. See that the response gives no sign that revision 2 is the current
revision.

## What Changed

- `getIssueDocumentRevision` now also returns `latestRevisionId`,
`latestRevisionNumber` and `isLatest`.
- Shared type `ExactDocumentRevision` describes this response. The UI
client uses it.
- The OpenAPI description of the exact-revision read names these fields
and points to `GET /api/issues/{id}/documents/{key}` for the current
text.
- The service test checks `isLatest` for an older and a current
revision.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/documents-service.test.ts
src/__tests__/openapi-routes.test.ts` (42 passed).
- `npx tsc --noEmit -p packages/shared` passes.

## Risks

- Low risk. The change only adds fields to a read response. No caller
removes or renames a field. No migration.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), 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 21:29:24 -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 2a2d3f6e23 fix(ui): stop refetching the @task mention pool on every task event (#19931)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI keeps its caches fresh from live events in
`LiveUpdatesProvider`
> - Each task activity event invalidates the whole `["issues",
companyId]` query prefix
> - That prefix also holds the `@task` mention pool: the 100 most
recently updated tasks, about 315 KB per response
> - A chat turn emits many comment, status and run events, so an open
task page downloads that list again for almost every event
> - This pull request keeps the pool fresh from push events without a
refetch per event: a closed server flag marks real membership or title
changes, progress on pooled tasks reorders the cache in place, and other
progress refetches at most once per 30 seconds
> - The benefit is less bandwidth and CPU for users with an open task or
chat, and smaller eval traces

## Linked Issues or Issue Description

**What happened**
While a task chat page is open, the browser repeats `GET
/api/companies/:id/issues?limit=100&sortField=updated&sortDir=desc`
(about 315 KB each). One 10-minute chat-eval attempt recorded about 194
of these requests. The trigger is `invalidateActivityQueries` in
`ui/src/context/LiveUpdatesProvider.tsx`. For every `entityType:
"issue"` activity event, it invalidates
`queryKeys.issues.list(companyId)`. That prefix matches
`queryKeys.issues.mentionPool(companyId)`, which `IssueDetail` (and
`NewIssueDialog`) keep active for the `@task` picker. Invalidation
refetches active queries and ignores `staleTime`.

**Expected behavior**
Comments, run progress and status changes on any task do not download
the full mention pool again. The pool still updates when a task that the
picker can show is created, deleted or renamed.

**Steps to reproduce**
1. Run `pnpm dev` and create a company with about 140 tasks.
2. Open a task page, for example `/REF/issues/REF-1`.
3. Every 4 seconds for 15 rounds, post comments on the open task, on a
task in the top 100, and on a task outside the top 100, and change the
open task's priority (60 events).
4. Count requests to `issues?limit=100&sortField=updated`. Before this
change there are 15 (about 3.3 MB). After this change there are 2 (about
480 KB).

## What Changed

- New shared helper `issueActivityChangesTaskIdentity`
(`packages/shared/src/issue-activity-identity.ts`). It is true for a
created, deleted, hidden, unhidden or renamed task.
- `server/src/realtime/live-events-ws.ts`: task `activity.logged` events
now carry the closed boolean `taskIdentityChanged`. Authenticated
sockets still get no `details`; the flag carries no task content.
- `ui/src/context/LiveUpdatesProvider.tsx` refreshes the mention pool
this way:
- The task identity flag is true (or full details show a create, delete,
hide, unhide or rename): the pool refetches at once.
- Other events on a task already in the pool: the task moves to the
front of the cached list. There is no request. The freshness clock does
not change, and a pending invalidation is never overwritten.
- Other events on a task outside the pool: one trailing refetch at most
every 30 seconds, because the task can now be in the top 100.
  - The picker is closed: the pool is only marked stale.
- `ui/src/lib/query-invalidation-batcher.ts`: new `excludingQueryKeys()`
keyed exclusion predicate. The batcher coalesces it and uses it for
prefix coverage. The broad `["issues", companyId]` invalidation uses it
to skip the pool, so a burst is again one broad refresh per batch.
- Tests: `ui/src/context/LiveUpdatesProvider.mention-pool.test.ts`
(authenticated event shape, hide and unhide, the 30-second window,
reorder safety, a burst through the batcher), new batcher tests,
`packages/shared/src/issue-activity-identity.test.ts`, and socket
assertions in `server/src/__tests__/live-events-toast.test.ts`.

## Verification

- `cd ui && npx vitest run src/context/LiveUpdatesProvider
src/lib/query-invalidation-batcher`: 231 tests pass.
- `cd server && npx vitest run src/__tests__/live-events-toast.test.ts`:
11 tests pass. In authenticated mode the socket keeps `details` off and
sends `taskIdentityChanged` (false for routine updates, true for a
create, rename or hide).
- `cd packages/shared && npx vitest run
src/issue-activity-identity.test.ts`: 14 tests pass.
- The new UI tests fail against the first commit of this PR: the
authenticated burst, hide, unhide, the 30-second window and the batcher
burst.
- `tsc --noEmit` for ui, server and shared: no new errors.
- Browser measurement: a local `pnpm dev` instance (local trusted),
headless Chromium on a task page, 141 tasks, 60 events over about 63
seconds (the reproduction steps above):
  - Before (master UI): **15** mention-pool requests, about 3.3 MB.
  - After: **2** requests, about 480 KB (one per 30-second window).
- Freshness after the fix: one rename, hide, unhide or create each
caused **1** refetch. A priority change caused **0**.
- I did not run a browser session in authenticated mode. The
authenticated event shape is covered by the socket test and the UI unit
tests.

## Risks

- A task that moves into the top 100 can be missing from the `@task`
picker for up to 30 seconds.
- The cached order is approximate between refetches: a moved task goes
to the front, but its `updatedAt` value is not changed.
- Other queries under `["issues", companyId]` (descendants,
created-from, summaries, onboarding first task) still refresh for each
event batch. They are small, but they can get the same treatment later.

## Model Used

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

## Checklist

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

> - Paperclip is the open source app people use to manage AI agents for
work
> - A control-plane restart can land between two durable writes and
leave an in-progress record owned by a dead process
> - The restart-chaos campaign found these windows (20 ms–1 s) by luck,
with random kill timing
> - The durable ownership plan (Phase 5, "Testing: enumerate crash
points") asks for named crash points at each durable write boundary, so
a suite can kill the process at each one on purpose
> - Phases 2–4 run in parallel and need the helper early for their
per-kind tests
> - This pull request adds only the `crashPoint` helper and the registry
of named points
> - The benefit is one shared, zero-cost way to mark a boundary and to
arm it in a test

## Linked Issues or Issue Description

Refs #19831 (plan, "Testing: enumerate crash points", Phase 5). Refs
#19846 (Phase 0 inventory). Refs #19861 (Phase 1 registry; the
crash-point `kinds` use its kind ids).

**Problem**
No code marks the durable write boundaries. A test cannot stop the
control plane at a chosen boundary, so restart bugs are found only when
random chaos timing hits a window.

**Expected behavior**
A call `crashPoint("<name>")` at each boundary. In test mode an
environment variable makes the process SIGKILL itself at the nth hit of
one name. In production the call does nothing and costs nothing
measurable.

**Scope**
Helper and registry only. The call sites, the suite runner and the CI
workflow follow in separate pull requests.

## What Changed

- `packages/shared/src/crash-points.ts` (subpath export
`@paperclipai/shared/crash-points`, server-side only):
- `crashPoint(name)`. Disarmed, it is one comparison against a module
constant.
- `PAPERCLIP_CRASH_POINT=<name>[:<nth>]` arms one point when the module
loads. The nth hit appends a JSON receipt to
`PAPERCLIP_CRASH_POINT_RECEIPT` (optional), writes a `[crash-point]`
line to stderr, and sends SIGKILL to the process.
- `PAPERCLIP_CRASH_POINT_GATE=<file>` (optional): hits before the file
exists do not count, so setup work cannot fire the point.
- The variable is ignored when `NODE_ENV=production`. An unknown name
throws at import, so a typo cannot arm nothing.
- `armCrashPointForTest(spec)` throws `CrashPointReached` at the nth hit
instead, for in-process per-kind tests (Phases 2–4). It returns a disarm
function.
- `packages/shared/src/crash-point-registry.ts` (browser-safe data): 20
named points from the inventory's write-ahead table. Each has a
description, the environments that reach it (local, Daytona), the
ownable kind ids it affects, and the invariants (I1–I8) at risk. Names
follow `<subsystem>.<record>.<before|after>_<step>`.
- `packages/shared/src/crash-points.test.ts`: registry shape, spec
parsing, throw mode, real SIGKILL of a child process with a receipt, the
gate, production disarm, unknown-name refusal, and a disarmed-cost
check.

## Verification

- `cd packages/shared && npx vitest run src/crash-points.test.ts` → 11
passed.
- Disarmed cost on an M-series Mac: `crashPoint disarmed: 3.66 ns/call
(empty call 0.61 ns)`. A turn passes a handful of points, so the added
time before `turn.start` is in nanoseconds (plan invariant 8). The test
fails if the cost exceeds an empty call by more than 10 ns.
- `cd packages/shared && npx tsc --noEmit -p .` → clean.

## Risks

- Low risk. Nothing calls `crashPoint` yet, and nothing reads the
variable in production.
- An operator who sets `PAPERCLIP_CRASH_POINT` on a non-production
server makes it kill itself. The variable name and the `[crash-point]
armed` stderr line make this visible.
- No persisted run state changes (rules/upgrade-safe-run-state.mdc: no
previous-release story is needed).

## 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.

## 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:37:32 -06:00
DottaandClaude Opus 5.5 7fd98682ca fix(workspaces): give every subtask its own execution workspace (#19860)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents split work into child tasks, and each task runs in an
execution workspace (a checkout, branch, or sandbox folder)
> - Since early 2026, a new child task used its parent's realized
workspace by default: the same checkout, branch and sandbox
> - A parent and its child often run at the same time. Two writers in
one folder lose each other's files, the waking parent is cancelled with
`workspace_busy`, and sandbox lease ownership crosses from the parent
into the child, so retained-file recovery refuses the workspace as
ambiguous
> - The product rule is simple: a subtask gets its own workspace,
independent of the parent's saved files. Paperclip never needs to carry
a parent's unmerged changes into a child
> - This pull request makes that the only behaviour on every creation
path, and moves existing shared children to their own workspace on their
next run, with no user or admin action
> - The benefit is that parents and children never block or overwrite
each other, and recovery ownership stays with one task

## Linked Issues or Issue Description

**What happened?**
A child task inherited its parent's realized execution workspace by
default (`createChild` defaulted to `executionWorkspaceInheritanceMode:
"linkage"`, and `issueService.create` fell back from
`inheritExecutionWorkspaceFromIssueId` to `parentId`). When the parent
woke while the child wrote to the shared workspace, the parent run was
cancelled with `workspace_busy`. In sandbox environments, the lease
ownership chain crossed from the parent into the child, and
retained-file recovery refused the workspace with
`allocation_ownership_ambiguous`.

**Expected behavior**
Every new child task gets its own execution workspace, realized from the
project base exactly as a top-level task in that project. It never uses
the parent's checkout, branch, saved snapshot or unmerged changes. A
parent and its descendant never share a workspace by any route.

**Steps to reproduce**
1. Run a parent task in a project with isolated (git worktree) or
sandbox workspaces, so the parent has a realized execution workspace.
2. From the parent run, create a child task with only `parentId` (or
through the runner `create_task` tool, chat suggestions, decision
effects, plugins, or the New sub-task dialog).
3. See that the child's `executionWorkspaceId` is the parent's, with
`executionWorkspacePreference: "reuse_existing"`.
4. Wake the parent while the child runs in a sandbox environment. The
parent run is cancelled with `workspace_busy`.

**Additional context**
Refs #9233 (the existing `strategy_only` opt-out).
The opt-out `executionWorkspaceInheritanceMode: "strategy_only"` already
existed for accepted-plan decomposition. This change makes that
behaviour (tightened to drop a pinned existing branch) the only
behaviour.

## What Changed

- **One rule for every creation path
(`server/src/services/issues.ts`).** `issueService.create` still uses
the parent (or `inheritExecutionWorkspaceFromIssueId`) as a source for
the project, project workspace and workspace *strategy*, but it never
copies the source's `executionWorkspaceId`, `reuse_existing` preference,
or pinned existing branch. All server paths that create a task go
through `create()`/`createChild()`: issue routes (`POST /issues`, `POST
/issues/:id/children`, the run-bound default parent and run workspace
source), runner `create_task`, chat suggestion acceptance, native status
decisions, accepted-plan decomposition, decision effects, plugins
(`ctx.issues.create`), external agents, watchdog follow-ups, monitor
recovery issues, case handoffs and email channels.
- **Explicit ancestor workspaces are dropped.** If a create names an
`executionWorkspaceId` (or an `existing` workspace selection) that an
ancestor of the new task created or is bound to, `create()` drops it and
keeps only the strategy. Older clients and skills that copied the
parent's id keep working and get an independent workspace.
- **`createChild` no longer has a "linkage" mode.**
`executionWorkspaceInheritanceMode` is still accepted from internal
callers and is ignored.
- **`inheritExecutionWorkspaceFromIssueId` decision.** I kept the field
for API compatibility, but it now copies only the source issue's project
and workspace strategy. I removed its workspace-sharing behaviour for
every pair, not only parent/child: the source task and its follow-up can
run at the same time (the runner, watchdog and status-decision callers
all create the follow-up while the source run is live), so sharing would
again let two tasks write one folder. Explicit sharing between unrelated
tasks stays available through an explicit `existing` workspace
selection.
- **New `server/src/services/task-workspace-lineage.ts`.**
`buildIndependentTaskWorkspaceSettings` (strategy only: mode,
environment, runtime, network egress, shared-workspace concurrency, base
ref, branch template, commands; no `reuse_existing`, no
`operator_branch`, no `existingBranch`), `loadTaskLineageIssueIds` (the
parent chain plus, for a task that depth placement put beside its
requester, the requester's chain via `privacyParentIssueId`) and
`isAncestorExecutionWorkspace` (the workspace's `sourceIssueId` is in
that lineage, or a lineage issue is bound to it). A child in another
project does not take the parent's strategy.
- **Upgrade-safe rebind at admission.**
`executionWorkspaceService.releaseAncestorWorkspaceBindingAtAdmission`
runs at the same admission point as the pending workspace selection
(`heartbeat.ts`). For a child (or a flattened task beside its requester)
that an older release bound to an ancestor's workspace, it clears the
child's binding (keeps the project workspace and strategy, bumps the
binding revision) so the run realizes the child's own workspace. It does
nothing while another run of the child is running, while the child's
earlier run still owns workspace finalization or recovery, for native
restart/lease-owner recovery, for a process-loss retry, or for a Retry
that keeps the current workspace. The parent and its workspace are never
changed.
- **Projects without a project workspace (per-task repository
acquisition).** A parent with no project workspace worked in its own
task files, so its settings (for example `isolated_workspace`) do not
describe the project base. A child of such a parent, and an older child
released from one, takes the project's defaults exactly like a top-level
task, instead of an isolated mode that would resolve to a git worktree
with no git base ("needs a project / project workspace"). When the
child's new workspace is created at admission,
`executionWorkspaceRepositoryService.prepareForAdmission` repeats the
parent's (or requester's) repository requests as the child's own pending
requests (identity, URL, catalog id and requested ref only; no pinned
commit, branch or files). The normal acquisition path then clones a
fresh copy into the child's task files with the child's own access
checks. This runs only for a workspace created by that admission, so
existing tasks never gain repositories.
- **UI.** The New sub-task dialog no longer pre-selects the parent's
workspace. `buildSubIssueDefaults` drops `executionWorkspaceId` and
`reuse_existing`, and derives the parent's independent mode
(`shared_workspace`, `isolated_workspace` or `agent_default`) from its
preference, saved settings or current workspace. The "This sub-task will
no longer use the parent task workspace" warning is removed.
- **Contracts and docs.** Shared validator and plugin SDK comments
describe the new meaning of `inheritExecutionWorkspaceFromIssueId`.
Updated `doc/execution-semantics.md` (new rule in §6 and a wording fix
in the retained-sandbox paragraph), `doc/SPEC-implementation.md` (child
workspace rule, requester keeps *strategy* inheritance, watchdog
follow-ups), `doc/SPEC.md` (task workspace ownership),
`docs/specs/external-task-protocol.md`, `skills/paperclip/SKILL.md`
(step 9 and the workspace bullet),
`skills/paperclip/references/issue-workspaces.md`, the CEO onboarding
`HEARTBEAT.md`, and the plugin SDK README.

### Previous-release story (`rules/upgrade-safe-run-state.mdc`)

- **Migrated on next admission.** A child that the previous release
bound to its parent's (or any ancestor's) execution workspace keeps that
binding until its next normal admission. Then the server clears the
binding and the run realizes the child's own workspace from the project
base. No schema migration is needed. No user or admin action is needed.
- **In-flight writers finish first.** A running child is not
interrupted. Its current run, its native restart recovery (admitted
native input or lease owner), a process-loss retry, and a Retry that
keeps the current workspace all finish in the shared workspace. While a
child's earlier run is still running or its workspace finalization or
recovery is unsettled, the binding stays and a later admission releases
it.
- **No files are dropped.** The release changes only the child's binding
row. The parent keeps its binding, and the shared workspace row, folder,
branch and saved snapshots are not touched. Anything the child left
there stays in the parent's workspace.
- **No new refusal codes.** Saved run identity (native execution input,
leases, checkpoints) is not changed or refused; the rebind only affects
new admissions.

## Verification

- New `server/src/__tests__/subtask-workspace-independence.test.ts`
(embedded Postgres): `create` with `parentId`, `createChild` with no
mode / `"linkage"` / `"strategy_only"`,
`inheritExecutionWorkspaceFromIssueId` pointing at the parent, explicit
parent and grandparent `executionWorkspaceId`, a non-child follow-up,
and unchanged top-level defaults and explicit top-level reuse. 7
creation cases fail on `master` and pass with this change. Admission
cases: an old-release child is rebound while its parent is running and
the parent keeps its workspace; a running child is not rebound (deploy
lands mid-run); an unrelated explicit share is kept; the parent is never
rebound.
- New heartbeat case in
`server/src/__tests__/heartbeat-workspace-busy.test.ts`: an old-release
child bound to its running parent's workspace in a sandbox environment
runs to `succeeded` with no `workspace_busy`, gets a new workspace, and
the parent run and binding stay unchanged. It fails on `master` (the
child waits with `workspace_busy`).
- Updated tests that asserted the old inheritance in
`issues-service.test.ts`, `task-workspace-binding.test.ts`,
`chat-project-tools.test.ts`, `task-workspace-child-trust.test.ts`,
`heartbeat-git-restore-point-resume.test.ts` (a child bound to a closed
parent's expired workspace now starts from the project base and leaves
the parent's restore point alone), and
`ui/src/lib/subIssueDefaults.test.ts`. Remote-workspace reservation,
continuation and writer-wait suites now seed the older-release shared
shape directly (or an unrelated explicit share), so those safety paths
stay covered.
- `npx vitest run` on the changed suites and about 45 related server
suites (heartbeat workspace, recovery admission, issue routes, routines,
plugins), `ui` `subIssueDefaults` and `NewIssueDialog` tests, `pnpm
check:token-gates`, `pnpm -r typecheck`.

- New
`server/src/__tests__/heartbeat-subtask-acquired-repository.test.ts`: a
project with no project workspace, a parent whose task files hold an
acquired repository with an unmerged change, and a child (new, and the
older-release shape bound to the parent's workspace) that runs to
`succeeded` in its own task files with a fresh clone at the project base
and its own ready repository receipt. The parent's checkout keeps its
change. Both cases fail on the previous head with "needs a project /
project workspace".

## Risks

- **Project-level configuration still applies to children exactly as to
top-level tasks.** In a project configured for `shared_workspace`, every
task (top-level or child) uses the project's one folder under the
existing shared-workspace concurrency policy. A branch that the project
itself pins applies to every task in the project. This PR removes only
the parent-to-child carry-over of the realized workspace; it does not
change those project defaults.
- **Standalone follow-ups that an older release bound through
`inheritExecutionWorkspaceFromIssueId`** are migrated only when the
source is in their recorded lineage (parent or privacy parent). Others
keep their explicit share and the existing writer-wait serialization.

- **Behaviour change for agents that relied on a shared checkout.** A
child or follow-up no longer sees the parent's unpushed or uncommitted
changes. Agents must push or merge code they want a subtask to build on.
The skill and onboarding text now say this.
- **Explicit ancestor workspace ids are silently dropped** instead of
rejected, so older clients do not break. The task gets an independent
workspace.
- **First run of a migrated child starts from the project base.** If an
older child had uncommitted work in the parent's workspace, that work
stays in the parent's workspace and is not copied into the child's new
workspace.
- **Admission adds one indexed read** for tasks with a parent and a
bound workspace; the locking transaction and ancestor query run only
when the bound workspace was created by another issue.

## Model Used

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

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [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 18:34:09 -06:00
DottaandClaude Opus 5.5 5f1b1bb81d fix(recovery): continue from the last save when a retained source cannot be verified (#19867)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - When a native run cannot save its files, a retained-file recovery
hold blocks the task until the worker saves them from the retained
sandbox.
> - The worker verifies the recorded source strictly. This includes the
reusable-lease chain of the sandbox.
> - A reusable sandbox chain can cross from a parent task into a child
task. The check then refuses the source with
`allocation_ownership_ambiguous` on every attempt.
> - Retry repeats the same refusal. The task stays blocked by three
gates: the saved-work wait, the repair flag and the remote writer hold.
The only text left tells a person to inspect the run and its
environment.
> - The source check must stay strict. Recovery must still work without
a person.
> - This pull request adds an automatic fallback: the task continues
from its last committed workspace save, the unverified files are
attached to the task but never applied, and the unverified workspace
epoch is abandoned so its root is never reused.
> - The benefit is that tasks with an unverifiable retained source
continue by themselves, with no lost saved work and no admin action.

## Linked Issues or Issue Description

Refs #19840 (removes the "inspect the run and its environment" text and
keeps Retry; this change removes the need for that Retry).

**What happened?**

A child task ran in a reusable sandbox that it inherited from its parent
task. The run saved a checkpoint, wrote more files, and then failed to
save them. Retained-file recovery refused the source with
`allocation_ownership_ambiguous`, because the lease history of the
sandbox crosses into the parent task. The hold was exhausted after one
attempt. Retry made a new generation that failed the same way. The task
stayed blocked with `saved_work_recovery_failed`,
`workspaceRepairRequired: true`, and the failed run still the dirty
writer of the workspace. A second task had a stale exhausted hold for a
run whose files a sibling recovery action had already saved. That hold
also kept the task from continuing.

**Expected behavior**

Recovery works without a person. When the source cannot be verified, the
task continues from its last saved version. The unverified files are not
applied, but they stay available. A stale hold whose files were already
saved settles as recovered.

**Steps to reproduce**

1. Use a native run with a remote workspace whose reusable sandbox lease
history includes a lease of another task.
2. Let the run save a checkpoint, write more files, and fail its final
save.
3. Let retained-file recovery run, or press Retry.
4. Before this change, the hold is exhausted with
`allocation_ownership_ambiguous` and the task stays blocked.

**Paperclip version or commit**

Observed on deployed builds that include `630411af53`. Holds saved by
the Nov 3–6 releases have this shape.

## What Changed

- New `server/src/services/workspace-recovery-fallback.ts`:
- `continueFromLastSavedWorkspace` runs when a hold is exhausted by a
permanent source refusal: `allocation_ownership_ambiguous`,
`source_binding_invalid`, `source_unavailable` or
`historical_source_missing` with retry class `service_required` or
`use_alternative_strategy`. It applies only to remote workspace sources
in the `prepared` state.
- Quarantine (secrets omitted under the workspace file policy: `.env`,
keys, repository metadata, dependency directories): if the retained root
is readable, the worker copies it with the existing read-only capture
(no write to the sandbox), converts it to a `.tar`, stores it, and
attaches it to the task as `unverified-files-run-<runId>.tar`. A
`workspace_recovery_quarantine` operation records it as `verified:
false, applied: false`. If the root is not readable, the worker records
a closed reason label.
- Stop proof: every allocation of the source run must carry a provider
stop receipt. A missing receipt is obtained through the same stop-only
cleanup as normal recovery (`prepareSandboxStopAndRetain` /
`prepareHistoricalSandboxStopVerification`,
`retryPendingSandboxTeardown`, `settleStopOnlyCleanup`). A sandbox that
another lease holds (`active` / `pending_cleanup`) is never read or
stopped from here; the hold waits.
- One transaction (lock order: task, coordinator, run, recovery action,
workspace) re-checks all facts including the stop receipts, abandons the
epoch, resolves the hold with the named outcome
`retained_source_unverifiable_continued_from_last_saved`, records an
`executionReconciliation` decision with `retainedSourceFallback`, and
denies native replacement. A Blocked task belongs to the hold only while
the hold's own `workspaceFinalizationBlock` receipt still owns that
status version, or, for receipt-less holds saved by older releases,
while the block was set by the run's terminal failure (within its
finalization window) and no status decision followed. Only then is the
task restored to `todo` and a continuation scheduled; a newer block is
kept and no continuation starts. The decision carries machine
`workspaceRepairEvidence`, which the reconciled admission requires for
runs with a restore failure.
- Continuation: with no waiting user input, the hold uses
`continuationDelivery: "pending"`, so the existing reconciled delivery
starts one continuation. Waiting user input continues the task itself
(`not_requested`), so there is never a second continuation.
- Agent note: the decision's `outcomeEvidence` (part of the
continuation's recovery outcomes) says that the run's unsaved changes
could not be verified and were not applied, where the attached copy is,
that the agent should check what to redo, and that completed external
actions must not be repeated.
- `settleSiblingRecoveredHold`: an exhausted hold whose run has a
verified repair receipt from a different recovery action settles as
recovered. It runs for every exhausted hold, including adoptable native
sources. A sibling marked recovered whose receipt no longer verifies
records a permanent deferral, so discovery does not reselect it.
- Deferral: when the fallback cannot settle yet, the hold records
`retainedSourceFallbackCheck` (reason, generation, count). Transient
reasons (live writer, running operation, missing stop, sandbox in use,
pending status effect, lock busy, storage failure) are checked again
after 5 minutes, at most 12 times. Permanent reasons (uncertain external
action, non-remote source) wait for new input, Retry or a new
generation. Storage or database failures after a successful read are
retryable and never recorded as "unreadable".
- New `server/src/services/workspace-recovery-fallback-discovery.ts`:
SQL for pending and due fallbacks. `retainedWorkspaceCandidates` (the
production worker's obligations sweep) and `nextWorkspaceRecoveryAt`
(its scheduler) include them, so the worker reaches old holds without
saved input.
- New `server/src/services/remote-workspace-epoch-abandonment.ts` (kept
out of `remote-workspaces.ts`, which is near 1,000 lines): authority
transition `abandonUnverifiedRemoteWorkspaceEpoch`. It clears the writer
and the dirty flag, keeps `snapshotId`, drops `location`, advances the
epoch, clears `providerRef` and the migration root, and records the
abandoned epoch. The next preparation restores the last save into a new
`<workspace>-<epoch>-<lease>` root.
- `server/src/services/workspace-recovery.ts`: hooks. An exhausted hold
first tries the sibling settlement, then the fallback (under the
existing workspace ownership lock). A fresh permanent refusal in an
attempt runs the fallback in the same pass, under the same ownership.
- `remote-workspace-transport.ts`: export the existing read-only
capture.
- `packages/shared`: optional `retainedSourceFallback` on
`ExecutionReconciliation`.
- `doc/execution-semantics.md`: document the fallback.
- No new polling or worker: the fallback runs inside the existing
retained-workspace recovery pass
(`rules/sleep-aware-background-work.mdc`).

Safety:

- The cross-task lease check (`retiredReusableLeasePredecessors`) is
unchanged and stays strict. Unverified files are never applied.
- No second executor: the source run, its controller and its finalizer
must be stopped. A live writer of the task or workspace, or a running
workspace operation, keeps the hold.
- No repeated external side effects: pending status effects,
non-completed or unknown API receipts, and uncertain tool invocations
keep the existing reconciliation path. The continuation keeps the
existing API receipt fences.
- Never overwrite another run's files: only the exact epoch that the
failed run wrote is abandoned, and the old root is never reused.
- Bounded and idempotent: a settled hold is never revisited. The
quarantine record is reused. A changed generation or revision leaves the
hold unchanged.

Previous-release story (`rules/upgrade-safe-run-state.mdc`): holds saved
by earlier releases carry forward. A hold saved by the Nov 3–6 releases
(`systemRecovery` exhausted with `allocation_ownership_ambiguous` /
`service_required`, board owner, old admin text) takes the fallback on
the next worker pass after deploy (restart sweep, Retry, or a new
message). No migration is needed. Refusals that do not qualify keep
today's behavior. The only new persisted shapes are additive:
`retainedSourceFallback` and `siblingWorkspaceRecovery` on the hold,
`remoteWorkspaceAbandonedEpochs` on the workspace, and a
`workspace_recovery_quarantine` operation. Earlier releases ignore them.

## Verification

- `cd server && npx vitest run
src/services/workspace-recovery-fallback.test.ts` — 15 tests:
- cross-task hold, saved by the previous release and refused fresh:
continues from the checkpoint snapshot, the quarantine `.tar` holds the
unverified file, the epoch is abandoned, `getExecutionBlocker` is
`null`, the task is `todo`, explicit continuation is not blocked by
`saved_work_recovery_failed` or `workspace_repair_required`, one
reconciled continuation is delivered, and the next writer restores into
a new root while the old root keeps its files;
  - unreadable root: continues and records the reason;
- idempotency: a second pass changes nothing and delivers no second
continuation;
  - waiting user input: no second continuation;
- negatives (live writer, pending side effect, uncertain external
action, sandbox held by another lease): the hold stays, the authority is
unchanged, nothing is attached or delivered, the deferral reason and
recheck time are recorded;
- an allocation without a stop receipt is stopped (stop-only) before the
hold settles;
- the production worker (`reconcileRetainedWorkspaces` with
`obligationsOnly`) and `nextWorkspaceRecoveryAt` discover and settle an
old hold with no saved input; a transient deferral is not revisited
until due, then settles;
- an old-release hold without a Blocked receipt keeps its status; a
current hold with the receipt is restored to `todo`;
- stale exhausted hold with a sibling-saved receipt settles as
recovered; one whose sibling receipt no longer verifies is recorded and
no longer discovered;
- a newer Blocked decision is kept and no continuation is delivered,
while the file hold settles;
  - the attached copy omits `.env` and key files.
- RED: with `workspace-recovery.ts` and `system-recovery.ts` reverted to
master, all tests fail.
- Regression suites pass: `workspace-recovery-warm-handoff`,
`workspace-recovery-retry-copyback`,
`workspace-recovery-preparation-gates`, `workspace-recovery-source`,
`task-retry`, `execution-blocker-read` (288 tests), and
`workspace-recovery-admission`, `workspace-recovery-preparation*`,
`remote-workspaces.integration`, `native-failed-workspace-recovery`,
`remote-workspace-transport`, `workspace-restore-repair-route` (683
tests).
- `server` typecheck passes.

## Risks

- A task now continues without the unverified changes of its failed run.
The agent receives a note and the attached copy, so it can redo or
re-apply the work. This replaces an indefinite block.
- Abandoning an epoch on a workspace shared with a parent task also
moves the parent to the last save. This is correct: the unverified
changes are not part of the saved lineage.
- Clearing the migration root means sessions bound to the old root start
fresh. The fallback continuation already forces a fresh session.
- The quarantine capture reads a sandbox that can be shared with another
task. It writes nothing there, and it verifies the copy against
inventories taken before and after.

## 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 18:34:05 -06:00
Nicky Leach 0363ebec70 fix(connectors): stop GitHub access refreshes from advancing agent runtimes (#19902) 2026-11-07 14:43:39 -08:00
Dotta eb7c7b083a Recover Muse verification after contact, restart and access restoration (#19909)
## Thinking Path

> - Paperclip manages AI agents and their work under company access
rules.
> - Muse readiness uses a saved connection, receiver contact and an
authenticated challenge reply.
> - A failed challenge write could leave a saved contact with no later
recovery trigger.
> - A server restart or restored access must recover that contact under
current authority.
> - This pull request uses the existing delivery coordinator and saved
Muse state for recovery.
> - It keeps the normal receiver schedule, authorization checks and real
deadlines.
> - It lets eligible verification recover and lets idle verification
stop work.

## Linked Issues or Issue Description

**What happened?**

The first Muse receiver contact could persist while readiness challenge
creation failed. The contact then had no durable retry or startup
recovery path. Later membership, lifecycle or feature restoration could
also leave pending verification without a notice.

**Expected behavior**

Retry a temporary failure through the existing worker. Recover pending
contact after restart. Recheck the current company, agent, operator,
credential and binding generation before issuing a challenge. Stop
verification work while it is ineligible or complete. Do not revive
revoked credentials.

**Steps to reproduce**

1. Use the normal invitation and OAuth registration, PKCE and token
flow.
2. Send one receiver contact and let its normal persistence schedule
run.
3. Fail the first readiness challenge operation before it commits.
4. Observe whether the existing worker retries without another contact
or a manual Verify action.
5. Restart the App with a new database owner. Check that it creates one
usable challenge from the saved contact.

**Paperclip version or commit**

The causal baseline test used the unchanged production implementation at
`90148317b2a447794ff29a4acae5648ad11a9770` plus its normal fixture. The
accepted positive test used `9d383041028776ad8469ac4af3fe14ba6c369425`.
The initial published head was
`fe518c9b72504daf6374225987f24d45bc7bcf75`. The lease/auth correction
was locally qualified at `cfd515a207416097fe8b586277d70c4fd4a679fa`. The
current candidate is `6efe722e7eb1e22acd466125c7b4a627611d0273`; its
added Cloud actor fixture correction passed the full normal owning
program and all 35 tests. Its current normal build, lifecycle owner
controls, healthy-wait and restoration measurements are described below.
Earlier results below are bound to their stated revisions.

Related public changes: Refs #19819 for original-principal
authentication. Refs #19750 for the transaction-owned external feature
read. These are related foundations, not duplicate fixes. The current
Muse title and head-branch searches found no duplicate
contact-verification recovery PR. Related maintenance work is #17000 and
terminal assignment settlement is #17793.

## What Changed

- Add a typed, exact-binding verification notice through the existing
Muse delivery queue.
- Register the first-contact notice before its durable write in the same
transaction.
- Recover pending contact from saved state at startup. Retry temporary
failures through the existing scheduler.
- Recheck current feature, lifecycle, credential and access state after
commit. Park unavailable authority without recurring verification scans.
- Add narrowly scoped notices to the existing access, lifecycle,
configuration, company, budget and settings write owners.
- Save a healthy lifecycle verification wait in the existing operation
JSONB. Keep generic unresolved-work and error retries unchanged.
- Commit authenticated confirmation and its lifecycle recheck in one
transaction. Retain a raced recheck under the active lease, and recover
saved waits once at startup.
- Retain a committed lifecycle recheck while an expired attempt still
owns the row, so its late wait cannot park accepted confirmation.
Replacement claims keep their existing owner fence and fresh fact read.
- Keep the receiver gap, persistence and per-segment contact limits
unchanged.
- Add real App OAuth, failure, retry, restart, challenge and quiet
evidence. Add two normal-route restoration tests.
- Document recovery, reconnect requirements and the existing
connected-receiver sleep restriction.

## Verification

- Causal baseline: the real invitation and OAuth flow saved one contact.
The first challenge operation failed. The normal test failed because it
observed one verification attempt instead of a retry. No setup or
cleanup failure was counted as this regression.
- At `9d383041`: normal own Shared/SDK, DB with migration 0441 assets,
Runner TypeScript core and surfaces, module boundaries, and all seven
normal App programs passed. One new redundant role comparison first
failed typecheck; that diagnostic remains recorded and the corrected
full App gate passed.
- At `9d383041`: all 17 maintenance component tests passed with zero
skips. All original deadline, rollback, drain, terminal settlement and
startup controls remain.
- At `9d383041`: the one normal App case passed. Real OAuth authorized
one receiver signal. A retry occurred about 5.01 seconds after the first
failure. A new Db/App owner created one challenge, advanced revision 2
to 3, and accepted authenticated confirmation and mailbox ACK. Query
instrumentation had an active positive control. All measured query,
callback, session and verifier counters stayed equal across the
unchanged scheduled 15-second quiet window. Token revocation and
old-token/signal rejection passed.
- Known App, worker, HTTP and database owners closed without cleanup
errors. The tests used bounded local PostgreSQL and HTTP fixtures. They
did not call a live Muse model.
- Current integrated semantics: the full normal App driver completed all
programs. Only a new test callback had two undefined-value diagnostics.
An explicit defined-value guard corrected that test. The full 416-root
owning program then passed with empty diagnostics. A separate collector
incorrectly required an explicit child `noEmit` field; the normal
inherited setting was verified without repeating the compiler. The
original failures remain recorded.
- The first restoration run failed both cases. Permission parking
recorded 120 main-pool queries without more verifier calls. The
lifecycle counter recorded two denials without their exact scope. These
observations remain unexplained at the individual-query/call level.
Source separately shows a lifecycle readiness retry every two seconds
while the real external reply is absent.
- At `fe518c9b`: normal Shared/SDK and DB producers, module policy, all
seven full App programs, and all 33 pure diagnostic controls passed. The
lifecycle owner file passed all 19 cases, including both
confirmation/park orders, rollback, duplicate notices, startup recovery
and authority controls.
- At `fe518c9b`: the normal OAuth failure/retry/new-Db reconstruction
case passed. Its new healthy-wait window measured 15,002 ms with
unchanged total activity before genuine confirmation; the existing ready
quiet and revocation checks also passed.
- At `fe518c9b`: both normal restoration tests passed in Vitest.
Ordinary viewer-to-operator restoration and Pause/Resume recovered the
same binding, grant and single contact; each committed one challenge and
advanced revision 2 to 3. All six parked, restored and revoked/disabled
quiet windows measured at least 15 seconds with active positive read
controls and unchanged total queries, callbacks, sessions and verifier
calls. Both cleanups passed.
- The restoration wrapper still recorded failure: it required lifetime
lifecycle denial count zero, whereas the measured count was one before
the parked quiet window. The reviewed fixture explicitly permits a
current-authority denial from an earlier observation and requires no
committed challenge before restoration. Precise denial timing relative
to Pause was not serialized. The original wrapper failure is retained;
no rerun or retrospective explanation of the earlier failures is
claimed.
- Review exposed an expired-lease race. The added real-store baseline
test failed after the late defer restored `waitingForSignal`; no setup
or timeout was counted as the regression. On `cfd515a`, the whole
lifecycle owner file passed all 20 tests, including that case. The full
normal production declaration program (1067 roots), verification program
(662), and auth owning program (405) passed; all 1942 declarations
remained byte-identical.
- The original remote auth-session shard failed three tests because
their partial query adapters lacked the new binding query's
join/order/limit chain. A fixture-only correction preserved the real
notifier and existing authorization assertions. All 12 auth-session
tests passed locally.
- Complete remote inspection also found 33 failures in the 35-test Cloud
actor fixture with the same missing `innerJoin` method and downstream
incomplete-auth assertions. Its original full log and exact synthetic
Source comparison are retained. At `6efe722e`, the three-line fixture
adapter repair passed the complete normal 662-root test program and the
whole 35-test file with zero failures or skips. All existing lock,
authorization, settings, debounce and unfinished-auth assertions remain.
- The earlier healthy/restoration quiet measurements remain bound to
`fe518c9b`. Those steady 15-second windows did not exercise a
still-owned attempt passing its 120-second lease. The targeted
expired-owner regression qualifies that new boundary; it is not a fresh
measurement of the earlier windows.
- The initial `fe518c9b` remote gates failed the auth fixtures and
reported a Greptile 4/5 P1. The aggregate failure reported the
underlying shard. Those actual causes were fixed and qualified above.
- Current `6efe722e` remote gates completed with **57 successful checks
and three explicit skips**. The full latest edited Greptile summary is
**5/5**, explicitly reviews this exact head, and has no new finding. The
original P1 thread is resolved. All general, inline and nested review
pages were read.
- One current-head CI attempt timed out after 30 seconds in the Runner
evidence/privacy file, with 6,864 passes and one failure. That file and
its transitive inputs are byte-identical to the CI base. A separate
local diagnostic preserved every assertion and the original timeout, ran
the full normal Runner test project and the entire 41-case file, and
passed all 41 with zero skips. Its private bounded timing records
measured the target body at 74 ms. The diagnostic was then removed,
restoring the exact original test. This is local recovery evidence; the
original CI stalled await was not recorded and its cause remains
**unproven**.
- One retry of only the failed jobs on the unchanged head passed the
Runner support suite and dependent aggregate verification. The complete
second attempt and original failure logs are preserved. No test,
timeout, privacy guard or product code was weakened. This result does
not turn the original timeout into an explained failure or a diagnosed
flake.
- Full recursive typecheck, full `test:run` and a full release build
were not repeated locally. The normal full App programs, focused
producers and tests above passed, and the required current-head remote
gates passed. Final review is against the exact unchanged head; local
quiet remains separate from deployed suspension.

Reviewer commands use the normal project entry points: `node
server/scripts/typecheck-layers.mjs`, the whole
`server/src/services/muse-maintenance.test.ts` file, and both normal App
PostgreSQL files. PostgreSQL fixtures require explicit owned
fixture/admin URLs and normal migrations before imports. Run the files
serially with one worker. The private controller retains exact Source,
output, worker and owner-closure receipts.

## Risks

- Missing or early producer notices could lose a restoration trigger.
Notices stay in the transaction that commits the write. The worker
checks current authority after commit.
- A stale notice must not grant access. Binding generation, current
membership, company, agent and credential checks remain authoritative.
Revoked credentials require reconnecting.
- The change adds no table, migration, alternate task queue or periodic
verification scan. It adds two optional fields to existing lifecycle
operation JSONB. Generic pending/error deadlines, setup holds and manual
repair remain obligations.
- Confirmation can race with an active verification attempt. The park
compare-and-set must retain the durable recheck. Startup recovery uses a
fixed cutoff and keyset pages; it must not become another recurring
scan. Wrong company, stale version, pause and revoked credentials must
stay fenced.
- The connected receiver still has its documented tenant-sleep
restriction. Worker quiet is not proof of deployed suspension or
external wake.
- The normal challenge reply is sent by an authenticated protocol
fixture. Live Muse background replies, cost/usage, full Muse lifecycle
and full hosted Dot behavior remain unqualified. Partial Dot profile ACK
and unsigned-device cutover stay withheld.
- Restoration coverage for the additional company, budget, configuration
and feature producers remains a separate qualification obligation. This
pull request does not mark the whole hosted area complete.

## Model Used

OpenAI `gpt-6.1-sol` authored the implementation and tests with local
code and tool use. A GPT-6-based Codex coordinator reviewed the changes
and owns integration. Its exact underlying model ID is not exposed.
Context window sizes are unavailable. No context size is inferred.

## 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
2026-11-07 16:41:15 -06:00
DottaandClaude Opus 5.5 adb7633d7b fix(status-cards): give agents the person's restore route when archiving a card (#19903)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Status cards are saved summaries of company work. People and agents
can archive and restore them.
> - A person asked a chat agent to archive a card "to reuse it next
quarter". The agent archived it correctly.
> - The archive response had no board route, and the PATCH operation had
no description. The completion guidance tells agents not to guess
routes.
> - So the agent told the person to restore the card "by clearing
`archived`". That is an API instruction, not a route a person can use.
> - This pull request gives single-card responses an `href` to the
Status page and documents archive and restore on the PATCH operation.
> - The benefit is that an agent can give the person a usable link and a
true account of restore, which starts a fresh summary.

## Linked Issues or Issue Description

**What happened**
A chat agent archived a status card. Its reply told the person to
restore the card by clearing `archived` on the card. It gave no link to
the place where a person restores it.

**Expected behavior**
The reply links the card on the Status page, where a person can show
archived cards and click Restore. It also says that restore starts a
fresh summary.

**Steps to reproduce**
1. Enable status cards and create a card as an agent.
2. Ask the CEO agent in chat to archive the card for later reuse.
3. Read the reply. It has no usable restore route, because `PATCH
/api/status-cards/{id}` returns no `href` and has no description.

## What Changed

- `POST /api/companies/{companyId}/status-cards`, `GET
/api/status-cards/{id}` and `PATCH /api/status-cards/{id}` now return
`href`, the company-scoped Status page route for the card.
- The shared `statusCardSchema` has an optional `href` field.
- The `PATCH /api/status-cards/{id}` OpenAPI operation has a
description. It says that archive keeps the card and its history, that a
person restores it on the Status page with the returned `href`, and that
restore starts a fresh full summary.
- The create route reads the company issue prefix before it creates the
card. A failed link lookup can not delete a card whose compile task is
queued.
- Regression tests cover the `href` on the archive and get responses,
the new PATCH description, and a failed prefix read on create (no card
and no task are left).

## Verification

- `pnpm exec vitest run server/src/__tests__/status-cards.test.ts
server/src/__tests__/openapi-routes.test.ts` (71 tests pass). The new
create-route test fails on the first commit and passes on the fix.
- End to end: the chat eval `status-cards.archive-card` runs with this
change on an eval branch. The result is in the eval report for that
case.

## Risks

- Low risk. The change adds one optional response field and one
description. List responses do not change. The UI ignores the new field.
- Each single-card response does one more small company read for the
issue prefix. On create, this read occurs before the card exists, so its
failure leaves nothing behind.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used 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
- [ ] 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:11 -06:00
DottaandClaude Opus 5.5 fc6e1b0346 perf(native): keep local Codex and OpenCode runners warm by default (#19888)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - When someone messages an agent, the turn must start fast. Time to
first token (TTFT) is a first-class metric.
> - The Phase 0 baseline (#19846) found that every local turn launches a
new runner and loads the provider session again, because `per_turn` is
the default lifecycle. That made a follow-up message slower than a new
task.
> - Warm runners keep the runner and provider session alive between
runs. #19874 fixed the defect that broke warm OpenCode follow-ups, and
#19879 bounds an idle warm runner whose controller dies.
> - This pull request makes warm the default for local Codex and
OpenCode agents, and migrates existing agents that only carried the old
creation default.
> - The benefit: follow-up TTFT p50 drops from 12.0 s to 4.7 s (Codex)
and from 21.7 s to 4.2 s (OpenCode), with no new restart-chaos failures.

## Linked Issues or Issue Description

Refs #19846 (TTFT baseline), #19852 (startup sub-spans), #19874 (warm
completion contract), #19879 (warm reconnect grace).

**What existing behavior does this improve?**
The default runner lifecycle for native agents, and how a follow-up
message starts.

**Current behavior**
Agent creation writes `lifecycleMode: "per_turn"` into every native
agent. Every follow-up launches a new runner and restores the provider
session: 4.2 s (Codex) and 16.3 s (OpenCode) at p50 before the turn
starts.

**Proposed behavior**
New native agents store no lifecycle. On a local target, an unset
lifecycle resolves to `warm` with the default 5-minute idle window for
Codex and OpenCode. Other harnesses, remote targets and managed
providers without warm support stay `per_turn`. An explicit choice
always wins. Migration 0443 clears the stored creation default from
existing agents, except where an edit set "Turn by turn" explicitly.

**Reason and benefit**
Measured locally (`doc/plans/2026-11-07-warm-follow-up-ttft.md`). Warm
follow-ups are 2.6–5× faster for Codex and OpenCode, and restart
recovery latency is equal or lower.

## What Changed

- `native-lifecycle.ts`: an unset `agentMode` resolves to warm (5-minute
idle window) for local `codex_app_server` and `opencode_server` agents.
`runtime-selection.ts` passes `localTarget`. Explicit `per_turn` or
`warm` wins. An admitted session keeps its persisted policy. ACPX Claude
stays `per_turn`: it relaunches its provider process on every follow-up
by design, so warm did not shorten its follow-up (10.7 s warm vs 10.5 s
per_turn).
- Creation and configuration writes no longer store `lifecycleMode:
"per_turn"` for Codex, Claude or OpenCode. This covers
`normalizePaperclipRunnerAdapterConfig` (runs on every create and
update), `paperclipRunnerTransitionConfig` (switching to the native
runner), `agent-runner-selection.ts`, `codex-local` `build-config.ts`,
and the simplified new-agent setup (`paperclipRunnerCreationLifecycle`
in `@paperclipai/shared`). Grok, Pi and Cursor keep their explicit
value.
- Agent form: the Runner lifecycle field shows the unset state as
"Automatic". "Turn by turn" and "Warm session" remain.
- Migration `0443_native_warm_follow_up_default.sql`: clears
`lifecycleMode = 'per_turn'` from native Codex, OpenCode and Claude
agents unless `agent_config_revisions` shows an edit that set it to
`per_turn`. Each migrated agent gets an `agent.updated` activity entry
with the old value (`actor_id =
migration:native-warm-follow-up-default`). Creation records no revision,
so a "Turn by turn" pick made at creation cannot be told apart from the
old default. The product owner approved migrating those agents. Claude
agents follow the default, which is `per_turn` today.
- Restart chaos harness and TTFT driver: `--lifecycle
per_turn|warm|default` (saved in the wave plan). `default` removes the
catalog's pinned lifecycle, so a wave exercises the real default path.
- Docs: `doc/DEVELOPING.md` "Runner lifecycle default", and the results
in `doc/plans/2026-11-07-warm-follow-up-ttft.md`, which also documents
baseline finding 4 (Claude's resumed turn-start gap is ACPX's per-turn
process generation, not the provider).

## Verification

- `server`: `native-lifecycle.test.ts` (new default cases: local Codex
and OpenCode warm, Claude, remote, unknown target, unmeasured harness,
managed Claude, admitted per-turn session, explicit choices, non-native
adapter), `agent-runner-selection.test.ts`, `runtime-selection.test.ts`
(335 pass).
- `packages/db`: `native-warm-follow-up-default-migration.test.ts` on
embedded Postgres. Default agents are cleared, the explicit edit is
kept, other harnesses and legacy adapters are untouched, audit rows are
written once, and a rerun is a no-op. Without the explicit-edit filter
the test fails. `check:migrations` passes.
- `codex-local` `build-config.test.ts` (65), `ui`
`config-fields.test.tsx` (40), restart-chaos unit tests (134), `tsc
--noEmit` for `server`.
- `adapter-utils` `paperclip-runner-lifecycle-default.test.ts`:
configuration writes and adapter conversion store no lifecycle for
Codex, OpenCode and Claude, keep `per_turn` for the others, and preserve
explicit choices. It fails without the fix.
- End-to-end default check on this branch (`ttft-baseline.ts --lifecycle
default`, codex and opencode, 2 samples): the agents read back with no
stored lifecycle, and every follow-up reused the runner and provider
session (no `runner.process.launch`). Follow-up TTFT was 3.6–4.8 s. This
check found the per-write `per_turn` injection fixed here.
- Restart chaos (codex and opencode, A1/B1/B5, K1/K2/K6, seed 4242), 8/8
PASS each with no open signatures: forced warm, forced per_turn, and
`--lifecycle default` on this branch (agents created without a
lifecycle).
- `pnpm verify` selects broader CI (harness config is a global input),
so required CI supplies the broad coverage.

## Risks

- Behavior change: local Codex and OpenCode agents keep a runner and
provider process for up to 5 minutes after a run. Instance sleep waits
for that idle window. The process is reaped by the idle timer, by
graceful shutdown, or by the #19879 reconnect grace if its controller
dies.
- Migration: agents whose "Turn by turn" was chosen at creation become
Automatic. The activity entry records the old value; choosing "Turn by
turn" restores it.
- Previous-release story (`rules/upgrade-safe-run-state.mdc`): a task
session keeps the lifecycle policy it was admitted with, so sessions
saved before this change stay `per_turn`. New sessions get the default.
No run, checkpoint or launch state changes shape.

> 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 14:37:36 -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
Héctor Villarreal Ortega 576d6f6430 fix(ssh-restore): reset the local index to HEAD after importing remote git history (#16644)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can run on a remote SSH environment. After the run, Paperclip
restores the workspace and imports the remote git history.
> - The import moves the checked-out branch with `git update-ref`.
`update-ref` does not change the index.
> - The index then still matches the old tip. `git status` in the
workspace shows the new files of the run as staged deletions.
> - An operator who commits in that workspace can remove the work of the
agent by mistake.
> - The sandbox restore already resets the index after the import
(#8595). The SSH restore does not.
> - This pull request calls the same `resetLocalGitIndexToHead` after
the SSH restore.
> - The benefit is a clean `git status` after each SSH run, the same as
after a sandbox run.

## Linked Issues or Issue Description

Refs #8595 (the same fix for the sandbox restore). Refs #15662, Refs
#16440 (other open changes to the same SSH restore code).

**What happened?**
After a remote SSH run imported a commit, `git status` in the local
workspace showed every new file of that commit as a staged deletion, and
every changed file as a staged modification. The files on disk were
correct.

**Expected behavior**
`git status` is clean after the import, because the working tree and
HEAD are the commit of the run.

**Steps to reproduce**
1. Use an opencode_local agent on an SSH environment with a git_worktree
project workspace.
2. Let the agent add a file and commit on the remote copy.
3. After the run, run `git status` in the local worktree. The new file
shows as `D` in the index.

**Paperclip version or commit**
paperclipai 2026.916.1 (also present on master 272316808)

## What Changed

- `packages/adapter-utils/src/ssh.ts`: the SSH restore merge gets an
`afterApply` that calls `resetLocalGitIndexToHead`, when remote git
history was imported. The sandbox restore does the same since #8595.
- `packages/adapter-utils/src/ssh-fixture.test.ts`: "round-trips remote
git commits through the managed runtime restore path" now also checks
that nothing is staged after the restore.

## Verification

- I ran the SSH fixture tests in a container with `sshd` (they skip on a
host without it): `ssh-fixture.test.ts` and
`git-workspace-sync.test.ts`, 87 tests pass.
- Without the change, the managed runtime restore test leaves `M
tracked.txt` staged (`git diff --cached`) and `git status` shows `MM
tracked.txt`. With the change, nothing is staged and `git status` shows
` M tracked.txt`, which is the uncommitted remote edit.
- `npx tsc --noEmit -p packages/adapter-utils` passes.
- I found the bug on a real SSH environment (opencode_local in a runner
container, paperclipai 2026.916.1).

## Risks

Low risk. It reuses the index reset that the sandbox restore already
uses. It runs only when remote git history was imported. Work staged on
the host before the run is reset to HEAD, the same as in the sandbox
path.

## Model Used

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

## Checklist

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

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its native runner tests verify exact provider shutdown and recovery.
> - Four fixture call sites invoke `/bin/kill` directly.
> - NixOS supplies `kill` through PATH, so those tests fail before
checking process state.
> - This pull request resolves `kill` through PATH, as the neighboring
runner tests already do.
> - The same stop, probe, and resume assertions can run on non-FHS
hosts.

## Linked Issues or Issue Description

**What happened?**

Four Codex provider tests fail with `No such file or directory` on
NixOS. Each failure comes from a test-owned `/bin/kill` invocation. The
executable is installed on PATH.

**Expected behavior**

Tests should probe and signal their disposable provider processes with
the installed `kill` command.

**Steps to reproduce**

On a NixOS host with `kill` on PATH and no `/bin/kill`, run the
`codex_provider` Rust integration target. Three `durable_stop_*` cases
and `structured_question_round_trips_through_the_normalized_backend`
fail. All four failures were reproduced separately on unmodified
`e9debd3eac328e6abe87ab89e9ff40ee2bb56b73`.

Related: these failures also blocked the broader local qualification of
#14474. This fixture fix has no dependency on that runtime change.

## What Changed

- Replace four test-only `/bin/kill` executable paths with `kill`.
- Retain every signal, PID/process-group operand, assertion, and unwind
cleanup.
- Match existing PATH lookup in this file and
`tests/process_supervisor.rs`.

## Verification

Rebased onto master `820c458465` (single commit `ad1fb1043`).

- `cd packages/paperclip-runner/runner && cargo test -p
paperclip-runner-core --test codex_provider --locked -j4 --
--test-threads=2`: 94 passed, 0 failed, 2 ignored (subprocess helper
entries that their parent tests run). The four tests that used
`/bin/kill` pass on a host without `/bin/kill`.
- No production code or documentation changes.
- Hosted CI: `General tests (workspaces-b)` timed out in
`packages/db/src/backup-lib.test.ts` and `e2e shard (1/8)` hit
`ERR_STREAM_UNABLE_TO_PIPE`. Neither area is touched by this test-only
diff. Master pushes currently run the "CI profile skipped" workflow, so
there is no master run of those jobs to compare against.

## Risks

Low risk. Tests require a working `kill` on their test PATH. Fixture
PIDs and arguments stay unchanged.

## Model Used

- Anthropic Claude Sonnet 5.5 (`claude-sonnet-5-5`) through Claude Code
with tool use and code execution, for the rebase and verification.
Original change: OpenAI `gpt-6-astra` through OpenCode.

## 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
2026-11-07 13:02:29 -06:00
DottaandClaude Opus 5.5 dcaada5f59 fix(native): carry each warm run's own completion contract (#19874)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A warm runner (`lifecycleMode: warm`) keeps runnerd and the provider
process alive between runs, so a follow-up message can start fast.
> - Each run has its own completion contract. The revision changes with
each follow-up, and the agent must name that revision when it finishes.
> - On a warm attachment the transport rebuilt the `run.attach` payload
from its constructor options. Those options belong to the first run.
Every later run kept the first run's contract.
> - The OpenCode proxy checks the finish claim locally against that
stale contract. Every warm OpenCode follow-up was refused ("Semantic
result completion contract revision does not match"), and the agent
looped until the run timed out.
> - This pull request carries each run's own contract into the warm
attachment, and lets runnerd adopt it without a cold provider resume.
> - The benefit: warm follow-ups finish correctly, and they keep the
live provider process, which is the reason to be warm.

## Linked Issues or Issue Description

Refs #19852 (startup sub-spans), #19846 (TTFT baseline).

**What existing behavior does this improve?**
Warm cross-run attachment for native Codex and OpenCode agents.

**Current behavior**
In a local measurement wave with `lifecycleMode: warm`, 4 of 4 OpenCode
follow-ups looped for 8+ minutes. Each finish call was refused with
"Semantic result completion contract revision does not match". The saved
`runAttachTemplate` after the attachment still had revision `1`, while
the follow-up turn named revision `2`. Codex hid the defect because its
finish claim goes through the controller's tool path, which uses the
current input.

**Proposed behavior**
The attachment carries the attaching run's contract. Runnerd updates the
contract in place for Codex and OpenCode and keeps the provider process.
The OpenCode proxy receives the current contract with the run identity
on `turn/start`.

**Reason and benefit**
Warm follow-ups are the largest TTFT lever in the baseline. A
correctness-only fix (cold-resume the provider when the contract
changes) would make every warm follow-up relaunch Codex or OpenCode,
because the revision changes on every follow-up.

## What Changed

- `attachRun` carries `completionContract` through `NativeSession`, the
harness backend, the Codex harness session and the runnerd transport.
`executeNativeSession` passes the current input's contract. The
transport uses it instead of its first-run `resumeCompletionContract`.
- Runnerd (`codex_provider.rs`, `attach_run_in_place`): a changed
contract no longer forces a cold resume for `codex` and `opencode`.
Codex app-server never receives the contract, and the OpenCode proxy now
receives it per turn. Other providers keep the cold-resume boundary. A
changed tool set still forces it.
- Runnerd sends `paperclipCompletionContract` with each OpenCode
`turn/start`, next to the existing `paperclipRunId`.
- The OpenCode proxy passes that contract to `attachRun` when the run
changes. `OpenCodeHarnessSession.attachRun` replaces its envelope's
contract and validates it.

## Verification

- `src/live/runnerd-codex-transport-warm-attachment.test.ts` (new case):
real runnerd and fake Codex. After a warm attachment with a new
contract, the saved `runAttachTemplate` has the new revision and the
Codex pid is unchanged.
- `src/cli/opencode-proxy-input.test.ts`: the real proxy bundle serves a
warm second run with revision `proxy-v2`. The second
`paperclip/runResult` is produced and names `proxy-v2`. Without the fix
the finish call is refused.
- Each test fails without its part of the fix: the proxy test without
the proxy change; the transport test without the transport change (stale
saved revision); and, with `PAPERCLIP_ATTACH_TRANSITION_RUNNER` pointed
at a runnerd built without the `codex_provider.rs` change, the Codex pid
changes.
- `cargo test -p paperclip-runner-core --lib warm` (11 tests),
`native-session-runtime.test.ts` (232), the OpenCode driver, backend and
warm-attachment suites, and `tsc --noEmit` for the runner package pass.

## Risks

- Previous-release story (`rules/upgrade-safe-run-state.mdc`): no
persisted shape changes. Saved `runAttachTemplate` values carry forward,
and the next attachment writes the current contract. A new runnerd with
an old OpenCode proxy behaves as today: the proxy ignores the new
parameter and keeps the stale contract. An old runnerd sends no
parameter, so a new proxy keeps its launch contract.
- In-place adoption widens the warm-retention path for Codex and
OpenCode to changed contracts. The idle, accounting and pending-work
checks still apply first.

> 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 12:40:51 -06:00
DottaandClaude Opus 5.5 5a20fca9f7 fix(recovery): judge an orphaned run's pid only in the PID space that recorded it (#19871)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The orphan reaper (`terminalizeOrphanedRunningRun`) ends a `running`
run whose recorded process is gone
> - It judged "gone" with `kill(pid, 0)` on the local host, wherever the
process actually ran: another host, another PID namespace, or a sandbox
> - So a run whose process is alive elsewhere could be terminalized
while it still works
> - #19868 fixed the same gap for native coordinator takeover and the
runtime-control sweep. This pull request finishes finding 2 for the
reaper
> - The benefit is that a dead local pid never proves an owner on
another host is gone

## Linked Issues or Issue Description

**Bug description**
The Phase 0 ownership inventory (finding 2) names the orphan reaper
`terminalizeOrphanedRunningRun` (`recovery/service.ts`) as a proof that
accepts "the pid is dead here" for a process anywhere. `heartbeat_runs`
recorded `process_pid` with no PID space, and a sandbox pid could
collide with a local one.

**Steps to reproduce**
1. A run records `process_pid` on host A, or in a sandbox.
2. On host B, that pid number is not alive.
3. B's stale-lock sweep terminalizes the run as `orphaned_running_run`.

**Expected behavior**
The process-death authority applies only in the PID space that recorded
the pid. Issue-terminal authority is unchanged.

Refs #19846, follows #19868

## What Changed

- Migration `0442`: add nullable `heartbeat_runs.process_host_hash`.
- `heartbeat/run-lifecycle.ts` `persistHeartbeatRunProcessMetadata`, the
one writer of `process_pid`, stores `currentProcessSpaceHash()` for a
local pid and `"remote"` for a sandbox pid.
- `recovery/service.ts`: the process-death authority runs only when
`process_host_hash` is null (rows saved before this column, for one
release) or `sameProcessSpace` matches.
- Tests: the reaper leaves a dead pid alone for another host, a sandbox,
and an unreadable space, and terminalizes it in this space. The writer
stamps local and remote pids.

## Verification

- `npx vitest run
src/__tests__/recovery-stale-issue-lock-sweep.test.ts`: 46 passed.
- Red before the fix: with the old `recovery/service.ts`, the three
cross-space cases fail (the run is terminalized). Green after.
- `pnpm db:generate` produced `0442_low_salo.sql` (one `ADD COLUMN`),
and the snapshot set was pruned to five. `pnpm --filter @paperclipai/db
check:migrations` passed. Server `tsc --noEmit` shows no errors in the
changed files.

## Risks

- Previous-release story: runs saved with `process_host_hash = null`
keep the previous local proof for this release. That matches #19868,
because stranding in-flight runs at upgrade would be a restart failure.
New rows are strict.
- A run whose process really lived on another host now stays `running`
until issue-terminal authority, its controller lease, or another owner
settles it.
- No new I/O before `turn.start`. The hash is computed at module load,
and the stamp rides the existing process-metadata update.

## 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 12:22:15 -06:00
DottaandClaude Opus 5.5 68db7ed9e3 fix(recovery): never accept a dead local pid as proof for an owner on another host (#19868)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a restart, recovery decides that an in-progress record's owner
is gone and takes it over
> - Two of those proofs use `kill(pid, 0)` on the local host. A pid that
is dead here says nothing about a process on another host or in another
PID namespace
> - So a controller or runtime-control owner that is still alive
elsewhere can be taken over while it still runs
> - This pull request records the owner's PID space (the existing
`controller-process-identity` host hash) and judges a pid only in that
same space
> - The benefit is that a different host is never proof. Those records
follow their existing lease or TTL instead

## Linked Issues or Issue Description

**Bug description**
The Phase 0 ownership inventory (finding 2) found three proofs that
accept "the pid is dead here" as proof for an owner on another host:
`evaluateNativeControllerTakeover` (`native_run_finalizations` has no
host column), the runtime-control sweep (`workspace-operations.ts`), and
the orphan reaper `terminalizeOrphanedRunningRun`. Only
`controllerProcessExited` checked the host.

**Steps to reproduce**
1. Run two control-plane processes on different hosts, or in different
PID namespaces, against one database.
2. Let host A own a native finalization lease or a runtime-control
operation.
3. On host B, run restart recovery. If A's pid number is not alive on B,
B takes over the live owner.

**Expected behavior**
An owner whose PID space differs from this process's, or cannot be read,
is unprovable. Its record waits for its lease, fence, or TTL.

Refs #19846

## What Changed

- Migration `0441`: add nullable
`native_run_finalizations.controller_host_hash`.
- `controller-process-identity.ts`: export `currentProcessSpaceHash()`
and `sameProcessSpace()`. An unreadable space is stored as `"unknown"`,
which never matches.
- `native-restart-recovery.ts`: `currentNativeControllerIdentity`
carries `hostHash`, which both coordinator claim writes
(`native-restart-recovery.ts`, `native-session-executor.ts`) store.
`evaluateNativeControllerTakeover` returns `controller_host_unprovable`
before any pid probe when the recorded space is not this one.
`native-dead-controller-result-lease.ts` selects the column.
- `workspace-operations.ts`: runtime-control owner stamps record
`hostHash`. `ownerProcessProvenGone` requires the same PID space (EPERM
= alive), otherwise the existing staleness TTL applies.
- Not in this PR: the orphan reaper `terminalizeOrphanedRunningRun`.
`heartbeat_runs` has no host for `process_pid`, and its writers are
spread across legacy and native paths. It follows as a separate PR with
its own column.

## Verification

- `npx vitest run
src/services/native-runtime/native-restart-recovery.test.ts
src/__tests__/workspace-runtime-control-recovery.test.ts`: 55 passed.
New cases cover another host and an unreadable space (dead and recycled
pid) for takeover, plus another host, unreadable space, and legacy stamp
for the sweep. The sweep cases also confirm that the TTL still reclaims
after the heartbeat lapses.
- Red before the fix: the 5 new cases fail on the old code. Green after.
- `native-controller-recovery.integration.test.ts`,
`native-failed-run-owner-release.test.ts`,
`native-startup-cleanup-recovery.test.ts`: 71 passed.
- `pnpm db:generate` produced `0441_lucky_chameleon.sql` (one `ADD
COLUMN`) and pruned to five snapshots. `pnpm --filter @paperclipai/db
check:migrations` passed. Server `tsc --noEmit` shows no errors in the
changed files.

## Risks

- Previous-release story: `native_run_finalizations` rows saved before
this column have `controller_host_hash = null`. They keep the previous
local dead-pid proof for this release. Making them unprovable would
leave every native run in flight at the upgrade in `awaiting_evidence`
with no TTL, because local takeovers have no lease fallback. Rows
written by this release onward are strict. Runtime-control stamps
without `hostHash` are strict now, because that path has a staleness
TTL.
- Owners built from other receipts (workspace finalization owner,
workspace stop proof) pass no host and keep the previous behavior. Track
2's shared `OwnerStamp` should absorb them.
- A new row on a host that really moved (redeploy onto a new machine)
waits for its lease or an operator instead of being taken over by pid.
That is the intended "different host is never proof" rule.
- No new I/O before `turn.start`. The hash is computed once at module
load.

## 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 11:14:48 -06:00
DottaandClaude Opus 5.5 a9ac2e2039 feat(native): split local runner startup into stage, launch and session-load spans (#19852)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - When a person messages an agent, the agent must start its turn fast.
Time to first token (TTFT) and agent boot are first-class metrics.
> - The Phase 0 baseline (#19846) found that local runner startup costs
1.6–17 s at p50 and up to 50 s at p95. It is the largest part of TTFT.
> - The run log records that startup as one span
(`runner.session.bootstrap` or `runner.session.resume`). We cannot see
if the time goes to staging, to the `paperclip-runnerd` process launch,
or to the provider session load.
> - This pull request splits local runner startup into three sub-spans
in the run log.
> - The benefit: the follow-up work to cut follow-up-turn TTFT can
measure each step before and after.

## Linked Issues or Issue Description

Refs #19846 (TTFT baseline, finding 1 and gap "No sub-spans exist inside
local runner startup"). Refs #19838.

**What existing behavior does this improve?**
The run-log performance spans for local native runner startup.

**Current behavior**
The local path records one `runner.session.bootstrap` or
`runner.session.resume` span that covers runtime staging, the runner
process launch and the provider handshake together. The remote path
already records `runner.runtime.stage` and `runner.process.launch`; the
local path does not.

**Proposed behavior**
The local path records three sequential child spans of
`runner.session.startup`: `runner.runtime.stage`,
`runner.process.launch` and `provider.session.load`.

**Reason and benefit**
The TTFT work needs to know which step dominates before it changes the
runner lifecycle. Without these spans, a change to warm runner reuse
cannot show which cost it removes.

## What Changed

- `RunnerdCodexTransport` has a new optional `onStartupStep` callback.
`#start` and `#resume` time three milestones and call it synchronously.
A failing callback never changes startup. A nested startup (exact
adoption from `thread/start`) keeps the outer clock.
- `runner.runtime.stage`: launch preparation before the runner process
exists.
- `runner.process.launch`: spawn until runnerd accepts the run
(`run.prepare`, or the recovery attachment on resume). An adopted live
runner records no launch. This keeps the existing meaning in
`summarizeStartupReuse`: a launch span means a fresh runner.
- `provider.session.load`: `session.open` (or the recovery probe) until
the provider identity is confirmed.
- `recordNativeRunnerStartupStep` (`native-run-trace.ts`) records each
step as a `run.performance.span` row with `mode` and `target: "local"`.
It uses `trace.defer`, so the run-log write never delays `turn.start`.
- The native executor passes the callback only for a local target.
Remote launchers keep their own spans, so there are no duplicates.
- `redaction.ts`: allowlist `runner.process.launch` and
`provider.session.load` as native span names.
- `doc/run-log-events.md`: new section "Local runner startup sub-steps".

Data path (AGENTS.md §5.7): run log only, plus Observability spans when
an operator sets an OTLP endpoint. The attributes (`mode`, `target`,
`outcome`) are already in the closed span-attribute allowlist. No
Telemetry change.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/live/runnerd-codex-transport-startup-steps.test.ts`: a real runnerd
and the fake Codex fixture. Bootstrap and a cold resume each report
stage, launch and session load in order, with `ok` outcomes and no
overlap. A throwing sink does not change startup.
- `cd server && npx vitest run
src/services/native-runtime/native-run-trace.test.ts
src/__tests__/redaction.test.ts`: the rows have the correct parent,
offsets, durations and failed level. A stalled run-log sink does not
block the caller. The rows settle before
`task.provider_session.measured`. The names survive redaction.
- The existing `runnerd-codex-transport*` suites pass (412 tests). Under
host load (load average 16–27), the first full run had 7 timing failures
in suites that do not set the callback. Each failing file passed when I
ran it again alone. `runnerd-codex-transport-settlement` passed 3/3 for
the failing case alone.
- `tsc --noEmit` passes for `packages/paperclip-runner` and `server`.

## Risks

- Low risk. Without `onStartupStep`, the transport behaves as before:
the clock is not created and each milestone call is an optional-chain
no-op.
- More run-log rows: three per local runner startup.
- The step boundaries are controller-side observations.
`runner.process.launch` includes the runnerd authentication and the
`run.prepare` acknowledgement, not only the OS spawn.

> 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 10:57:31 -06:00
DottaandClaude Opus 5.5 e7936fb1ad fix(heartbeat): gate process-loss retries on the shared failure budget (#19856)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a server restart loses a run's process, the orphan reaper
decides whether to retry the run
> - `doc/execution-semantics.md` says process loss uses the shared
durable failure retry counter, and `enqueueProcessLossRetry` already
schedules against that budget
> - The reaper still guards on `processLossRetryCount < 1`, but nothing
has written that column since the move to the shared budget, so the
guard does nothing
> - So an exhausted chain still says "retrying once" and skips the
interaction continuation path, and a legacy row with a non-zero counter
is refused a retry it is owed
> - This pull request replaces the dead guard with
`failedRunRetryBudgetSpent`, the budget that the retry actually spends
> - The benefit is one restart budget, read and written in one place,
with honest run messages

## Linked Issues or Issue Description

**Bug description**
The Phase 0 ownership inventory (finding 5) found that
`processLossRetryCount` has no writer and that its only reader is the
reaper guard at `heartbeat/recovery.ts:2675`. The guard always passes
for new rows. For rows written by earlier releases, it refuses a retry
even when the shared failure budget has room.

**Steps to reproduce**
1. Seed a legacy run that a restart stopped before adapter dispatch.
Spend its whole failure retry budget
(`executionRetryAccounting.failureRetries = max`).
2. Run `reapOrphanedRuns()`.
3. The run fails with "Process lost -- server may have restarted;
retrying once", but no retry is scheduled.

**Expected behavior**
An exhausted budget gives no promise of a retry. A legacy counter value
does not block a retry that the shared budget allows.

Refs #19846

## What Changed

- `heartbeat/recovery.ts`: `shouldRetry` now checks
`!failedRunRetryBudgetSpent(run)` instead of `processLossRetryCount <
1`. When the budget is spent, the reaper falls through to
`scheduleInteractionContinuationInfrastructureRetryIfEligible`, which is
what the old guard intended.
- `packages/db/src/schema/heartbeat_runs.ts`: a comment marks
`process_loss_retry_count` as legacy. There is no schema or migration
change.
- Tests: the three cases that used `processLossRetryCount: 1` to mean
"retry already spent" now spend the real failure budget. Two new cases
cover a legacy counter (a retry is still scheduled) and a spent budget
(no "retrying once", `retryEligible: false`, no retry run).

## Verification

- `npx vitest run src/__tests__/heartbeat-process-recovery.test.ts -t
"process-loss|failure budget|bypass unknown process outcomes|immediate
terminal-run cleanup|paused-tree work|restart stopped before adapter
dispatch|reached adapter dispatch"`: 7 passed.
- Red before the fix: with the old `recovery.ts`, both new cases fail.
Green after.
- Server `tsc --noEmit` shows no errors in the changed files.
- The full `heartbeat-process-recovery.test.ts` file (419 cases) stalled
locally at 0% CPU after about 15 minutes. That run was on a worktree
whose package builds were stale at first. I ran the focused subset
instead, and required CI runs the full file.

## Risks

- Low risk. The retry decision now follows the same budget that
`enqueueProcessLossRetry` already enforced. A run with budget left
behaves as before.
- Previous-release story: rows saved with `process_loss_retry_count > 0`
are no longer refused for that reason alone. The shared failure budget,
which earlier releases also wrote, bounds them. The column stays, so
earlier-release readers (`queue.ts` startup resume) keep working.

## 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 10:37:39 -06:00
377bc87df1 feat(connectors): show and select personal AI account defaults (#19845)
Show a crown on each saved personal AI provider default. Add a provider-specific menu action to other eligible accounts. Share saved-state queries, block duplicate writes, and keep the saved crown until a write succeeds.

Keep account details available for accessible grants. Show session lookup errors with Retry. Resolve list permissions once per request and index account lookups. Add UI and API regression coverage and account documentation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
2026-11-07 10:26:08 -06:00
DottaandClaude Opus 5.5 b5b972ceb1 fix(runner): stop provider tool processes that leave the owned process group (#19727)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The local runner (`paperclip-runnerd`) supervises each provider
process, such as the ACPX sidecar for Claude, in its own process group
> - Run teardown stops that process group, and nothing else
> - Claude Code starts each Bash tool command in a new session
(`setsid`). A command such as `nohup ./bench.sh & echo $!` leaves a job
in that session, and the wrapper exits at once. The job is then a child
of init and outside the owned group
> - In a restart chaos storm, a run failed after a restart resume. Its
tool job kept running beside the task's next run, in the same workspace,
for more than four minutes
> - This pull request makes the supervisor record every process that its
provider starts, including processes that leave the group, and stop them
all before teardown reports success
> - The benefit is that a run's tools cannot outlive the run and cause
duplicate side effects in the next run

## Linked Issues or Issue Description

**What happened?**
A restart chaos storm ran a local ACPX Claude task. After a restart
resume, the agent ran `nohup ./bench.sh <nonce> > log 2>&1 & echo
"started pid $!"` and then ended its turn without a result. The run
failed with `native_finalization_missing`. The runner tore down the
sidecar group, and the guardian killed the provider group. `bench.sh`
was in the session that Claude Code made for the tool (pgid and sid =
the exited wrapper, ppid = 1). It was never signalled. It kept forking
`sleep 1` while the task's next run started and finished in the same
workspace.

**Steps to reproduce**
1. Run a local ACPX Claude task whose agent runs a Bash tool command
such as `nohup ./bench.sh x > log 2>&1 & echo $!`.
2. End the run (any outcome, including failure after a restart resume).
3. Run `ps -axo pid,ppid,pgid,command | grep bench.sh`. The job is still
running with ppid 1.

**Agent adapter(s) involved**
Claude Code (local ACPX). The gap is in the shared supervisor, so any
provider whose tools leave the group is affected.

**Root cause**
`SupervisedProcess`
(`packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs`)
spawns with `process_group(0)` and stops only that group in
`terminate_group`, `terminate_group_immediately`, `wait` and `Drop`. Its
own comment said that a caller that must exclude escaped or re-parented
descendants needs a separate lifetime fence. No caller had one. The ACPX
sidecar transport (`acpx_sidecar_transport.rs`, `shutdown` and `poison`)
and the ACPX provider guardian (`installation-integrity.ts`, which kills
only its own group) therefore left every `setsid` tool job behind. The
failure path after the resume was not special: the same gap applies to
every teardown.

**Expected behavior**
When a run ends for any reason, every process that its provider or tools
started stops, and teardown proves that they are gone. Identity comes
from PID, group, session and start time, never from a command name.

## What Changed

- Add the `paperclip-process-table` crate. It reads PID, parent, process
group and start time for every process (libproc on macOS, `/proc` on
Linux). `runner-core` forbids unsafe code, so the two checked libproc
calls live in this small crate.
- Add `process_lineage.rs` to `runner-core`. A sampler thread (every 50
ms) records each descendant of the supervised process by PID and start
time. It also records each group and session that a descendant leads,
with the leader's start time. A process belongs to the lineage when its
parent belongs, or when it is in a recorded group or session and started
no earlier than its leader. Groups and sessions that the supervised
process only inherited (runnerd's session) are never recorded. A
recorded ID whose PID now names a different process is dropped.
- `SupervisedProcess` samples the lineage just before it signals, and
after the leader is reaped it stops every live member: TERM, then KILL
after the shutdown grace. It fails with an error until it proves that no
member is alive (grace plus 5 s). This applies to `terminate_group`,
`terminate_group_immediately`, `wait` and `Drop`.
- If the lineage sampler cannot start, the supervisor stops the process
it just started and reports the failure, so a provider never runs
without tool cleanup.
- Table reads and state updates happen under one lock, so an older read
cannot erase members that a newer read recorded.
- The process table reports processes that exist but cannot be read. A
recorded member that cannot be read stays unresolved, and teardown fails
until its exit is proven.
- The Runner workspace now has two members.
`scripts/runner-release-crate-cache.mjs` and
`scripts/runner-debug-crate-cache.mjs` cache and mark current the
process-table lib unit with the Runner crate (otherwise Cargo rebuilds
the Runner crate after every restore). The debug contract accepts only
this library-only member and still refuses any other workspace change.
The workflow cache path lists and the debug clean step are updated to
match.
- `terminate_group_immediately` now accepts `EPERM` from the group
signal when the leader has already exited. macOS returns `EPERM` for a
group whose only member is the unreaped leader. The lineage pass then
proves that no other member remains.

## Verification

- `cargo test -p paperclip-runner-core --lib -- process_lineage tool_job
immediate_group_fence` (macOS, local).
- New real-process tests in `process_supervisor.rs`: a supervised shell
starts a wrapper with `setsid` (Perl `POSIX::setsid`), the wrapper runs
`nohup <loop> &`, writes the job PID and exits. The test asserts that
the job is outside the owned group and re-parented, then calls
`terminate_group`, `terminate_group_immediately` or drops the process,
and asserts that the job is gone when the call returns.
- Red: with the tracker disabled, all three fail with `tool job <pid>
outlived its provider`.
  - Green: all three pass.
- Unit tests in `process_lineage.rs` cover a re-parented job, a job that
left its group but stayed in the recorded session, a session made after
the first sighting, PID reuse, and no claim through the supervisor's
inherited session.
- `node --test scripts/__tests__/runner-release-crate-cache.test.mjs
scripts/__tests__/runner-debug-crate-cache.test.mjs` (new case: the
process-table library is cached with the crate, and any other member
still needs review).
- `cargo test -p paperclip-process-table` (includes an exited process
reported as gone), and `cargo test -p paperclip-runner-core --lib` (570
passed).
`acpx_provider_backend::tests::unconfirmed_suspension_state_becomes_recoverable_only_after_cleanup_and_suspend`
fails sometimes under full-suite load. It fails at the same rate with
the tracker disabled, so it is a flake that already existed.
- Sampler cost: one table read takes about 0.4 ms for 1,200 processes on
macOS (about 0.8% of one core for each supervised process).
- The restart-chaos replay (`--replay w-20261107T023921-s7106/t67`) did
not run. The local session was not permitted to load provider
credentials. It is not proven against a live Claude run.

## Risks

- Teardown now stops all the processes that a provider started, also
intentional background jobs. This is the intended contract: nothing
outlives the run.
- A wrapper that lives less than one sample (50 ms) and orphans a job
before the sampler sees it is not recorded. Claude Code's wrapper
sources a shell snapshot and lives for seconds. On Linux, a
child-subreaper would close this gap. That is not in this change.
- When `runnerd` itself is killed with SIGKILL, this code does not run.
The provider guardian still kills its own group, but escaped tool
sessions are not stopped in that case.
- Teardown can return a new error when it cannot prove the lineage
stopped. Callers already handle `terminate_group` errors as provider
shutdown failures.
- The server does not yet gate the next run on the tool lineage of the
prior run directly. It relies on the runner finishing its teardown
before the runner exits.

## 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 08:24:41 -06:00
DottaandClaude Opus 5.5 b2af45f0f1 fix(runner): let OpenAI API-key Codex agents generate images with their existing key (#19654)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - New users often connect a Codex agent with an OpenAI API key during
onboarding.
> - A user asked such an agent for a logo. The agent said that the
built-in image generator was not available and that its fallback needed
a separate API key. It then drew an SVG by hand.
> - #19516 removed `--disable image_generation`. That change is correct,
but it does not reach this path. The pinned Codex 0.160.0 shows its
`image_gen` tool only when Codex signs in with ChatGPT. The check is
`image_generation_available` in `codex-rs/core/src/tools/spec_plan.rs`.
An API key never gets the tool.
> - The runner keeps the provider key out of the agent's shell on
purpose, so the skill's `scripts/image_gen.py` fallback cannot run.
> - This pull request adds a Paperclip `generate_image` tool. The server
makes the image with the agent's existing OpenAI key. The key never goes
to the agent process or the shell.
> - The benefit is that API-key Codex agents can make images with no
second key. The spend goes to the agent's budget and the budget can stop
it.

## Linked Issues or Issue Description

Refs #19516

**What happened?**
An agent connected through onboarding with an OpenAI API key (Codex
harness, `gpt-5.6-sol`) could not generate images. The agent said: "The
image skill's built-in generator isn't available in this run, and its
fallback would require a separate API key." It then drew an SVG instead.

**Steps to reproduce**
1. Create a company with a Codex agent that uses an OpenAI API key.
2. Ask the agent for a raster image, for example a logo PNG.
3. The agent reports that image generation is not available. It does not
make the image.

**Expected behavior**
The agent makes the image with the OpenAI key that already powers it.

**Root cause**
The agent ran on the native runner's Codex app-server path, with
`auth.json = {"OPENAI_API_KEY": ...}`. Codex 0.160.0 removes
`image_gen.imagegen` from the model's tools unless
`current_auth_uses_codex_backend()` is true. That function is true only
for a ChatGPT sign-in.

I proved this with the real pinned binary. I gave it the runner's
isolated launch arguments and the API-key auth file, and pointed it at a
local Responses endpoint. The first model request offers only `exec`,
`wait`, `request_user_input` and the collaboration tools. #19516's test
checked only `codex features list`, so it could not see this gate.

## What Changed

- **New `server/src/services/native-runtime/generate-image-tool.ts`:**
  - The tool schema and definition.
- `imageGenerationBindingForRun`. It applies only to native Codex runs
on a direct OpenAI `api_key` connection with no router selection.
- The OpenAI Images API call (`gpt-image-2`, `b64_json`, PNG check, 180
s timeout). The reply is read as a stream with a byte cap before it is
parsed. A decoded image larger than 20 MiB is refused.
- A reviewed rate card for `gpt-image-2`: text input $5, image input $8
and image output $30 per 1M tokens, from
developers.openai.com/api/docs/pricing, retrieved 2026-11-06.
  - A create-only, symlink-confined local write.
- **`paperclip-runner-tool-authority.ts`:**
  - Adds the `imageGeneration` binding.
- Advertises the tool only for standard mode with a workspace. A remote
target must also have an installer. Dot, Muse, Grok and native review do
not get the tool.
- Adds the `#generateImage` handler. Before any spend, it checks again:
the run and task, budget authority, `issue:mutate`, case handoff, and
the connection.
- Stores a claim-before-spend per-call receipt in
`resultJson.imageToolReceipts`. A retry or recovery replays the receipt
and does not pay again.
- Saves a `charged` receipt (usage, price, request ID and charge time)
as soon as OpenAI returns. A failed save, failed accounting write or
crash can then still settle the spend. A retry or recovery records the
cost once and never buys again.
- Records one `cost_events` row through `createCostEventInTransaction`.
The row is `metered_api` with `costStatus: "estimated"`, and the budget
is evaluated. The row is recorded even if saving fails.
- Checks the task's project budget, as well as the company and agent
budgets, before spending.
- Writes content-free `runner.image_generate` activity entries: started,
completed, failed and unconfirmed.
- **`ai-connections.ts`:** adds `selectImageGeneration`. It checks again
that the connection is an active, healthy OpenAI `api_key` connection
with no custom routing. It also checks the responsible user's
membership, the grant audience and the agent or company installation.
These are the same rules that runtime selection uses.
- **`native-session-executor.ts`:** gets the binding from the run's
admitted `contextSnapshot.aiConnection`. The value comes only from the
server, never from tool input.
- **`native-session-resume.ts`:** the native tool contract changes from
`v18` to `v19` and now lists `generate_image`. A persisted provider
thread cannot add tools when it resumes, so older threads must rotate.
- **`doc/runner-api-tools.md`:** a new section about image generation
for OpenAI API-key Codex agents.
- **#19516: kept, not reverted.** Its changes have no effect on the
API-key path. They are still correct for Codex agents that sign in with
ChatGPT, because `image_gen` works there and saves to
`CODEX_HOME/generated_images`. Those agents need the read grant that
#19516 added. On the API-key path, the new tool replaces the built-in
tool. The tool's description tells the agent to use it instead of
`image_gen` or `scripts/image_gen.py`.

## Verification

**Red on `master` (e482f0f632).** Behavioural red with the first version
of the new test:

```
 FAIL  src/services/native-runtime/generate-image-tool.test.ts > generate_image for OpenAI API-key Codex agents (N-12) > lets an API-key Codex agent make an image with its existing OpenAI key, saved in its workspace and billed
AssertionError: expected [ 'connections_search', …(51) ] to include 'generate_image'
 ❯ src/services/native-runtime/generate-image-tool.test.ts:78:60
      Tests  1 failed | 3 passed (4)
```

The final test file on master's source also fails. The reason is that
the module does not exist: `Cannot find module
'./generate-image-tool.js'`.

**Real pinned Codex binary.** The new test is
`packages/paperclip-runner/src/drivers/codex/codex-api-key-image-tools.test.ts`.
It uses the runner's isolated launch, the API-key `auth.json` and a
local Responses endpoint. It shows that the model gets no `image_gen`,
`imagegen` or `image_generation` tool, and that a server-advertised
`generate_image` tool does reach the model:

```
 Test Files  1 passed (1)
      Tests  1 passed (1)
```

**Green after the fix:**
`server/src/services/native-runtime/generate-image-tool.test.ts` uses
embedded PostgreSQL and a stubbed OpenAI fetch.

```
 Test Files  1 passed (1)
      Tests  11 passed (11)
```

The 11 tests cover these behaviours:

- **Main path.** The tool is advertised. The request goes to
`api.openai.com/v1/images/generations` with the existing key. The PNG is
written to the workspace. The key does not appear in the result or in
activity. One `cost_events` row is written: 50 text tokens and 1000
output tokens, which is $0.03025, estimated, from the rate card. A retry
and `recover()` replay the receipt with no second fetch and no second
cost row.
- **No binding:** the tool is not offered and `execute` refuses.
- **Budget hard-stop.** The stop pauses the agent, so the call is
refused before any fetch and no file is written.
- **Bad grant:** the call is refused before any fetch.
- **Project budget stop.** The task's project is paused by its budget,
so the call is refused before any fetch.
- **Charge settlement.** A crash left a `charged` receipt. The retry and
`recover()` record the cost once, report the image path and do not buy
another image.
- **Oversized reply.** The read stops before the body is parsed.
- **Save failure.** The spend is still recorded. A same-call retry does
not pay again and reports the failure.
- **Binding gate.** Only Codex on a direct OpenAI API key gets the
binding. A subscription, ACPX, OpenRouter or a routed connection does
not.
- **Rate card math.**
- **Provider errors.** The error text does not contain the key, and
non-PNG data is rejected.

**Neighbouring suites:**

- `src/drivers/codex` in the runner package: 24 files, 350 tests passed.
- Server: `paperclip-runner-tool-authority.test.ts`,
`native-session-resume.test.ts`, `fast-responses.test.ts`,
`ai-connection-session.test.ts` and the new suite. 157 of 158 passed.
- The one failure is `native-session-resume.test.ts › archives a damaged
prior epoch … with real runnerd` (ENOENT on `authority-epochs`). It
fails the same way on unmodified `master` in this local environment, so
this change did not cause it.
- `tsc --noEmit -p server/tsconfig.json`: clean.
- `pnpm verify` exits 2 (broad). Required CI supplies the broad
coverage.

## Risks

- **New security surface: this needs maintainer sign-off.** The server
now sends the agent's OpenAI API key to `api.openai.com` on the agent's
request. The tool is limited in these ways:
  - The key stays in the server.
- Each call checks run, task, budget, mutation and connection authority
again.
- The destination name comes from the server and must match
`^[a-z0-9-]+-[0-9a-f]{8}\.png$`. The write is create-only and confined
to the workspace root. On macOS it uses `O_NOFOLLOW_ANY`. On Linux it
uses a `/proc` descriptor walk.
- **Spend.** Each call costs money. The tool description tells the agent
to make only what the task needs. The cost is an estimate from a rate
card (`costStatus: "estimated"`), not an invoice. The rate card applies
the image-output rate to all output tokens, so the estimate errs on the
high side.
- **Checkpoint rotation.** The tool-contract bump from v18 to v19 makes
existing native provider threads start fresh once at their next turn.
This is what the contract requires when the tool surface changes.
- **Pricing drift.** If OpenAI changes `gpt-image-2` prices, the rate
card must be updated.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 07:48:39 -06:00
DottaandClaude Opus 5.5 5eac099fbb fix(agents): batch independent follow-up questions into one card (#19634)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents ask users for input with the `request_human_input` tool or
the `ask_user_questions` interaction. Paperclip shows each call as one
question card.
> - In an onboarding walk, a Codex execution agent asked five
independent questions (name, menu and prices, contact, style, output).
It posted five cards, one after the other. Each card came 20-60 s after
the previous answer.
> - The tool description said: "For one question at a time, ask only the
next unanswered question and wait for its real answer." Agents read this
as a rule. The paperclip skill showed only a one-question example and
did not tell agents to batch.
> - No server cap exists. A question set accepts up to 64 questions.
> - This pull request changes the guidance. Agents now ask independent
inputs together in one card. They use a follow-up card only when a
question depends on an earlier answer. One-at-a-time stays available
when the user asks for it.
> - The benefit is fewer waits for the user: one card and one agent turn
instead of five.

## Linked Issues or Issue Description

**What happened?**
An execution agent asked a user five independent follow-up questions in
five separate question cards. Each card appeared only after the user
answered the previous card and the agent finished another turn (20-60 s
each).

**Expected behavior**
When an agent needs several independent inputs, it asks them together in
one multi-question card. The onboarding interview already does this ("1
of 4" pager).

**Steps to reproduce**
1. Complete onboarding on the Codex API path and hand off a task such as
"Create printable cake menu and logo".
2. Watch the execution agent's first turns.
3. See five sequential single-question cards.

**Paperclip version or commit**
master at 4938a930d6 (local dev server).

**Agent adapter(s) involved**
Codex (gpt-5.6-sol). The guidance is shared by all adapters that use the
Runner `request_human_input` tool or the `paperclip` skill.

## What Changed

-
`packages/paperclip-runner/src/protocol-actions/request-human-input.ts`:
replace the ambiguous "For one question at a time…" sentence. The new
text tells agents to ask independent inputs together in one questions
card. They use a follow-up card only when it depends on an earlier
answer. It keeps one-at-a-time for when the user asks for it.
- `skills/paperclip/SKILL.md` and
`skills/paperclip/references/api-reference.md`: add the same batching
rule to the human-input sections.
- `packages/mcp-server/src/tools.ts`: add the batching hint to the
`paperclipAskUserQuestions` tool description.
- Regenerated runner contracts (`semantic-tool-contracts.json`,
`protocol/manifest.json`, `native-execution-seeded.json`) and updated
the legacy MCP alias semantics in `spec/capability/mcp-tool-map.yaml`.
- New tests: `core-contracts.test.ts` (tool description) and
`paperclip-skill-utils.test.ts` (skill and API reference).

## Verification

Red on master (tests added, source unchanged):

```
packages/paperclip-runner: npx vitest run src/protocol-actions/core-contracts.test.ts -t batch
 × tells agents to batch independent questions into one card instead of asking them one by one
AssertionError: expected 'Create a durable human question or ap…' not to contain 'For one question at a time'
 Tests  1 failed | 20 skipped (21)

npx vitest run server/src/__tests__/paperclip-skill-utils.test.ts -t batch
 × tells agents to batch independent human-input questions into one card
AssertionError: expected ' For an open answer, use a text field…' to contain 'ask them together in one card'
 Tests  1 failed | 21 skipped (22)
```

Green after the fix:

```
packages/paperclip-runner: npx vitest run src/protocol-actions src/drivers/runner-tool-bridge.claude-schema.test.ts
 Test Files  4 passed (4)   Tests  46 passed (46)
npx vitest run server/src/__tests__/paperclip-skill-utils.test.ts            Tests 22 passed (22)
npx vitest run server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts   Tests 41 passed (41)
node scripts/generate-semantic-contracts.mjs --check / generate-capability-contract --check / generate-semantic-action-catalog --check / generate-replay-goldens --check: current
```

`packages/mcp-server` has one failure ("allows create issue requests to
omit status…"). It also fails on master without this change, so it is
not related.

This is a guidance change. The automated check proves that the guidance
the agent receives tells it to batch. I did not run a live-model eval.

## Risks

- Low risk. The change is text only: tool descriptions and skill docs.
No schema, API, or storage change.
- An agent could put a dependent question into the same card. The text
tells agents to use a follow-up card for dependent questions.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) 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 07:48:28 -06:00
5a92d53f7d fix(adapter-utils): handle closed child stdin safely (#14057)
Handle closed child stdin without losing the child result.

Keep each child stdin error handler scoped to that child. Expected EPIPE
and stream-destroyed failures no longer crash the host. The child close
event remains the source of its exit result.

Verification: 2,024 tests passed and 12 live/platform cases skipped across
87 complete adapter-utils suites. The final 140-test server-utils suite
passed. The regression reproduces the EPIPE crash on unchanged master.

Final qualification: all applicable CI passed and exact-head Greptile Apex
scored 5/5 with no unresolved findings.

Co-Authored-By: Grzegorz Golaś <193330749+greegorij@users.noreply.github.com>
Co-Authored-By: Devin Foley <devinfoley@users.noreply.github.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-11-07 03:01:03 -08:00
DottaandClaude Opus 5.5 2468b3f907 fix(native): tell a restart continuation that earlier processes may be gone (#19804)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a control-plane restart loses a turn, the native runtime sends
a same-conversation continuation (#19501, #19699, #19771)
> - A restart can also kill shell or background processes that earlier
turns started
> - In three restart storms, the continuation agent could not inspect
its killed `bench.sh` and set the task to blocked
> - The continuation prompt told the agent not to "blindly rerun an
action whose outcome is unknown", so the agent did not re-run the
command
> - This pull request tells the agent that earlier processes may be
gone, and to check the workspace outputs and re-run a command whose
expected output is missing
> - The benefit is that a task can finish after a restart instead of
blocking on a process that no longer exists

## Linked Issues or Issue Description

Refs #19501, #19699, #19771

**What happened?**

Restart chaos workload B5 (Codex and Claude) failed this way three
times: storm 6b t14, s7111 t16 and storm 10 t12. The agent started
`bench.sh` in the background. A restart killed it. The restart
continuation agent tried to find its tool session and got "process
inspection is denied". It then blocked the task. The comment read:
"Blocked by the Paperclip runtime restart: the benchmark session is
unavailable…". It added that it "cannot verify whether the original run
is still active or recover its output, so I have not rerun it".

**Expected behavior**

The agent knows it is in a resumed session after a restart. It checks
the workspace for the command's outputs. If the expected output is
missing, it re-runs the command and finishes the task.

**Root cause**

`packages/paperclip-runner/src/native-session-runtime.ts`,
`restartContinuation`: the prompt did not say that processes from
earlier turns may be gone. It told the agent to inspect each unfinished
command's "current state" and not to "blindly rerun an action whose
outcome is unknown". With the process gone, the agent could not inspect
it, so it blocked.

## What Changed

- The restart continuation prompt now says:
  - This is a resumed session after a Paperclip control-plane restart.
- Shell commands and background processes from earlier turns may no
longer be running, and their tool sessions may be gone. Do not block
only because one cannot be found or inspected.
- Check the workspace for the outputs or artifacts the command should
have produced. If the expected output is present, use it. If the
expected output is missing, re-run the command.
- Do not repeat completed work. Before repeating an action with external
effects (a push, a message, a deployment), check whether it already took
effect. If you cannot tell, report the blocker.
- The prompt does not say that re-running is always safe. Process
teardown (#19727) has not merged.
- `doc/execution-semantics.md`: one additive sentence.

## Verification

- `native-session-runtime.test.ts`: the three restart continuation tests
(Codex, ACPX, OpenCode) now check the new guidance. They were red before
the prompt change and are green after it.
- Guard: "uses the admitted session cwd in the task envelope" now checks
that fresh turns and ordinary resumed turns do not contain the restart
guidance.
- `npx vitest run src/native-session-runtime.test.ts src/drivers/codex/
src/live/runnerd-restart-continuation.test.ts` in
`packages/paperclip-runner`: 603 passed.
- `tsc -p tsconfig.json` and `tsc -p tsconfig.test.json` for
`packages/paperclip-runner` are clean.
- Not run: a live restart-chaos replay with a real model.

## Risks

- Low. This changes only the prompt text of the restart continuation.
Ordinary turns do not change.
- An agent may now re-run a command whose earlier run had partial side
effects. The prompt asks it to check external effects first, and to
re-run only when the expected output is missing.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edits, 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 03:46:20 -06:00