Commit Graph
7484 Commits
Author SHA1 Message Date
DottaandClaude 626f552e88 test(chaos): wait for the disk-full verdict before clearing DF1; require a remote workspace
A board-owned recovery that is still checking its saved source looked like a
rest, so DF1 cleared too early and that recovery's save succeeded. The harness
now waits at an unfinished rest for the sandbox_disk_full cause, up to 10
minutes. DF1 also needs --storage s3, and the docs point at R1, since the
chaoscli fixture syncs a local worktree and runs no remote save.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 07:42:55 -06:00
DottaandClaude 57e8ebe355 test(chaos): add DF1, a sandbox disk-full fault on the final workspace save
A test-only server hook, armed only by PAPERCLIP_DISK_FULL_FAULT_CONTROL and
never under NODE_ENV=production, makes an armed task's final workspace saves
get what a full sandbox returns. The harness arms DF1 tasks at launch, clears
them at the first rest, and grades the sandbox_disk_full rest, the offered
Retry and whether that Retry finished the task.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 07:12:51 -06:00
DottaandPaperclip 556b249897 fix: preserve exact Markdown in native document writes (#20605)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents save Markdown documents through native tools and REST routes.
> - The native write_document handler trims the supplied body.
> - This removes final newlines and can change Markdown whitespace.
> - This pull request preserves the original body after required-content
validation.
> - Agents can now write and read the same Markdown string across these
paths.

## Linked Issues or Issue Description

**What happened?**

The native write_document tool removes leading and trailing whitespace.
A body with one final newline loses that newline on read-back. The REST
document PUT and write_task_document preserve it.

**Expected behavior**

The native write_document tool must preserve the supplied Markdown body
exactly. Required-content checks and key and title normalization must
stay in place. REST-backed paths retain surrounding whitespace and keep
their existing escaped-line-break conversion for single-line payloads.

**Steps to reproduce**

1. Write a disposable document through write_document with a final
newline.
2. Read the document and compare the full body with the supplied string.
3. Repeat through write_task_document and REST. These paths preserve the
string.

Related work: Refs #18824. That change aligns document keys. This change
preserves body strings. A search of open and closed PRs found no
duplicate fix.

## What Changed

- Use a separate untrimmed required-string helper for the document body.
Keep the existing requiredString callback signature and all other field
normalization.
- Add real tool-handler, database, and authenticated HTTP tests for
create, update, read-back, and replay. Cover leading spaces, hard
breaks, blank lines, final newlines, no final newline, Unicode, and
mixed line endings.
- Keep blank and invalid bodies rejected. Document whitespace
preservation and the existing REST escaped-line-break exception.
- Fix the Runner support archive-scanner shutdown defect exposed by CI:
rejected ZIP scanners now receive SIGKILL so an ignored SIGTERM cannot
leave publication waiting for process closure. Keep all secret and
archive limits unchanged. A real child-process regression fails before
the change and passes afterward.
- Address all three initial review findings: callback type
compatibility, contribution comment policy, and the REST documentation
limit.

## Verification

Final tested head: `c9ff8cf3f83aa19e5d8313c65816a3a2e6562368`.

- All applicable [CI
checks](https://github.com/paperclipai/paperclip/actions/runs/43019885807)
passed. This includes typecheck, build, Runner support, Rust, native
Runner, general tests, serialized suites, all eight E2E shards, and the
required aggregate gates.
- [Greptile
review](https://github.com/paperclipai/paperclip/runs/130520170309)
passed on this head with confidence 5/5. All three review threads are
resolved. The independent Paperclip review also reports 5/5 with no
findings.
- Red at `7368a0b15c2f02107778a961144d86e2eb59a952`: `pnpm --dir server
exec vitest run
src/services/native-runtime/task-document-body.integration.test.ts`
failed because write_document removed the final newline. Cross-task and
REST cases passed.
- Green on the final source: 176 tests passed across the document body,
document key, native tool authority, task scope, document service, and
document route suites. The tests cover create, update, exact read-back,
replay, blank rejection, revision guards, and company scope.
- The ZIP shutdown regression failed before repair with “scanner did not
stop”. After repair, 54 tests passed across zip-inspection-shutdown,
private-reporter-artifacts, manual-private-cell-report-assets, and
private-evidence-budget. The regression uses a real process that ignores
SIGTERM.
- Earlier CI runs had two archive-related timeouts. The source repair
above replaces blind reruns. The Runner support suite and aggregate
verify gate now pass.
- A disposable live native document reproduced the original whitespace
loss. REST preserved the same string. The fixed handler passed local
integration tests. The running service has not been deployed.
- Local environment: Linux x64, Node 25.9.0, Vitest 5.0.3, and embedded
PostgreSQL. Local Rust preparation was unavailable because cargo is
absent. Final-head CI passed Rust verification. `pnpm verify --explain`
requested broad CI because evidence.ts is a global input. Required CI
supplied that coverage.

## Risks

Low risk. The change retains required-content validation. The product
change affects document body whitespace handling. The CI repair
forcefully stops only already-rejected archive scanners; it retains
fail-closed publication and all existing scan limits. It does not change
schema, authorization, revision guards, receipts, or persistence.

## Model Used

CI repair continuation: GPT-6.1, using repository tools and code
execution.


OpenAI Codex, GPT-6 (the exact serving model ID and context window were
not exposed in this session). Used reasoning, repository tools, and code
execution.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
canary/v2026.1111.0-canary.9
2026-11-11 06:38:32 -06:00
DottaandPaperclip 84fc67317e feat(runner): export authorized read responses as artifacts (#20606)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native agents use authorized API reads to get task evidence.
> - Small responses return inline JSON, so an agent must copy each
document into a local helper.
> - Copying changes bytes and uses model output for data transport.
> - This pull request adds an explicit artifact export for non-sensitive
GET reads.
> - Helpers can download the exact response bytes and check their
identity.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The native `call_api` response capture path.

**Current behavior**

Small JSON responses have no artifact reference. Local helpers need the
model to copy the JSON data.

**Proposed behavior**

`saveResponse: true` saves a complete authorized GET response below or
above the inline threshold. Zero-byte success returns an explicit empty
receipt without storage. The receipt returns an artifact ID, byte size
and SHA-256 for `download_artifact`. Other callers keep the current
behavior.

Related work: #17393 covers saved app-tool result size. #18105 covers
mutation response paging. This change adds an explicit read export.

## What Changed

- Add GET-only `saveResponse` validation and capture through the current
artifact store.
- Refuse sensitive operations, denied reads, partial responses and
combined text paging.
- Keep route authorization, actor visibility, storage budgets and
download integrity checks.
- Update the native tool contract, generated catalogs and API guide.
- Add boundary and real-route byte comparison tests.
- Preserve saved run state. This is an optional input field; old calls
and receipts keep their current shape. No schema migration or identity
reset is needed. The seeded catalog fixture is generated.

## Verification

- Final published head `d47b52e1bf412418e24768f865cc139097a6d068`:
[typecheck, build, all tests, Linux Rust and aggregate verify
passed](https://github.com/paperclipai/paperclip/actions/runs/42918883477);
[Windows Rust
passed](https://github.com/paperclipai/paperclip/actions/runs/42918883348);
[quality gate
passed](https://github.com/paperclipai/paperclip/actions/runs/42918883016).
Greptile completed successfully at this exact head, 5/5; all five review
threads resolved. Automated Paperclip review remains queued due to a
failed reviewer run; no final verdict is claimed for that service.
- Current head: `d47b52e1bf412418e24768f865cc139097a6d068`. Merged
current master to refresh GitHub's stale base snapshot; the PR now
contains exactly nine intended artifact transport files, with no
lockfile or UI changes. Fresh final-head CI/review is required.
- Previous head `986ad87490fff93cfec313ba6fd56d3dc0bb665b` passed
typecheck, build, Rust verification, artifact/native suites and
completed test shards; remaining shards were still running when the base
snapshot was refreshed. Greptile was 5/5, with one unrelated master UI
finding resolved as outside the corrected PR diff.
- Review fixes verified at `986ad87490fff93cfec313ba6fd56d3dc0bb665b`:
2,135 checks passed across the five focused suites; Runner TypeScript
build and regenerated contract/catalog checks passed. This head replays
the original implementation on current master and retains the original
PR commit in its history.
- Review red/green at replayed baseline `da3a1d4de9`: empty HTTP 200/204
tests failed with real storage's `File is empty`, then passed with zero
new assets or quota reservations. Error-body tests failed without
size/truncation fields, then passed.
- Empty successes now return `empty: true`, `byteSize: 0`, `data: null`
without storage. Nonempty JSON results such as `[]` export normally.
Error receipts report byte size and truncation. Docs and tool guidance
state the existing 10 MiB download limit and paged text alternative.
- Previous-head CI typecheck/build/tests passed except the Rust
process-lineage cleanup test (`unattributed_orphan`); final-head CI and
fresh review remain required.
- Red at `7368a0b15c`: the small-document export test failed with
`Invalid call_api arguments` before production edits. The same check
passed after the change.
- Final post-rebase run at `6e7a35ed6a59bb74f4218312d9f10557099433c3`:
all 2,124 checks passed across the API, capture, artifact boundary,
real-route export and artifact download suites.
- Real-route export test passed: HTTP bytes, stored bytes and downloaded
bytes were equal. Foreign-company access and forged download metadata
were rejected.
- Existing API integration and artifact suites passed. Nearby API
integration checks also passed (59 passed, 2 skipped).
- Disposable native helper scan passed: 12 existing documents, 15 GET
requests, zero mutations, 73,919 bytes, 17.985 seconds. All files passed
size/hash checks. The bridge passed file references only. Production
token savings were not measured.
- Runner TypeScript build and generated contract checks passed.
- `pnpm verify` returned exit 2 and required broader CI coverage. Local
preflight stopped at missing Cargo after dependency and SDK setup. The
local server typecheck stopped during the cycle layer without a
diagnostic; the container recorded an OOM kill. This check has no pass
verdict. Required CI must cover typecheck, build and test at the final
head.

## Risks

- Each explicit export uses the current capture budget and storage
admission. Frequent small exports can reach that budget.
- An artifact holds one HTTP response. Callers must still follow list
pagination.
- No database or saved-state migration is required. The option does not
change default response behavior.

## Model Used

OpenAI `gpt-6.1-sol`, through Codex in Paperclip Runner. Used reasoning,
shell execution and native tools. The active agent configuration
supplied this exact model ID.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-11 06:26:41 -06:00
DottaandPaperclip bc453342ef fix(native): start a cold server from active database checkpoints (#20792)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs save their controller state in complete encrypted
database checkpoints.
> - A rolling update starts a replacement with an empty local home while
the old server still owns active remote work.
> - Startup required a local controller marker before it checked the
active database authority.
> - The replacement could report `native_controllers_require_migration`
even when the checkpoint, workspace and live owner were valid.
> - This change uses the existing complete checkpoint proof when the
local marker is absent. A marker that exists keeps its strict
validation.
> - The benefit is that a cold replacement can start while supported
remote work continues.

## Linked Issues or Issue Description

**What happened?**

A volume-free replacement failed startup with
`native_controllers_require_migration`. The old server had a local
external-control marker and a valid active database checkpoint. The
replacement had the checkpoint but no local marker.

**Expected behavior**

An absent local marker must not make a complete, valid database
checkpoint into a local migration obligation. Missing, corrupt or
mismatched durable evidence must still refuse startup.

**Steps to reproduce**

1. Save a full native checkpoint with its real checkpoint writer. Keep
its source run active with a live generation-one owner and its bound
remote workspace.
2. Remove only the local controller marker to represent a cold
replacement home.
3. Inspect native controller obligations. Before this change, the
inspection returns true.

**Paperclip version or commit**

Reproduced on `eb75c9bb855ce83915256efc9d6b308a272cdeff`. The fix is
based on `c44a422607f9f08c39ad98f9c830fe6e3dba8dc2`.

**Deployment mode**

Volume-free server with native remote execution and database checkpoint
persistence.

## What Changed

- Let an absent local marker use the existing full checkpoint, run,
session, workspace and owner checks.
- Keep validation of every present marker and the final file reread that
detects a change during inspection.
- Add a cold-home variant of the existing real PostgreSQL
active-checkpoint regression. Its live source owner has a different boot
ID from the inspecting process.
- Run the same refusal controls for missing checkpoint parts, invalid
identity, stale leases, changed task/workspace bindings and invalid
local markers.

## Verification

- Genuine RED: the cold-home case failed on unchanged production code
with `expected true to be false` after the real checkpoint was saved and
only its local marker was removed.
- Rebased GREEN, Node 24: `node node_modules/vitest/vitest.mjs run
--project @paperclipai/server
server/src/services/native-runtime/native-controller-migration.integration.test.ts
-t 'offline native controller migration
\(active_(durable|durable_cold|warm)\)'` — 3 passed, 53 unrelated cases
filtered out. These controls use disposable real PostgreSQL and the
canonical encrypted checkpoint writer/reader.
- Server layered TypeScript check: `cd server && node
scripts/typecheck-layers.mjs` — PASS for all source and test layers.
- `node scripts/check-module-boundaries.mjs` and `git diff --check` —
PASS.
- [Required
CI](https://github.com/paperclipai/paperclip/actions/runs/43003635878)
passed on `7c5cb8fc16f3f7e3bcfec0ce10bf0b43d638d09b`. All 60 check runs
are terminal: 55 success, 4 skipped and one neutral 5/5 review. Greptile
is 5/5 with no review threads. Live rolling-update acceptance remains a
separate delivery check.

## Risks

- No schema or saved-state format changes. State saved by the previous
release uses the same checkpoint reader.
- The absent-marker path still needs a complete valid checkpoint and the
existing identity, workspace and owner proof. Present legacy or
malformed markers still refuse.
- This changes eligibility only. It does not take ownership, launch
work, copy state, release a lease or alter admission.
- No command or configuration changes need operator documentation.

## Model Used

OpenAI Codex, GPT-6, with reasoning, tool use and code execution. The
host does not expose a more specific model ID or context-window size.

## Checklist

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

Co-authored-by: Paperclip <noreply@paperclip.ing>
canary/v2026.1111.0-canary.8
2026-11-11 04:42:05 -06:00
DottaandClaude c44a422607 fix(native): retry a follow-up whose own server stopped serving before its remote runner (#20786)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

Refs #20514, #20721

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 04:22:49 -06:00
DottaandClaude 59ded526be fix(native): ask once to re-check Paperclip before resting blocked on its own stall (#20779)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs report their result through a completion report. The
server checks the report before it accepts it.
> - When the Paperclip server pauses or restarts, the agent's Paperclip
tool calls stay unanswered. They return later, all together.
> - The agent can read that silence as an outage. It then reports a
blocker owned by a "Paperclip ... service" with reasonCode
`external_service_unavailable`.
> - The server accepts that report. The task rests blocked with no
Retry. Paperclip's own stall stops the task. Our rule says that must not
happen.
> - This pull request rejects that blocker report one time per run. The
correction tells the agent that Paperclip is reachable now and to
re-check.
> - The benefit is that the agent continues in the same turn and reports
the real outcome.

## Linked Issues or Issue Description

**What happened?**
In a restart-chaos test, the server was paused (SIGSTOP) for about 2 to
5 minutes while a Codex agent saved a task document. `write_document`
and `read_document` did not return during the pause. After the pause,
the save had succeeded, but the agent reported a blocker: owner
"Paperclip document service", reasonCode `external_service_unavailable`.
The run succeeded with disposition `blocked`. The task rested blocked
with `canRetry=false` and no automatic recovery. A second test (two long
server holds, then a same-run resume) gave the same result.

**Expected behavior**
A Paperclip stall or restart must not leave a task stuck. The agent must
re-check Paperclip after it is reachable and finish the task.

**Steps to reproduce**
1. Start a native Codex task that saves a task document.
2. Pause the server process with SIGSTOP for about 2 minutes during the
save, then resume it.
3. The agent reports a blocker owned by a Paperclip service. The task
rests blocked with no Retry.

**Paperclip version or commit**
master at 62b7733161 (test base) and eb75c9bb85.

## What Changed

- `nativeCompletionFeedback` now rejects a `blocked` report one time per
run when all of these are true: the blocker owner kind is `system`, the
owner name contains "Paperclip", the reasonCode is
`external_service_unavailable`, the run is still running, and it owns
the task.
- The correction tells the agent: Paperclip answered this report, so it
is reachable now. Re-read the current state before writing again. Repeat
a needed write with the same `idempotencyKey`. Report blocked only if a
fresh Paperclip call fails now.
- The second identical report in the same run is accepted as before, so
a persistent real failure still rests blocked.
- Added a regression test. It also covers concatenated owner names such
as "PaperclipAI document service" and "paperclip_runner" (the match is a
case-insensitive substring).

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-completion-feedback.test.ts` passes
(32 tests).
- Red on master: the new test fails with `promise resolved "'Blocker
report accepted. ...'" instead of rejecting`. Green with this change.
- `cd server && npx tsc --noEmit -p tsconfig.json` passes.

## Risks

- Low risk. The change only adds one correction message inside the same
turn. It does not change a status, a database row, or a schedule.
- The once-per-run limit is kept in process memory. After a server
restart, a run can get the correction one more time. This is bounded and
harmless.
- The match uses the agent's own owner name. That only selects the
correction. It does not prove anything or replay a side effect. The
agent's next Paperclip calls give the real proof. Writes use idempotency
keys, so a repeated write does not apply twice.
- This does not add a Retry to a task that already rests blocked from an
older report. That is a separate product decision.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
canary/v2026.1111.0-canary.7
2026-11-11 04:22:09 -06:00
DottaandClaude eb75c9bb85 fix(native): resend a throttled harness backup upload instead of losing it (#20772)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - After a native run in a per-turn remote sandbox, the server saves a
durable harness backup to object storage. The next run restores the
provider session from it.
> - On staging, two OpenCode runs had an accepted result, but
`harness_state.backup.persist` failed. The run log for both says `remote
checkpoint failed: Please reduce your request rate.` This is the S3
`SlowDown` (HTTP 503) answer.
> - The backup and the OpenCode provider-artifact bundle were uploaded
as one stream `PUT`. The S3 SDK cannot send a stream body again, and
this path had no retry for a request-rate limit. One `SlowDown` lost the
backup.
> - The sandbox was then released. The follow-up run had no state to
resume and failed with `PRP provider resume state is unavailable` (class
P, see #20757).
> - This pull request uploads both objects from their encrypted scratch
file, as workspace snapshots already do. The existing throttled-retry
path then sends them again.
> - The benefit is that a short request-rate limit no longer loses the
only copy of a provider session.

## Linked Issues or Issue Description

Refs #20757 (the failure this causes on the next run). Refs #19917
(added the file-source upload path that this change uses).

**What happened?**

1. An OpenCode run in a per-turn Daytona sandbox finished, and its
result was accepted.
2. The server uploaded the encrypted harness backup with
`storage.putFile` and a stream body.
3. Object storage answered `503 SlowDown` ("Please reduce your request
rate."). Nothing sent the request again.
4. `harness_state.backup.persist` failed, and
`native.post_result_failure` was recorded.
5. The next run for the same chat task started in a new sandbox and
could not resume the provider session.

**Expected behavior**

A request-rate limit slows the upload down. It does not lose the backup.

**Steps to reproduce**

1. Point the storage service at an S3 endpoint that answers the first
`PUT` of each object with `503 SlowDown`.
2. Call `persistNativeHarnessBackup`.
3. On `master` it throws `SlowDown: Please reduce your request rate.`

**Paperclip version or commit**

`master` at `9574065a28`.

## What Changed

- `server/src/services/native-runtime/native-harness-backup-storage.ts`:
pass the encrypted scratch file as `sourceFile` to `putFile`.
- `server/src/services/native-runtime/native-provider-artifacts.ts`: the
same for the provider-artifact bundle.
- `native-harness-backup-throttle.integration.test.ts` (new, focused
file): a real S3 client against a local endpoint that throttles the
first `PUT` of each object once. Cases for a Codex backup, and for an
OpenCode backup with provider artifacts. Each case also throttles the
first read-back of each object once. Each case checks that the backup is
current and restores the provider session bytes.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-harness-backup-throttle.integration.test.ts`:
both cases fail on `master` with `SlowDown: Please reduce your request
rate.` and pass with this change.
- `cd server && npx vitest run
src/services/native-runtime/native-harness-backup-storage.integration.test.ts`:
68 pass (file unchanged).
- `cd server && npx tsc --noEmit -p .`: clean.
- `pnpm verify`: it selected the full server suite and failed 16 of
34,997 tests, all local-environment failures unrelated to object
storage: missing `.claude/skills/*/SKILL.md` files in this worktree,
`ENAMETOOLONG` under the macOS temp directory, and 30 s hook timeouts
under load. Required CI is the authority for that suite.

## Risks

- Low. A file source also sends a `Content-MD5` header, so the backend
rejects any byte it did not receive intact. Workspace snapshots have
used this path since #19917.
- An upload above 16 MiB now uses the file multipart path. That path has
the same verified-part retry.
- The read-back check (`getObject`) needs no change: a GET has no body,
so the S3 client resends it after `SlowDown`. The test now also
throttles the first read of each object once and proves the backup still
publishes.
- The Rust Runner is unchanged.

> 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 edit, test execution) and read-only staging API
reads.

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 02:56:52 -06:00
Nicky Leach 4d141cdf64 Carry plugin task refusals and batch project reads (#20762) 2026-11-11 00:49:03 -08:00
DottaandClaude Opus 5.5 f125b823f4 fix(recovery): release a parent's Retry once a done child's recovered files are saved (#20728)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A parent task waits for its done child tasks to save their workspace
files before it runs again (the workspace-finalize barrier)
> - "Recover saved files" saves the retained work of a done child and
records a successful `workspace_finalize`
> - That finalize row had no `execution_workspace_id`. The barrier reads
finalize rows by the blocker's workspace, so it never saw the row
> - The parent's saved Retry then waited forever with "Saving its files"
on a child that had already saved its files
> - This pull request lets the barrier accept the recovery's own
finalize for the recovered task's finished operations of the same run.
It does not make the recovery finalize a workspace-wide save
> - The benefit is that recoveries work by themselves: the parent's
dependency wait releases and its saved Retry starts on the next
dependency pass, with no migration

## Linked Issues or Issue Description

**What happened?**
A parent task had three done children. One legacy child shared the
parent's execution workspace. The operator pressed "Recover saved files"
on that child. The recovery saved the files and marked the old run
succeeded. The operator then pressed Retry on the parent. The server
accepted the Retry, but the parent stayed on "Retry is waiting for a
finished task this one depends on to save its files" for more than 15
minutes. `blockerAttention.pendingFinalizeBlockerIssueIds` named the
recovered child. The child had no active recovery action and no
execution blocker. The workspace was clean (`dirty: false`,
`writerRunId: null`).

The child's workspace operations showed the cause. The latest operation
on the workspace was a succeeded `workspace_recovery`. The recovery's
succeeded `workspace_finalize` existed, but with `execution_workspace_id
= NULL`. Earlier finalize attempts of that run had failed.
`pendingFinalizeBlockerCondition` only looks at operations on the
blocker's workspace, so it found no successful finalize after the
recovery.

**Expected behavior**
When "Recover saved files" saves a done child's work, the child must
stop holding its dependents. The parent's saved Retry must start without
another operator action.

**Steps to reproduce**
1. Let a child task finish as `done` with a retained remote workspace
whose finalize failed.
2. Run "Recover saved files" on the child, so recovery saves the remote
workspace snapshot.
3. Block a parent task on the child. The parent can share the child's
workspace.
4. Read the parent's dependency readiness. Before this fix it reports
the child in `pendingFinalizeBlockerIssueIds` forever.

**Paperclip version or commit**
Seen on staging at `a46b9ac7`. Reproduced on `master` at `e039be9202`.

## What Changed

- `server/src/services/issues.ts` (`pendingFinalizeBlockerCondition`):
the blocker task's latest workspace operation is settled when a
succeeded recovery finalize (`owningService: workspace_recovery`) from
the same company, task, and run started at or after that operation
finished. The operation must be finished (`succeeded`, `failed`, or
`skipped`). The recovery finalize can have no workspace (all rows
written so far) or this workspace.
- The rule is scoped to the blocker task and its run. It does not change
the workspace-wide `finalized_at`, so other tasks that share the
workspace keep their own barrier.
- `server/src/services/workspace-recovery-dependency-finalize.test.ts`:
new regression tests that run the real remote "Recover saved files"
path, make the child `done`, and block a parent that shares the child's
workspace.
- `doc/execution-semantics.md`: documents how the recovery finalize
settles the dependency barrier.

## Verification

- Red (unfixed `issues.ts`): `npx vitest run
src/services/workspace-recovery-dependency-finalize.test.ts` fails 3 of
4 tests. The parent stays not ready with the recovered child in
`pendingFinalizeBlockerIssueIds`.
- Green (fixed): the same file passes 4 of 4 tests:
- a real recovery releases the parent in `getDependencyReadiness` and in
the issue list `blockerAttention`;
- snapshot-fallback shape: the preservation receipt has no workspace and
the child's latest operation is the run's failed finalize. The parent is
released;
- safety: a sibling task that started work on the shared workspace while
the recovery waited for teardown still holds the parent;
- safety: child work that starts after the recovery still holds the
parent.
- Focused suites pass: `src/__tests__/issues-service.test.ts`,
`src/services/workspace-recovery-admission.test.ts`,
`src/services/task-retry.test.ts`,
`src/services/saved-bootstrap-continuation.test.ts`,
`src/services/remote-workspace-recovery-receipt-fallback.test.ts`,
`src/__tests__/issue-dependency-wakeups-routes.test.ts` (414 tests with
the new file).
- `pnpm verify` exits 2 (a doc change is a global CI input), so required
CI supplies broad coverage. Server `tsc --noEmit` passes.

## Risks

- Previous-release story: no persisted shape changes. Recovery finalize
rows already saved (with no workspace) are read by the new rule.
Affected parents become dependency-ready on the next read, with no
migration or backfill. The deferred-receipt and dependency backstop
passes then start the saved Retry.
- The barrier keeps its protection. Unfinished operations, operations
from another run, operations that start after the recovery finalize, and
other tasks on a shared workspace still wait for their own save.
- No schema change and no write-path change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, code search, test execution, read-only
staging API inspection).

## 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>
canary/v2026.1111.0-canary.6
2026-11-11 01:42:29 -06:00
DottaandClaude 1ac8b6a711 fix(workspaces): stop re-saving a retained sandbox whose disk is still full (#20758)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a native run on a remote sandbox, Paperclip saves the sandbox
workspace back to durable storage. When that save fails, saved-work
recovery saves the files again from the retained sandbox, with no
provider turn.
> - A staging census of the last 48 hours
(`doc/plans/2026-11-11-staging-failures-vs-chaos-coverage.md`, class F)
found saves that failed because the sandbox disk was full.
> - #20678 already ends finalization at the first full-disk copy-back
and hands the work to saved-work recovery. Recovery then deletes
inactive tool caches that it can prove safe, and saves again.
> - When that save still found the disk full, recovery recorded a
transient `export_interrupted` failure with cause `provider_error`. It
then saved again on its automatic schedule (up to 5 retries in 30
minutes). Nothing frees space in a stopped, retained sandbox between
those attempts, so each attempt failed the same way. The task showed
"Recovering saved work…" and no Retry while this went on.
> - This pull request ends the recovery generation at the first such
failure with a named cause, `sandbox_disk_full`, and hands it to the
board with a Retry.
> - The benefit is that the task does not spend 30 minutes on saves that
cannot succeed, the user sees why, and one Retry saves the files and
starts one run.

## Linked Issues or Issue Description

Refs #20678, #20695 (named sandbox disk-full failures for copy-back and
restore)

**What happened?**
A native run on a remote sandbox finished, and its workspace copy-back
hit a full sandbox disk. Finalization handed the task to saved-work
recovery (#20678). Recovery's own save also hit the full disk. Recovery
classified this as `export_interrupted` / `retry_transient` with cause
`provider_error`, so it scheduled more automatic saves of the same
sandbox. Each one failed the same way. The task showed "Recovering saved
work…" with no Retry until the 30-minute window ran out.

**Expected behavior**
A save that still finds the disk full after the safe cache cleanup is
not repeated automatically. The recovery ends at once with the cause
`sandbox_disk_full`, and the board gets a Retry. The workspace keeps its
previous saved version, and the run's unsaved files stay in the retained
sandbox. One Retry saves the files once there is room and starts exactly
one run.

**Steps to reproduce**
1. Give a native run an accepted result whose copy-back failed
(`native_workspace_sync_out_retry_exhausted`).
2. Make every save from its retained sandbox fail with
`remote_workspace_sandbox_disk_full`, with no inactive tool cache to
delete.
3. Run saved-work recovery. Before this change, it records
`export_interrupted` with cause `provider_error` and schedules another
save.

## What Changed

- `workspace-recovery.ts`: a save that fails with
`remote_workspace_sandbox_disk_full` after a confirmed cache cleanup (or
with nothing to clean up) now fails the generation with
`export_interrupted`, retry class `user_action_required`, and cause `{
label: "sandbox_disk_full", code: "ENOSPC" }`. That class is not retried
automatically, and it is not a permanent-source class, so recovery never
falls back to the last save and never drops the run's unsaved files. The
next sweep hands the hold to the board.
- A cleanup whose outcome is unknown (lost acknowledgement, interrupted
journal) keeps the existing scheduled retry, because its journal resumes
on the next attempt and can still free space.
- `retained-provider-cache.ts`: `reclaimInactiveNativeProviderCache`
returns `{ changed, confirmed }` instead of a boolean, so the caller can
tell "nothing to delete" from "outcome unknown". It has one caller.
- `system-recovery-state.ts`: the board handoff text for this cause says
that the sandbox disk is full, that Paperclip deletes only inactive tool
caches it can prove safe, and that Retry saves the files again.
- New `workspace-recovery-disk-full.test.ts` covers the whole path: one
failed attempt, no further automatic saves, the board handoff text, the
previous saved version unchanged, the agent's files still in the
sandbox, no unfinished in-sandbox capture attempt, a Retry offered in
the task presentation and blocker, `POST /issues/:id/retry` accepted
(202), the files saved after Retry, and exactly one run started (also
with a replayed dispatch).
- `doc/execution-semantics.md`: documents the behavior.

## Verification

- Red on master: `cd server && npx vitest run
src/services/workspace-recovery-disk-full.test.ts` fails. The recovery
state is `scheduled` with cause `provider_error` instead of `exhausted`
with cause `sandbox_disk_full`.
- Green on this branch: the same command passes.
- Related suites pass: `workspace-recovery-retry-copyback.test.ts` (79
tests, including the 8 cache-cleanup resume tests that need the
scheduled retry after an unconfirmed cleanup),
`system-recovery-state.test.ts`,
`workspace-recovery-capture-policy.test.ts`,
`workspace-recovery-fallback.test.ts`,
`workspace-recovery-stop-proof.test.ts`,
`native-run-finalizer-telemetry.test.ts` (167 tests in all).
- `npx tsc --noEmit -p server/tsconfig.json` is clean.
- `pnpm verify`: exit 2 (broader verification required), because
`doc/execution-semantics.md` is a global CI input. The focused checks
above pass. Required CI supplies the broad coverage.

## Risks

- Low. The change touches only the saved-work recovery path when its
save fails with the exact full-disk message.
- Upgrade-safe: the failure code (`export_interrupted`), the retry class
(`user_action_required`) and the cause label all pass the earlier
release's recovery schema. An earlier release that reads this state
shows its generic "Retry to try again" text.
- A full-disk failure no longer gets automatic retries in its 30-minute
window. If space became free by itself in that window, the user must now
press Retry. Nothing in a stopped, retained sandbox frees space by
itself, so this is expected to be rare.
- No Runner (Rust) change, so no new sandbox image is needed.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file editing, 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 01:31:50 -06:00
DottaandClaude ea25af759e fix(native): name a resume that finds no saved Runner state, so Retry starts a fresh session (#20757)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native run can continue the provider session of the previous run
on the same task. In a remote sandbox, the Runner keeps the session
state inside the sandbox, and the server saves a harness backup after
each run.
> - On staging, five runs in 48 hours failed with
`native_session_recovery_failed: Error: PRP provider resume state is
unavailable` (class P in
`doc/plans/2026-11-11-staging-failures-vs-chaos-coverage.md`).
> - In each case we read, the previous run had an accepted result, but
its `harness_state.backup.persist` failed. Its per-turn sandbox was then
destroyed. The next run started in a new sandbox with no Runner state
for the session.
> - The Runner transport threw an untyped error. The runtime could not
tell it apart from a transport drop. A governed-wait or same-session
checkpoint ended as a generic `native_session_interrupted` with an
"ambiguous state" hold that said not to open a replacement session.
> - This pull request types that refusal and lets the driver classify it
as `provider_state_incompatible` when nothing is in flight. The existing
recovery policy then decides.
> - The benefit is that the run ends with the permanent named code
`native_provider_state_incompatible`. Retry, or the next message,
continues in a fresh provider session in the same workspace. No provider
turn or tool call can be repeated, because the follow-up never started a
turn.

## Linked Issues or Issue Description

Refs #20712, #20723 (the same classification for a moved remote
workspace root). Refs #20691 (fixes the `provider_identity_incomplete`
part of class P).

**What happened?**

1. Run A on a chat task finished. Its result was accepted. Its harness
backup failed to save (`harness_state.backup.persist` failed,
`native.post_result_failure`).
2. Run B started for the next chat message in a new sandbox. It rebound
run A's checkpoint with `providerRecoveryPolicy:
allow_replacement_after_governed_wait`.
3. The Runner found no control-plane state and no archived authority
epoch. It threw `PRP provider resume state is unavailable` as a plain
`Error`.
4. The runtime refused a replacement for the governed-wait policy and
failed run B with the generic `native_session_recovery_failed`. The task
stayed blocked with an "ambiguous state" hold.

**Expected behavior**

The run ends with a permanent named code and a working Retry, as it does
for other deterministic refusals.

**Steps to reproduce**

1. Create a Runner transport that resumes a saved provider session from
an empty state directory.
2. Request `thread/read`. On `master` it rejects with a plain `Error`.
3. Recover a saved Codex, OpenCode or ACPX session through the driver
when the transport fails that way. On `master` the result has no
`failureClass`.

**Paperclip version or commit**

`master` at `9574065a28`.

## What Changed

- `packages/paperclip-runner/src/contracts/harness-driver.ts`: add
`NativeRunnerResumeStateUnavailableError` (same message as before) and
`isRunnerResumeStateUnavailable`.
- `packages/paperclip-runner/src/live/runnerd-codex-transport.ts`: throw
the typed error at the three branches where the control-plane state is
absent. The branch with no PRP identity and the quarantine branch are
unchanged.
-
`packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts`:
classify the typed error as `provider_state_incompatible` when the saved
session has no turn or runtime request in flight. Extract the in-flight
test that the moved-root check already used, without changing it.
- `doc/execution-semantics.md`: describe the new classification
(additive).
- Tests: a transport test (local and external Runner state), driver
tests for Codex, OpenCode and ACPX Claude, in-flight cases that stay
unclassified, and a server case that shows the blocked task offers Retry
and that one click starts exactly one fresh-session run.

## Verification

- `cd packages/paperclip-runner && npx vitest run
src/backends/native-backend-factory.test.ts -t "no saved state"`: 5
pass. With the driver change reverted, the 3 "reports … as provider
state incompatible" cases fail (no `failureClass`).
- `cd packages/paperclip-runner && npx vitest run
src/live/runnerd-codex-transport-cold-restore.test.ts`: 5 pass. On
`master` the error is a plain `Error`.
- `cd packages/paperclip-runner && npx vitest run
src/backends/native-backend-factory.test.ts
src/drivers/codex/codex-app-server-driver.recovery.test.ts
src/live/runnerd-codex-transport-cold-restore.test.ts
src/native-session-runtime.test.ts`: 410 pass.
- `cd server && npx vitest run
src/services/workspace-recovery-retry-copyback.test.ts -t "does not
continue automatically"`: 5 pass, including the new case. It checks
`canRetry: true`, an accepted Retry, exactly one queued successor, and
`forceFreshSession: true`.
- `cd packages/paperclip-runner && npx tsc --noEmit -p .`: clean.
- `pnpm verify`: exit 2 (broader verification required) because
`doc/execution-semantics.md` is a global CI input. The focused checks
above pass. Required CI supplies the broad coverage.

## Risks

- Low. The change only adds a class to a failure that already failed. A
checkpoint with `allow_replacement_after_resume_failure` already
replaced the session for this failure, and still does.
- For `same_session_only` and `allow_replacement_after_governed_wait`,
the run now ends with `native_provider_state_incompatible` instead of a
retryable `native_session_interrupted`. Same-run retries stop at once.
They could not succeed, because the state is absent.
- A session with a turn or runtime request in flight keeps the old
unclassified behavior. This PR does not change it.
- The server code path is unchanged. The Rust Runner is unchanged, so no
new runner image is needed.

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 01:30:27 -06:00
DottaandClaude 9574065a28 fix(workspaces): hand on a proven-unchanged preparation epoch instead of abandoning it (#20736)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run on a remote sandbox first restores its workspace's
saved version, publishes that root as its dirty epoch, and only then
starts work
> - A restart that kills the run in that window leaves the epoch dirty.
The ownership settle (`settlePreparationOnlyRemoteWorkspaceEpoch`)
always abandons it: the root is forgotten and the retry restores the
saved version into a new directory
> - The saved provider session still names the old directory, which is
what #20712 and #20723 now have to handle
> - In the common case the killed preparation only restored the saved
version and changed nothing. A durable proof for exactly that already
exists (`proveRemoteWorkspaceUnchanged`, used when the next admission
finds an ended writer)
> - This pull request makes the settle use that proof first, and keeps
abandoning whenever the proof fails
> - The benefit is a stable workspace root across a restart during
preparation, with no change for anything unproven

## Linked Issues or Issue Description

Refs #20712

**What happened?**
In restart-chaos waves, a server kill during a follow-up's preparation
(right after the workspace restore published) made the ownership settle
abandon the epoch. The retry restored into `<workspace>-<epoch>-<lease>`
instead of the published root, and the inherited session checkpoint
named a root that no longer existed.

**Expected behavior**
When durable records prove the killed preparation only restored the
saved version, the workspace is handed on at that saved version and
keeps its root. Anything unproven is abandoned exactly as before.

**Steps to reproduce**
1. Run a remote native task to a saved version, then start a follow-up
or continuation run.
2. Kill the server after the follow-up's restore publishes (crash point
`remote_workspace.prepare.after_publish_before_provision`).
3. Let ownership reconciliation settle the dirty epoch, then let the
retry prepare.

## What Changed

- `recovery/remote-workspace-unsaved-files-settlement.ts`: before it
abandons a preparation-only epoch, the settle calls the existing
`releaseUnchangedRemoteWorkspaceWriterById` (lock the workspace, check
the writer, then `releaseUnchangedRemoteWorkspaceWriter`). That releases
the writer at its saved version (dirty false, writer cleared, location
and root kept, baseline marked released, run reference set to `saved`)
only when `proveRemoteWorkspaceUnchanged` holds. The proof requires all
of these durable records:
- the unchanged-baseline marker for this exact epoch, run and saved
version. It is written in the same transaction that publishes the
restored root, so a kill mid-restore leaves no marker;
- no recorded process, process group, process start or provider session;
- no execution-start run event, no native result and no native runner
checkpoint (the controller writes one before every runner command);
- no workspace operation other than the file-neutral restore, finalize
and recovery receipts (provisioning and saves record theirs before they
touch files);
  - every workspace repository already in the saved version.
- If the proof fails for any reason, the settle abandons the epoch
exactly as before. The existing settle preconditions (run terminal,
still `preparing`, no finalization, no work-started event, no save in
flight) are unchanged and run first.
- `packages/shared/src/crash-point-registry.ts` and
`remote-workspaces.ts`: add the crash point
`remote_workspace.prepare.after_publish_before_provision`, so the chaos
harness can kill exactly at this boundary. It counts only a preparation
that published an unchanged baseline (a restore of a saved version), and
it is a no-op unless a test arms it.
`tests/runner-e2e/source-sdk-public-inputs.json` is refreshed because
the registry file is a pinned SDK input.
- Test `ownership-reconciliation-report.test.ts`: a proven-clean epoch
is handed on with its root kept, with no abandonment receipt, and the
writer hold is free for the retry. These cases are still abandoned
exactly as before: no baseline (an epoch saved by the previous release),
a baseline for another epoch, an already-released baseline, a
provisioning operation. A run with a recorded process is still neither
handed on nor abandoned by this settle.

Previous-release story: an epoch saved by a release that did not write
the baseline marker has no proof, so it is abandoned as before. No
persisted shape changes; the marker already exists on master.

## Verification

- Red: with the release branch disabled, the new test fails on the clean
case (the epoch is abandoned). Green with the change.
- `cd server && npx vitest run
src/__tests__/ownership-reconciliation-report.test.ts
src/services/remote-workspace-snapshot-reuse.test.ts
src/__tests__/crash-point-call-sites.test.ts` passes. `packages/shared`
`crash-points.test.ts` passes. `tsc --noEmit -p server` passes.
- Live Daytona waves (claude, workload S1, the new crash point at the
continuation's preparation (`:2` before the point was gated to saved
restores; `:1` now), image built from master `e039be9202`, one task
each):

| | before (release disabled) | after |
| --- | --- | --- |
| Killed epoch 2 | abandoned (`workspace.remote_epoch_abandoned`) |
handed on (`workspace.snapshot_skipped_unchanged`, epoch 2) |
| Retry root | new root `…-4-ecf1c81e…` | same published root
`…-1-33e93b90…` |
| Retry `task.prepare` | 4598 ms | 4731 ms |
| Retry restore / environment acquire | 1141 / 1381 ms | 1106 / 1478 ms
|
| Task result | done, no Retry | done, no Retry |
| Stopped or orphan sandbox at rest | none | none |

Preparation time does not change in this setup. The Chaos Lab
environment uses ephemeral leases, so every run gets a fresh sandbox and
a full restore with or without this change, and the killed run's sandbox
is destroyed either way. The gain is the stable root, which keeps the
saved session's working directory valid.

## Risks

- Low. The release path is the same one admission already uses for an
ended writer, under the same proof. The settle now tries it first.
- A proof false positive would keep a root whose files changed. The
proof refuses on every record that precedes a write, and on any missing
or older-release evidence.

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
canary/v2026.1111.0-canary.5
2026-11-11 00:33:18 -06:00
DottaandClaude 62b7733161 test(restart-chaos): long mid-tool K15 outages, Runner evidence, and TM1 stalls on Daytona (#20711)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The restart-chaos harness (`tests/restart-chaos/`) restarts or
stalls the control plane while real agent tasks run.
> - The staging census (#20679,
`doc/plans/2026-11-11-staging-failures-vs-chaos-coverage.md`) found two
open classes that the harness could not create. In class R, a deploy
lands mid-turn, the server stays down for 4–7 minutes, and recovery then
finds the remote Runner gone
(`runner_remote_process_identity_unavailable`). In class L, the server
stalls for about 100 s without a restart, and legacy runs that are still
`preparing` end up waiting for a manual Retry.
> - K15 restarted at once. No Daytona workload kept a tool running
across the fault. TM1 was local-only with a fixed range. Nothing
recorded what the remote Runner did while the controller was away.
> - This pull request adds a K15 outage hold, a mid-tool workload,
sandbox-side Runner evidence, and TM1 stalls on Daytona with a
configurable hold.
> - The benefit: these staging failure shapes can now be reproduced, and
each wave records the Runner's own state.

## Linked Issues or Issue Description

Refs #20679

**What happened?**
On staging, two failure shapes had no matching chaos scenario:
- Class R: a deploy landed mid-turn and the server stayed down for 4–7
minutes. Recovery then found the remote Runner gone. Nothing captured
why the Runner exited.
- Class L: a 100 s server stall without a restart lapsed every
controller lease. Legacy `preparing` runs then waited for a manual
Retry.

**What did you expect?**
The harness should create a long mid-turn deploy outage and a stall
without a restart on Daytona. It should also save the sandbox-side
Runner evidence.

**Steps to reproduce**
1. Use the census report and the staging run records listed in it.
2. Compare them with the harness options before this change: K15 has no
hold, TM1 is local-only, and there is no Daytona tool workload.

## What Changed

- `--k15-hold-seconds <n|min-max>` (0–600). A K15 stops the server with
SIGTERM, keeps it down for the seeded hold, then starts the next deploy
generation. The range is drawn only for K15 cells, so existing seeds
keep their plans. Storms use it too.
- New opt-in workload `T1`. The agent runs one shell loop in the
foreground for about 100 s, then saves `TICKS-<nonce>=50`. Its fault
window, 35–80 s after launch, lands mid-tool.
- New `daytona-runner-logs.ts`, a read-only probe of each wave sandbox
through the Daytona toolbox. It runs only on sandboxes with the wave's
labels, and only on `started` ones. It records every process, memory,
disk, `dmesg` OOM lines, and the tails of `runnerd.stderr.log`,
`runner-process.identity` and `runner-state.json`. It redacts known
secrets. It runs right after a K15 stop, just before the next deploy
starts, and at wave end. Output goes to `results/<wave>/daytona-runner/`
and to `runner_logs_captured` in the timeline. The probe never stops,
starts or deletes a sandbox, and a failed capture is recorded instead of
thrown.
- TM1 now runs on `--env daytona`, and `--tm1-hold-seconds <n|min-max>`
(10–600) replaces its default 95–150 s range with the same single draw.
TM2 and TM3 stay local.
- Tests: `k15-long-hold.test.ts` and `tm1-stall.test.ts`. The README is
updated.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`:
45 files and 531 tests pass.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`:
passes.
- Daytona waves, all with T1 and K15:

  | Wave | Revision and image | Hold | Result |
  | --- | --- | --- | --- |
| `w-20261111T015441-s9310` | master 8f6ca25976, image `bc7ca92d…` |
267–408 s, OpenCode | 4 of 4 pass |
| `w-20261111T022637-s9311` | master 8f6ca25976, image `bc7ca92d…` | 300
s, OpenCode and Claude | 4 of 4 pass. Every stop capture shows the T1
loop running. |
| `w-20261111T025307-s9320` | master a1ec05a1a4, image `09321542…` |
460–482 s, past the Runner's 7-minute reconnect grace | 4 of 4 pass |

- In every pre-restart capture, the same `runnerd` PID was alive for the
whole outage (etimes 491–549 s in the last wave). Every sandbox was
`started`. There was no `transport_reconnect_grace_exceeded` and no OOM.
- So the class R Runner loss does not reproduce on current master with
the e2e image. The census document lists the remaining differences from
staging.
- TM1 Daytona waves on master a1ec05a1a4 found a **new product gap**
(RED; the harness only reports it):
- `w-20261111T034105-s9330` (TM1 at `--fault-after-seconds 10`): 2 of 3
pass. The failing OpenCode run was stalled at
`native.process_start_requested`. Afterwards it waited forever with
`controller_still_alive` / `restartKind: hard`, where the controller it
waited for was its own live server.
- `w-20261111T040439-s9331` (TM1 mid-tool, T1): 0 of 3 pass. After the
stall the run had no further events and stayed `running` for more than
20 minutes, with no Retry.
- Review fixes in 3540bfe700: matrix cells take both holds, T1's
`fault_crossed_tool` check, and one 45 s deadline per Runner capture.
The suite is now 45 files and 531 tests.

## Risks

- Low risk. This change is test harness and docs only, with no product
code.
- The Runner probe executes one fixed read-only command in this wave's
own sandboxes through the Daytona toolbox. It never targets fleet
sandboxes, because it filters on the wave's company and environment
labels.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edit, GitHub CLI).

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 00:29:06 -06:00
DottaandClaude f25fee3532 fix(native): hash runner artifacts and run archive tar off the event loop (#20722)
## Thinking Path

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

## Linked Issues or Issue Description

Refs #20694, #20709

**What happened?**

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

**Expected behavior**

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

**Steps to reproduce**

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

## What Changed

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

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

Refs #20712

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

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

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

## What Changed

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

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

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

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

Refs #20675, #20720

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 00:12:41 -06:00
DottaandClaude Opus 5.5 1143abaf92 fix(recovery): give due exhausted holds their own fair lane in the recovery worker (#20725)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The workspace recovery worker saves the files of failed runs. It
also re-arms exhausted holds under the rules of #20295, #20569, #20641
and #20648
> - After #20680 and #20661 were deployed to staging, the worker still
re-armed none of 29 exhausted holds in 36 minutes. One hold's id sorts
in the first percent of all candidates, so the restart starvation that
#20680 fixed was not the only cause
> - The worker skipped every obligation while any unfinished save was
selectable. Discovery is instance-wide. A save that another server owns
stays unfinished on every run, so obligations never ran. The obligation
pass also put due holds behind hundreds of settled holds whose saved
input waits, at two per run. A completed pass never revisited a hold
that became due by time alone
> - This pull request gives due exhausted holds their own lane, and it
shares the worker's slots fairly between unfinished saves, due holds and
the obligation pass
> - The benefit is that every due hold is reached within a few runs of
the worker, whatever its other load. The holds save their files and free
their tasks without a person

## Linked Issues or Issue Description

Refs #20680
Refs #20661
Refs #20648
Refs #20641
Refs #20569
Refs #20295

**What happened?**
On staging, 36 minutes after a deploy that included #20680 and #20661,
the workspace recovery worker had touched none of the 29 exhausted holds
of the company (22 capture-policy holds and 7 stop-proof or fallback
holds). They had no `stopProofRecovery` or `capturePolicyRecovery`
marker, no re-arm activity, and `updatedAt` from Nov 2 to Nov 10. The
worker did run manual "Recover saved files" requests: one stop-proof
hold saved its files 57 seconds after the request. So the worker was
alive and the stop proof was available. The holds were not reached.

**What did you expect to happen?**
The worker reaches each exhausted hold whose re-arm or fallback is due,
re-arms it, and saves the files, within a bounded time.

**Steps to reproduce**
1. Create a scheduled save whose run's finalization lock another
database session holds (another server owns the run).
2. Create an exhausted stop-proof hold and an exhausted capture-policy
hold, in the evidence shape that the Nov 4 and Nov 10 releases saved.
3. Start the real workspace recovery worker.
4. The worker retries the busy save on every run and never re-arms
either hold.

## What Changed

- `server/src/services/workspace-recovery.ts`:
`reconcileRetainedWorkspaces` draws candidates from lanes. Each lane is
a page that its own cursor advances: unfinished saves; for the worker,
exhausted holds whose re-arm or fallback is due; and the obligation
pass. Before, the obligation pass ran only when no unfinished save was
selectable (the gate at `const unfinished = rows.length > 0; if
(!rows.length) { ... }`).
- Each lane with work takes one free slot before any lane takes a
second. The lane that goes first rotates on each run, so with one free
slot (a long local export holds the other) each lane still gets it in
one run of three. Actions of a run that one lane took join that run's
job without another slot.
- The due lane wraps at its tail. A hold that becomes due by time alone
(a lease expiry or a re-arm backoff) is reached after the obligation
pass completed with no committed change.
- `retainedWorkspaceCandidates` takes a `dueOnly` filter (due re-arm or
fallback of an exhausted hold). The obligation filter uses the same
condition. One `now` is used for the whole query.
- The startup sweep (no worker sweep state) also shares its slots
between unfinished saves and its general pass.
- `doc/execution-semantics.md`: document the lanes and the fairness
rule.
- New `workspace-recovery-passive-fairness.test.ts` with four
real-Postgres regressions.

## Verification

- Red on `origin/master` (156212cfd5), green with this change:
- "re-arms both staging holds while a save that another server owns
stays unfinished": the real worker, a busy unfinished save, and the two
staging hold shapes. On master neither hold is re-armed in 20 s.
- "reaches due holds within two runs however many settled holds with
waiting input sort before them": ten settled holds with waiting user
input sort before the two holds. On master the capture-policy hold is
not re-armed after two runs.
- "re-arms a hold that becomes due after its pass completed, with no
committed change": on master the hold is never re-armed.
- "re-arms the holds with one free slot while a long local job holds the
other and a busy save stays unfinished" (from the Greptile review): red
on this PR's first revision (05b261e5fe), green with the lane rotation.
- `npx vitest run` of the twelve suites that use discovery or the worker
(`heartbeat-observed-workspace-preservation`,
`server-startup-feedback-export`, `execution-control-work`,
`workspace-recovery-preparation-dispatch`, `-retry-copyback`,
`-fallback`, `-warm-handoff`, `-preparation-gates`, `-capture-policy`,
`-preparation`, `-stop-proof`, `-admission`) plus the new suite: 646
passed.
- `npx tsc --noEmit -p server/tsconfig.json`: clean.
- `pnpm verify` exits 2 because `doc/execution-semantics.md` is a global
CI input. Required CI supplies the broad coverage.
- Staging (read-only GET requests): the 29 exhausted holds, their saved
shapes, and the manual Retry timeline. Staging exposes only one company
to this account and no worker logs, so the exact unfinished row that
starved the pass on staging is not named. The fix removes the whole
class: no lane can starve another.

## Risks

- Previous-release story: no persisted state changes. Lane positions
stay in memory, as the existing cursors did. Holds saved by earlier
releases (no marker, no capture policy version) are due at once and are
reached in the first runs after deploy, with no migration. A deploy that
lands mid-run only resets the in-memory cursors.
- Behavior shift: obligations now share the two slots with unfinished
saves. With two free slots, a new save or a manual Retry gets one in two
runs of three. With one free slot, it gets it in one run of three.
- A due hold that its re-arm refuses again is revisited once per
due-lane pass. The worker already ran every interval while such a hold
was due, so this adds no new wakeups.
- One more bounded query per worker run (the due lane, 5 rows).

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use (shell,
code editing, browser GET reads) in Claude Code.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:59:01 -06:00
DottaandClaude 16024ffa2a fix(native): a same-boot stall never leaves a run waiting on itself (#20721)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs keep an execution lease. Restart recovery takes over a
run only when it can prove that the recorded controller stopped.
> - A stall (SIGSTOP) longer than the lease, with no restart, makes the
executor lose its own lease. The executor then detaches for "the next
controller" and exits.
> - The next controller is this same process. Recovery asks the PID
probe if the recorded controller is alive. The recorded controller is
this process, so the answer is always "alive".
> - The task then waits forever: `in_progress`, no Retry, an active run,
and an active environment lease.
> - A second gap: after back-to-back stalls mid-tool, the executor
returns and nothing in this process ever looks at the run again. No
terminal state, no recovery and no Retry.
> - This pull request lets recovery recognise its own process. When this
process no longer drives the run and its own lease lapsed, the prior
owner is gone. The existing evidence checks then decide, as for any
stopped controller.
> - It also makes heartbeat schedule that recovery as soon as such an
executor exits.
> - The benefit is that a same-boot stall never needs a manual Retry.
When provider work may have started, the run fails with a working Retry
instead of a wait with no end.

## Linked Issues or Issue Description

Refs #20694 (the same class of fix for legacy `preparing` runs), #20529,
#20691.

**What happened?**
Chaos wave `w-20261111T034105-s9330`, task t02 (Daytona, OpenCode, fault
TM1). The server process was frozen with SIGSTOP for about 84 s while
run `89a3922b` prepared. After SIGCONT, `native.process_start_requested`
was recorded and `native.session.execute` failed after 118 ms: the lease
renewal found the lease lapsed, and the executor detached and exited.
From then on, recovery recorded `native.recovery.transition` "waiting
for safe ownership evidence" with reason `controller_still_alive` every
30 s for more than 20 minutes. The run stayed `running`, the issue
stayed `in_progress`, no Retry was shown, and the environment lease
stayed `active`.

Chaos wave `w-20261111T040439-s9331` (Daytona, codex/claude/opencode,
T1, TM1). Three stalls of about 90 s came back to back, with less than 1
s between them. Each run was in a tool call. After the second thaw,
`native.session.execute` failed for all three runs (lease lost, executor
detached). After that, no run had any event: no terminal state, no
`native.recovery.transition` and no Retry, for 20 minutes. The
in-sandbox turns kept going. A single 90 s stall (wave s9340) stays
green, because a later recovery signal happens to find the run. With
stacked stalls, that signal can land while the executor is still tearing
down, so it skips the run and sets no deadline.

**Expected behavior**
When this process no longer drives a run that it recorded as its own,
recovery takes the run back. No turn reached the runner here, so the
bootstrap restarts on the same run. When a turn or runner work may have
started, the run fails and shows a working Retry.

**Steps to reproduce**
1. Start a native remote (Daytona) run.
2. Send SIGSTOP to the server during preparation, and hold it longer
than the execution lease.
3. Send SIGCONT. The executor loses its lease, detaches and exits.
4. See that every recovery scan waits with `controller_still_alive`, and
the run never settles.

The new tests in `native-runner-restart-recovery.integration.test.ts`
("same-boot controller whose own lease lapsed") reproduce this at the
recovery layer.

## What Changed

- `claimNativeRestartRecoveries`: when the recorded controller is this
exact process (same boot ID and PID):
- If this process still drives the run (heartbeat execution or native
executor registered), recovery leaves the run to it
(`current_controller_preparing`).
- If neither drives it and the self lease lapsed, the takeover is
allowed (`current_controller_released`). The existing evidence checks
then choose the outcome: bootstrap again on the same run when a durable
record proves no command reached the runner, or fail with a Retry
(`blocked`) when runner or provider work may have started.
- An unexpired self lease still waits. A claim that has not reached its
executor holds it, and it lapses by itself.
- A run that this process handed to the next server (graceful drain
`restart_suspended` record, or the in-process hot-restart handoff
marker) stays with the next server.
- `heartbeat.ts`: when the executor ends with
`NativeControllerDetachedForRestartError`, there was no shutdown or
hot-restart handoff, and this process's own lease lapsed
(`ownNativeExecutionLeaseLapsed`), heartbeat schedules restart recovery
for the run when the executor exits. Recovery then reattaches the remote
runner whose turn is still running, restarts an unstarted bootstrap, or
fails the run with a working Retry.
- `claimNativeRestartRecoveries`: a self-owned lease that is still live,
with no driver here, now returns a `retryAt` at its expiry, so the wait
is bounded.
- `heartbeat/shutdown-handoff.ts`: add `isRunHandedOffForShutdown`.
Heartbeat recovery passes it to the claim.
- `suspendedByCleanDrain` now uses the shared
`handedToNextControllerSinceClaim` helper. Behavior is the same.
- Heartbeat test: an executor detaches after its own lease lapsed, with
no restart. The same process must take the run back. Red before (timed
out, the run stayed `running`), green after.
- Tests: five cases for the same-boot lapsed lease: bootstrap again with
no runner command; fail with a working Retry when a runner identity was
recorded; leave the run to a live in-process executor; leave a
handed-off run to the next server; still wait for a different live
controller.

## Verification

- Red before the fix: the three same-boot cases returned
`awaiting_evidence` / `controller_still_alive`.
- Green after the fix:
- `pnpm --dir server exec vitest run
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
src/services/native-runtime/native-restart-recovery.test.ts
src/__tests__/heartbeat-native-restart-suspension.test.ts`
- Daytona wave `w-20261111T050504-s9331` from this branch (same seed and
shape as s9331: 3 tasks, concurrency 3, three 90 s TM1 stalls with about
1 s between them). All 3 tasks reached `done` with no Retry. Run
`632f0564` (claude) shows the exact s9331 sequence:
`native.session.execute` failed after 216 s, then
`reattach_remote_runner` 0.4 s later (before the third stall), then a
second reattach after it, and the turn completed. Two tasks are graded
FAIL only on the T1 crossing check: their own fault landed after their
tool loop ended (for example loop ended 05:11:26, fault 05:11:44). Their
status is `done`, with no Retry.
- `tsc --noEmit -p server` is clean.

## Risks

- A same-process recovery scan can now claim a run that this process
recorded as its own. The guard needs all of these: the self lease
lapsed, no in-process execution of the run, and no handoff to the next
server. A claim that is still on its way to its executor holds a live
lease, so it is not taken twice.
- Upgrade safety: the decision uses only the coordinator row and
in-process state. Old saved rows are judged by the same rules. A new
process never matches an old boot ID, so deploys keep the existing proof
path.
- No new waits or file operations run before `turn.start`.
- There is still no ceiling on `awaiting_evidence` for a genuinely
different live controller (case c). This PR does not change that policy.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code, with tool use
(shell, file edit) and extended reasoning.

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 23:55:10 -06:00
DottaandClaude fcf4e39b41 fix(runner): do not count stopped time against a turn start or an ACPX sidecar request (#20720)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run starts each provider turn with `turn.start`. The
control plane waits for the Runner, and an ACPX Runner waits for its
sidecar, which starts the provider and sends the prompt.
> - Both waits use a 30 s bound. When the host or container stops the
whole tree (SIGSTOP, a host sleep), the clocks keep moving while no
process can act.
> - On thaw both bounds have passed. The control plane reports `PRP
command turn.start timed out`, and the Runner fails the sidecar request
and closes the ACPX session.
> - #20704 added a stopped-time clock for startup and recovery. This
pull request puts `turn.start` and its follow-up waits on the same
clock, and adds the same rule to the Runner's sidecar response wait.
> - The benefit is that a freeze during a turn start no longer fails the
turn or closes the session.

## Linked Issues or Issue Description

**What happened?**
Restart-chaos wave `w-20261111T011704-s9107`, task t05 (local, claude,
workload C1, fault TM2). A follow-up run sent `turn.start` at 01:22:21.
The harness then stopped the server, Runner, sidecar and Claude twice:
28 s, then 33 s after a 1 s thaw. At 01:23:25 the control plane reported
`provider_transport_failed: PRP command turn.start timed out`. Three
same-run resumes then failed with `ACPX session is not open`, recovery
stopped with `native_session_retry_exhausted`, and the task needed a
manual Retry. The Retry run succeeded.

**Expected behavior**
Time that the processes spend stopped does not count against the
turn-start bound or the sidecar request bound. A real timeout still
fires while the processes run.

**Steps to reproduce**
1. `cd packages/paperclip-runner && npx vitest run
src/live/runnerd-codex-transport-stall.test.ts`. On master it fails with
`provider_transport_failed: PRP command turn.start timed out`.
2. `cargo test --manifest-path
packages/paperclip-runner/runner/Cargo.toml -p paperclip-runner-core
--test acpx_sidecar_stopped_time`. On master it fails with `ACPX sidecar
request timed out at initialize`. It stops the process early in the wait
and again in its last half second.

Note on the repeated `ACPX session is not open`: the wave ran
`eafd5853d9`, which does not contain #20675. On master, #20675 settles a
cut-off start from the sidecar's dispatch journal. If the prompt never
left, the Runner reopens the session and sends it once. If the journal
shows the prompt left, or cannot prove it, the session stays closed and
the run ends with a working Retry, as the class rule requires. This pull
request stops the freeze from cutting off the start in the first place.

Refs #20704, #20675

## What Changed

- `packages/paperclip-runner/src/live/runnerd-codex-transport.ts`:
`#startTurn` runs under `#withAdmissionClock`. The turn-start deadline,
the held-tool-call wait, the start-event wait, the adoption check time
and the `session.snapshot` wait all read `#waitNow()`, so they share one
stopped-time credit.
-
`packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs`:
`receive_response` blocks in 1 s slices. `credit_stopped_time` treats a
slice overshoot beyond 2 s as a stop. It moves the deadline by the
overshoot, and to at least 5 s after the thaw, so a stop in the last
slice still leaves the sidecar time to answer. Total credit per request
is capped at 15 min.
- The control-plane clock (`RunnerStallLedger`) now uses the same rule:
a stop is credited in full plus a 5 s grace, capped at 15 min per
admission.
-
`packages/paperclip-runner/src/live/runnerd-codex-transport-stall.test.ts`
(new): drives the real transport and Runner. It SIGSTOPs the Runner
tree, jumps `Date.now` by 40 s mid-wait, then resumes the Runner. It
fails if the turn start leaves the clock, or if the command wait stops
reading it (checked by removing `clock: this.#admissionClock`). This is
the transport-level test requested in the #20704 review.
-
`packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_stopped_time.rs`
(new, its own test binary): a sidecar stops the test process for 6 s
against a 3 s bound and answers after the thaw.
- Unit test for `credit_stopped_time`.

## Verification

- Red on master, green here: both commands in "Steps to reproduce".
- `npx vitest run src/live/runnerd-codex-transport.test.ts
src/live/runnerd-codex-transport-stall.test.ts
src/live/runnerd-acpx-source-reconciliation.test.ts
src/live/runnerd-codex-transport-adoption.test.ts`: 342/342 pass.
- `cargo test -p paperclip-runner-core --lib acpx_sidecar_transport` (12
pass), `--test acpx_sidecar_transport --test acpx_park_preparation
--test acpx_provider_turns` (all pass), `cargo fmt --check` clean.
- `npx tsc --noEmit -p tsconfig.json` and `-p tsconfig.test.json`:
clean.
- Not run: a new chaos wave.

## Risks

- A real stall longer than 2 s in the Runner (or 5 s in the control
plane) during a turn start or a sidecar request now extends that wait by
the stall plus 5 s. The waited-on process could not be observed during
the stall either. The 15 min cap bounds a process that keeps stalling.
- The Rust wait now wakes once per second while it waits for a sidecar
reply. This is an active request with no I/O per wake.
- The 1 s control-plane ticker from #20704 now also runs during a turn
start, and stops when the start returns.
- No persisted state, launch identity or saved-session shape changes.
Old saved runs resume through the same code, and an older Runner keeps
its old wait. Nothing new runs before `turn.start`, and no wait is added
on the normal path.

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
canary/v2026.1111.0-canary.4
2026-11-10 23:25:32 -06:00
DottaandClaude 1fa7e92fa8 fix(native): restart in place a Runner that only settled an earlier attempt's suspend (#20724)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native run talks to a Runner process. When the control plane loses
that Runner, the run resumes itself with a limited number of same-run
attempts.
> - A server freeze while the controller suspends a finished turn can
stop the Runner inside `runner.suspend`. The controller journal then
keeps that suspend as pending.
> - The next attempt starts a new Runner. It settles the old suspend
from the welcome, records `suspended`, and exits with code 0 before it
accepts any command of the new attempt. The server reported "runnerd
exited unexpectedly with code 0", so one freeze spent one same-run
attempt. If a second fault then occurs, no attempt is left in reserve.
> - #20714 made both sides agree on that suspend's acknowledgement. This
pull request handles the clean exit that follows it.
> - During startup, the server restarts that Runner in place once,
through the existing `#recoverRunnerProcess` path, and only when the
durable Runner journal proves the exit.
> - The benefit is that one freeze during a suspend costs zero same-run
attempts.

## Linked Issues or Issue Description

Refs #20714.

**What happened?**

A restart-chaos task (local Runner, Claude, a freeze of the server
during a live turn) needed a manual Retry. #20714 fixed the identity
mismatch. The second same-run attempt still ended with
`runner_local_connect_failed: runnerd exited unexpectedly with code 0`,
because that Runner only settled the suspend left by the first attempt
and then exited as suspended.

**Expected behavior**

The attempt continues. The server restarts the Runner once. The new
Runner starts from a clean `suspended` state and serves the attempt's
commands. No provider work is repeated.

**Steps to reproduce**

1. Start a native run. Let the controller send `runner.suspend` when it
closes the session.
2. Stop the Runner after it journaled the suspend and before the
controller records the result (a server freeze does this).
3. Resume the same run. The new Runner settles the old suspend and exits
with code 0, and startup fails.

The new live test reproduces this with the real runnerd and the fake
Codex provider.

**Paperclip version or commit**

`master` at e719d42557 (after #20714).

## What Changed

- `runnerd-codex-transport.ts`: when the transport opens its controller,
it records the number of commands already in the journal. These commands
come from earlier attempts.
- When a Runner exits with code 0 during startup, the transport reads
the local Runner state once. It restarts the Runner in place, once per
attempt, with a bounded 30 s grace through the existing
`#recoverRunnerProcess`, only when all of these are true:
- the Runner's last command is a `runner.suspend` from an earlier
attempt, with the same command id, type and controller sequence in both
journals;
  - the controller has settled that suspend;
- the Runner is `suspended`, with no terminal fence, no provider cleanup
fence and no warm transition;
- no command of this attempt has a result, so no turn started and no
provider work ran.
- While that one restart runs, the startup waits for the replacement.
The startup does not fail on the old process.
- An exit that does not have that proof keeps its earlier failure. So do
remote, external-state and adopted Runners.
- The proof is a pure function, `carriedSuspendSettlementProven`,
exported through `runnerdRecoveryInternals`.
- `docs/durable-recovery.md`: it describes the restart.

## Verification

- Red, then green. New live test
`runnerd-codex-transport-carried-suspend.test.ts` (real runnerd binary,
fake Codex):
- `journaled`: the journals are rewritten to the freeze moment. Before
the change: `runner_local_connect_failed: runnerd exited unexpectedly
with code 0`. After the change: `thread/read` succeeds in the same
attempt, the old suspend is `failed / execution_indeterminate`, and the
provider has no `turn/start`.
- `fenced`: a released build left a terminal fence. The next Runner
keeps a provider cleanup fence, so it is not restarted. The test checks
that the earlier failure stays.
- Unit negatives for the proof: a suspend that this attempt queued, an
unsettled suspend, a last command that is not a suspend, a Runner that
went on to a later command, a command of this attempt with a result, a
Runner that is not suspended or has a fence, a receipt mismatch, and
another run identity.
- The live test passes with the Runner binary from before #20714 and
with the current `master` Runner.
- `npx tsc --noEmit -p packages/paperclip-runner`: clean.
- `runnerd-codex-transport.test.ts`, `-cut-attach`, `-settlement`,
`-suspension`, `-cold-restore`: 335 of 336 passed in one parallel run.
The one failure was a 20 s settlement case under load, and it passed
when run alone (19/19).

## Risks

- A clean exit that is not this case keeps its earlier failure. The
proof needs exact durable evidence. The restart happens at most once per
attempt and is bounded to 30 s.
- No command runs again. The old suspend is never executed again. The
replacement Runner starts from `suspended` and receives this attempt's
commands for the first time.
- Old Runners and old saved state: the proof only reads the Runner state
fields that released Runners already write. A state with a fence or a
provider cleanup fence is not restarted.
- The normal path has no added waits or file reads. The Runner file is
read only after a code-0 exit during startup.
- This changes no Runner (Rust) code. No new Runner image is needed.

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 23:25:29 -06:00
DottaandClaude e039be9202 fix(runner): replace a follow-up session whose remote workspace root moved (#20712)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs keep an execution lease. Restart recovery takes over a
run only when it can prove that the recorded controller stopped.
> - A stall (SIGSTOP) longer than the lease, with no restart, makes the
executor lose its own lease. The executor then detaches for "the next
controller" and exits.
> - The next controller is this same process. Recovery asks the PID
probe if the recorded controller is alive. The recorded controller is
this process, so the answer is always "alive".
> - The task then waits forever: `in_progress`, no Retry, an active run,
and an active environment lease.
> - This pull request lets recovery recognise its own process. When this
process no longer drives the run and its own lease lapsed, the prior
owner is gone. The existing evidence checks then decide, as for any
stopped controller.
> - The benefit is that a same-boot stall never needs a manual Retry.
When provider work may have started, the run fails with a working Retry
instead of a wait with no end.

## Linked Issues or Issue Description

Refs #20694 (the same class of fix for legacy `preparing` runs), #20529,
#20691.

**What happened?**
Chaos wave `w-20261111T034105-s9330`, task t02 (Daytona, OpenCode, fault
TM1). The server process was frozen with SIGSTOP for about 84 s while
run `89a3922b` prepared. After SIGCONT, `native.process_start_requested`
was recorded and `native.session.execute` failed after 118 ms: the lease
renewal found the lease lapsed, and the executor detached and exited.
From then on, recovery recorded `native.recovery.transition` "waiting
for safe ownership evidence" with reason `controller_still_alive` every
30 s for more than 20 minutes. The run stayed `running`, the issue
stayed `in_progress`, no Retry was shown, and the environment lease
stayed `active`.

**Expected behavior**
When this process no longer drives a run that it recorded as its own,
recovery takes the run back. No turn reached the runner here, so the
bootstrap restarts on the same run. When a turn or runner work may have
started, the run fails and shows a working Retry.

**Steps to reproduce**
1. Start a native remote (Daytona) run.
2. Send SIGSTOP to the server during preparation, and hold it longer
than the execution lease.
3. Send SIGCONT. The executor loses its lease, detaches and exits.
4. See that every recovery scan waits with `controller_still_alive`, and
the run never settles.

The new tests in `native-runner-restart-recovery.integration.test.ts`
("same-boot controller whose own lease lapsed") reproduce this at the
recovery layer.

## What Changed

- `claimNativeRestartRecoveries`: when the recorded controller is this
exact process (same boot ID and PID):
- If this process still drives the run (heartbeat execution or native
executor registered), recovery leaves the run to it
(`current_controller_preparing`).
- If neither drives it and the self lease lapsed, the takeover is
allowed (`current_controller_released`). The existing evidence checks
then choose the outcome: bootstrap again on the same run when a durable
record proves no command reached the runner, or fail with a Retry
(`blocked`) when runner or provider work may have started.
- An unexpired self lease still waits. A claim that has not reached its
executor holds it, and it lapses by itself.
- A run that this process handed to the next server (graceful drain
`restart_suspended` record, or the in-process hot-restart handoff
marker) stays with the next server.
- `heartbeat/shutdown-handoff.ts`: add `isRunHandedOffForShutdown`.
Heartbeat recovery passes it to the claim.
- `suspendedByCleanDrain` now uses the shared
`handedToNextControllerSinceClaim` helper. Behavior is the same.
- Tests: five cases for the same-boot lapsed lease: bootstrap again with
no runner command; fail with a working Retry when a runner identity was
recorded; leave the run to a live in-process executor; leave a
handed-off run to the next server; still wait for a different live
controller.

## Verification

- Red before the fix: the three same-boot cases returned
`awaiting_evidence` / `controller_still_alive`.
- Green after the fix:
- `pnpm --dir server exec vitest run
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts
src/services/native-runtime/native-restart-recovery.test.ts
src/__tests__/heartbeat-native-restart-suspension.test.ts`
- `pnpm verify` (see the PR comments for the selected scope).
- `tsc --noEmit -p server` is clean.

## Risks

- A same-process recovery scan can now claim a run that this process
recorded as its own. The guard needs all of these: the self lease
lapsed, no in-process execution of the run, and no handoff to the next
server. A claim that is still on its way to its executor holds a live
lease, so it is not taken twice.
- Upgrade safety: the decision uses only the coordinator row and
in-process state. Old saved rows are judged by the same rules. A new
process never matches an old boot ID, so deploys keep the existing proof
path.
- No new waits or file operations run before `turn.start`.
- There is still no ceiling on `awaiting_evidence` for a genuinely
different live controller (case c). This PR does not change that policy.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code, with tool use
(shell, file edit) and extended reasoning.

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 22:57:54 -06:00
DottaandClaude e719d42557 fix(runner): accept the acknowledgement of an indeterminate suspend replay (#20714)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native run talks to a Runner process (runnerd). The controller in
the server sends commands. The Runner journals each command before it
acts and replies with a result.
> - `runner.suspend` and `runner.shutdown` are terminal commands. The
Runner waits for a `command_result_ack` before it exits. It compares
that acknowledgement with the receipt that it journaled.
> - A Runner that is killed inside `runner.suspend` journals the command
but not its result. The next Runner replays the command as
`indeterminate`. It sends it on the wire as `failed` with
`execution_indeterminate`, and it still treats it as terminal.
> - The controller acknowledged the `failed` status that it received.
The Runner compared that with its journal status `indeterminate` and
exited with "terminal command result acknowledgement changed its durable
identity". The controller also lifted its terminal fence for a failed
suspend and sent the next command to a Runner that was about to exit.
> - This pull request makes both sides agree on the status of that one
receipt, and keeps the fence.
> - The benefit is that a freeze or restart during a suspend no longer
burns the same-run retries and leaves the task at a manual Retry.

## Linked Issues or Issue Description

**What happened?**

A restart-chaos task (local Runner, Claude, a 28-second freeze of the
server during a live turn) needed a manual Retry. Retry worked.

1. The turn completed. The server then sent `runner.suspend`. The server
froze before the Runner reported the result.
2. The close found the Runner not durably suspended (`commandStatus:
pending`, `runnerLifecycle: ready`). The run failed with
`native_runner_checkpoint_owner_lost`, and same-run recovery stopped the
old runnerd. The suspend was journaled but not finished.
3. Attempt 2 started a new runnerd on the same state. The welcome
delivered the stale suspend. The Runner replayed it as `indeterminate`
and sent `failed`. The controller acknowledged `failed`. The Runner
exited with code 1: "terminal command result acknowledgement changed its
durable identity". The controller had recorded the suspend as `failed`.
4. Attempt 3 found the fence for a suspend that the controller no longer
listed as pending. The Runner reconciled it and exited with code 0. The
server reported "runnerd exited unexpectedly with code 0", and
`native_session_retry_exhausted` stopped automatic recovery.

**Expected behavior**

The controller acknowledges exactly the receipt that the Runner sent.
The Runner accepts that acknowledgement, records `suspended`, and the
next attempt starts from a clean suspended Runner.

**Steps to reproduce**

1. Start a native run with a local Runner. Let the turn complete so that
the server sends `runner.suspend`.
2. Freeze the server (SIGSTOP) for about 30 seconds before the suspend
result arrives, so that recovery stops the Runner inside the suspend.
3. Let same-run recovery start a new Runner. It exits with "terminal
command result acknowledgement changed its durable identity". The unit
tests in this PR reproduce the same sequence without a live run.

**Paperclip version or commit**

`master` at 200c6e40e2. The failing wave ran on 9296c74e8d plus #20656.

**Root cause**

`command_result_envelope` changes the journal status `indeterminate` to
`failed` on the wire. `wait_for_terminal_result_ack` compared the
acknowledgement with the journal status, not with the status that it
sent. The controller also treated a `failed` suspend as a refused
suspend (the Runner keeps serving), but the Runner treats an
indeterminate suspend as terminal.

## What Changed

- Runner (`runner.rs`): a new `wire_command_status` gives the status
that goes on the wire. `wait_for_terminal_result_ack` accepts the
acknowledgement when its status is the wire status (`failed`) or the
journal status (`indeterminate`). Command id, type, and controller
sequence must still match exactly.
- Controller (`durable-prp-control-plane.ts`): for a result with
`execution_indeterminate`, the `command_result_ack` names
`indeterminate`. Runners that shipped before this change accept only
that status. The controller keeps the terminal-lifecycle fence for an
indeterminate suspend, so it does not send the next command to a Runner
that is exiting. The command stays `failed` in the controller journal,
as before.
- Runner Rust test
`indeterminate_suspend_replay_accepts_the_acknowledged_wire_status`: it
seeds a journaled suspend, starts the Runner against a fake controller
that acknowledges `failed` and then `indeterminate`, and checks that the
Runner finishes as `suspended` with no fence. Before the fix it failed
with the production message.
- Controller test: it checks the `indeterminate` acknowledgement and
that the next queued command is not delivered on that connection.
- `docs/durable-recovery.md`: it describes this case.

## Verification

- Red, then green: `cargo test -p paperclip-runner-core --lib
indeterminate_suspend_replay` failed with `DurableRunnerError("terminal
command result acknowledgement changed its durable identity")` before
the Runner change and passes after it.
- Red, then green: `npx vitest run
src/control-plane/durable-prp-control-plane.test.ts -t "indeterminate
suspension"` failed (`status: "failed"`, expected `"indeterminate"`)
before the controller change and passes after it.
- `cargo test -p paperclip-runner-core --lib durable`: 152 passed.
- `npx vitest run src/control-plane/durable-prp-control-plane.test.ts`:
215 passed.
- `npx vitest run src/live/runnerd-codex-transport-settlement.test.ts
src/live/runnerd-codex-transport-suspension.test.ts`: 30 passed. These
use the real runnerd binary built from `master`, so they also cover the
new controller with an old Runner.
- `npx tsc --noEmit -p packages/paperclip-runner`: clean.
- `pnpm verify` returned exit 2 (broad input). Required CI supplies the
broad coverage.
- Not run: a chaos wave. The unit tests reproduce the exact sequence at
the Runner and controller boundary.

## Risks

- Version mix. Old Runner with new server: the acknowledgement says
`indeterminate`, which is the old Runner's journal status, so it is
accepted. New Runner with old server: the acknowledgement says `failed`,
which is the new Runner's wire status, so it is accepted. New with new:
accepted.
- No command runs again. The indeterminate suspend is never executed
again; only its acknowledgement changes. The Runner still refuses a
different command id, type, or controller sequence.
- The Runner change takes effect only after a new Runner image. The
controller change alone removes the failure for Runners that already
shipped.
- Remaining gap: the attempt that delivers the stale suspend still ends
when that Runner exits as suspended. The next same-run attempt then
starts from a clean suspended Runner. A later change can restart the
Runner inside the same attempt.

> 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, in Claude Code with
tool use (shell, file edits, test runs). Extended reasoning.

## Checklist

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 22:53:53 -06:00
DottaandClaude 156212cfd5 test(chaos): close a covering modal before clicking Retry, and name it when it blocks (#20715)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The restart chaos harness (`tests/restart-chaos/`) clicks every
needed Retry in the real board UI with Playwright.
> - In one wave, a task failed and showed Retry. The "You completed 5
tasks!" star modal was open over the page.
> - The modal is a Radix dialog. It sets `aria-hidden="true"` on the
rest of the page, so `getByRole('button', { name: 'Retry' })` did not
find the visible Retry.
> - The grade said `ui_retry_button_missing`. That name hides the real
cause.
> - This pull request makes the harness close a covering modal before it
looks for Retry. When a modal does not close and keeps a visible Retry
unreachable, the grade says `ui_retry_blocked_by_modal`.
> - The benefit is that a chaos grade names the real cause, and a
promotional prompt does not fail a wave.

## Linked Issues or Issue Description

Refs #20713 (product fix: the star prompt no longer opens over a task
that needs Retry)

**What happened?**
A restart chaos wave saved a "Retry missing" screenshot. The screenshot
shows the Retry banner and the star modal over it. The saved DOM shows
the Retry button as visible, with a box, inside the banner. The harness
still graded the task `ui_retry_button_missing`.

**Expected behavior**
The harness closes a modal that covers the page, as a user does, and
then clicks Retry. If a modal does not close, the grade names the modal
as the cause.

**Steps to reproduce**
1. Open a task page that shows a failed attempt with Retry.
2. Open a Radix modal over the page (the star prompt did this).
3. Run `BoardUi.clickRetry` on that task. It reports
`ui_retry_button_missing`.

## What Changed

- `board-ui.ts`: before and while it waits for Retry, `clickRetryOnPage`
closes an open modal that does not itself offer Retry. It clicks "Not
now" (or "No thanks", "Maybe later", "Dismiss") when present, else it
presses Escape. It also closes a modal that opens after Retry appears
and swallows the click, then clicks again. At most three dialogs are
closed. Each click has a timeout equal to the per-button request wait.
- The click result has a new optional `dismissedDialogs` list (first
line of each closed dialog). The observer copies it into the task's
retry attempt.
- New failure reason `ui_retry_blocked_by_modal` (in `types.ts`): a
visible Retry exists in the DOM, it is not reachable by role, and a
modal stays open. R3 in `grader.ts` reports it with the banner text,
like the other UI reasons.
- `README.md`: the board UI Retry paragraph names the dismissal and the
new reason.
- Tests in `board-ui.test.ts` use real Chromium pages with a modal and
an `aria-hidden` page: "Not now" closes it and Retry is clicked; Escape
closes it and Retry is clicked; a modal that opens on hover over Retry
is closed and Retry is clicked; a modal that does not close gives
`ui_retry_blocked_by_modal`. `grader.test.ts` covers the new R3 detail.

## Verification

- Red first: the three new `board-ui.test.ts` cases failed on master
(the covered Retry was reported as missing).
- Green: `cd tests/restart-chaos && npx vitest run board-ui.test.ts
grader.test.ts triggers.test.ts` (with `observer.test.ts`) gives 99
passed.
- `npx tsc --noEmit -p tests/restart-chaos/tsconfig.json` passes.
- `pnpm verify` exits 2 because the README is a global CI input. CI
supplies the broad coverage.

## Risks

- Low risk. This changes only the test harness.
- The harness can close a dialog that the product meant to keep open.
The closed dialog is recorded in `dismissedDialogs`, so the evidence
still shows 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 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 <noreply@anthropic.com>
2026-11-10 22:40:49 -06:00
DottaandClaude f77b8a288e fix(ui): keep the GitHub star prompt off tasks that need a Retry (#20713)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A task that fails shows a banner with a Retry button. The user
clicks Retry to run the task again.
> - Self-hosted boards also show a "You completed 5 tasks! Star on
GitHub" modal (#20453).
> - The modal can open on the task page while the task shows its Retry
banner.
> - The modal is a Radix dialog. It sets `aria-hidden="true"` on the
rest of the page and sets `pointer-events: none` on the body. The Retry
button is visible, but a click does not reach it and assistive
technology cannot find it.
> - This pull request makes the star prompt wait for a calm moment. It
does not open on a task or agent chat page, and it does not open while a
task Retry or status banner is on screen.
> - The benefit is that a visible Retry is always reachable.

## Linked Issues or Issue Description

Refs #20453

**What happened?**
A restart chaos run left a task in the failed state with the Retry
banner. The "You completed 5 tasks!" star modal opened over the same
page. The Retry button was in the DOM and visible. Playwright
`getByRole('button', { name: 'Retry' })` did not find it, because the
modal had set `aria-hidden="true"` on the banner. The body also had
`pointer-events: none`, so a user click on Retry did not reach the
button.

**Expected behavior**
A promotional prompt never covers a task that needs the user. A visible
Retry is always reachable.

**Steps to reproduce**
1. On a self-hosted instance, finish five tasks in one company. Do not
open the star prompt before.
2. Let a sixth task fail so that it shows "Couldn't finish this attempt"
with Retry.
3. Open that task. The star modal opens over the Retry banner.

**Paperclip version or commit**
master at 200c6e40e2

## What Changed

- `GitHubStarPrompt` waits while the location is one task
(`/:prefix/issues/:ref`) or one agent chat (`/:prefix/chats/:agent`).
Task list views (`issues/all`, `issues/done`, and so on) do not count as
one task.
- `GitHubStarPrompt` waits while a task Retry
(`data-testid="task-execution-retry"`) or a task status banner
(`role="status"` with `aria-label="Task status"`) is on screen. It uses
the same DOM observer that already waits for other dialogs.
- If the prompt is open and the user goes back to a task or chat page,
or a task Retry or status banner appears, the prompt closes.
- `useModalOpen` in `AnnouncementWell.tsx` takes an optional selector.
The default is the existing modal selector, so other callers do not
change.
- New tests in `GitHubStarPrompt.test.tsx`: the prompt stays closed
while a Retry banner is on screen and the banner is not `aria-hidden`;
the prompt stays closed on a task and a chat page and opens on the task
list. The prompt closes when the user goes back to a task, and when a
Retry appears.

## Verification

- Red first: the two new tests failed on master. A temporary probe in
the red run showed the prompt open, the banner with
`aria-hidden="true"`, and `document.body.style.pointerEvents ===
"none"`.
- Green: `cd ui && npx vitest run
src/components/GitHubStarPrompt.test.tsx
src/components/BoardDialogs.test.tsx` passes.
- `pnpm verify` passes (481 UI test files).
- `pnpm check:token-gates`: all gates clean.
- `npx tsc --noEmit -p ui`: no errors in the changed files.

## Risks

- Low risk. The prompt can show later than before, because it waits for
a non-task page. It still shows only once per browser.
- The "seen" marker is written only when the prompt opens, so a user who
stays on task pages does not lose the prompt.

> 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 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 <noreply@anthropic.com>
2026-11-10 22:36:40 -06:00
DottaandClaude 08ea18085e fix(workspaces): restore a saved workspace into one copy of sandbox disk (#20695)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A task on a remote (Daytona) sandbox continues from its saved
workspace. Before the agent starts, the server uploads the saved archive
into the sandbox and a sandbox script extracts it.
> - The extraction wrote a full second copy of the workspace beside the
uploaded archive. A 7.5 GB workspace on a fresh sandbox then needed
about 15 GB, and the restore failed with `stage=extract code=ENOSPC
disk=full`.
> - The failure was classified as retryable, so every automatic retry
and every Retry repeated it on a same-size sandbox. The task could not
continue.
> - On a retained sandbox it is worse: the old root, the archive, and
the new tree all share the disk.
> - This pull request makes the restore need about one copy of the
workspace. The sandbox removes only data that control-plane storage
already holds durably, and it never removes a root whose save the server
did not prove.
> - The benefit is that a workspace that fits once on the sandbox disk
restores. A disk that is too small for one copy now gets a named failure
that does not loop.

## Linked Issues or Issue Description

**What happened?**
A saved remote workspace of about 7.5 GB could not be restored on a new
Daytona sandbox. The run failed with `remote_workspace_restore_failed:
step=sandbox_restore; remote_workspace_operation_failed:
operation=restore exit=1; remote_workspace_restore_failed: stage=extract
code=ENOSPC disk=full exit=1`. The run marked the failure as retryable,
and each retry failed the same way.

**Steps to reproduce**
1. Save a remote workspace whose archive is larger than half of the free
sandbox disk.
2. Start the next run on another sandbox, so that the server restores
the saved archive.
3. The upload succeeds. The extraction writes the second copy and fails
with ENOSPC.

**Expected behavior**
A workspace that fits once on the sandbox disk restores. A disk that
cannot hold one copy gives a specific failure that is not retried
automatically, and Retry works after the sandbox has more space.

**What the disk holds during a restore (from the code)**
- The uploaded archive
(`<parent>/.paperclip-runtime/workspace-snapshots/<operation>/workspace.pcws`,
about the workspace size, because the archive is not compressed).
- The extracted tree (`<root>.restoring-*`, the workspace size).
- On a retained sandbox only: the old root, which stayed until after the
new root was published.
- After a restore that died: its partial tree and its archive, which no
later attempt removed.

Refs #20678 (the save side of the same full-disk class).

## What Changed

- `workspaceArchive` restore has a `consumeArchive` mode. It creates the
directories and symlinks first, then writes the file bodies from the
last to the first. After it verifies each file against the manifest
hash, it truncates the archive behind that file. The archive and the
tree together need the workspace size plus the largest file. The default
mode, which the server uses for local restores, does not change.
- The sandbox restore script uses that mode on a private rename of the
uploaded archive. A later upload to the same path cannot change the file
that is being consumed. The script removes the consumed file when it
stops.
- The helper's own staging is now inside the root-specific
`<root>.restoring-*` folder, not in a shared `.workspace-restore-*`
sibling.
- While it holds the root's restore lock, and after the epoch fence, the
script removes leftovers of an earlier attempt on the same root that
died: `<root>.restoring-*` folders, `.consuming` archives in its own
staging, and archive staging folders that a `restore-target.json` binds
to the same root. Each restore writes that binding after the reclaim
step.
- When the server proved that a retained root is its own earlier, fully
saved allocation (`replaceRetiredRoot` and a Paperclip receipt, as
before), the script removes the root's files before the extraction, not
after the publish. The receipt and `.paperclip-runtime` stay, so an
interrupted restore proves the same root again and continues.
- A full disk at any restore stage reports `reason=sandbox_disk_full`.
The existing named-refusal rule makes it non-retryable, so it goes to
the board recovery action and does not loop.
- `doc/execution-semantics.md` describes the disk need, the reclaim, the
early removal, and the new reason.

## Verification

- `cd server && npx vitest run
src/services/remote-workspace-transport.test.ts
src/services/remote-workspace-snapshot-archive.test.ts` (287 passed).
- `cd server && npx vitest run
src/services/remote-workspaces.integration.test.ts
src/services/remote-workspace-lifecycle.test.ts
src/services/remote-workspace-archive-worker.test.ts
src/services/remote-workspace-snapshot-archive-environments.test.ts`
(122 passed, 16 skipped).
- `cd server && npx tsc --noEmit -p tsconfig.json` (pass).
- The new tests run the real sandbox scripts against a folder with a
byte capacity. A `--require` preload makes each file write fail with
ENOSPC when the folder would exceed the capacity.
- Red on `origin/master`, green with this change:
- Fresh sandbox, room for the archive plus one file: master fails with
the exact production error `stage=extract code=ENOSPC disk=full exit=1`.
The fix restores every byte and leaves only the restored files.
- Retained root that the server proved, room for the old root, the
archive, and one file: master fails with ENOSPC. The fix restores and
keeps the runner state.
- Too small for one copy: the failure is `stage=extract
reason=sandbox_disk_full`, with no partial tree or archive left. A retry
with more space then restores.
- Both sandbox processes are killed mid-extraction (a restart): a retry
with the same identity and a retry with a new identity and epoch both
reclaim the leftovers and restore into the same capacity.
- The command dies right after it removed a proven root's files: the
next restore proves the root again from its kept receipt, restores, and
keeps the runner state.
- Integration test through `prepareRemoteWorkspace`: a full disk gives
`retryable: false` with `reason=sandbox_disk_full`. On master the same
case is `retryable: true`.
- Guards (pass on master and with the fix): a retained root without the
server proof, or without a Paperclip receipt, is refused with
`reason=occupied_root`, and none of its files are removed.
- Previous-release story: the archive format and the saved state do not
change. A partial `<root>.restoring-*` tree from the previous release is
reclaimed (test). Archive staging from the previous release has no
binding, so it is kept (test). The previous release's shared
`.workspace-restore-*` siblings cannot be bound to a root, so they are
kept.

## Risks

- The consumed archive cannot be read again. Each restore uploads a new
copy from control-plane storage, and the early-return path for an
already restored root does not read the archive. Every file is still
checked against the manifest, and the manifest is checked against the
saved digest.
- The reclaim step removes data only under the root's restore lock and
after the epoch fence. It removes only names that a restore of the same
root creates. Staging that names another root, or no root, is kept.
- The early removal of a retained root depends on the existing server
proof (`isRetiredOwnAllocation`) and the receipt check. If a restore
fails after the removal, the saved version in storage is the source of
truth, and the next restore proves the same root again.
- Files in a lower overlay layer of a resumed sandbox image do not free
space when they are removed. Such a sandbox can still be full.
- A workspace that is larger than the free disk after the upload, plus
its largest file, still fails. It now fails with the named,
non-retryable `reason=sandbox_disk_full`.
- No change to `packages/paperclip-runner`, the database schema, or the
API.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 22:19:43 -06:00
DottaandClaude 855e241887 fix(server): record a long event-loop block with the deltas that name its cause (#20709)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - One server process owns the controller leases of its runs, renews
them on timers, and serves every request
> - On a hosted instance, the process stopped for about 103 seconds
without a restart. Every lease lapsed, and two runs needed a manual
Retry (the symptom is fixed in #20694)
> - Nobody can say what blocked the process. The server records no
event-loop delay, and it has no CPU, GC or memory record for that minute
> - This pull request logs one local line for each event-loop block of 2
s or more. The line holds the deltas that tell the possible causes
apart, and a likely-cause class
> - The benefit is that the next stall names its class (main-thread CPU,
GC, paging, CPU throttling, host suspend, or a blocking sync call) and
its exact time, so we can match it to the run timeline and fix the cause

## Linked Issues or Issue Description

Refs #20694

**What happened?**

On a hosted instance, the merged run-event timeline of all active runs
had no events from 08:19:52.04Z to 08:21:35.37Z (103.3 s). Events from
streaming runs arrived about every 0.5 s before and after the gap. The
process did not restart (the run diagnostic shows an observer uptime of
about 24 minutes when the reaper ran). The first event after the gap was
a span with a 60 s timeout that ended after 103.5 s. Thus the timer
callback ran about 43 s late. This agrees with a blocked event loop, or
with a frozen process. Nothing in the process recorded which one.

**Expected behavior**

A long event-loop block is a product failure. It lapses every lease and
freezes every user. The server must record each long block with enough
data to identify its cause.

**Steps to reproduce**

1. Start the server.
2. Block the main thread for more than 2 s, for example with a
synchronous read on a slow disk or a busy loop.
3. Before this change: the logs show nothing. After this change: the
logs show `event loop blocked for <n> ms (likely <cause>)` with the
measured deltas.

## What Changed

- `server/src/event-loop-stall-monitor.ts` (new): one unref'd 1.5 s
timeout, re-armed after each check, measures how late it runs. (Server
startup creates no interval timers, so it is not a `setInterval`.) When
a check is 2 s or more late, it logs once with these fields:
  - stall duration, start and end time (wall clock and monotonic clock)
- process CPU (user and system), GC pause time and count (`perf_hooks`
GC entries)
- major and minor page faults, file-system reads and writes, voluntary
and involuntary context switches (`process.resourceUsage()`)
- cgroup v2 `throttled_usec` delta (Linux; read asynchronously, best
effort)
  - RSS, heap and external memory
- `likelyCause`: `host_suspended`, `gc`, `memory_paging`,
`cpu_throttled`, `main_thread_cpu`, or `blocked_off_cpu` (sync file I/O,
a sync child process, or another off-CPU wait)
- `server/src/index.ts`: starts the monitor when the listener binds. It
stops the monitor at the start of shutdown, and when a failed startup
closes the listener. It writes the line with the existing `logger.warn`.
- This is a local log line only. It is not the Telemetry path and not
the Observability (OpenTelemetry) path. No data leaves the process.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/event-loop-stall-monitor.test.ts
src/__tests__/server-startup-feedback-export.test.ts` (131 passed; the
monitor file passed 5 runs in a row):
- an 800 ms busy loop is recorded as `main_thread_cpu`, with the stall
and CPU time
- an 800 ms `Atomics.wait` (blocks without CPU, like a sync read on a
stalled disk) is recorded as `blocked_off_cpu`
- nothing is logged while the loop is responsive, or after `stop()`
(injected clock)
- the file-read delta of a stall is kept while the cgroup read is
pending
  - server startup still creates no interval timer
- a 100 s stall with 90 s of cgroup throttling is recorded as
`cpu_throttled`, with the stall start and end times
  - each cause class is selected from its deltas
- `pnpm --filter @paperclipai/server exec tsc --noEmit` passes.
- CI supplies the broad server suites.

## Risks

- Low risk. The timeout reads only process counters. Every 10 s it reads
one small cgroup file asynchronously. It does not query the database or
the network, so it does not keep an idle instance or its database awake.
It is unref'd and stopped on shutdown.
- `process.cpuUsage()` counts all threads of the process, not only the
main thread. A busy libuv thread pool during a stall can make an off-CPU
block look like `main_thread_cpu`. The raw deltas are in the line, so a
reader can check this.
- If a platform suspends and resumes the process, each resume can log
one `host_suspended` line. This is expected and useful.
- The monitor names the class of a block, not the call stack. If the
class is not enough, the next step is a bounded CPU profile around the
stall time.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 22:19:13 -06:00
DottaandClaude 6d1eae73c0 fix(runner): do not count time the control plane was stopped against a Runner command wait (#20704)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The native runtime resumes a run after a control-plane restart. It
launches a Runner and waits for recovery barriers (`run.attach`, a
`runner.drain` probe, the provider identity).
> - These waits poll with a wall-clock deadline. When the control plane
and its Runner are stopped together (SIGSTOP, host sleep), the wall
clock passes the deadline before either process can act.
> - On thaw, the wait reports a timeout although nothing was slow.
Recovery then spends an attempt and a retry delay.
> - This pull request gives each startup or recovery admission a clock
that does not count stopped time.
> - The benefit is that a freeze or host sleep during an admission no
longer turns into a false `PRP command ... timed out` failure.

## Linked Issues or Issue Description

**What happened?**
A restart-chaos wave (local, codex, fault TM2) stopped the server and
its Runner tree for 44 s, 1 s after a resumed Runner started. The resume
barrier `runner.drain` had a 30 s deadline. On thaw, the server reported
`runner_local_connect_failed: PRP command runner.drain timed out` 45 s
after the wait began (`provider.session.load` span 45130 ms). Recovery
spent attempt 1 and waited 30 s more before attempt 2.

**Expected behavior**
Time that the waiting process spends stopped does not count as waiting
time. A real timeout still fires when the Runner does not answer while
both processes run.

**Steps to reproduce**
1. Start a Runner command wait with a 30 s deadline.
2. After 1 s, stop the process for 44 s (the test moves the system clock
without running timers).
3. Let the Runner complete the command 2 s after thaw.
4. Before this change, the wait rejects with `runner.drain timed out`.
After this change, it resolves.

Note: in that chaos task, this false timeout was not the reason that the
task needed a manual Retry. An earlier SIGKILL of the whole server tree
stopped the Codex turn while a foreground shell command (`./bench.sh`)
was running. On restore, Codex had no active turn
(`provider_turn_lost_on_restore`). Recovery correctly blocked automatic
replay (`uncertain_provider_action`), because no durable record proves
that the interrupted command had no side effect. The manual Retry then
completed the task. This PR does not change that policy.

Refs #20675

## What Changed

- `packages/paperclip-runner/src/live/runnerd-codex-transport.ts`: add
`RunnerStallLedger`, the clock of one startup or recovery admission.
`now()` is wall time minus the stopped time observed since the admission
began. Its poll loops and a 1 s ticker share one last-observation time,
so each stop is credited once, wherever the process was when it stopped
(a poll loop, a checkpoint flush, a spawn). Only the part of a gap
beyond 5 s is credited.
- `#start` and `#resume` run under one ledger (`#withAdmissionClock`). A
nested startup keeps the outer ledger. The ticker stops when the
admission ends.
- During an admission, `waitForRunnerCommand`, the provider-identity
wait, the cold admission deadline, the recovery admission deadline, and
the default 30 s command deadline all use this clock.
- Waits after admission, including `turn.start` and its event and
snapshot waits, keep wall-clock deadlines. Their behavior does not
change.
- Tests in `runnerd-codex-transport.test.ts`: the chaos sequence (red
before, green after), the unchanged wall-clock timeout outside an
admission, a barrier that still times out after a freeze, a 70 s freeze
between two barriers on one cold deadline, and a slow-but-running
process that still reaches its deadline.

## Verification

- Red: `cd packages/paperclip-runner && npx vitest run
src/live/runnerd-codex-transport.test.ts -t "stopped"` failed before the
fix with `runner_local_connect_failed: PRP command runner.drain timed
out`.
- Green: the same command passes after the fix.
- `npx vitest run src/live/runnerd-codex-transport.test.ts`: 301/301
pass, including the existing "shares Pi's absolute cold deadline across
recovery barriers and rejects a late ACK" test.
- `npx tsc --noEmit -p packages/paperclip-runner/tsconfig.json`: clean.
- `pnpm verify` exits 2 (changes outside resolved test graphs). Required
CI supplies the broad coverage.
- Not run: a new chaos wave. The unit tests reproduce the exact wait and
clock sequence from the artifacts.

## Risks

- A real event-loop hang longer than 5 s during an admission now extends
that admission by the hang minus 5 s. The Runner answer could not be
observed during the hang either. Each credited gap still costs 5 s of
budget, so a process that is slow on every observation still reaches its
deadline.
- The ticker runs only while a startup or recovery admission runs (an
active operation), every 1 s, does no I/O, and is cleared in `finally`.
It does not keep an idle process awake.
- A `turn.start` that is cut by a freeze still reports a wall-clock
timeout, as before. That case needs its own change, because its event
and snapshot waits share one deadline.
- No persisted state, launch identity, or saved-session shape changes.
Old saved runs resume through the same code. A deploy mid-run only
changes how long an in-process admission wait tolerates a stop. No new
wait or file operation runs before `turn.start`.

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

**What happened?**

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

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

**Expected behavior**

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

**Steps to reproduce**

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

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

**Paperclip version or commit**

`master` at `7435abcc63`.

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 21:35:38 -06:00
DottaandClaude 4baff83225 fix(chat): never batch messages of two chat conversations into one wake (#20697)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat channels let a linked user talk to an agent from GitHub, Slack
and other platforms. Each external thread is a chat conversation. Many
conversations can share one task.
> - A reviewed chat run attests exactly one conversation. #17054 keeps
each conversation in its own wake batch, but it compares only the
conversation that a wake records.
> - The work-intent outbox saves unanswered user messages as a plain
comment wake. That wake records no conversation, even when its messages
are chat messages. A wake with no recorded conversation matched every
conversation, so a message from another conversation merged into it.
> - The promoted run held messages of two conversations. The binding
check correctly refused it
(`reviewed_chat_execution_binding_not_authorized`, `binding_denied`),
and the task needed Retry.
> - This pull request compares the conversations of the queued messages
themselves. Wakes of different conversations never merge. Each
conversation gets its own run, in order.
> - The benefit is that replies in several threads of one task each get
an attested run, without a failed run or a Retry.

## Linked Issues or Issue Description

Refs #17054 (the earlier per-conversation batching fix). Refs #20689 (a
different shape of the same error).

**What happened?**
On staging, a GitHub pull-request review task had two runs fail with
`setup_failed` / `reviewed_chat_execution_binding_not_authorized`
(`reviewedChatBinding.denialReason: binding_denied`). Each failed run
held the same two older saved messages plus one new message. The two
runs recorded two different `chatConversationId` values. The work-intent
outbox had saved the older messages as plain deferred wakes with no
conversation. A new message from each conversation then merged into one
of these saved wakes.

**Steps to reproduce**
1. Bind two chat conversations to one `in_review` task.
2. Let the outbox save an unanswered message of conversation B as a
deferred wake while a run is active.
3. As the linked user, send a message in conversation A while the run is
still active.
4. See that the message of A merges into the saved wake of B. The
promoted run fails the binding check.

**Expected behavior**
The saved message of B runs as its own wake. The message of A runs after
it, in its own attested run.

## What Changed

- New
`server/src/modules/wake-queue/adapters/chat-conversation-batch.ts`:
reads the inbound chat links (`chat_message_links`, direction `inbound`)
of queued messages and finds messages of a conversation other than the
expected one.
- Deferred-wake lookup (`findExistingDeferredWake`): when the incoming
wake has a conversation, a saved wake qualifies only if none of its
queued messages is an inbound message of another conversation. This is
one more predicate in the same query (no new round trip). It uses the
existing `chat_message_links_company_comment_idx` index.
- Coalescing into the active run (`coalesceIntoActiveExecutionRun`):
returns no merge when the merged batch would hold messages of a
conversation other than the one it records. The wake is then saved
behind the run, which is the existing fallback.
- Release drain (`findNextDeferredWake`), upgrade repair: a saved chat
wake that older code already merged across two conversations is split.
The other conversation's messages move to a plain saved wake (the outbox
shape), ordered just before it. The original wake keeps its conversation
and its own messages. The drain then reads the queue again.
- `doc/execution-semantics.md`: describe the comparison and the upgrade
repair.
- New test `chat-wake-conversation-batch.test.ts` (embedded Postgres,
real admission and release code, real
`authorizeChatConversationForBoundRun`).

## Verification

- Red on `origin/master` (test file only): `pnpm --dir server exec
vitest run
src/modules/wake-queue/adapters/chat-wake-conversation-batch.test.ts`
gives 4 failed, 6 passed. The 4 failures are the bug: the new message
merges into the other conversation's saved wake (`[[earlier, reply]]`),
two conversations cross-merge, a message coalesces into a queued run of
another conversation, and the old merged shape stays merged. The 6
passing cases pin unchanged batching.
- Green with the fix: 10 of 10 pass. Each separated wake passes
`authorizeChatConversationForBoundRun` for its own conversation. The old
merged shape fails it with
`paperclip_runner_chat_attachment_binding_denied` before the split.
- Batching that stays the same (pinned, pass before and after): comment
wakes with no conversation merge; messages of one conversation merge; an
outbox-saved wake of the same conversation still absorbs the reply; a
chat message and plain comments merge in both orders; same-conversation
messages still coalesce into a queued run; two recorded conversations
stay apart.
- Related suites pass locally: `src/modules/wake-queue`,
`src/modules/run-dispatch`, `chat-run-milestone-work`,
`durable-chat-wakeup`, `heartbeat-comment-wake-batching`,
`heartbeat-reviewed-chat-binding.integration`,
`slack-conversation-lifecycle`, `queued-comment-consumption`,
`issue-queued-comments-routes` (23 files, 1037 tests).
`heartbeat-process-recovery`: see the comment below.
- `tsc --noEmit -p server/tsconfig.json` passes.

## Risks

- Low risk. No schema change and no migration.
- Behavior change only for chat messages: a message no longer joins a
saved wake or a run that holds another conversation's inbound messages.
It gets its own wake behind them, and it runs after them. Before this
change, that batch always failed the binding check.
- Board comments have no inbound chat link, so they belong to no
conversation. Wakes without a conversation, wakes of one conversation,
and mixed board-comment and chat wakes batch exactly as before.
- Ordering: the deferred queue still promotes one wake per release,
oldest first. The second conversation waits for the issue execution
lock. Single-assignee and atomic checkout are unchanged.
- Upgrade: a saved wake that older code merged across conversations is
split at promotion. No message is dropped or delivered twice: each
message id moves to exactly one wake. A run that older code already
promoted in the merged shape is not changed; it fails the binding check
as before and keeps its existing Retry path.
- No wait is added before a turn starts. Admission adds one predicate to
an existing query. Coalescing adds one indexed read only when the merged
batch has a chat conversation.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
canary/v2026.1111.0-canary.3
2026-11-10 21:15:40 -06:00
DottaandClaude Opus 5.5 d79b7f29f2 fix(heartbeat): bind queued card-answer interrupts to the delivered answer (#20698)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A person can queue direction while a task's run is active, then use
Interrupt to stop the run and start a successor that receives the
direction
> - The queued direction can be an answer to a card, for example an
accepted confirmation of a saved plan
> - `explicitOperatorRunIdentity` verifies the click from the coalesced
queue receipt before the successor run can start
> - That check required comment ids in the receipt, but a card answer
carries its interaction and no comment ids
> - So every claim of the successor failed with 403, and the direction
never reached a run
> - This pull request binds a card-answer receipt to the answer that the
successor run delivers, and to the user who gave it
> - The benefit is that Interrupt works for queued card answers, and the
operator identity stays verified

## Linked Issues or Issue Description

Refs #14043, #14510

**What happened**

A person accepted a `request_confirmation` card on a task while its run
was active, and then clicked Interrupt. The active run stopped
("Interrupted to send queued messages"). The successor run was then
cancelled with "Cancelled because the queued run cannot be claimed:
Queued-message interrupt authority is unavailable". The coalesced
receipt had the interaction id and no `queuedCommentIds`. The successor
run's context had the same `interactionId` and the receipt id in
`queuedCommentSourceWakeIds`. The chat eval
`steering.interrupt-with-direction` recorded this.

**Expected behavior**

The successor run starts, receives the accepted direction once, and runs
with the clicking user as its responsible user. A receipt that the run
does not deliver, or an answer that another user gave, still fails
closed.

**Steps to reproduce**

1. Start a run on a task.
2. While the run is active, accept a confirmation card on that task. The
answer goes into the deferred queue.
3. Click Interrupt for the queued messages.
4. Before this change, the successor run is cancelled with
`queued_run_claim_rejected`.

## What Changed

- `server/src/services/run-identity.ts`: the shared receipt binding
(consumed `coalesced` receipt for the same company, agent and run; same
task; `user` request from the marker's actor) runs first, as before.
Then:
- A receipt with comment ids keeps the existing rule: all ids must be
delivered to the run.
- A receipt without comment ids must be a queued card answer
(`queuedInteractionId`). The run context must deliver the same
`interactionId`. The interaction must be on the same company and task,
with the receipt's status, and `resolvedByUserId` must be the clicking
user. Then the run gets the operator identity
(`queued_comment_interrupt`). Otherwise the claim still fails with 403.
- `server/src/__tests__/heartbeat-queued-run-claim-isolation.test.ts`: a
successor of a card-answer interrupt is claimed, runs, and has the
clicking user as responsible user. Two negative cases: the run delivers
a different answer, and another user resolved the answer. Both stay
rejected.

Relation to #14510: that open PR fixes the same claim failure (#14043)
by returning `null` for card-answer receipts, so the run starts without
the operator identity. Its Risks section notes that keeping the operator
identity needs a check that the actor resolved the interaction. This PR
adds that check instead, so the successor keeps the identity that
enqueue time records. If maintainers prefer #14510's approach, this PR's
regression test still applies to it.

## Verification

- `cd server && npx vitest run
src/__tests__/heartbeat-queued-run-claim-isolation.test.ts`: the new
positive test fails before the change (successor cancelled) and passes
after it. The negative tests and the existing tests pass.
- Suites that cover interrupt identity, each run separately:
`run-identity` (37), `heartbeat-responsible-user-invariant` (18),
`issue-queued-comments-routes` (129), `deferred-execution-receipt-work`
(50), `task-interruption-query` (31), `explicit-native-continuation`
(436), `run-cancellation` (8), `workspace-recovery-admission` (147),
`wake-queue/adapters/postgres` (113): all pass. In
`heartbeat-process-recovery`, the interrupt-named tests pass except
`dispatches interrupted CLI input and cleans up early fixture exits`,
which fails before any interrupt (the CLI child does not start:
`controller_lease_lost`). It fails the same way on unchanged master on
this loaded host (3 of 3 attempts failed at least one case), so it is
not caused by this change. CI covers it.
- `server` `tsc --noEmit`: no errors in the changed files.
- Live chat eval
`chat-features.runner-opencode.local.steering.interrupt-with-direction`
at this branch (run 42938970466): passed. The old run stopped with
"Interrupted to send queued messages". Exactly one successor run
started, delivered the accepted card answer, ran with the clicking user
as responsible user, and saved the approved single page and its delivery
document. Before this change, the same case failed with the successor
cancelled as `queued_run_claim_rejected`.

## Risks

- Low risk. The change adds a verified path only for receipts that
previously always failed. Comment receipts keep their checks.
- If a different user answered the card than the user who clicked
Interrupt, the claim still fails. The person who answered can click
Interrupt.

> 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 and code execution.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:10:28 -06:00
DottaandClaude ce3881f1b3 fix(skills): release a skill cache lock whose holder's deployment was replaced (#20688)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Run preparation publishes skill revisions into a shared runtime
skill cache. A file lock serializes each publication. A lock whose
holder provably exited is released at once.
> - That check used `controllerProcessExited`, a PID proof. It fails
closed whenever the recorded process space (host, kernel boot, PID
namespace) differs from this server's.
> - Every Railway deploy starts the server in a new process space. A
lock left by a server that a deploy stopped mid-publish can never be
released. Each later preparation of that skill waits 60 s and fails with
"Runtime skill cache publisher is busy … remove it", until an operator
deletes the file.
> - The rest of recovery already uses one shared prover
(`proveOwnerGone`). Across process spaces it accepts a replaced platform
deployment on the same exclusive volume, or a stopped or fenced server
boot.
> - This pull request makes the skill cache lock use that prover. It was
the last caller of the separate PID-only proof.
> - The benefit is one rule for "the controller from a previous
deployment is provably gone", and no manual file removal after a deploy.

## Linked Issues or Issue Description

**What happened?**
A census of staging failures after deploys showed that
`controllerProcessExited` always fails closed after a deploy, because
the process-space hash changes. On `master`, the only remaining caller
is the runtime skill cache lock check. The restart chaos harness
restarts in the same process space, so the PID proof succeeded there and
hid the gap. I did not find this failure in the staging run census. It
is a latent dead end with the same cause as the census class.

**Expected behavior**
A lock whose holder ran in a deployment that the platform replaced on
the same exclusive volume, or whose server boot stopped or was fenced,
is released at once. Any other holder is still waited for.

**Steps to reproduce**
1. Run the server on Railway with a volume.
2. Redeploy while a skill revision is being published (the lock file
exists).
3. Prepare a run that needs the same skill revision on the new
deployment.
4. Observe a 60 s wait and `Runtime skill cache publisher is busy; retry
preparation.` on every attempt.

**Paperclip version or commit**
`master` at `c444da7d05` (the new test fails there).

## What Changed

- `server/src/services/runtime-skill-cache.ts`:
`runtimeSkillCacheLockHolderExited` builds a local owner stamp from the
recorded controller (boot ID, PID, process space, start time, and the
Railway deployment and volume when recorded). It returns true only for
the `gone` verdict of `proveOwnerGone`.
- Same-space PID rules do not change. A dead PID is gone, a live PID
with no start time is unprovable, and this server's own boot is alive.
- `server/src/__tests__/runtime-skill-cache.test.ts`: a new test for a
holder in another process space whose deployment the platform replaced.
- The proof is bounded by the lock wait (5 s, never past the deadline).
A timed-out or failed proof keeps the lock held. A proof still running
is joined by later waits on the same holder, so a stalled read adds at
most one database query per holder.
- `server/src/services/controller-process-identity.ts`: removes
`controllerProcessExited`, which has no caller left; its test assertions
now check `sameProcessSpace`.

## Verification

- `npx vitest run src/__tests__/runtime-skill-cache.test.ts
src/services/controller-process-space-identity.test.ts` (from
`server/`): 31 passed.
- New: "releases a lock whose holder ran in a deployment the platform
replaced". It fails on `master` (`expected false to be true`) and passes
with this change.
- The same test keeps the lock held for: the same deployment ID, another
volume, a non-exclusive volume, another service, and no recorded
deployment.
- The existing test "never treats age, another host, or a legacy holder
as proof that a lock holder exited" still passes. Without a deployment
or controller lease proof, another host stays held.
- `npx tsc --noEmit -p server/tsconfig.json` passes.
- `pnpm verify` selected 2504 of 3294 test files (the change reaches
`owner-proof.ts` importers) and hit my 30 min local limit with no
failure seen. Required CI supplies the broad coverage.
- Live proof: not done (tests only).

## Risks

- Across process spaces, a held lock can now trigger a read of
`controller_instances` for the holder's boot. This happens at most once
per second, and only while a lock is held. Uncontended preparation does
no extra work.
- The prover may fence a boot row whose lease lapsed past its grace, as
it already does for every other recorded owner.
- No persisted shape changes. Older lock files without a `controller`
field stay held, as before.

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 20:57:03 -06:00
DottaandClaude 8c0dbffa70 fix(heartbeat): retry a legacy run whose lease this process lost before dispatch (#20694)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The heartbeat orphan reaper settles legacy (non-native) runs that
lost their controller lease
> - #16355 taught the reaper to retry a legacy run that a restart
stopped while setup was still `preparing`, because revocation fences the
old controller and `preparing` proves no adapter dispatch
> - That proof was applied only when the lapsed lease belonged to a
different boot
> - A stall can make the current process lose its own lease while setup
is still `preparing`, and its executor can end without settling the row
> - The reaper then wrote "retrying once" but recorded no bootstrap
evidence, so the retry was refused and the task was blocked for
reconciliation
> - This pull request applies the same revocation proof to a lease from
the current boot
> - The benefit is that these tasks recover by themselves instead of
waiting for a person to press Retry

## Linked Issues or Issue Description

Refs #16355

**What happened?**

On a hosted instance, one server process stalled for about 100 seconds
(lease renewals were 42–46 s late). Several legacy `paperclip_runner`
runs on a remote sandbox were still in setup (`executionStage:
"preparing"`, no process id). Their controller leases expired. Some
executors settled their own runs as `controller_lease_lost`. Two runs
had no executor left, so the orphan reaper of the same process settled
them 4 minutes later. Each run got `error: "Process lost -- server may
have restarted; retrying once"` and
`processLossDiagnostic.retryEligible: true`, but no `executionRecovery`
evidence. `enqueueProcessLossRetry` then saw a run that needs
reconciliation and scheduled nothing. The next sweep recorded
`legacy_execution_requires_reconciliation` with
`preserve_without_replay_v1`, and both tasks stayed `blocked` until a
person pressed Retry.

**Expected behavior**

Revocation commits the stage. Renewal requires the old boot id, so no
controller can move a revoked run to `dispatching`. A revoked run that
is still `preparing` with no process identity did no provider work,
whichever boot owned the lease. The reaper should record the bootstrap
evidence and use the bounded retry.

**Steps to reproduce**

1. Claim a legacy run in this process: `controllerBootId` is the current
boot, `executionStage: "preparing"`, no process id.
2. Let its controller lease expire with no local executor (for example
after an event-loop or database stall).
3. Run the orphan reaper.
4. Before this change: `process_lost; retrying once`, no
`executionRecovery`, no retry, and a reconciliation hold. After this
change: bootstrap evidence and a `scheduled_retry` successor.

## What Changed

- `server/src/services/heartbeat/recovery.ts`:
`restartInterruptedBeforeDispatch` no longer requires the lease to come
from another boot. The other clauses stay: legacy runtime, a stamped
controller boot id, and `preparing` with no process id or group in the
state committed by the revocation. Locally tracked runs and runs with a
live lease are still skipped before this point.
- `server/src/__tests__/heartbeat-process-recovery.test.ts`: two cases
in the hosted shape (assignment wake and due-monitor wake,
`paperclip_runner`, prepared remote workspace, same-boot expired lease,
`preparing`). They expect bootstrap evidence, a scheduled retry, no
recovery hold after a second sweep, and no adapter call. A control case
keeps a same-boot `dispatching` run held for reconciliation.

## Verification

- Red on `origin/master` (source change reverted): both new cases fail.
The monitor case shows the exact hosted shape: `retryEligible: true`
with no `executionRecovery`.
- Green with the change: `pnpm --filter @paperclipai/server exec vitest
run src/__tests__/heartbeat-process-recovery.test.ts -t "lease this
process lost|this process dispatched held|restart stopped before adapter
dispatch|reached adapter dispatch held|lost monitor|older build without
a controller stamp|failure budget"` (10 passed).
- Related files, run together: `heartbeat-process-recovery`,
`live-recovery-work`, `interrupted-preparation-hold-settle`,
`resource-wait-stop`, `legacy-controller-lease`,
`execution-recovery-dead-controller`, `legacy-process-settlement`,
`run-failure-report` (876 passed; 2 timing failures under load in
unrelated setup-failure tests, both pass when run alone).
- `pnpm --filter @paperclipai/server exec tsc --noEmit` passes.

## Risks

- Low risk. The proof is the one #16355 already relies on across boots:
the revocation compare-and-set replaces the boot id, so the in-memory
controller of this process also fails its next renewal and cannot
dispatch.
- A same-boot run reaches this code only when it is not in
`activeRunExecutions` or `runningProcesses` and its lease expired. If an
executor is stalled before it registers, it aborts at its first renewal.
Setup work can run again in the retry; provider work cannot.
- Upgrade safety: no persisted shape changes. The reaper reads the same
fields (`executionStage`, `controllerBootId`, process ids) that older
releases write. Runs that older code already parked keep their hold and
their working Retry.
- Runs that reached `dispatching`, recorded a process, or have no
controller stamp keep the current behavior.

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

**What happened?**

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

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

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

**Expected behavior**

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

**Steps to reproduce**

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

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

**Paperclip version or commit**

`master` at `7435abcc63`.

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 20:46:29 -06:00
DottaandClaude Opus 5.5 69c69e4c20 fix(recovery): finish the passive recovery pass before restarting it (#20680)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The workspace recovery worker saves the files of failed runs. One
passive pass of the worker visits holds with obligations: saved input
that waits, cleanup, and due re-arms and fallbacks of exhausted holds
> - #17741 restarts that pass after each committed workspace-recovery
change, so new work is found quickly
> - On staging, changes commit continuously (stop receipts, new holds,
recovery progress). Every pass restarted from the first candidates,
which are settled holds whose saved input waits behind another hold, and
it never reached later candidates
> - After #20295, #20569 and #20661 were deployed, no due re-arm ran:
five stop-proof holds and about twenty capture-policy holds kept their
exhausted state, with no marker and no activity
> - This pull request restarts the pass only after it reaches its tail,
and it continues an unfinished pass on the next run
> - The benefit is that every candidate is visited on a busy instance,
so the exhausted holds re-arm, save their files and free their tasks
without an administrator

## Linked Issues or Issue Description

Refs #17741
Refs #20295
Refs #20569
Refs #20661

**What happened?**
Exhausted workspace holds were not re-armed on staging, even after the
fixes that add their re-arm were deployed. Their recovery actions stayed
unchanged for days (`updatedAt` on Nov 5 or Nov 10). No
`stopProofRecovery` or `capturePolicyRecovery` marker and no
`issue.workspace_recovery_rearmed` activity was recorded. The worker did
process new restore holds, which are selected by its unfinished pass.

**What did you expect to happen?**
The worker's passive pass reaches every hold with a due re-arm or
fallback, re-arms it, and saves the files.

**Steps to reproduce**
1. Create several settled holds with saved user input that waits, with
action ids that sort before an exhausted stop-proof hold.
2. Start the real workspace recovery worker.
3. Commit a workspace-recovery notification every 50 ms, as stop
receipts and new holds do on a busy instance.
4. The worker never re-arms the exhausted hold. Without the
notifications, it re-arms the hold.

## What Changed

- `workspace-recovery-work.ts`: a committed change marks the passive
pass for restart (`restart`). It no longer resets the cursor. While a
restart is pending, an unfinished pass that made progress continues on
the next run.
- `workspace-recovery.ts`: when the passive cursor passes the last
candidate and a restart is pending, the same run starts a fresh pass
from the first candidate, so a new change is not delayed by another run
interval. Without a pending restart the pass records `complete` and does
not wrap, so paused input does not keep the worker active.
- `doc/execution-semantics.md`: document that a pass is never restarted
midway.
- `workspace-recovery-stop-proof.test.ts`: add a real-worker regression
in the staging shape (six earlier settled holds with waiting user input,
one exhausted stop-proof hold, continuous notifications).

## Verification

- Red: on master, the new test "re-arms the hold on a busy instance
whose passive sweep keeps selecting earlier settled holds" fails
(`stopProofRecovery` stays undefined). Control: the same test without
the notifications passes on master. This shows that the restarts cause
the starvation.
- Green: with the fix, the test passes. These suites pass:
`workspace-recovery-stop-proof` (12), `execution-control-work` (27),
which includes "visits a newly committed earlier Retry during a busy
recovered-source pass, then becomes quiet", plus
`workspace-recovery-capture-policy`,
`workspace-recovery-preparation-dispatch`,
`workspace-recovery-warm-handoff`, `workspace-recovery-preparation` and
`server-startup-feedback-export` (279). The timing-sensitive
`heartbeat-observed-workspace-preservation` suite passed 6 of 6 runs (an
earlier revision that restarted only on the next run made it flaky).
- Staging evidence (read-only GET requests): the 28 exhausted holds of
the company have no re-arm marker and no fallback check. Many blocked
tasks with settled holds have deferred wakes.

## Risks

- A change committed during a pass is now picked up at the next pass,
not at once. Unfinished physical work still uses its own pass first, so
new recoveries are not delayed. Saved input in a passive obligation can
wait up to one full pass.
- Previous-release story: no persisted state changes. Holds saved by
earlier releases are selected on the first full pass after deploy, with
no migration.
- Quiet behavior stays: a completed pass with no new change keeps the
worker idle.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use (shell,
code editing, browser GET reads) in Claude Code.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:44:09 -06:00
DottaandClaude Opus 5.5 a8f21a171c fix(cases): hold direct case status writes to terminal handoff authority (#20686)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The experimental Cases feature closes a case through a terminal
handoff to `done`
> - The terminal handoff lets an agent close a case only when the agent
owns the case's primary task
> - The direct case write routes (`PATCH /api/cases/:id` and the keyed
upsert on `POST /api/companies/:companyId/cases`) also write `status`
and `completedAt`
> - These routes did not apply the primary-task ownership rule to agent
status writes
> - This pull request applies the same ownership rule to those routes,
and sends `done` through the terminal handoff
> - The benefit is that a case closes only through the authority and
gates that the product describes

## Linked Issues or Issue Description

No public issue exists. This is the bug description.

**What happened**

An agent that did not own a case's primary task tried `handoff_case` to
`done` and got "Agents may complete only their own case work". The same
agent then sent `PATCH /api/cases/:id` with `status: "done"`. The route
returned 200 and closed the case. A chat eval (`cases.close-case`)
recorded this sequence.

**Expected behavior**

An agent changes a case with a primary task to or from
`done`/`cancelled` only with the authority that the terminal handoff
requires. `done` goes through the terminal handoff, so primary-task
completion keeps its normal gates (`doc/PRODUCT.md`, Cases section). The
dedicated `update_case` tool already excludes `status`.

**Steps to reproduce**

1. Create a case with a primary task that is assigned to agent A.
2. As agent B in the same company, send `PATCH /api/cases/:id` with
`{"status":"done"}`.
3. Before this change, the response is 200 and the case status is
`done`.

## What Changed

- `server/src/services/case-handoffs.ts`: add
`assertCaseStatusWriteAuthority`. For an agent actor, on a case that has
a primary task, a status change into or out of `done`/`cancelled` must
pass the same check as the terminal handoff (`authorizeTerminalTask`:
the agent is the primary task's assignee). A change to `done` then
returns 422 and names the terminal handoff. Board actors, non-terminal
status changes and fact edits do not change.
- `server/src/routes/cases.ts`: call the check inside the case write
transaction in `PATCH /api/cases/:id` and in the keyed upsert branch of
`POST /api/companies/:companyId/cases`.
- The terminal ownership denial now names the owning agent and the
primary task, and asks the caller to contact that agent on the task. The
message keeps its old first sentence.
- The keyed upsert falls back to the locked case row (not the pre-lock
read), so an upsert without `status` cannot undo a concurrent close.
- `server/src/routes/experimental-api-paths.ts`: add a catalog summary
and description for `PATCH /api/cases/{id}` that states the closure
rule.
- `server/src/__tests__/cases-routes.test.ts`: regression test for
non-owner `done`/`cancelled` through PATCH and upsert, the owner's
`done` through PATCH (422), owner cancel, non-owner reopen, and board
close.

## Verification

- `cd server && npx vitest run src/__tests__/cases-routes.test.ts -t
"terminal handoff's primary-task"`: fails before the route change
(`expected 403, got 200`) and passes after it.
- `cd server && npx vitest run src/__tests__/cases-routes.test.ts
src/__tests__/case-mutation-fence.integration.test.ts
src/__tests__/case-handoff-native.integration.test.ts
src/__tests__/case-handoff-legacy.integration.test.ts
src/services/case-handoffs.test.ts
src/services/native-runtime/native-case-tools.integration.test.ts
src/__tests__/heartbeat-case-terminal-handoff.test.ts
src/__tests__/openapi-routes.test.ts
src/__tests__/case-history-api-discovery.test.ts
src/services/native-runtime/runner-api.test.ts`: all pass. (One local
run of the legacy integration suite failed in fixture scratch
preparation under load; two reruns with the change pass.)
- `server` `tsc --noEmit`: no errors in the changed files. The fresh
worktree had unrelated errors from unbuilt workspace packages; CI covers
the full typecheck.
- Live chat eval
(`chat-features.runner-opencode.local.cases.close-case`, run
42936492869, at the first commit): the non-owner agent's terminal
handoff was denied with the owner named, and the agent did not change
case status through another route. The eval did not pass: the first chat
turn did not finish inside the scenario's 180-second observation window.
That is a separate latency issue in the eval.

## Risks

- Behavior change for agents: an agent can no longer set `done` through
PATCH on a case with a primary task, and a non-owner agent can no longer
cancel or reopen it. The agent skill reference already says "Do not
change case status separately".
- Cases without a primary task keep the old behavior, because they have
no owner to check and the terminal handoff does not apply to them.
- The check reads the primary task without a row lock, to keep the task
-> run -> case lock order. A concurrent reassignment can race one
request. The terminal handoff itself still locks and rechecks.

> 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 and code execution.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:41:27 -06:00
DottaandClaude Opus 5.5 cbd636d652 fix(evals): wait for rendered replies, bound runs by role and grade chat outcomes structurally (#20685)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The chat-feature eval catalog runs real chat journeys and grades
saved state, rendered links and run ledgers.
> - The second full-cohort run on master `9296c74e8d` failed 15 cases
where the product did the right thing and the harness did not.
> - The causes were three harness classes: links read before the reply
rendered, raw run counts that rejected product-caused follow-up runs,
and keyword graders on free-form replies. Four browser locators also
targeted retired controls.
> - This pull request fixes those harness layers. It does not change the
product.
> - The benefit is that these cases measure the feature again: correct
behaviour passes, and the relevant broken behaviour still fails.

## Linked Issues or Issue Description

**What happened**
In the second full-cohort chat-eval run on master `9296c74e8d`, these
cases failed although the reply and the saved state were correct:
`agents.choose-primary`, `cases.case-history`,
`budgets-costs.set-budget` (links read about 2s before the reply
rendered); `task-coordination.independent-review`,
`cases.update-case-facts`, `routines.manage-other-owner`,
`questions-and-approvals.review-plan` (a legitimate continuation or the
product's completion follow-up exceeded a raw run count or was
cancelled); `cases.cases-disabled`, `inbox-search.find-design`,
`navigation-preferences.broken-link`, `cases.browse-cases` (keyword or
clause graders rejected correct replies); `agent-chat.conference-room`,
`workspaces.inspect-git`, `task-coordination.block-on-design`,
`skills.author-skill` (browser steps used stale settings or controls).

**Expected behavior**
A correct reply and correct saved state pass. A wrong outcome, extra or
duplicate agent work, a substitution or a missing route still fails.

**Steps to reproduce**
Run `runner-full-stack-e2e.yml` with `cadence=manual`, `all=false` and
an `id` list of the cases above against master `9296c74e8d`. Read
`result.json` and `snapshots/` in each cell's evidence.

Refs #20600

## What Changed

- `chat-rendered-links.ts`: `collectConversationLinks` waits (at most
10s, once per reply per page) for the latest saved lead reply to render
when its conversation is open. A page that does not show the
conversation does not wait. `observeInternal` and the flow conversation
read use it.
- `budgets-costs.set-budget`: waits for the rendered reply before it
reads anchors. It fills the budget only after the form shows the saved
cap, and checks that the typed value stays.
- New `chat-catalog-domains/run-roles.ts`: identifies a run by role (a
worker woken by a comment that a named run saved on the task; the CEO's
chat completion follow-up).
- `task-coordination.independent-review`: admits one Morgan continuation
woken by the CEO's authorization comment and one Riley review run. The
review request may come from that continuation's native status decision.
Other runs still fail.
- `cases.update-case-facts`: a phase settles when its CEO turn and at
most one Morgan continuation woken by that turn's task comment finish. A
new `role-bound-runs` check rejects any other run. `terminal-counts`
gets an optional `maximumTerminalStatusCounts`.
- `routines.manage-other-owner`: admits one Morgan run on a task that
the CEO created for Morgan. A schedule change must be Morgan's own write
from that run. The ownership check is structural (a linked owner task,
or a link to the routine or Morgan's page). The keyword checks on the
reply are removed.
- `questions-and-approvals.review-plan`: after the stale-review check,
lets the product's completion follow-up settle before final run custody.
The existing grader already validates it.
- `cases.cases-disabled`: the negation-blind false-claim regex is
removed. The case passes with a reply link to the Experimental settings
page (the scripted person follows it and sees the Cases control) or a
pending choice card. Saved state still proves that nothing was created.
- `inbox-search.find-design`: the per-clause regex is removed. The first
task link in the reply must be the task with the approved revision.
- `navigation-preferences.broken-link`: a link to the similar task fails
only when its label names the original (identifier or exact title). A
separate mention passes.
- `cases.browse-cases`: an excluded case must not be a result row. When
the reply has no result list, every case link is a result, so an
excluded case must not be linked.
- Browser steps: `conference-room` reloads after the fixture enables the
flag. `inspect-git` and `block-on-design` open the panel with "Show
properties" and pick the tab from the side panel launcher.
`author-skill` does not repeat the full inspection in `verifyResult`
when `act` already inspected the same saved version.
- Each changed case has a new `oracleVersion`.
`chat-catalog-descriptors.json` is in sync.

## Verification

- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts <119
affected test files> --testTimeout=180000`: 1673 passed, 3 skipped. One
file (`chat-factual-query-input.test.ts`) did not load in the local
worktree because `@paperclipai/plugin-sdk` is not built there. It does
not import changed code. CI runs it.
- `chat-catalog-consistency.test.ts` passes (242 descriptors).
- `npx tsc --noEmit -p tests/runner-e2e`: no errors in changed files.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`:
no pin change.
- `node --test` on the three `chat-full-catalog-testops-*.test.mjs`
files: 40 passed.
- Calibration against retained evidence: the retained replies and run
shapes of cases-disabled, browse-cases, find-design, broken-link,
independent-review, update-case-facts and manage-other-owner now pass.
Broken variants fail (closed case as a result row, earlier design linked
first, original label on the similar task's link, unprompted or
duplicate worker runs, a second review run, a routine change outside the
owner's run, no route link).
- Paid runs on this branch: see the PR comments.

## Risks

- Low risk. Only eval harness code changes.
- The structural graders do not read reply wording. A reply that words
the outcome wrongly but links the right records can pass. A person reads
the reply.
- `navigation-preferences.sidebar-order` is not changed. The shipped
sidebar has no per-project order or collapsible Agents section, so the
case premise needs an owner decision.

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:24:41 -06:00
DottaandClaude 3fdcc2a28c fix(runner): resend an ACPX turn whose start was cut off before the prompt left (#20675)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native ACPX run (for example Claude) sends `turn.start` to its
Runner. The Runner asks the ACPX sidecar to start the turn, and the
sidecar sends the prompt to the provider
> - A start can fail without a definite answer. A host freeze can
outlast the sidecar request deadline, or the sidecar or Runner can die
during the start
> - The Runner then saved the session `closed`. Every same-run resume
failed with `ACPX session is not open`, and the task needed Retry
> - The Runner cannot tell from its own state whether the provider has
the prompt. Resending could duplicate it. A continuation could lose it
> - This pull request adds a durable record that answers that question:
a sidecar prompt-dispatch journal, written before the prompt reaches the
provider
> - The benefit is that a start cut off before the prompt left resends
the prompt exactly once, with no Retry. Every case the record cannot
prove keeps today's behavior

## Linked Issues or Issue Description

**What happened?**
Local restart-chaos wave `w-20261110T185315-s9107`, task t05 (claude,
workload C1, fault TM2). A follow-up run reopened the parked Claude
session and sent `turn.start`. The sidecar first starts Claude and loads
the conversation; the prompt goes out only after that. After 24 s
without a reply, the harness froze the server, Runner, sidecar and
Claude (SIGSTOP) for 33 s. On thaw the Runner's 30 s sidecar deadline
had passed. It reaped the sidecar and saved the session `closed`. Three
same-run resumes failed with `PRP command turn.start failed: … ACPX
session is not open`, recovery stopped with
`native_session_retry_exhausted`, and a person had to click Retry.

**Expected behavior**
When a durable record proves the prompt never reached the provider, the
run resends it once by itself. When the record cannot prove it, the run
behaves as today.

**Steps to reproduce**
1. On master, run `cargo test --manifest-path
packages/paperclip-runner/runner/Cargo.toml -p paperclip-runner-core
--test acpx_park_preparation cut_turn_start`.
2.
`a_start_cut_off_before_the_prompt_left_reopens_and_sends_the_prompt_once`
fails: the session is `closed` and the next start fails with `ACPX
session is not open`.

Refs #20507, #20645, #20674

## What Changed

- New file contract in
`protocol/provider-schemas/acpx-sidecar.schema.json`:
`turnDispatchJournalFile` (`paperclip-turn-dispatch.jsonl`) and
`turnDispatchJournalRecord` (`received` / `dispatched`). The generator
emits the file name and schema into TypeScript and Rust, so the pack
contract digest covers them.
- Sidecar (`turn-dispatch-journal.ts`, `codex-runtime-adapter.ts`,
`runtime-host.ts`, `acpx-runtime-sidecar.ts`): when the runtime port
starts a turn, before any provider work, it truncates the journal and
writes `received`. ACPX calls `onAcpMessage("outbound")` synchronously
before it writes a message to the provider. On the turn's first
`session/prompt` request the port appends `dispatched` and fsyncs it
there, so the record exists before the bytes leave. A failed dispatch
write aborts the prompt write. A failed `received` write removes the
file and runs the turn unjournaled.
- Runner (`acpx_turn_dispatch.rs`, `acpx_provider_backend.rs`): the
journal is read only after the provider lifetime fence proves that the
sidecar and provider are gone. A lone complete `received` record for the
turn proves the prompt never left.
- A start that failed in flight saves the session `prepared` with no
active turn and returns `ACPX turn start failed before its prompt
reached the provider`. The run's next `turn.start` reopens the
conversation and sends the prompt once. Until the proof settles, the
saved state stays `turn_starting`, so a Runner killed during the proof
goes through the restore check below.
- A Runner killed while the session was `turn_starting` restores the
session idle (`session.resumed`, no `turn.failed`). The same-run resume
then starts the turn once instead of sending a continuation that would
lose the prompt.
- A `dispatched` record, another turn's record, a torn or malformed
file, or no file keeps today's behavior (closed session, or lost-turn
recovery).
- Fake sidecar: writes the same journal and adds `hang-before-dispatch`
/ `hang-after-dispatch` faults.
- Docs: `packages/paperclip-runner/docs/durable-recovery.md` ("ACPX turn
starts cut off before the prompt left"). The design note is
`doc/plans/2026-11-10-durable-turn-start-and-tool-call-records.md` (in
#20674).

## Verification

- Red on master, green here: `cargo test -p paperclip-runner-core --test
acpx_park_preparation cut_turn_start` (4 tests: cut before dispatch
reopens and sends once; cut after dispatch stays closed and never
resends; Runner killed before dispatch restores idle; Runner killed
after dispatch or with no journal reports the lost turn). The two
"before" cases fail on master.
- `cargo test -p paperclip-runner-core`: all pass. `cargo fmt --check`
clean.
- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/drivers/acpx/turn-dispatch-journal.test.ts
src/drivers/acpx/codex-runtime-adapter.test.ts`: 121 passed. One test
uses a real ACPX runtime and an ACP peer that refuses a prompt arriving
before its `dispatched` record; it fails if the hook is removed.
- `node scripts/generate-acpx-sidecar-contract.mjs --check`, `node
--test scripts/contracts/contract-fingerprints.test.mjs`, and `tsc -p
tsconfig.test.json` pass.
- Time to first token, measured with the same open/write/fsync sequence
on this Mac (APFS, load average ~11, 300 samples): `received` p50 0.24
ms, p95 0.59 ms; `dispatched` (with fsync) p50 4.7 ms, p95 11.5 ms.
Against a Claude TTFT budget of 9.5–16.5 s p50 this is under 0.1%. No
wait, round trip or file transfer is added. Recovery work (fence,
journal read) runs only after a failed start.
- Live restart-chaos wave and runner image: see the comments on this
pull request.

## Risks

- A false "not dispatched" proof would duplicate a prompt. It needs a
lone, complete `received` record for this exact turn ID, read after the
fence proves the sidecar is gone. `dispatched` is fsynced before the
prompt write, and a failed write aborts the send. A failed `received`
write removes the file, so it cannot leave a lone record.
- Upgrade safety: saved provider state gains no field (its decoder
denies unknown fields), and only lifecycles an older Runner already
handles are written. An older Runner ignores the journal file. State
saved by an older Runner has no journal, which reads as unknown and
keeps today's behavior. A new Runner with an older sidecar never sees a
`received` record, so no proof forms.
- The changed sidecar schema changes the pack contract digest, so
Daytona needs a new runner image. Older image packs are not reused.
- A failed start now waits for the provider lifetime fence (at most 5 s,
usually immediate) before it settles. This is only on a failure path.

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use (shell,
file editing) in Claude Code. Extended reasoning was used.

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
canary/v2026.1111.0-canary.2
2026-11-10 20:15:48 -06:00
DottaandClaude b93df4ca38 fix(chat): consume a saved chat wake whose message a completed run answered (#20689)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - External chat (for example GitHub review requests) wakes the
assigned agent with a saved chat message
> - When the agent is already running, the chat wake is saved behind
that run and released after it finishes
> - The running agent often reads the new message as part of its current
turn, so the message is already answered when the saved wake is released
> - The release kept that chat wake anyway, removed its now-delivered
message ids, and started a reviewed chat run with no message
> - A reviewed chat run with no message cannot be attested, so it failed
with `reviewed_chat_execution_binding_not_authorized` and the task
became blocked
> - This pull request treats a chat message wake like a comment wake:
when a completed run already received all its messages, the saved wake
is consumed
> - The benefit is that a message that arrives during an active run no
longer blocks the task after it was answered

## Linked Issues or Issue Description

**What happened?**
A GitHub chat message arrived for a task while the assigned agent's run
was active. The wake was saved behind that run. The run read the message
and answered it. When the run finished, the saved chat wake was still
promoted. Its message ids were removed because a completed run already
received them. The new run had source `chat:<provider>` and no message,
so reviewed chat attestation denied it with `context_mismatch`. The run
failed with `reviewed_chat_execution_binding_not_authorized`.
Review-path recovery inherited the same context and failed too, and the
task was blocked.

**Steps to reproduce**
1. Assign a task in review to an agent that is running.
2. Deliver an external chat message for the task. The wake is saved
behind the active run.
3. Let the active run receive the message as wake input and succeed.
4. Release the task's execution lock.
5. See that the saved chat wake is promoted with no message, instead of
being consumed.

**Expected behavior**
The saved chat wake is consumed with "Queued messages were already
delivered to a completed run". No new run starts. The chat does not get
a "not started" notice for a message that was answered.

**Paperclip version or commit**
`origin/master` at 6e524d8a69.

**Additional context**
This was seen on a staging deployment on five runs across three tasks in
one day. It is a message timing race, not a deploy or authorization
problem.

## What Changed

- `server/src/modules/wake-queue/adapters/postgres.ts`: a saved wake
whose reason is `External chat message received` and whose payload
mutation is `chat_message_received` is treated as message-only. If a
completed run already received all of its messages, the release consumes
it, the same as a comment wake. If any message is still unread, it is
promoted as before.
- `server/src/services/chat-inbound-wakeup-publications.ts`: a chat
receipt consumed for that reason does not publish "This follow-up was
not started". Other declines keep that notice.
- `server/src/services/queued-comment-delivery.ts`: constants for the
chat message wake reason and the delivered-message reason.
- New test
`server/src/modules/wake-queue/adapters/chat-wake-delivered.test.ts`.

## Verification

- `cd server && npx vitest run
src/modules/wake-queue/adapters/chat-wake-delivered.test.ts`
- RED on `origin/master`: the release promotes the consumed chat wake
("Unexpected promotion of a consumed chat wake"), and the receipt
resolves to a `not_started` notice.
- GREEN with this change: the wake is cancelled with the delivered
reason, no run starts, no "not started" notice, and a chat wake with an
unread message is still promoted.
- Related suites: `src/modules/wake-queue`,
`heartbeat-reviewed-chat-binding.integration.test.ts`,
`durable-chat-wakeup.test.ts`,
`heartbeat-native-status-context.test.ts`,
`external-chat-wait.integration.test.ts`,
`heartbeat-held-comment-wake.test.ts`.
- `npx tsc --noEmit -p server` passes.

## Risks

- A chat wake that merged with another kind of wake takes the merged
wake's reason and mutation. Only a wake whose current reason and
mutation are both the chat message values is treated as message-only, so
other merged intents keep promotion.
- A visible "Your follow-up is queued." notice is not followed by a "not
started" notice when the message was already answered. That matches the
existing behavior when the wake is promoted.
- Not changed: a separate case where messages from two different chat
conversations are merged into one saved wake. That case fails with
`binding_denied` and needs its own change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edit, read-only API access). Extended
reasoning was used.

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 20:15:13 -06:00
DottaandClaude 06af074452 fix(native): deliver the retry after a failed native turn, once per chain (#20681)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The native runtime decides a task's next status after each run,
through the status arbiter and the status-decision committer
> - When a native turn fails without a classified provider error, the
arbiter keeps the task's status and asks for one retry
(`schedule_retry`)
> - The native-status wake dispatcher refused that retry wake, because
its provenance check accepted only `enqueue_continuation` effects
> - So the task stayed `in_progress` with no live run, no queued wake
and no visible cause, until a person noticed it
> - This pull request lets the dispatcher accept the committed retry
effect, keeps the retry on its chat route, and limits the automatic
continuation to one per chain
> - The benefit is that a failed turn always ends with either queued
work or a blocked task with a working Retry

## Linked Issues or Issue Description

**What happened?**
A native run that ends with a failed turn and no classified provider
error leaves its task stranded. Examples: an OpenCode server that the
sandbox killed with SIGKILL during a turn (`opencode_server_exited`), or
an OpenCode turn that ended without an answer
(`opencode_empty_response`). The run's accepted result is finalized as
`run_failed_partial_evidence_preserved`. The committer queues a
`schedule_retry` wake. The dispatcher then skips the wake with
`chat_control_recovery_proof_unresolved`. The task stays `in_progress`
with no run, no wake and no blocker.

**Steps to reproduce**
1. Run a native task. Make the provider turn fail with
`opencode_server_exited` or `opencode_empty_response` and an accepted
`needs_review` result.
2. Finalize the run.
3. Run the native-status wake dispatcher.
4. See that the retry wake and its dispatch receipt are `skipped`, and
that no run starts.

**Expected behavior**
The task gets its automatic continuation. If that continuation fails the
same way, the task is blocked with a clear board blocker and a Retry
that starts a new run.

**Paperclip version or commit**
`origin/master` at c444da7d05.

**Additional context**
The task stays `in_progress` for hours with no owner. On a staging
deployment, two tasks stayed in this state for more than 7 and 11 hours.

## What Changed

- `server/src/services/chat-control-recovery-stop.ts`: the native-parent
proof accepts the `schedule_retry` effect, as well as
`enqueue_continuation`, when the effect targets the wake intent. Other
effect kinds (for example `notify_owner`) are not changed. The other
checks (applied decision, delivered effect, same company, task and
agent) do not change.
- `server/src/services/native-runtime/status-arbiter.ts`: new input
`failedTurnContinuationUsed`. When it is true, a failed run is decided
as `native_run_failed_continuation_exhausted`: the task is blocked with
a board blocker and no wake. New constants
`NATIVE_RUN_FAILED_RETRY_CAUSE` and
`NATIVE_RUN_FAILED_CONTINUATION_EXHAUSTED_ACTION`.
-
`server/src/services/native-runtime/native-failed-turn-continuation.ts`
(new): `isFailedTurnContinuationRun` reads the run's dispatcher-set
`nativeStatusWakeIntentId` and proves from the intent row (company,
agent, task, committer actor, `retryCause`) that the run is the
automatic continuation of a failed turn. A copied or unknown id does not
count.
- `server/src/services/native-runtime/native-run-finalizer.ts`: passes
`failedTurnContinuationUsed` to the arbiter for failed runs that the run
still owns; the committer rechecks that ownership under the issue lock
(`requireProviderFailureOwner`).
- `server/src/services/native-runtime/native-continuation-delivery.ts`:
the failed-turn retry keeps the failed run as its chat conversation
source, so its answer reaches the original chat.
- `doc/execution-semantics.md`: documents the retry wake, its provenance
and the one-continuation bound.
- New test `native-failed-turn-continuation.integration.test.ts`.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-failed-turn-continuation.integration.test.ts`
- RED on `origin/master` (c444da7d05): the dispatcher reports
`dispatched: 0` and the wake is skipped with
`chat_control_recovery_proof_unresolved`.
- GREEN with this change: for both `opencode_server_exited` and
`opencode_empty_response`, the dispatcher starts one continuation run.
When that continuation fails the same way, the task is blocked with the
board blocker, no new wake is queued, and the task Retry is accepted and
starts a new run. A copied wake id does not exhaust the continuation.
- Related suites pass: `status-arbiter.test.ts`,
`native-provider-failure.integration.test.ts`,
`heartbeat-dependency-scheduling.test.ts`,
`chat-control-admission-retry.test.ts`.
- `npx tsc --noEmit -p server` passes.
- `pnpm verify` selects broad verification (exit 2) because
`doc/execution-semantics.md` changed. Required CI supplies that
coverage.

## Risks

- Behavior change: a failed native turn now gets the automatic
continuation that the arbiter already requested. That continuation is a
new run of the same task, with the normal admission gates. It does not
replay the failed turn.
- The continuation is bounded to one per chain, so a deterministic
failure cannot make a wake loop.
- Tasks that are already stranded by the old behavior keep their skipped
wake. Their task Retry still works.
- Upgrade safety: an old pending `schedule_retry` intent is accepted by
the new dispatcher. A run that started before this change has no proven
intent, so it gets one continuation.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, through Claude Code
with tool use (shell, file edit, read-only API access). Extended
reasoning was used.

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 20:10:48 -06:00
DottaandClaude 52160eba35 test(restart-chaos): K15 redeploy, F1 workspace symlink, and the staging failure census (#20679)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The restart-chaos harness (`tests/restart-chaos/`) restarts the
control plane while real agent tasks run. It grades whether every task
finishes without a human Retry.
> - The hosted staging stack deploys about every 40 minutes. In 48
hours, 276 runs failed and 63 tasks stayed blocked. About a quarter of
the failures came within 10 minutes of a deploy. The harness passed the
same areas.
> - A hosted deploy replaces the server container. The new server runs
in another process space and has a new deployment id. The PID-based "the
earlier owner exited" proofs never apply across that boundary. A harness
restart on one machine keeps the process space, so every PID proof
succeeds and the hosted recovery path is never tested.
> - This pull request adds K15, a redeploy fault. It also adds the
staging census that shows the gap.
> - The benefit: chaos waves can now exercise the recovery path that
staging runs after every deploy, on local and Daytona.

## Linked Issues or Issue Description

**What happened?**
Many staging tasks that a deploy crossed ended blocked. Examples are
`native_remote_recovery_lease_mismatch`,
`runner_remote_process_identity_unavailable`,
`prior_owner_terminal_state_indeterminate` and `PRP provider resume
state is unavailable`. The restart-chaos Daytona waves passed for the
same adapters.

**What did you expect?**
The chaos harness should reproduce the deploy conditions that staging
has, so that these failure classes show up in a wave first.

**Steps to reproduce**
1. Compare the staging run records' `controllerProcess.hostHash` and
`deployment.deploymentId` across a deploy. Both change on every deploy.
2. Compare the same fields across a harness K2 restart. Both stay the
same.
3. Read `controllerProcessExited` and `sameProcessSpace` in
`server/src/services/controller-process-identity.ts`. A different
`hostHash` means that no PID proof applies.

**Additional context**
The full census, the failure classes and the per-class harness gaps are
in `doc/plans/2026-11-11-staging-failures-vs-chaos-coverage.md`. The
task identifiers in it are neutral labels.

## What Changed

- New fault `K15` (redeploy), in `tests/restart-chaos/redeploy.ts` and
`redeploy-preload.mjs`. A K15 wave gives every server an emulated host
name through a `--import` preload, which changes the process-space hash.
It also gives Railway-shaped deployment variables with the instance home
as the exclusive volume. A K15 stops the server with SIGTERM and starts
the next generation, with a new host name and deployment id.
- `supervisor.ts`, `main.ts`, `config.ts` and `types.ts`: select K15
with `--faults K15`. It works on `--env local` and `--env daytona`. The
receipt records `redeploy` and the timeline records `redeploy_switched`.
Config refuses K15 together with MS1–MS3.
- New opt-in workload `F1` (`workloads.ts`): the agent keeps an absolute
symlink in its repository workspace. A staging save-back refused such a
link and blocked the task. The oracle needs `done` and the saved notes
file, with no Retry. `f1-symlink.test.ts` covers the prompt, the grading
and the option planning.
- `redeploy.test.ts`: identity derivation, the environment, the argument
order, a real child process that proves the preload changes both CJS and
ESM `hostname()`, option parsing and plan detection.
- `tests/restart-chaos/README.md`: a K15 section and a row in the
"Pieces" table.
- `doc/plans/2026-11-11-staging-failures-vs-chaos-coverage.md`: the
48-hour staging census, 12 failure classes, the deploy timeline, the
reason the harness missed each class, and proposed scenarios.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts
tests/restart-chaos/redeploy.test.ts
tests/restart-chaos/scheduler.test.ts
tests/restart-chaos/repeated-restarts.test.ts`: 68 tests pass. The full
restart-chaos unit suite passes: 43 files, 516 tests.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`:
passes.
- Live Daytona waves on 8f6ca25976, image
`paperclip-daytona-runner@sha256:bc7ca92d…`:

  | Wave | Faults and cells | Result |
  | --- | --- | --- |
| `w-20261111T002313-s9301` | K15, opencode and claude, A1 and C1, reuse
lease, 6 tasks | 5 of 6 pass. t05 (OpenCode, C1) failed R5
`silent_dead_run`: 183 s without evidence after the redeploy, then
succeeded. |
  | `w-20261111T004241-s9301` | K2 control, same seed | 6 of 6 pass |
| `w-20261111T005008-s9302` | K15, warm sandbox, comment trigger, 4
tasks | 4 of 4 pass |
| `w-20261111T010000-s9303` | K15, opencode only, 8 tasks | 8 of 8 pass
|
  | `w-20261111T011044-s9303` | K2 control, same seed | 8 of 8 pass |
| `w-20261111T012523-s9304` | F1 with K2, codex and opencode, `--storage
s3` | 2 of 2 pass |

- Every K15 fault switched the generation, with a new host name and
deployment id on the same volume. The server was ready again in 14–24 s.
- K15 does not yet reproduce the staging refusal codes on current
master. The census document explains the open conditions, such as a code
change at the deploy.

## Risks

- Low risk. The change is in the test harness and docs only. No product
code changes.
- K15 is an emulation. It changes the host name only in the server
process (and forked Node children). It does not emulate a new kernel
boot or PID namespace. The cloud stop-proof service is not emulated, so
stop proofs that need it stay unsupported, as on a self-hosted Railway
deploy. The README lists these limits.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edit, GitHub CLI).

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 20:05:17 -06:00
DottaandClaude 7435abcc63 fix(native): wait out a run-event append before saving provider-pack admission (#20682)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native OpenCode run in a remote sandbox records a provider-pack
admission receipt on its run row after the provider session opens.
Artifact retention then requires that receipt.
> - `recordNativeProviderPackAdmission` locked the run row with `FOR
UPDATE NOWAIT`.
> - A run-event append locks the same run row for a few milliseconds
(`FOR NO KEY UPDATE` since #20670, `FOR UPDATE` before). Both conflict
with `FOR UPDATE`. Startup appends many events at the same moment.
> - When the two met, the admission write failed with `55P03`. The
admission state stayed `pending`. Artifact retention then failed the run
with `native_provider_artifacts_admission_unverified`. The task stopped
as "Recovery needed" with no Retry.
> - This pull request makes the admission write wait for the run-row
lock within the existing 1000 ms `lock_timeout`. The coordinator claim
keeps its `NOWAIT` fence.
> - The benefit is that an ordinary concurrent event append no longer
fails a healthy run before its first turn.

## Linked Issues or Issue Description

Refs #20670 (changed the append lock mode; the conflict with `FOR UPDATE
NOWAIT` remains).

**What happened?**
On a hosted staging instance, three OpenCode runs on remote sandboxes
failed in the first minute with
`native_provider_artifacts_admission_unverified`. Each run log shows
`native provider-pack admission receipt could not be saved` about
0.2–0.7 s after the runner authenticated. The run profile kept
`nativeProviderPackAdmissionState.state = "pending"`. The tasks stayed
blocked.

**Steps to reproduce**
1. Start a native run and hold its `heartbeat_runs` row lock in another
transaction, as a run-event append does.
2. Call `recordNativeProviderPackAdmission` for that run.
3. Observe `could not obtain lock on row in relation "heartbeat_runs"`
(`55P03`).

**Expected behavior**
The admission waits a short, bounded time for the append to commit, then
saves the receipt.

## What Changed

- `recordNativeProviderPackAdmission` sets `lock_timeout = 1000ms` for
every call, not only `waitForClaim` calls.
- The run-row lock is now `FOR UPDATE` without `NOWAIT`, so it waits for
a concurrent append within that bound.
- The coordinator (`native_run_finalizations`) lock keeps `NOWAIT` when
the caller does not wait for the claim. The existing fence tests still
pass.
- New PostgreSQL test: `waits for a concurrent run-event append's
run-row lock instead of losing the admission` (both `waitForClaim`
modes).

## Verification

- RED on `origin/master` (`c444da7d05`): the new test fails with `55P03`
in both modes.
- GREEN with this change: `pnpm --filter @paperclipai/server exec vitest
run src/services/native-runtime/native-provider-pack-admission.test.ts`
(37 passed).
- `pnpm --filter @paperclipai/server typecheck` passes.
- `pnpm verify`: see the result below.

## Risks

- Low risk. The normal path has no contention and does not wait. Under
contention the admission waits at most 1000 ms, before any provider
turn.
- A lock wait between the coordinator row and the run row can now meet a
deadlock. PostgreSQL detects it and raises an error. That error takes
the same caught path as the earlier `55P03`, so the result is no worse
than before.

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 19:39:42 -06:00
DottaandClaude 6e524d8a69 fix(workspaces): stop retrying a copy-back that hit a full sandbox disk (#20678)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a native run, Paperclip copies the agent's sandbox workspace
back to durable storage. This is finalization.
> - The copy-back builds an archive inside the sandbox. When the sandbox
disk is full, the copy-back fails with
`remote_workspace_sandbox_disk_full`.
> - Finalization treated that failure as transient. It retried three
times. Each attempt took about 15 minutes for a large workspace, and
nothing freed space between attempts.
> - Saved-work recovery already reclaims the retained sandbox's inactive
provider cache when a save hits a full disk. It runs only after
finalization ends.
> - This pull request ends finalization on the first full-disk copy-back
failure, with the accepted result kept, so saved-work recovery starts at
once.
> - The benefit is that the task gets its files saved sooner, and the
user sees what happened instead of "failed after 3 attempts".

## Linked Issues or Issue Description

Refs #19619 (related: records why the read-only capture fallback failed;
this PR does not change that path)

**What happened?**
On staging, a native run finished its turn and its result was accepted.
The workspace copy-back then failed three times with
`remote_workspace_sandbox_disk_full` (about 7.5 GB of workspace, about 6
GB free). The three attempts spanned about one hour across deploys.
Finalization then ended as `native_workspace_sync_out_retry_exhausted`.
Saved-work recovery reclaimed the inactive provider cache (about 3 GB
freed) and saved the files.

**Expected behavior**
A full sandbox disk is recognised on the first failure. Paperclip does
not spend the finalization budget on attempts that cannot succeed. The
agent's result is kept. The user sees that the disk is full and that
Paperclip saves the files again without another provider turn.

**Steps to reproduce**
1. Run a native task on a remote sandbox whose workspace is larger than
the free sandbox space.
2. Let the provider turn finish.
3. Observe three copy-back attempts that each fail with
`remote_workspace_sandbox_disk_full`.

## What Changed

- `recordRetryableFailure` treats a workspace failure whose cause starts
with `remote_workspace_sandbox_disk_full:` as an exhausted budget on the
first attempt. The code stays
`native_workspace_sync_out_retry_exhausted`, so the existing export
hold, saved-work recovery, and export Retry apply unchanged.
- The run error for that case says that the sandbox disk is full and
that Paperclip saves the files again without repeating the agent's work.
It no longer says "after 3 attempts".
- Other copy-back failures keep their bounded retry.
- `captureRemoteWorkspaceArchive` rethrows the read-only fallback's own
failure when that fallback only lost its connection (`transport_closed`,
`provider_unavailable`, `provider_rate_limited`,
`sandbox_state_transition`). That failure is not disk-full, so
finalization keeps its bounded retry for it. Other fallback failures
keep the disk-full code.
- `remoteWorkspaceFailureCause` records the server-built read-only
capture failure, so a failed save names where the fallback stopped.
- The transport exports `REMOTE_WORKSPACE_SANDBOX_DISK_FULL_CODE`. The
finalizer and tests use it.
- `doc/execution-semantics.md` describes the new rule.

## Verification

- `cd server && npx vitest run
src/services/native-runtime/native-run-finalizer-telemetry.test.ts
src/services/remote-workspace-transport.test.ts` (195 pass).
- New test "hands a full sandbox disk to saved-work recovery on the
first copy-back failure" fails on `origin/master` (the failure stays
`retryable_failure`) and passes with this change. It completes an
accepted result first and checks that the result stays linked and that
no agent wakeup is created.
- New transport test "keeps a full disk only when the read-only fallback
did not just lose its connection": the two dropped-connection cases fail
on `origin/master` and pass with this change.
- New test "keeps other copy-back failures on their bounded retry"
passes before and after.
- `cd server && npx tsc --noEmit` passes. `pnpm verify` selected 2504
test files because the finalizer is widely imported; its first selected
batch passed, and required CI supplies the full run.

## Risks

- Low risk. The terminal state is the same state that three failed
attempts produce today. Only the timing changes.
- A sandbox whose disk frees space by itself between attempts now waits
for saved-work recovery instead of the next finalizer attempt.
Saved-work recovery repeats the save with its own bounded attempts, so
this does not lose work.
- Upgrade safety: a run saved by an older release in `retryable_failure`
with this cause ends on its next full-disk failure. No migration is
needed.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use (shell,
file edits) in Claude Code. Extended reasoning was not configured.

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 19:31:33 -06:00
DottaandClaude Opus 5.5 4a61a2501e fix(storage): never promise a storage retry no storage wait owns (#20676)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A run that hits a file-storage outage gets a bounded storage wait: a
retry that starts again after storage is back
> - The run error says "Paperclip will retry automatically when storage
is back"
> - On staging, a server restart reattached a remote native runner while
its turn was still running. A storage outage then failed the
reattachment
> - Restart recovery kept the workspace under a no-replay hold, so the
run took the code `native_continuation_requires_reconciliation`. Only a
`storage_unavailable` run gets a storage wait, so no retry was
scheduled. The run still showed the storage retry promise, and the task
stayed blocked for about 15 hours
> - This pull request makes the storage text follow the run's final code
and the real storage wait outcome, and reads old rows truthfully
> - The benefit is that every storage message is true: it promises a
retry only when one is scheduled, and it offers a working Retry when
nothing retries the run

## Linked Issues or Issue Description

**What happened?**
A native run on a remote sandbox was reattached after a server restart.
Its provider turn was still in flight. File storage failed during the
reattachment. Restart recovery settled the run as
`native_continuation_requires_reconciliation`, kept its files, and did
not replay the unfinished turn (this is the existing policy for a turn
with an unknown outcome). The run error said "Paperclip will retry
automatically when storage is back". No storage wait was scheduled, so
nothing retried. The task stayed blocked until an operator clicked
Retry.

**Root cause**
`server/src/services/heartbeat/run-completion.ts` chose the message and
the error code separately. The message used
`isStorageUnavailableError(err)`. The code used a chain where
`nativeTerminalFailureCode` wins over `storage_unavailable`. The storage
wait is scheduled only when the code is `storage_unavailable` (and no
native terminal failure is recorded). So the message promised a wait
that the code made impossible. The same false promise stayed when the
storage wait was refused (`not_scheduled`) or its write failed.

**Expected behavior**
A storage failure promises an automatic retry only when the storage wait
is scheduled. Otherwise it offers Retry. Rows saved by earlier releases
read the same way after deploy, with no migration.

**Steps to reproduce**
Fail a native run with a storage-unavailable error after its native
coordinator is settled as `terminal_failure` /
`native_continuation_requires_reconciliation`. See the new regression in
`run-completion.test.ts`.

## What Changed

- `storage-unavailable.ts`: add the held messages (outage and rejected
credentials). They say Paperclip is not retrying automatically and offer
Retry or a message. Add `storageUnavailableHeldMessage()` and
`presentStorageFailureMessage()`.
- `run-completion.ts` (`failRun`): compute the error code first. Use the
storage retry promise only when the code is `storage_unavailable`. Under
any other code (for example a native recovery hold), use the held
message. No storage wait is created for such a run, because it could
only be refused.
- `run-completion.ts` (`failRun` and `failRunSetup`): replace
`markStorageWaitExhausted` with `settleStorageWaitMessage`. It keeps the
promise only when a retry is scheduled. It writes the exhausted text
when the budget is spent, and the held text when the wait was refused,
its write failed, or a case handoff took over.
- `run-completion.ts`: when the promise is withdrawn, the failure's
`error` event (written before the retry decision) gets the same final
text, so the transcript does not keep the promise after reload.
- `run-completion.ts` (`failRunSetup`): a lost controller lease keeps
its own code and text, not the storage promise.
- `run-lifecycle.ts`: `decorateHeartbeatRunRuntimeStatus` presents a
saved storage retry promise under a code other than
`storage_unavailable` with the held text. This covers run detail and
issue run presence. `listRuns` in `run-state.ts` applies the same
projection, so the company run list and the Inbox failure read agree.
Rows from earlier releases read truthfully with no migration.
- `doc/run-log-events.md`: document which runs get a storage wait and
what each message means.

Previous-release story (upgrade-safe run state): this change adds no
persisted shape. Rows saved by the previous release carry forward. A row
with the storage promise under another code reads with the held text. A
`storage_unavailable` row keeps its text. The native hold policy is
unchanged: an unfinished turn with an unknown outcome is not replayed
automatically, and the existing Retry continues it.

## Verification

- Red before the fix (assertion failures, not setup errors): the new
`run-completion.test.ts` cases received "Paperclip will retry
automatically when storage is back" under
`native_continuation_requires_reconciliation`, and the
`run-lifecycle.test.ts` cases read the saved promise unchanged.
- Green after the fix:
- `cd server && npx vitest run
src/services/heartbeat/run-completion.test.ts
src/services/heartbeat/run-lifecycle.test.ts -t storage` (13 passed)
- `cd server && npx vitest run
src/services/heartbeat/run-completion.test.ts
src/services/heartbeat/run-lifecycle.test.ts
src/storage/storage-unavailable.test.ts` (213 passed)
  - `cd server && npx tsc --noEmit -p tsconfig.json` (clean)
- Existing storage cases still pass: a scheduled storage wait keeps its
promise and suppresses rapid recovery; an exhausted wait writes the
exhausted text.
- `pnpm verify` exited 2 (a doc is a global CI input), so required CI
supplies the broad coverage.
- The full `run-completion.test.ts` file passed every test locally. Its
`afterAll` hook timed out under machine load (load average about 17),
also with the new tests skipped.

## Risks

- Low risk. The change affects only the text of failed runs and one read
projection. It does not change retry scheduling, recovery ownership, or
replay policy.
- The read projection matches this repository's own two promise
constants under a code other than `storage_unavailable`. A future change
to those constants must keep the projection in sync.
- Error events already written by earlier releases keep their original
text in the run log. Live events already published before a withdrawn
promise are not resent.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, browser reads for GET-only diagnostics).

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 19:14:43 -06:00
DottaandClaude e33ede9a8a fix(native): settle a restart-orphaned write_document from its transactional receipt (#20674)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run calls Paperclip tools such as `write_document`. The
runner saves each tool input durably before the server runs the tool,
and the server saves a result receipt
> - A control-plane restart can cut a `write_document` call after the
runner saved its input and before any result was saved
> - Recovery then has no proof of the outcome. A call from an earlier
server process settles as "unknown outcome" (#20628). A call the current
server process admitted stays pending, close cannot prove that the
provider drained, and the session quarantines
(`native_session_cleanup_quarantined`). A person must click Retry
> - `write_document` saves its revision and its idempotency receipt in
one database transaction, under the run and issue row locks. The
revision is also compare-and-swap on `baseRevisionId`. So the receipt is
a durable outcome record: present means applied, absent means not
applied
> - This pull request makes recovery use that proof. Recovery runs the
same receipt-guarded write. It replays the saved result, or it applies
the write exactly once
> - The benefit is that an orphaned `write_document` settles with its
real result. The document is never written twice, and the run does not
need Retry for this call

## Linked Issues or Issue Description

**What happened?**
Daytona restart-chaos wave `w-20261110T174930-s9205`, task t01 (codex,
workload C1, fault RR2). A restart cut a `write_document` call. The
close settlement showed `semanticTools.pending: [{ operationId:
"write_document", callId: "exec-8b79ab78-…" }]` and `providerDrained:
false`. The session quarantined with
`native_session_cleanup_quarantined`, and the task needed Retry. Since
#20628, a call that an earlier server process admitted settles as
`tool_call_interrupted_by_restart` with an unknown outcome. The agent
then has to guess whether the write applied.

**Expected behavior**
When a durable record proves the outcome, recovery uses it. A write that
applied returns its saved result. A write that did not apply runs once.
When the record cannot prove it, the call stays as it is today.

**Steps to reproduce**
1. On master, run `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/paperclip-runner-tool-authority.test.ts -t
"orphaned"`.
2. `authority.recover()` returns `null` for a `write_document` that has
no receipt, so the call stays unsettled.

Refs #20628, #20639, #20507

## What Changed

- `PaperclipRunnerToolAuthority.recover()` now settles `write_document`
through its transactional receipt (`#recoverTransactionalWrite`). It
runs the same receipt-guarded mutation as an ordinary call. If the
receipt exists, it returns the saved result and writes nothing. If no
receipt exists, it applies the write once.
- A definite refusal that rolls the transaction back (a 4xx such as a
stale `baseRevisionId`, a reused idempotency key, invalid input) settles
as a tool error. Any other failure leaves the call unsettled, as before.
- The set of operations with this proof is explicit:
`TRANSACTIONAL_RECEIPT_OPERATIONS` contains only `write_document`. Other
tools keep their current recovery rules.
- New test: an orphaned `write_document` with no receipt applies once; a
second recovery and a late original call replay the receipt; an applied
write recovers its saved result; a stale base settles as a tool error
with no change.
- Updated test: a reused idempotency key with other input now settles as
an `paperclip_runner_tool_idempotency_conflict` tool error instead of
staying pending. The receipt proves that this call can never apply under
that key.
- Docs: `packages/paperclip-runner/docs/durable-recovery.md` (semantic
operations that outlive a turn) and a new design note,
`doc/plans/2026-11-10-durable-turn-start-and-tool-call-records.md`. The
note also covers the second change (an ACPX turn start cut off before
the prompt reached the provider), which comes in a separate pull
request.

## Verification

- Red on master, green here: `pnpm --filter @paperclipai/server exec
vitest run
src/services/native-runtime/paperclip-runner-tool-authority.test.ts` (43
passed).
- `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/native-runner-restart-recovery.integration.test.ts`:
178 passed. One unrelated ACPX timing test failed once under load and
passed when run alone.
- Server typecheck: `npx tsc --noEmit -p server/tsconfig.json` passes.
- `pnpm verify` exits 2 (a runner doc changed), so required CI supplies
the broad coverage.
- Live restart-chaos wave: see the comment on this pull request.

## Risks

- Recovery can now apply a write that never applied before the restart.
This is the call the agent made, applied once. The write is
compare-and-swap on `baseRevisionId`, so it cannot overwrite a revision
that someone wrote after the agent read the document. That case settles
as a conflict error.
- Upgrade safety: no saved shape changes. Receipts saved by the previous
release have the same shape. A server on the previous release keeps its
current recovery for these calls.
- Time to first token: no change. This code runs only during recovery.
- No runner source changes, so no new runner image is needed.

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use (shell,
file editing) in Claude Code. Extended reasoning was used.

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 18:49:26 -06:00
DottaandClaude Opus 5.5 ad0543ae90 test(recovery): re-arm a capture-policy save whose finished coordinator names a lapsed owner (#20672)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Workspace recovery re-arms a save that an older capture policy
exhausted (#20569), so the files are saved without a person
> - On staging, one save of this kind was not re-armed after the deploy.
Its export hold also carries a workspace finalization block, and it has
a resolved no-replay sibling for the same run
> - #20661 found that discovery skipped a hold when the run's finished
coordinator still named a lease owner whose lease had lapsed. The same
discovery clause gates the capture-policy re-arm
> - This pull request adds a regression test for that exact
capture-policy shape. It shows that #20661 alone makes the worker re-arm
and save it
> - The benefit is that the capture-policy path keeps this guarantee in
later changes

## Linked Issues or Issue Description

Refs #20661
Refs #20569

**What happened?**
An exhausted capture-policy save (`export_interrupted`, cause
`provider_error`, 6 of 6 attempts, phase `preserve_files`) was not
re-armed on the worker's first pass after #20569 was deployed.

**What did you expect to happen?**
The worker re-arms the save once under the current capture policy, saves
the files, and returns the hold to the system.

**Steps to reproduce**
1. Exhaust a save under capture policy 1 with `export_interrupted`. Add
a workspace finalization block and a resolved no-replay sibling for the
same run.
2. Leave a lapsed `native-finalizer:` lease owner on the run's
`terminal_failure` coordinator.
3. Run the workspace recovery worker. Before #20661,
`nextWorkspaceRecoveryAt` returns null and the hold is never selected.

## What Changed

- `workspace-recovery-capture-policy.test.ts`: add "re-arms a save whose
finished coordinator still names a lapsed finalizer owner". The test
uses the staging record: export hold with `historicalWorkspaceSource`
and `workspaceFinalizationBlock`, a resolved no-replay sibling, waiting
user input, and a lapsed finalizer owner. It drives the worker's own
discovery loop until the worker is idle.
- No production code changes.

## Verification

- Red: with the source files from before #20661 (`git checkout
686893636d^ -- <the four source files>`), the new test fails.
`nextWorkspaceRecoveryAt` returns null, so the worker is never woken.
- Green: on current master, `pnpm --filter @paperclipai/server exec
vitest run src/services/workspace-recovery-capture-policy.test.ts`
passes (5 tests). The file passed 4 runs in a row.
- The test asserts that the save completes under policy 2, the hold
returns to the system, `releasedLapsedOwner` is recorded, and the lapsed
owner is released.

## Risks

- Low risk. This change adds a test only.
- Previous-release story: the test uses rows in the shape an older
release saved, with no capture-policy marker. No migration is needed.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use (shell,
code editing, browser GET reads) in Claude Code.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:47:29 -06:00
DottaandClaude Opus 5.5 e9db5e0d03 perf(github): read the inherited review waits of an issue list in one pass (#20673)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - An agent can review a GitHub pull request. After the agent publishes
the review, the task can wait in review for the next GitHub reply. A
later run of the same task can inherit this wait.
> - Issue lists and inbox attention show these waits. For each waiting
task, the server reads the inherited GitHub review wait: the source
comment, the latest review, and the review publication.
> - The server did these reads one time for each waiting task. A list
request with N waiting tasks sent N copies of the prepared review
statement and N copies of the publication read.
> - On a hosted database, the prepared review statement ran about
270,000 times in about 2.5 hours (up to 87 calls per second). The run
identity read before it ran the same number of times. The list read that
calls it ran only about 1,400 times, so each list read started about 190
review reads on average. No other caller reads the waits of many runs in
a loop.
> - This pull request reads the reviews and the publications of all
waits of one list request with one statement each. Each wait gets the
same result as before.
> - The benefit is about 200 times fewer review statements and fewer
pool connections for each issue list.

## Linked Issues or Issue Description

Refs #20400 (shares review-wait reads between the callers of one
request).
Refs #20551 (authorizes the waits of one issue page with one read; this
change does the same for the inherited GitHub review reads of that
page).

**What happened**

`listExternalReviewWaits` called `readInheritedGitHubReviewWait` one
time for each candidate wait, inside `Promise.all`. Each call sent a run
identity read, a source-message read, the prepared
`inherited_github_review_wait` statement, and a publication read. A list
with N waits sent each statement N times.

**Expected behavior**

One list request sends one review statement and one publication read for
each company. The result of each wait does not change.

**Steps to reproduce**

1. Put many tasks of one company in review with an inherited GitHub
review wait.
2. Open the issue list or the inbox.
3. Count the statements of the database. The review statement runs one
time for each waiting task.

## What Changed

- `server/src/services/chat-channels/github/review-wait.ts`:
- Split `readInheritedGitHubReviewWait` into shared steps: the source
comment read, the review checks, the publication key, and the published
result. The single-run read uses the same prepared statement and the
same checks as before.
- Add `readInheritedGitHubReviewWaits(db, runs)`. It reads the source
comment of each run with the same lineage check as before. Then, for
each company, one `DISTINCT ON` statement reads the latest review for
each (source comment, task, agent) key, with the same joins, filters and
order as the prepared statement. One read finds the processed
publications. Each run gets the same result as a separate call: `null`,
`revoked`, or `published`.
- `server/src/services/native-runtime/external-review-wait.ts`:
`listExternalReviewWaits` uses the batch read. The rest of the list and
its authorization do not change.
- `server/src/__tests__/github-review-wait.test.ts`: a new test reads 18
runs in one batch and compares each result with a separate single-run
read. The runs include each revocation case (connection disabled or
inactive, review state, review agent, repository, repository id, thread,
publication, identity, company), a newer unreviewed head, no identity,
no task, an endpoint of another agent, a run in another company and a
duplicate run. The test also makes sure that the batch sends one review
statement and one publication read for each company. The existing
revocation tests also check the batch read.
- `server/src/__tests__/helpers/github-review-wait.ts`: the seed helper
can add a wait to an existing company and agent.

## Verification

- `pnpm vitest run server/src/__tests__/github-review-wait.test.ts
server/src/__tests__/github-review-check-state.test.ts
server/src/__tests__/issue-list-review-wait-authorization.integration.test.ts
server/src/__tests__/blocked-inbox-review-wait-scope.test.ts`
- Focused local run of every test that imports the changed modules or
the seed helper (7 files, 335 tests passed), including
`external-chat-wait.integration.test.ts`, `external-review-wait.test.ts`
and `legacy-execution-recovery.test.ts`. The change to the shared seed
helper makes `pnpm verify` select the full server suite, so CI supplies
that coverage.
- Local measurement on real PostgreSQL (embedded PostgreSQL, `ANALYZE`
after seeding). One company and agent, each wait has 5 reviews and 10
outbound messages. "Before" is the previous list code: one
`readInheritedGitHubReviewWait` call for each run in `Promise.all`.
"After" is `readInheritedGitHubReviewWaits`. The runs alternate order in
each pair. Both return equal results in every sample.

| Waits | Version | Statements | Review statements | Median ms | Min–max
ms | Samples |
| --- | --- | --- | --- | --- | --- | --- |
| 200 | before | 800 | 200 | 106.8 | 98.0–117.5 | 10 |
| 200 | after | 402 | 1 | 44.3 | 41.6–50.9 | 10 |
| 20 | before | 80 | 20 | 9.8 | 8.6–14.7 | 12 |
| 20 | after | 42 | 1 | 6.3 | 5.3–7.0 | 12 |

- `EXPLAIN (ANALYZE, BUFFERS)` of the batch review statement for 200
waits: index scans on each join, 6.7 ms execution. The single statement:
0.05 ms execution and about 2.7 ms planning for each call.
- The run identity read and the source-message read still run one time
for each wait. They are cheap on the hosted database (about 0 ms each),
and they keep the lineage check for each run.

## Risks

- Low risk. The batch statement uses the same joins, filters and order
as the prepared statement, and `DISTINCT ON` keeps the first row for
each key. The array filters can also match a mix of two keys. The code
uses only rows of exact requested keys.
- The single-run read and its prepared statement do not change. The
finalizer, the status committer, and the attachment reuse paths still
use it.
- Errors do not change: a 403 from the lineage check gives `revoked`,
and other errors are thrown.
- No migration.

## Model Used

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

## 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>
canary/v2026.1111.0-canary.1
2026-11-10 18:35:39 -06:00