Commit Graph
2217 Commits
Author SHA1 Message Date
DottaandPaperclip 81c9143fe4 fix: upgrade Better Auth to 1.7.16 with account migration (#18906)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - The board uses Better Auth for credential accounts and sessions.
> - The server pins Better Auth 1.7.2.
> - The November security release fixes 12 issues in Better Auth.
> - The new version stops writing the old required account issuer field.
> - This change updates the packages and the database contract together.
> - New users can sign up, and existing users can still sign in.

## Linked Issues or Issue Description

**What happened?**
The server uses Better Auth 1.7.2. The [security
release](https://better-auth.com/blog/security-release-2026-11-03) fixes
12 issues in 1.7.16. Earlier update PRs #17909 and #17911 target 1.7.7,
which is below this security release.

**Expected behavior**
Use the patched stable release. Keep new and existing credential
accounts usable.

**Steps to reproduce**
Read the server dependency pin. Upgrade it without a schema change.
Better Auth then rejects the required account issuer column. See the
[upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide).

## What Changed

- Pin Better Auth to 1.7.16. The repository refresh bot owns the
committed Core lockfile.
- Make the old issuer column nullable. Keep existing issuer values.
- Replace the issuer index with a unique provider/account index.
- Generate the migration and retain the five newest schema snapshots.
- Test new signup, new-account sign-in, legacy issuer sign-in, duplicate
rejection, and separate provider identities with real PostgreSQL.
- Update the database schema test. Test the historical issuer backfill
separately. Verify upgrades over existing rows and rollback on duplicate
provider/account keys.
- Document the duplicate check and migration order.
- Add the approved two-line dependency resolution step before the backup
job’s frozen install. Leave the frozen check and backup tests intact.

## Verification

- Core head: `af70282c7e24335b8688c8f3cc88fdf65045632c`; parent
`0f1481e4462100357a7caa496b875cf0bc500d95` includes master `84fc67317`.
- Nine changed files. The only workflow edit is the approved two-line
backup dependency resolution step; no managed lockfile edits.
- Migration `0465_better_auth_account_identity.sql` is three SQL
statements; snapshot changes only `public.account`, five newest
snapshots retained.
- DB typecheck and 23 focused tests passed on the parent. Both real
PostgreSQL migration/signup suites were rerun November 11: all 4 tests
passed. Application source and tests are unchanged in this workflow-only
commit.
- The parent’s sandbox-provider lane passed all 205 file-sync tests but
reported an unhandled EPIPE during the symlink rejection case; its
aggregate verify therefore failed. No unrelated source change was made.
The new head reruns the full applicable CI.
- Reproduced the old backup install failure locally. Resolving manifests
followed by a frozen install passed locally; restored the bot-owned
committed lock afterward. Hosted backup qualification remains required.
- Current-head CI and review are running; no final-head green claim or
merge yet.

## Risks

Apply the migration before the new server starts. Check for duplicate
provider/account pairs first. Duplicate rows stop index creation. Keep
separate users separate when resolving duplicates. The migration keeps
issuer data. Do not restore its NOT NULL constraint while new rows
contain null. No deployment is included.

## Model Used

OpenAI GPT-6 Codex, with reasoning and tool use. The runtime did not
expose a more specific model ID or context window.

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-11 08:26:58 -06:00
DottaandPaperclip aaf1337c7b fix(slack): require mentions only for channel roots (#20821)
## Thinking Path

> - Paperclip helps people manage AI agents for work.
> - Slack connects people to those agents through channels and threads.
> - A mention can start a conversation without making the bot answer all
channel traffic.
> - The current setting also requires mentions on every thread reply.
> - This interrupts an existing conversation and can cancel a queued
reply.
> - This change requires mentions on channel roots and allows replies
without a mention.
> - People can continue a thread without adding the bot name to each
message.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The Slack connection setting that requires an at-mention.

**Current behavior**
When the setting is on, a channel thread reply must mention the bot. The
delivery and execution checks reject a reply that has no mention.

**Proposed behavior**
Require a mention on a top-level channel message. Allow replies in
existing threads without a mention. Keep the current DM access controls.

**Reason and benefit**
A person can continue an existing conversation without repeating the bot
name.

**Breaking changes**
Existing connections with the setting on will accept thread replies
without a mention. The saved setting, default value, and API field stay
the same.

## What Changed

- Rename the control to **Require at-mention in channels**. Explain the
thread and DM rules in the help text and operator documentation.
- Keep the mention policy in the Slack module. Use the thread root and
message IDs to identify a reply.
- Call that policy from durable admission, wake authorization, and
queued run start. These shared paths must enforce the same setting when
a delivery resumes. Other providers keep their current behavior.
- Update PostgreSQL integration tests for thread replies, DMs, queued
deliveries, and combined wake inputs. Add tests that reject incomplete
reply identity.

## Verification

- Reproduced rejected thread replies and cancelled queued reply runs
before the fix.
- Passed 16 focused PostgreSQL integration cases after the fix.
- Passed 71 tests on revision `31b1adede2b92bad24b8fa21b8f6fffa9bd26d04`
after the rebase. This includes the full durable wakeup suite, Slack
policy tests, and eight selected Slack delivery cases.
- Command: `pnpm exec vitest run
server/src/services/chat-channels/slack/wake-authority.test.ts
server/src/__tests__/durable-chat-wakeup.test.ts
server/src/__tests__/chat-channels.integration.test.ts -t "at-mentions
by default|thread replies without mentions|rechecks queued
Slack|preserves running Slack tool authority|Slack channel mention
policy|durable inbound chat scheduler receipts"`.
- Strengthened the queued-root test to check the mention-rejection
reason and content redaction. A temporary change that disabled mention
enforcement caused this test to fail with the unrelated-message reason.
The real policy is restored.
- Removed the added source comments to follow the contribution rules.
- `node scripts/check-module-boundaries.mjs` and `git diff --check`
pass.
- `pnpm verify` returns exit 2 because the operator documentation is a
global CI input. Required CI must supply the wider coverage.
- Local preflight installed dependencies and built the plugin SDK. It
could not build Runner Rust binaries because this environment has no
`cargo` executable. The focused server tests run without those binaries.
- Live Slack and browser tests were not run. This change uses the
existing provider message and thread identities.

- Required CI passed on `31b1adede2b92bad24b8fa21b8f6fffa9bd26d04`: [PR
workflow](https://github.com/paperclipai/paperclip/actions/runs/43035551294),
including typecheck, build, server/chat tests, Runner checks, and
browser E2E. Current-head Greptile and Paperclip reviews are both 5/5
with no open findings.

## Risks

- Existing connections with mention gating on will now accept
unmentioned thread replies. This is the intended behavior change.
- A missing or invalid thread or message ID does not grant the reply
exception.
- No schema migration is required. Destination, sender, and company
access checks stay in place.
- The existing chat integration test file is large. This change updates
its current Slack cases to reuse the full delivery and authorization
fixture.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning and code execution tools.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-11 08:26:52 -06:00
DottaandClaude Opus 5.5 fa3cd6f330 fix(native): end provider startup rejections with Retry instead of an ownership hold (#20808)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:48:18 -06:00
DottaandClaude Opus 5.5 4c04c20c9e fix(recovery): wake a parent whose refused completion waited on dependencies that later clear (#20803)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A parent task that waits on blocker tasks resumes through the
`issue_blockers_resolved` wake
> - A periodic dependency sweep sends that wake when a blocked task's
dependencies become ready and no wake covers the current blocked cycle
> - The sweep skips a task that its own agent blocked, because that
agent waits on something outside the blocker graph
> - A native completion that Paperclip refuses for unresolved
dependencies is also recorded as a block by the agent's own run
(`durable_dependency_blocker_bound`). The sweep treated it as a
self-block. It woke the task only if a blocker later completed or
finalized on its workspace
> - When readiness cleared another way, nothing woke the task. It stayed
`blocked` with no next action
> - This pull request makes the sweep treat that refusal as a dependency
wait, and count a "Recover saved files" finalize without a workspace as
the blocker settling
> - The benefit is that a blocked task is not a silent dead end: it
resumes once, automatically, when its dependencies clear

## Linked Issues or Issue Description

Refs #20728

**What happened?**
A parent task had three done child tasks that block it. One child was
still listed as "saving its files", because a "Recover saved files"
finalize had no workspace. The parent's agent tried to finish. Paperclip
refused the completion because dependencies were unresolved and set the
parent `blocked` (`durable_dependency_blocker_bound`). After #20728 was
deployed, the parent's dependencies were ready. But the task stayed
`blocked` with no wake, no Retry, and no next action. A person had to
post a comment to wake it.

**Expected behavior**
When the last blocker of a blocked task clears, by status or by finalize
settlement, Paperclip wakes the assignee once. This also applies to
tasks already blocked before a deploy that changes readiness.

**Steps to reproduce**
1. Block a task on a done blocker whose workspace finalize is still
pending.
2. Let the assignee agent's native run report the task done. Paperclip
sets the task `blocked` with `durable_dependency_blocker_bound`.
3. Make the blocker settle without a later blocker completion or a
finalize on its workspace (for example, a recovery finalize with no
workspace, or a deploy that changes the readiness rule).
4. Run the dependency sweep. Before this fix it reports
`selfBlockedSkipped: 1` and sends no wake.

## What Changed

- `server/src/services/recovery/service.ts`
(`wasBlockedByAssigneeAgent`): a native block with reason
`durable_dependency_blocker_bound` is not a self-block. The dependency
owns its wake. Other native blocks and agent blocks keep the self-block
rule.
- The same function counts a later succeeded `workspace_finalize` with
`owningService: workspace_recovery` and no workspace as the blocker
settling, which matches the readiness rule from #20728.
- `server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts`:
regression tests for the refused-completion shape (one wake, no second
wake), the recovery finalize without a workspace, and a safety case for
a different native block reason.
- `server/src/services/workspace-recovery-dependency-finalize.test.ts`:
a characterization test that a real "Recover saved files" signals the
dependency sweep for the company. This path already existed; the test
keeps it.
- `doc/execution-semantics.md`: documents when the dependency sweep
wakes after the last blocker clears.

## Verification

- Red (unfixed `service.ts`): `npx vitest run
src/__tests__/heartbeat-issue-liveness-escalation.test.ts -t "refused
for unresolved|still holds a native block|recovery finalize without a
workspace"` fails 2 of 3 tests with `selfBlockedSkipped` 1 instead of 0.
- Green (fixed):
`src/__tests__/heartbeat-issue-liveness-escalation.test.ts` passes 44 of
44.
- Related suites pass:
`src/__tests__/issue-dependency-wakeups-routes.test.ts`,
`src/__tests__/heartbeat-dependency-scheduling.test.ts`,
`src/services/workspace-recovery-dependency-finalize.test.ts`,
`src/__tests__/issue-recovery-dependencies.test.ts` (43 tests). Server
`tsc --noEmit` passes.
- `pnpm verify` exits 2 because a doc change is a global CI input.
Required CI supplies broad coverage.
- Staging evidence (read only): the affected parent's activity shows
`native-status-committer` set it `blocked` with
`durable_dependency_blocker_bound`, and no later blocker completion or
finalize.

## Risks

- Previous-release story: no persisted shape changes. Tasks already
`blocked` with `durable_dependency_blocker_bound` and ready dependencies
are picked up by the existing dependency sweep after deploy (it visits
every blocked task at startup and on recovery signals). No migration.
- Bounded: the wake uses the cycle-aware ready-state key, so one blocked
cycle gets one wake. The sweep keeps its live-run, queued-wake,
pending-interaction, and pause-hold checks.
- No loop: if the woken agent blocks the task again while its
dependencies are ready, the reason is not
`durable_dependency_blocker_bound`, so the self-block rule applies.
- The recovery finalize rule only applies to a finalize on the blocker
task itself with `owningService: workspace_recovery` and no workspace.

## 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>
2026-11-11 07:04:13 -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>
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
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>
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 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 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 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 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>
2026-11-10 21:15:40 -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 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 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>
2026-11-10 20:15:48 -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 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 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 c14f2fe45d fix(native): read the scheduled-resume evidence the resume reconciler claimed (#20656)
## Thinking Path

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

## Linked Issues or Issue Description

Refs #20645

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

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

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

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

## What Changed

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

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

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 18:21:25 -06:00
DottaandClaude Opus 5.5 48c6e996da fix(native): continue a task whose quarantined completed turn's holds settle (#20662)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Before a remote run starts, workspace preparation attaches the
verified saved work of earlier runs of the same task.
> - Preparation failed with `remote_workspace_recovery_receipt_invalid`
when any earlier run had a recovery receipt that it could not verify.
> - A refusal cannot change the saved state, so every Retry failed in
the same way, and the task stayed blocked. Recoveries must fall back
automatically.
> - This pull request skips an unverifiable source. It attaches nothing
unverified and starts from the workspace's newest verified save.
> - The benefit is that Retry and continuations start again, while
strict receipt checks still decide what is attached, and no saved work
is changed.

## Linked Issues or Issue Description

Refs #20631

**What happened?**
On staging, after the deploy that included #20631, a user pressed Retry
on a task whose previous run failed with
`native_controller_recovery_missing_evidence:runner_not_settled`. The
new run failed in setup in under a minute with `setup_failed` /
`remote_workspace_recovery_receipt_invalid` (`executionRecovery:
bootstrap`, `providerWorkStarted: false`). The task stayed blocked.
Another Retry would fail the same way. #20631 accepted one shape of this
refusal (a source that saved only the snapshot its own restore loaded).
This task had a different shape.

**Expected behavior**
A Retry starts. A source whose receipt cannot be verified is not
attached, and its saved state stays intact for workspace recovery.

**Steps to reproduce**
1. Let a remote native run fail before provider work and leave a
recovery receipt or restore hold that does not verify (for example a
receipt on an unsaved reference, a receipt from another epoch, or a
saved reference without a receipt).
2. Press Retry on the task.
3. See the new run fail in setup with
`remote_workspace_recovery_receipt_invalid`.

**Paperclip version or commit**
Staging deploy that includes `e3bc4ca798`. The gap remains on `master`
at `a3b34a9d88`.

## What Changed

- `attachPreservedRemoteWorkspaceSnapshots` no longer throws for an
unverifiable source. Each of the five refusal shapes now skips the
source and logs `remote_workspace_recovery_receipt_invalid` with a
reason: `receipt_without_reference`, `reference_unreadable`,
`receipt_on_unsaved_reference`, `receipt_unverified`, and
`receipt_snapshot_missing` (was
`remote_workspace_recovery_snapshot_missing`).
- `retainRemoteRecoveryAttachments` no longer throws for a retained
recovered copy that it cannot verify. It does not publish that copy
again. The files stay in the workspace.
- The writer check (`remote_workspace_recovery_writer_changed`), the
workspace access check, and snapshot read and digest failures still
refuse.
- `doc/execution-semantics.md` describes the fallback rule.

## Verification

- Red on unfixed code:
`remote-workspace-recovery-receipt-fallback.test.ts` (moved out of
`workspace-recovery-preparation.test.ts` to keep that file under 1,000
lines) "starts a Retry from the newest verified save when a predecessor
has %s" (6 shapes: `receipt_without_reference`,
`unreadable_reference_with_hold`, `receipt_on_prepared_reference`,
`superseded_snapshot_with_hold`, `receipt_from_other_epoch`,
`receipt_for_other_run`). All 6 failed with
`remote_workspace_recovery_receipt_invalid`. The source is a native run
that failed with `runner_not_settled`, and the writer is a Retry
(`retryOfRunId`, `taskRetryRequestId`) that follows the real ancestry
query.
- Green: the same 6 tests pass. They also assert that nothing is
attached, and that the source run row, its workspace operations, and its
snapshot row are unchanged.
- Updated safety tests: the canonical-attachment fault matrix now
expects no attachment for receipt faults, and still expects a refusal
for a missing or corrupt archive and for a wrong company or task. The
retained-history test expects an unverifiable retained copy to be left
out of the published list, while verified copies stay published.
- `npx vitest run
src/services/remote-workspace-recovery-receipt-fallback.test.ts
src/services/workspace-recovery-preparation.test.ts`: 91 passed.
- `npx tsc --noEmit -p server/tsconfig.json` passes.
- `pnpm verify` returns exit 2, because a global doc input changed
(`doc/execution-semantics.md`). Required CI supplies the broad coverage.
- Not verified live on Daytona. I could not read the staging rows for
this task: the browser had no free tab. The tests cover every refusal
shape of this function, so the exact staging shape is one of them.

## Risks

- Previous-release state: the rule reads the same rows as before (run
reference, result hold, workspace operations, recovery actions,
snapshots). Rows that earlier releases saved carry forward. No
migration. A blocked task recovers on its next Retry or wake.
- A Retry can now start without files from an unverifiable source. Those
files are not lost: the source's snapshot, restore hold, receipts and
recovery action stay unchanged, and workspace recovery still owns them.
The skip is logged with a named reason.
- A retained copy that fails verification is dropped from the published
list. Its directory stays in the workspace and is no longer retired
automatically.
- A missing or corrupt snapshot archive still refuses. A permanently
lost archive therefore still blocks attach. This PR does not change
that.

> 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). Extended reasoning enabled.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:06:41 -06:00
DottaandClaude Opus 5.5 8f6ca25976 fix(workspaces): skip unverifiable recovery sources instead of failing Retry (#20666)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Before a remote run starts, workspace preparation attaches the
verified saved work of earlier runs of the same task.
> - Preparation failed with `remote_workspace_recovery_receipt_invalid`
when any earlier run had a recovery receipt that it could not verify.
> - A refusal cannot change the saved state, so every Retry failed in
the same way, and the task stayed blocked. Recoveries must fall back
automatically.
> - This pull request skips an unverifiable source. It attaches nothing
unverified and starts from the workspace's newest verified save.
> - The benefit is that Retry and continuations start again, while
strict receipt checks still decide what is attached, and no saved work
is changed.

## Linked Issues or Issue Description

Refs #20631

**What happened?**
On staging, after the deploy that included #20631, a user pressed Retry
on a task whose previous run failed with
`native_controller_recovery_missing_evidence:runner_not_settled`. The
new run failed in setup in under a minute with `setup_failed` /
`remote_workspace_recovery_receipt_invalid` (`executionRecovery:
bootstrap`, `providerWorkStarted: false`). The task stayed blocked.
Another Retry would fail the same way. #20631 accepted one shape of this
refusal (a source that saved only the snapshot its own restore loaded).
This task had a different shape.

**Expected behavior**
A Retry starts. A source whose receipt cannot be verified is not
attached, and its saved state stays intact for workspace recovery.

**Steps to reproduce**
1. Let a remote native run fail before provider work and leave a
recovery receipt or restore hold that does not verify (for example a
receipt on an unsaved reference, a receipt from another epoch, or a
saved reference without a receipt).
2. Press Retry on the task.
3. See the new run fail in setup with
`remote_workspace_recovery_receipt_invalid`.

**Paperclip version or commit**
Staging deploy that includes `e3bc4ca798`. The gap remains on `master`
at `a3b34a9d88`.

## What Changed

- `attachPreservedRemoteWorkspaceSnapshots` no longer throws for an
unverifiable source. Each of the five refusal shapes now skips the
source and logs `remote_workspace_recovery_receipt_invalid` with a
reason: `receipt_without_reference`, `reference_unreadable`,
`receipt_on_unsaved_reference`, `receipt_unverified`, and
`receipt_snapshot_missing` (was
`remote_workspace_recovery_snapshot_missing`).
- `retainRemoteRecoveryAttachments` no longer throws for a retained
recovered copy that it cannot verify. It does not publish that copy
again. The files stay in the workspace.
- The writer check (`remote_workspace_recovery_writer_changed`), the
workspace access check, and snapshot read and digest failures still
refuse.
- `doc/execution-semantics.md` describes the fallback rule.

## Verification

- Red on unfixed code:
`remote-workspace-recovery-receipt-fallback.test.ts` (moved out of
`workspace-recovery-preparation.test.ts` to keep that file under 1,000
lines) "starts a Retry from the newest verified save when a predecessor
has %s" (6 shapes: `receipt_without_reference`,
`unreadable_reference_with_hold`, `receipt_on_prepared_reference`,
`superseded_snapshot_with_hold`, `receipt_from_other_epoch`,
`receipt_for_other_run`). All 6 failed with
`remote_workspace_recovery_receipt_invalid`. The source is a native run
that failed with `runner_not_settled`, and the writer is a Retry
(`retryOfRunId`, `taskRetryRequestId`) that follows the real ancestry
query.
- Green: the same 6 tests pass. They also assert that nothing is
attached, and that the source run row, its workspace operations, and its
snapshot row are unchanged.
- Updated safety tests: the canonical-attachment fault matrix now
expects no attachment for receipt faults, and still expects a refusal
for a missing or corrupt archive and for a wrong company or task. The
retained-history test expects an unverifiable retained copy to be left
out of the published list, while verified copies stay published.
- `npx vitest run
src/services/remote-workspace-recovery-receipt-fallback.test.ts
src/services/workspace-recovery-preparation.test.ts`: 91 passed.
- `npx tsc --noEmit -p server/tsconfig.json` passes.
- `pnpm verify` returns exit 2, because a global doc input changed
(`doc/execution-semantics.md`). Required CI supplies the broad coverage.
- Not verified live on Daytona. I could not read the staging rows for
this task: the browser had no free tab. The tests cover every refusal
shape of this function, so the exact staging shape is one of them.

## Risks

- Previous-release state: the rule reads the same rows as before (run
reference, result hold, workspace operations, recovery actions,
snapshots). Rows that earlier releases saved carry forward. No
migration. A blocked task recovers on its next Retry or wake.
- A Retry can now start without files from an unverifiable source. Those
files are not lost: the source's snapshot, restore hold, receipts and
recovery action stay unchanged, and workspace recovery still owns them.
The skip is logged with a named reason.
- A retained copy that fails verification is dropped from the published
list. Its directory stays in the workspace and is no longer retired
automatically.
- A missing or corrupt snapshot archive still refuses. A permanently
lost archive therefore still blocks attach. This PR does not change
that.

> 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). Extended reasoning enabled.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:47:54 -06:00
DottaandClaude 9296c74e8d fix(native): let a restart claim take back its lapsed lease at budget admission (#20650)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a control-plane restart, restart recovery claims an
interrupted native run (`resume_dead_runner`) and grants a 20-minute
lease. Budget admission then re-checks that the claim still owns the run
before the resumed run may start provider work.
> - A host sleep, or the restart-chaos fault TM2, can make that stored
lease look expired while its owner is alive. #20625 lets the owner take
back such a lease when the row still shows exactly its own claim. That
rule did not cover budget admission.
> - So a claim whose lease lapsed before dispatch was refused with
`Native recovery no longer owns provider dispatch`. The run failed with
`adapter_failed`, recovery held it with
`native_continuation_requires_reconciliation`, and the user had to click
Retry.
> - This pull request applies the #20625 rule in budget admission: the
claim takes its lease back through the existing re-acquire condition.
> - The benefit is that a run resumed after a restart survives a time
jump before dispatch with no Retry. Every fence and every budget check
stays the same.

## Linked Issues or Issue Description

Refs #20625, #20474

**What happened?**
Restart-chaos wave L7 (seed 9107, local) on a branch with #20625, task
codex B5 TM2. A TM3 restart claimed the run (`resume_dead_runner`,
generation 2). TM2 then aged every database time by one hour before the
resume reached dispatch. Budget admission
(`lockRunBudgetRecoveryOwnerInTransaction`) required a live lease and
threw `Native recovery no longer owns provider dispatch`. The run failed
with `adapter_failed`, recovery stopped with
`native_continuation_requires_reconciliation` /
`preserve_without_replay_v1`, and the task needed Retry.

**Expected behavior**
The claim's own lapsed lease is taken back, and the resume is admitted
when the budget allows it.

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

## What Changed

- `native-execution-lease.ts`: two new helpers. Both use the #20625
own-claim rule. Neither takes a lock out of order.
- `readOwnNativeRecoveryClaimLease` (read-only) returns `live`,
`lapsed_own_claim` or `lost`. It compares the lease against the database
clock. A lapsed lease counts as `lapsed_own_claim` only when all of
these hold:
    - the same lease owner;
    - this process's controller boot, pid and process start;
    - a non-null `recovery_state`;
- `nativeExecutionLeaseHeldCondition({ remote: false,
controllerGeneration })`, which applies the #20625 own-claim rule and
every #20474 untouched check;
    - no non-local environment lease on the run.
- `extendOwnNativeRecoveryClaimInTransaction` extends such a lease by
the local execution TTL, with one conditional UPDATE on the same
predicate.
- `budget-reservations.ts`:
- `lockRunBudgetRecoveryOwnerInTransaction` still refuses another owner,
and refuses a lapsed lease unless it is the run's own untouched claim.
It no longer locks the run row, so the documented coordinator → issue →
run order holds.
- The lease is extended only at the end of an admitted reservation
(`reserveRunBudget`) or an admitted dispatch recheck
(`recheckRunBudgetReservationInTransaction`). At those points the caller
already holds the run row lock. A refused admission, for example one
over budget, leaves the lease lapsed.
- `heartbeat-native-restart-suspension.test.ts`: the test "still settles
a recovery whose claim merely expired" encoded "expired means not the
owner". #20474 and #20625 changed that rule for an untouched own claim.
The test now has two cases:
- an expired claim that another actor recorded on (`awaiting_evidence`)
still settles with no provider session;
  - an untouched expired claim is dispatched.
- `doc/plans/2026-11-09-explicit-native-lease-claim-tokens.md`: Step 1
lists the new user of the rule.

## Verification

- New
`server/src/__tests__/native-recovery-dispatch-lease-reacquire.test.ts`:
- `reserveRunBudget` takes back the claim's own lapsed lease and admits
the resume. This fails before the change.
  - An over-budget resume is still refused, and its lease stays lapsed.
- Fenced variants each throw `Native recovery no longer owns provider
dispatch` and leave the row unchanged: a graceful-shutdown suspension,
an appended failure settlement, `awaiting_evidence`, `blocked`, a claim
from an earlier generation, another boot, a remote sandbox lease, and
another lease owner.
- At the dispatch gate (`recheckRunBudgetReservationInTransaction`), an
admitted own claim gets its lease extended, and a fenced claim is
refused with the row unchanged.
- Related suites pass (308 tests):
`native-execution-lease-reacquire.test.ts`,
`closed-native-receipt-accounting.test.ts`,
`heartbeat-native-predispatch-retry.test.ts`,
`run-dispatch/adapters/postgres.test.ts`,
`heartbeat-native-restart-suspension.test.ts` and
`heartbeat-restart-claim-lapse.test.ts`.
- `cd server && npx tsc --noEmit` passes.
- Live: one L7 wave (seed 9107) ran on the first commit of this branch
(`w-20261110T212307-s9107`). It logged 0 `Native recovery no longer owns
provider dispatch` refusals, against 1 in the previous wave. Codex B5
TM2 got past dispatch, but it still needed Retry for another reason:
after a `runner.drain` timeout, the scheduled same-run resume did not
send the continuation of #20645. That case is tracked separately. The
other five tasks passed.

## Risks

- This widens when a restart claim can take back its own lapsed lease,
and only at budget admission. It is local only. It needs the same owner,
boot and generation, and the row must show exactly that claim. A claim
that any other actor touched still fails as before.
- Budget hard-stop behaviour is unchanged. The lease is extended only
after budget admission passes.
- No schema change and no migration. Rows saved by earlier releases
carry the same history shape.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 17:43:46 -06:00
DottaandPaperclip 7f72ea7534 fix(links): retain PR status across page reloads (#20654)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task messages contain GitHub pull request links.
> - A hover loads PR status into a shared memory cache.
> - A page reload clears that memory and returns each link to an unknown
state.
> - The browser already saves selected reads for each user and company.
> - This pull request adds learned link previews to that cache.
> - Reloads show the saved status. Only user interest can refresh stale
previews.

## Linked Issues or Issue Description

**What happened?**

After a user loaded PR status, a page reload removed the learned state.
The browser persistence rules excluded link-preview queries. Related
implementation: #20593. Searches of open and closed PRs found no
duplicate reload-cache fix.

**Expected behavior**

Keep learned PR status across reloads for the same account and company.
Do not load every PR on page render. Reuse fresh results and refresh
stale results on hover or keyboard focus.

**Steps to reproduce**

1. Open a task with repeated GitHub PR links.
2. Hover one link and wait for its status.
3. Reload the page.
4. Before this fix, both links return to unknown until another hover.

**Paperclip version or commit**

Reproduced against master source at
`30403781b9418355c78dc0ed978366af77036e76`.

## What Changed

- Add link-preview queries to the existing user and company persistence
rules.
- Keep at most 100 previews across accessible companies. Use the
existing 24-hour retention, build boundary, and sign-out cleanup.
- Keep the original read time and freshness when restoring previews.
Other query types keep their existing restore behavior.
- Add regressions for saved status, negative results, bounds, age,
account changes, membership loss, and sign-out.
- Add a production-component Storybook example and browser reload
checks. Update the cache documentation.
- Use one query-key definition for the component, persistence rules, and
cache tests.

The browser read-cache module owns persistence and account cleanup. This
change extends that owner. The link service and provider API contracts
stay the same.

## Verification

- Red: the new reload and retention tests failed because a fresh client
had no saved PR data.
- Green: 102 focused UI tests passed across the cache, sign-out,
account-query, and query-client suites.
- UI typecheck passed.
- Storybook catalog generation and the static Storybook build passed.
- All 21 browser checks passed with retries disabled. They prove status
and negative-cache restoration after real reloads, no eager requests, no
fresh-hover refetch, and one stale-interest refresh shared by duplicate
links. Existing light/dark and narrow PR states, keyboard access, exact
URLs, and card positioning also pass.
- The first browser attempt could not launch because this session lacked
Chromium. Chromium was installed before the complete passing run; that
first attempt supplies no test verdict.
- `pnpm verify --explain` returned exit 2. The browser test path
requires broad CI coverage. No full local suite is claimed.
- The supplied staging page was not accessible through the web tool. No
deployed acceptance is claimed.
- CI on the first head failed in a Slack long-publication timeout and a
font-loading-frame browser check. Both files are outside this change.
The two publication cases passed when run locally in isolation. The font
case also passed locally against the built UI. [Final-head
CI](https://github.com/paperclipai/paperclip/actions/runs/42909433167)
passes on `e19d5b8bb1a910b1cbb9bb9a62ab87cdf684737f`. Typecheck, build,
tests, E2E, and Rust checks pass. There are 58 successful check runs,
three expected skips, and one neutral Paperclip Review check. The legacy
Snyk status passes.

Both Greptile and the supplemental Paperclip review report 5/5 on the
final head. No actionable findings or unresolved review threads remain.
The Paperclip Review check itself has a neutral conclusion.

## Risks

- PR display data now remains in browser local storage until expiry,
build change, or sign-out. Each query requires the matching user and an
accessible company.
- A stale saved status can remain visible for up to 24 hours. Hover or
keyboard focus refreshes it after the provider freshness window.
- Storage restrictions retain the existing memory-only fallback. No
schema migration is required.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, code
execution, and browser automation. The runtime does not expose the exact
serving model ID or context-window size. No subagents were 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 17:43:06 -06:00
Nicky Leach 0d4b1d8c89 Gate agent plugin tabs by execution environment (#20657) 2026-11-10 15:04:44 -08:00
DottaandClaude Opus 5.5 686893636d fix(recovery): release a lapsed owner of a finished coordinator so stop-proof holds re-arm (#20661)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Workspace recovery saves the files of a run whose copy-back failed,
and it holds the shared workspace until the save is done
> - #20295 re-arms a save that was exhausted with
`agent_stop_unconfirmed`, so the run's stop is proven again and the
files are saved without a person
> - On staging, four holds of this shape were never re-armed. Their runs
ended six days ago, their sandbox leases are released with a provider
stop receipt, and the servers that ran them were replaced many times.
Their tasks stay blocked, and so do the parent tasks that wait for them
> - The finished coordinator of each run still names a lease owner whose
lease lapsed. Discovery treats that owner as "waiting for the
abandoned-owner settlement", but that settlement handles only observed
(unfinished) coordinators. Nothing ever releases the owner, so the
worker never selects the hold, the re-arm refuses it, and the attempt's
stop check refuses it too
> - This pull request makes a lapsed owner of a finished coordinator
count as stopped. The re-arm releases it in the same transaction, and
then the new attempt proves the sandbox stop and saves the files as
#20295 designed
> - The benefit is that these holds settle on the worker's next pass
after deploy, without a migration and without an administrator

## Linked Issues or Issue Description

Refs #20295

**What happened?**
An exhausted workspace save
(`native_workspace_sync_out_retry_exhausted`, last failure
`agent_stop_unconfirmed`) stayed blocked for six days. The task showed
"Paperclip tries again automatically", but no re-arm was ever recorded
(no `stopProofRecovery` marker, no `issue.workspace_recovery_rearmed`
activity), and the shared workspace stayed held by the failed run.

**What did you expect to happen?**
The recovery worker re-arms the save once the run is provably over,
saves the dirty files, and frees the workspace so the task and its
parent can continue.

**Steps to reproduce**
1. Exhaust a workspace save with `agent_stop_unconfirmed` (one attempt,
window spent).
2. Leave a lease owner on the run's `terminal_failure` coordinator with
an expired lease (or no expiry), as a finalizer pass of a replaced
server does.
3. Run the workspace recovery worker. `nextWorkspaceRecoveryAt` returns
null, discovery does not select the hold, and `rearmExhaustedStopProof`
refuses it.

## What Changed

- `workspace-recovery-stop-proof.ts`: add
`lapsedFinishedCoordinatorOwner` and `releaseLapsedCoordinatorOwner`. A
lease owner on a finished coordinator (committed, terminal failure,
applied) whose lease lapsed, or that has no expiry, does not block
`sourceRunProvablyOver`. The stop-proof re-arm clears that owner with a
compare-and-swap in its locked transaction, and records
`releasedLapsedOwner` in the marker and the activity.
- `workspace-recovery-capture-policy.ts`: the capture-policy re-arm uses
the same rule, because it shares the same check and discovery clause.
- `workspace-recovery-fallback-discovery.ts`: the discovery clause still
waits for the abandoned-owner settlement for a stale owner of an
unfinished coordinator, but it no longer excludes a hold whose finished
coordinator has a lapsed owner. A live lease still defers the re-arm to
its expiry.
- `system-recovery-state.ts`: add `FINISHED_COORDINATOR_PHASES`.
- `doc/execution-semantics.md`: document the rule.
- Tests: two red-then-green regressions in the staging shape (an expired
finalizer lease, and an owner without an expiry saved by an older
release), one strictness case (a live lease on a finished coordinator
keeps the hold and is not released), and the existing stale-owner case
now uses an observed coordinator, which still waits for the
abandoned-owner settlement.

## Verification

- Red before the fix: `pnpm --filter @paperclipai/server exec vitest run
src/services/workspace-recovery-stop-proof.test.ts` failed the two new
staging-shape cases (`nextWorkspaceRecoveryAt` returned null: the worker
was never woken for the hold).
- Green after the fix: `workspace-recovery-stop-proof.test.ts` and
`workspace-recovery-capture-policy.test.ts` pass (15 tests). The
staging-shape cases assert that the hold is re-armed, the save
completes, the dirty files are saved, the workspace hold is free, and
the lapsed owner is released.
- `pnpm verify`: exit 2 (broader verification required, because
`doc/execution-semantics.md` is a global CI input). Focused suites pass:
`workspace-recovery-stop-proof`, `workspace-recovery-capture-policy`,
`workspace-recovery-fallback`, `workspace-recovery-retry-copyback` (113
tests). Required CI supplies the broad coverage.
- Staging evidence (read-only GET requests): the run is `failed`,
finished six days ago, `controllerLeaseExpiresAt` is null, its only
lease is `released` with `cleanupStatus: success`, a `stopped`
termination receipt and a stop-and-retain receipt. The action's evidence
has no re-arm marker, and four holds of the same shape were not touched
after #20295 was deployed.

## Risks

- Previous-release story: rows saved by earlier releases carry the
lapsed owner and no marker. They are selected and re-armed on the
worker's first pass after deploy. No migration.
- Strictness stays where an owner could be live: a live lease on any
coordinator defers the re-arm to its expiry, and a stale owner of an
unfinished (observed) coordinator still waits for the abandoned-owner
settlement, which proves its controller stopped. The new attempt still
requires a provider stop receipt for the allocation before it reads
files.
- Clearing a lapsed owner matches the existing token-lease contract: an
expired finalizer or cleanup lease is free for the next claim, and a
holder's later write fails its token compare-and-swap.
- Low risk otherwise: the change is limited to the two exhausted-hold
re-arms and their discovery clause.

## 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 17:00:46 -06:00
Nicky LeachandPaperclip f30ac226d6 Keep project environment mappings independent of shared defaults (#20651)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A project supplies work context and repository storage to task runs.
> - Creation selected a storage owner but did not retain the selected
environment on the project.
> - Configuration stored an environment inside checkout policy, but
policy reads and run selection dropped it.
> - Projects therefore continued to follow shared agent and instance
defaults.
> - This pull request saves placement on each project and uses it for
new runs.
> - The benefit is an independent Project to Environment association
with the existing storage, trust, and recovery safeguards.

## Linked Issues or Issue Description

Refs #20459 and #11644. Follow-up to #20587.

The reported journey is Project > Configuration: changing one project's
environment must not change another project's mapping or shared
defaults.

Related open PR #18176 repairs placement through the legacy
workspace-policy field. This follow-up introduces a separate project
association, saves creation defaults, and migrates saved legacy
references. It overlaps that PR's placement, permission, and recovery
safeguards. The changes should be reviewed together. #9588 concerns
environment-variable response privacy and does not address placement.
ROADMAP.md has no separate project-association work planned.

## What Changed

- Add nullable `projects.environment_id` and expose
`Project.environmentId`. Save the effective creation default once on the
project. Keep environment placement separate from checkout/isolation
policy.
- Read and write Environment directly in Project Configuration. Keep the
control available with isolated workspaces disabled. Each form reads its
project's value, even when environment choices share a query cache.
- Offer environments compatible with the current storage owner. Change
the target and storage together when the existing storage selector
attaches a core project to a plugin. Preserve retained-data restrictions
and validate the observed storage owner again on save.
- Resolve new task runs from their organizational project's target
before agent and instance defaults. File sources from another project do
not change placement. Explicit low-trust sandbox choices and enforced
sandbox policies retain precedence. External provider placement remains
separate.
- Restore admitted native runs from their owned environment lease. Later
project edits apply to new admissions. Require workspace-write
permission for environment changes and clears. Check company bindings
before runtime admission and during lease creation.
- Migrate valid saved policy references into the project column.
Normalize legacy API writes. Count and clear matching project references
when an environment is deleted. Update API and behavior documentation.
- Refresh the maintained SDK source registry for the two changed shared
files. Runtime pins are unchanged.

## Verification

- [Hosted
CI](https://github.com/paperclipai/paperclip/actions/runs/42908045146)
is green on `8b3b7d5b5aed51fc4c8e9c2e3506508bbaf9e00c`: **60 successful
checks**, three skipped gates, no failures, and `ci / verify` passed.
GitHub reports no merge conflicts. Standard Greptile scored the same
commit **5/5** with both review threads resolved.
- Source checkout on macOS arm64, Node 26.12.0, with fresh isolated
PostgreSQL. No production data or live provider calls.
- The two-project component test changes the first form with isolation
disabled and a shared query cache. The second form keeps its target.
Storage changes update only the selected project. A saved target remains
selected while the list loads, and an explicit clear remains possible.
- API integration creates two projects from the same default, patches
one, reads both, and checks that agent and instance defaults remain
unchanged. A later instance-default change does not rewrite either saved
project target. Tests cover unavailable/incompatible choices, anonymous
and cross-company denials, and storage preconditions.
- The migration test runs the actual SQL against an existing project row
and verifies its environment and disabled checkout policy survive.
Deletion accounting and clearing affect only matching project
references.
- The final guard set passes **53 tests across four files**:
`project-environments.test.ts`,
`environment-selection-route-guards.test.ts`,
`heartbeat/environment-selection.test.ts`, and
`ProjectProperties.environment.test.tsx`. All **64
environment-run-orchestrator tests** pass, including direct lease
acquisition without an extra caller argument. The broader affected set
passed **109 tests across eight files** before the additional admission
guards. These sets overlap.
- UI typecheck, migration numbering/safety, token gates, module
boundaries, query-error rendering, and diff checks pass. `pnpm verify
--explain` returns exit 2 and requires broad CI because shared
schema/runtime paths and behavior documentation changed. Broad coverage
is delegated to current-head CI.
- Early iterations failed due to a legacy parser that drops the
environment field, a fixture without real company membership, and
incorrect expectations for privacy-preserving 404 denials. The service
now reads legacy references before normalization, the API fixture has
real membership, and denial assertions match the existing privacy
contract. The orchestrator mock also needed the reused company-binding
guard in its service contract. The focused suites above pass after those
corrections.
- Local server typecheck remains blocked by installed ACPX, Slack, AI
SDK, and Runner type mismatches in unchanged dependency-facing code. It
reports no errors in the changed production paths. This is not a full
local typecheck or suite pass. Hosted CI supplies full current-head
coverage.
- SDK source-proof and privacy qualification passes **56 tests across
three files**, including the real built SDK. The maintained source
registry matches the committed revision; runtime pins are unchanged.
- Review reproduced two failures: an incoming policy without a storage
owner could be validated before the existing owner was restored, and
uppercase legacy UUIDs missed migration matching. Preserve the owner
before validation and compare normalized UUID text. Both regressions now
pass. The post-fix project/storage/orchestrator batch passes **107
tests**: `node node_modules/vitest/vitest.mjs run
server/src/__tests__/project-environments.test.ts
server/src/__tests__/project-workspace-provider.integration.test.ts
server/src/__tests__/environment-run-orchestrator.test.ts --silent`.
- The first hosted run failed build, typecheck, and canary at the same
`run-completion.test.ts` call because lease acquisition required a new
argument. The lease method now enforces binding internally. Its existing
caller compiles without a new field, and the orchestrator tests prove
the binding flag reaches the runtime. The full server check no longer
reports that error; the local dependency errors above remain.

## Risks

- Apply the normal database migration before serving the new revision.
The migration adds a nullable foreign key and copies valid legacy
project selections. It does not move repository data or resize provider
storage. The five newest schema snapshots are retained.
- New projects retain their creation target when shared defaults change.
Existing projects without a saved selection continue to inherit
defaults. A null target explicitly opts into inheritance.
- Existing saved project selections now affect placement. Missing,
archived, incompatible, and foreign-company targets fail validation;
they do not silently choose another storage owner. A running admitted
process keeps its original owned lease.
- Local dependency limitations are listed above. No live-provider,
deployed-browser, or production rollout qualification is claimed.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, and code
execution. The exact serving snapshot and context-window size are not
exposed in this session.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 14:31:48 -08:00
DottaandClaude 30403781b9 fix(native): keep crediting proven unstarted restarts past the generation limit (#20647)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run survives a control-plane restart through restart
recovery, which resumes the interrupted provider turn
> - Restart recovery charges a provider attempt only when the provider
did work that could be lost, and it stops after three charged attempts
> - From controller generation 12 on, every restart charged an attempt
before that work rule was checked
> - In a busy restart wave, one long task sees 12 to 15 restarts, most
caused by other tasks. A restart that hit before the provider accepted a
turn was charged, the budget ran out, and a person had to click Retry
> - This pull request keeps the work rule past generation 12 for
restarts that a durable record proves did not start the provider, and
bounds a crash loop with a count of consecutive restarts without
provider progress
> - The benefit is that a long task survives many unrelated restarts
without a manual Retry, while a run that never makes progress still
stops

## Linked Issues or Issue Description

Refs #20625 (same restart recovery path). No public issue exists for
this case.

**What happened?**

`restartResumeKeepsAttempt` returned "charge" for every restart once the
run reached `NATIVE_PROGRESS_CREDIT_GENERATION_LIMIT` (12) controller
generations. It did this before it read the work-based outcome. In a
local restart chaos wave (L6, seed 9106, task t03, Claude B5 with RR1)
the run reached generation 13. The last restart interrupted a resume
whose runner started but whose provider had not accepted a turn. That
restart was charged at the last attempt, so the claim failed with
`execution_recovery_budget_exhausted` and
`native_restart_recovery_blocked`. The task needed a manual Retry.

**Expected behavior**

A restart that a durable record proves did not start provider work does
not spend an attempt, also past generation 12. A crash loop that never
makes provider progress still stops automatically. When it stops, the
task shows a Retry that works.

**Steps to reproduce**

Run a native task past controller generation 12. Make the last restart
hit after `native.process_start_requested` but before `turn.accepted`,
with the provider attempt at 3. The new integration test `does not
charge a restart past the generation limit whose resume never started
the provider` reproduces the failure before the fix.

**Paperclip version or commit**

`master` at ed5e7493bb.

## What Changed

- `restartResumeKeepsAttempt` now returns `{ keepsAttempt,
noProgressRestarts }`. A new helper judges the interrupted attempt as
`provider_not_started`, `provider_progressed` or `charged`, with the
same proofs as before.
- `noProgressRestarts` is 0 when the interrupted attempt is proven to
have made provider progress. Otherwise it is the previous claim's count
plus one. A clean-drain claim carries the previous count.
- Before generation 12 the decision does not change.
- From generation 12 on, a restart keeps the attempt only when the
interrupted attempt is proven `provider_not_started` and the count is
below the new `NATIVE_NO_PROGRESS_RESTART_LIMIT` (12). Provider work
(progress, or an accepted turn without progress) and unprovable cases
still charge.
- Each restart claim writes `noProgressRestarts` into its
`recovery_history` entry. No schema change: `recovery_history` is JSONB.
- Claims saved by earlier releases have no count. They are read as zero.
- Integration tests: the three generation-cap remote tests now check the
new bound. New tests cover the unstarted restart past the limit, the
consecutive-limit stop with a working Retry, the progress reset, an
accepted turn without progress, an unrecorded previous claim, and an
old-format claim.
- The durable-ownership plan (section F) records the rule.

**Why 12.** It equals the existing generation limit
(`PROVIDER_ATTEMPT_CREDIT_GENERATION_LIMIT`). The count includes
restarts before generation 12. Thus a run that never makes provider
progress is bounded exactly as before: the existing test `bounds a crash
loop that never starts the provider` passes unchanged. Only proven
progress resets the count. In the failing wave, the task had made
progress shortly before its last restart, so its count was 1.

## Verification

- `cd server && npx 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`
- Red before the fix: with only the new constant added to the old code,
the 9 new and changed tests fail. With the fix, they pass. The existing
crash-loop, progress and unstarted-resume tests pass without change.
- The consecutive-limit test checks that the stopped run is
`terminal_failure` / `native_restart_recovery_blocked` with no lease
owner and nothing scheduled, that `taskInterruptionPresentation` shows
`failed` with `canRetry: true`, and that `requestTaskRetry` accepts the
Retry.
- `cd server && npx tsc --noEmit -p .`
- Full local run of the three suites above: 322 passed, 14 skipped.
- Live: local restart chaos wave L6 (seed 9106, concurrency 3, `--env
local --adapters codex,claude --workloads A1,B5,C1 --faults RR1,RR2,RR3
--tasks 6`) at 7f7803ab0c: 6 of 6 tasks passed, and no task needed
Retry. Task t03 (Claude B5 RR1) reached controller generation 13 again.
The claim at generation 13 resumed the run (`resume_dead_runner`), and
the server log has no `execution_recovery_budget_exhausted`. The same
task on master was blocked at that claim and needed Retry.

## Risks

- A run that makes provider progress between restarts past generation 12
still charges each of those restarts. This is the same as before, by
design.
- An upgrade or a mid-run deploy reads old claims as count zero. A run
that was already in a no-progress loop can get up to 12 more
proven-unstarted restarts before the bound applies. It cannot be charged
where the old rule did not charge.
- A rollback ignores the new field and restores the old rule.
- Past generation 12 the claim now reads the event log, as it already
does before generation 12. This is not on the turn-start path.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 15:27:45 -06:00
DottaandClaude Opus 5.5 4f33bb3578 fix(workspaces): release the Blocked status of a sibling-settled no-replay hold (#20648)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a remote workspace save fails, recovery actions hold the task
until the run's files are saved
> - #20641 lets a resolved no-replay hold settle when a sibling action
of the same run saved the files
> - Review found that the settlement did not release the task's Blocked
status, and that a settled hold with a historical source stayed
selectable
> - A new test with the waiting user comment also showed that the worker
skipped the second hold of the same run in every pass, so it never
settled
> - #20641 was merged before these fixes were pushed
> - This pull request completes #20641 with those fixes
> - The benefit is that the task leaves Blocked and continues without a
person

## Linked Issues or Issue Description

Refs #20641, Refs #20569

**What happened?**
#20641 merged at its first head. The review follow-ups for it were
pushed later and are not on master:
- A settled no-replay hold that owned the task's Blocked status
(`nativeFailureBlock`) left the task `blocked`.
- A hold settled by a sibling stayed selectable through
`workspaceRecoverySourceEligible` when it named a
`historicalWorkspaceSource`.
- With saved user input waiting, the export hold stayed selectable and
came first in each worker pass. `reconcileRetainedWorkspaces` skipped
the other hold of the same run, so the no-replay hold never settled.

**Expected behavior**
After the save, the no-replay hold settles in the same or next pass. The
task returns to `todo` when that hold owns its Blocked status. The
settled hold is never selected again.

**Steps to reproduce**
1. Use the staging-shape test in
`workspace-recovery-capture-policy.test.ts`, with a waiting "try again"
comment.
2. Run the worker until nothing is due.
3. On master, the worker never goes idle and the no-replay hold stays
`resolved`/`blocked`.

## What Changed

- `workspace-recovery-fallback.ts`: new shared `holdOwnsBlockedStatus`,
used by `continueFromLastSavedWorkspace` and
`settleSiblingRecoveredHold`. A settled no-replay hold that owns the
Blocked status returns the task to `todo`. A newer decision's Blocked
status is kept. An export hold's status stays with its own result
finalization.
- `workspace-recovery-source.ts`: `workspaceRecoverySourceEligible`
excludes a resolved hold that carries `siblingWorkspaceRecovery`.
- `workspace-recovery.ts`: `reconcileRetainedWorkspaces` runs several
holds of one run one after another in that run's job, instead of
skipping the later ones.
- `workspace-recovery-capture-policy.test.ts`: the staging test now has
the waiting comment, and a variant with `nativeFailureBlock` and
`historicalWorkspaceSource`. It checks `todo`, no re-selection, and no
repeat handoff in a later pass.
- `doc/execution-semantics.md`: documents these rules.

## Verification

- Red, with each rule disabled: the status stayed `blocked`; the settled
hold was selected again; the worker never went idle (starvation).
- Green: `npx vitest run --root server` on 14 workspace recovery suites
(including `workspace-recovery-capture-policy`, `-fallback`,
`-admission`, `-source`, `execution-blocker-read`,
`preserved-native-response-wait.integration`): all passed.
- `npx tsc --noEmit -p server/tsconfig.json`: clean.

## Risks

- Several holds of one run now run in sequence in one job. Concurrency
per run is unchanged (one job per run).
- A no-replay hold releases the Blocked status only when it still owns
it. Otherwise the status is kept.
- Previous-release story: no new persisted state. Holds saved by earlier
releases are handled on the first worker pass after deploy.

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

Refs #20572, #20578

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

Refs #15297, #20625

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

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

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

## What Changed

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

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

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

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

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:39:45 -06:00
DottaandClaude Opus 5.5 d06f43b14f fix(workspaces): settle a resolved no-replay hold once a sibling saved its files (#20641)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a remote workspace save fails, recovery actions hold the task
until the files are saved
> - One failed run can have two holds: its export hold, and a no-replay
hold that a later "try again" opened for the same run
> - The no-replay hold is recorded resolved-but-blocked, and it is the
task's execution blocker
> - #20569 lets the export hold retry under the new symlink capture
policy and save the files
> - The sibling settlement that should then release the no-replay hold
only looked at active holds, so the task stayed blocked with no working
Retry
> - This pull request lets the sibling settlement and its discovery
include resolved no-replay holds
> - The benefit is that the task continues without a person once the
files are saved

## Linked Issues or Issue Description

Refs #20569

**What happened?**
On staging, a native run on a Daytona sandbox failed to save its
workspace (`remote_workspace_archive_unsafe_symlink`). Its export hold
(`native_workspace_sync_out_retry_exhausted`) was exhausted after 6
attempts and handed to the board. A user comment then opened a second
hold for the same run (`native_continuation_requires_reconciliation`,
`preserve_without_replay_v1`). That hold also failed 6 times and was
recorded `resolved` with outcome `blocked`. The task's execution blocker
is this second hold.

**Expected behavior**
After #20569 is deployed, the worker re-arms the export hold and saves
the files. The no-replay hold of the same run then settles, and the task
is no longer blocked.

**Steps to reproduce**
1. Create both holds with the exact staging evidence (see the new test
`stagingIncident`).
2. Run the recovery worker until nothing is due.
3. Before this change, the export hold completes, but the no-replay hold
stays `resolved`/`blocked` and `getExecutionBlocker` still returns it
with `canRetry: false`.

## What Changed

- `workspace-recovery-fallback.ts` (`settleSiblingRecoveredHold`): a
resolved no-replay hold (`isRetainedFileHold`) can now settle when a
verified receipt of a sibling action saved its run's files. Settling
removes `automaticRecovery`, so the hold stops blocking the task. It
starts no turn.
- `workspace-recovery-fallback-discovery.ts`: the worker discovers such
a resolved hold when a sibling of the same run is recovered.
- New test in `workspace-recovery-capture-policy.test.ts` with the exact
staging shape of both holds: board owner, `active_run_watchdog`, system
recovery policy version 1 without a capture policy version, stage
`restoring_files`, `export_interrupted` with cause `provider_error`, 6/6
attempts, `historicalWorkspaceSource` and `workspaceRestoreRecovery`
lease ids.
- `doc/execution-semantics.md`: states that a resolved no-replay hold
also settles from a sibling save.

## Verification

- Red before the fix: the new test failed. The no-replay hold stayed
`resolved`/`blocked`, and it stayed the execution blocker.
- Green after the fix: `npx vitest run --root server
src/services/workspace-recovery-capture-policy.test.ts` (3 passed). The
test drives the worker's own discovery (`nextWorkspaceRecoveryAt` +
`reconcileRetainedWorkspaces`) until nothing is due. It checks: the
export hold is re-armed once and saves the files; the no-replay hold is
`resolved`/`restored` by the sibling; `getExecutionBlocker` returns
null; the continuation hook ran when the task was free; no new run was
created; the worker goes idle.
- Related suites: `workspace-recovery-fallback`, `-admission`,
`-stop-proof`, `-source`, `-preparation`, `-preparation-gates`,
`execution-blocker-read`, `preserved-native-response-wait.integration`:
409 passed.
- `npx tsc --noEmit -p server/tsconfig.json`: clean.
- `pnpm verify` exits 2 (global doc input), so CI supplies the broad
coverage.

## Risks

- A resolved no-replay hold is now released when another action's
verified receipt saved the same run's files. The receipt check is
unchanged, so an unverified sibling still does not release it.
- Settling starts no turn. Waiting input and the run's own result
continue the task through normal admission, which still checks every
other hold.
- Previous-release story: holds saved by earlier releases need no
migration. The worker discovers them on its first pass after deploy.

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

## Model Used

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

## Checklist

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

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

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

## Thinking Path

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

## Linked Issues or Issue Description

Refs #20567, #20346

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

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:38:30 -06:00
DottaandClaude 24661683db fix(recovery): bootstrap a remote follow-up that a restart cut before its Runner launched (#20642)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a control-plane restart, restart recovery classifies each
claimed native run: reattach a live Runner, resume from a checkpoint, or
bootstrap again when durable records prove no provider work
> - A follow-up run on a continued session gets a rebound admission
checkpoint. It names the new run, but it keeps the previous turn's
provider session ID
> - Recovery read that copied provider session as this run's provider
work. A follow-up killed after `native.process_start_requested` but
before its remote Runner launched was classified
`reattach_remote_runner`
> - The reattach then had no Runner to find, failed with
`native_continuation_requires_reconciliation`, and the task needed Retry
> - This pull request stops the copied session from counting as this
run's work, and keeps the existing durable proofs as the authority
> - The benefit is that these restarts bootstrap again by themselves and
do not repeat provider work

## Linked Issues or Issue Description

Refs #20561, #20555

**What happened?**
Two Daytona restart-chaos tasks needed a Retry click:
- Wave `w-20261110T173243-s9202` t02 (claude C1, K2): the follow-up run
was killed at `stage.asset.home`, after
`native.process_start_requested`. Recovery chose
`reattach_remote_runner`. Verification failed with
`native.remote_runner_recovery_failed {stage: runner_state, reason:
state_read_failed}` (`runner_remote_state_unavailable: exit=1`):
`runner-state.json` did not exist because the Runner never launched. The
storage fault window (ST7) had ended 30 s before, so it was not the
cause.
- Wave `w-20261110T174930-s9205` t04 (codex C1, RR1): the scheduled
retry of a `process_lost` run was killed at
`session.checkpoint.restore`, after `native.process_start_requested`.
Recovery chose `reattach_remote_runner`. Verification failed with
`native_remote_recovery_lease_mismatch: stage=runner_verification
field=identity_run_id`: the local Runner identity belonged to the
previous run, because this run's Runner never started.

**Expected behavior**
A run whose durable records prove that no Runner received a provider
turn bootstraps again without a person.

**Steps to reproduce**
1. Start a remote follow-up run on a continued session (for example, a
comment after a completed turn).
2. Kill the server after `native.process_start_requested` and before the
remote Runner launches.
3. Observe `reattach_remote_runner`, a failed verification and a Retry
banner.

**Paperclip version or commit**
master close to `6d6b2d0e` (waves); still reproduced by the new tests on
master `90dd864bff`.

## What Changed

- `native-restart-recovery.ts`, no controller checkpoint for the run
(`remote_runner_never_received_commands`): a follow-up with
`sessionIdBefore` qualifies when it carries only its continued-session
checkpoint, and a predecessor's settled Runner identity
(`carriesInheritedRunnerIdentity`) no longer disqualifies it. A recorded
process identity, a Runner-sent event, a provider event or a pending
request still keeps the run held.
- `native-restart-recovery.ts`, controller checkpoint exists: the
existing Postgres proof (`nativeRunnerCheckpointRemoteBootstrapProof`)
now also runs when the run carries only a rebound checkpoint whose
provider session a settled earlier run of the same session saved
(`carriesSettledPredecessorSession`). The copied provider session then
does not count as this run's provider evidence. The controller
checkpoint stays the authority: any turn command or turn event keeps the
run held.
- `doc/execution-semantics.md`: describe the rule.

## Verification

-
`server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts`
(real Postgres):
- "restarts a follow-up whose controller never saved a checkpoint (s9205
t04)" and the variant "with its predecessor's remote runner identity":
red on master (`reattach_remote_runner`), green now.
- "restarts a follow-up whose saved controller checkpoint proves no
provider turn (s9202 t02)": red on master, green now.
- Held cases stay held: own recorded Runner identity, a carried active
turn, a provider session that no settled earlier run saved, a
`turn.start` command, a projected `turn.started` event.
- Whole file: 171 passed.
- `npx tsc --noEmit -p server` passes.
- The executor's existing `bootstrap_incomplete` path handles both
shapes: a scoped root that belongs to the previous run goes through the
existing prior-owner verification, and a root of this run restored from
the controller checkpoint goes through
`runnerdStateProvesIncompleteBootstrap` /
`runnerdStateProvesNoRemoteProviderTurn`.

## Risks

- A run with any recorded Runner process, Runner-sent event, provider
event, turn command or pending request keeps today's reattach or hold.
- A bootstrap claim charges one provider attempt, as today. The bounded
limit (3) still applies.
- No persisted shape changes.

> 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 14:37:56 -06:00
DottaandClaude ed5e7493bb fix(native): take back a lapsed lease held under the run's own restart claim (#20625)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native run holds an execution lease on its
`native_run_finalizations` row. The owner renews it every 5 minutes.
After a host sleep (or the restart-chaos time fault TM2), the stored
lease looks lapsed although the owner is alive.
> - #20474 lets the same owner take back a lapsed lease when no other
actor recorded anything on the row. One of the checks is
"`recovery_state` is null".
> - Restart recovery writes `recovery_state` (`resuming_session`,
`awaiting_runner_reattach` or `bootstrap_incomplete`) in the same update
that grants the lease. The executor keeps that value until a result is
accepted.
> - So a run that a restart resumed can never take back its own lapsed
lease. Renewal interrupts the turn, and failure settlement is refused
with `native_session_lease_lost`. The run fails with `adapter_failed`,
recovery holds it with `preserve_without_replay_v1`, and the user must
click Retry.
> - This pull request counts the claim's own record as untouched when
the row still shows exactly that claim and nothing recorded since.
> - The benefit is that a resumed run survives a later host sleep with
no Retry. Every real takeover still fences the late owner out.

## Linked Issues or Issue Description

Refs #20474

**What happened?**
The zero-retry restart-chaos matrix (local wave L7, seed 9107: TM1, TM2,
TM3 with 1 h aging) had 3 of 6 tasks that needed Retry. All three were
Codex runs. In each run, a TM3 kill made restart recovery resume the run
(`resume_dead_runner`, generation 2). The runner restored the
conversation, reported `provider_turn_lost_on_restore`, and the
same-conversation restart continuation started as designed. Then the TM2
fault aged every database time by one hour. The execution lease lapsed
while its owner was alive. The run then failed with
`native_session_lease_lost` (`adapter_failed`), and recovery stopped
with `native_continuation_requires_reconciliation` /
`preserve_without_replay_v1`. One run had already proposed its result
and completed its turn. The Retry click worked every time.

**Expected behavior**
A run whose lease lapsed only because time jumped, and that no other
actor claimed, continues with no Retry. This must also be true after a
restart resumed that run.

**Steps to reproduce**
`node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts
--env local --adapters codex,claude --workloads A1,B5,C1 --faults
TM1,TM2,TM3 --time-age-hours 1 --concurrency 3 --tasks 6 --seed 9107`

## What Changed

- `server/src/services/native-runtime/native-execution-lease.ts`: the
"untouched" part of `nativeExecutionLeaseHeldCondition` now also accepts
the run's own local restart-recovery claim. All of these must be true:
- the last `recovery_history` entry is that claim: same controller boot
and generation as the row;
- its disposition matches the current `recovery_state`
(`resume_dead_runner` → `resuming_session`, `reattach_existing_runner` →
`awaiting_runner_reattach`, `bootstrap_incomplete` →
`bootstrap_incomplete`);
  - `recovery_request_id` equals the request that claim recorded.
Any later actor appends a history entry (shutdown suspension, failure
settlement), moves the state (`awaiting_evidence`, `blocked`), or
replaces the owner, boot or generation. Each of these still fences the
late owner out. All other untouched checks are unchanged.
- `native-execution-lease-reacquire.test.ts`: new cases for each claim
kind, a requested claim, failure settlement under the claim, and eight
fenced variants.
- `doc/plans/2026-11-09-explicit-native-lease-claim-tokens.md`: Step 1
lists the new rule.

## Verification

- Red/green: `cd server && npx vitest run
src/services/native-runtime/native-execution-lease-reacquire.test.ts`.
Without the source change, 5 new cases fail (the three claim kinds, the
requested claim, and failure settlement). With it, all 37 pass.
- Related suites pass: `native-execution-lease.test.ts`,
`heartbeat-restart-claim-lapse.test.ts`,
`heartbeat-native-orphan-sweep-lease-race.test.ts`,
`native-runner-restart-lease-renewal.integration.test.ts`,
`native-runner-restart-preparation.integration.test.ts`.
- `cd server && npx tsc --noEmit` passes.
- Live: the same L7 wave (seed 9107, local) on this branch. The server
log has 0 `native_session_lease_lost` (the baseline had 3). The two
tasks whose cells failed this way in the baseline (codex C1 TM1, codex
C1 TM3) now pass with no Retry. Two other tasks needed Retry for
different causes, which this pull request does not change: codex B5 TM2
(a `runner.drain` timeout, then a same-run retry that may not continue a
turn lost on restore) and claude C1 TM2 (`ACPX session is not open` on
`turn.start` until the retry budget ran out). The L6 wave (seed 9106)
also ran on this branch: 5 of 6 passed. Its one failure is
`execution_recovery_budget_exhausted` and is not related to leases.

## Risks

- The rule widens when an owner may take back its own lapsed lease. It
applies only to local runs, before a result, with the same owner token,
attempt, boot, pid, process start and generation. A claim that any other
actor changed afterwards does not match.
- Rows saved by earlier releases carry the same history entry shape, so
a run claimed before a deploy is handled the same way. A deploy changes
the boot, so the new process never takes back the old process's lease.
- No schema change and no migration.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 14:31:15 -06:00
DottaandClaude 6505538fc5 fix(realtime): limit a shared live-event read set to 100 events and 250 ms (#20632)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The server sends the output of a running agent to the browser on the
company WebSocket.
> - #20624 lets the events that are already in the queue share one set
of permission and redaction reads.
> - That set could serve a full queue of 1,000 events for one second.
> - If access is revoked during that time, the principal still receives
the queued output. The security scan on #20624 reported this.
> - This pull request limits one set to 100 events and 250 ms.
> - The benefit is a small, stated limit on output that a revoked
principal can receive, with the same delivery speed.

## Linked Issues or Issue Description

Refs #20624

**What happened?**

After #20624, one set of permission reads can authorize each event that
was in the queue when the set opened. The queue holds at most 1,000
events or 1 MiB. The set stays in use for one second. If a membership,
an API key or a task grant is revoked in that time, the server still
sends the remaining queued events.

**Expected behavior**

A revocation stops delivery after a small, fixed quantity of queued
output. The documentation states that quantity.

**Steps to reproduce**

1. Open a company WebSocket as a user who can read a private task.
2. Publish 1,000 transcript events for a run of that task in one burst.
3. Revoke the task grant while the server delivers the burst.
4. The server delivers the remaining queued events.

**Paperclip version or commit**

`master` at 90dd864bff.

## What Changed

- `live-event-delivery.ts` replaces a shared read set after 100 events
or 250 ms. Before, the limit was one second with no event limit.
- `live-event-delivery.test.ts` pins the exact limits. One set serves
events 2 to 101 and event 102 opens a new set. Access is revoked during
that set, and event 102 is not delivered. A set is kept at 249 ms and
replaced at 250 ms.
- `doc/live-transcript-delivery.md` states the limit and that queued
output from before a revocation can still be sent up to that limit.

## Verification

- `npx vitest run src/realtime
src/__tests__/live-events-run-read.test.ts` in `server/`. 66 tests pass.
- The boundary tests fail when either limit changes by one in either
direction (99, 101 events; 249, 251 ms).
- The 200-event burst test against PostgreSQL issues 22 statements with
this limit. Separate reads issued 1,401.
- Not run locally: a clean server typecheck. The local worktree reports
type errors in `server/src/services/chat-channels/slack/adapter.ts`,
which this change does not touch. CI supplies the typecheck.
- Not verified: delivery delay on a hosted instance after deployment.

## Risks

- More permission reads for large bursts: one set for each 100 queued
events. A burst of 1,000 events uses 10 sets, not 1,000.
- Output produced before a revocation and already in the queue can still
be sent, up to 100 events or 250 ms. Before #20624 the limit was the one
event in flight.
- No schema, API or protocol change.

> 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, model ID `claude-fable-5-2`, 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 <noreply@anthropic.com>
2026-11-10 14:18:20 -06:00
DottaandClaude Opus 5.5 fd6e71a7ff fix(native): restore a warm backup when its source sandbox is idle (#20627)
## Thinking Path

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

## Linked Issues or Issue Description

Refs #20278

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

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:11:10 -06:00
DottaandPaperclip 76c787fffc fix(runner): report monitor notes length limits (#20604)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents use task monitors to resume a future check.
> - The monitor schema limits notes to 500 characters.
> - The field description did not state the limit. Rejection showed only
the schema path.
> - This pull request adds the limit to the description and to safe
length errors.
> - Agents can correct notes before they retry a rejected call.

## Linked Issues or Issue Description

Related prior fix: #16868.

**What happened?**

A native `set_task_monitor` call with 501 characters in `monitor.notes`
failed with a path that ended in `notes/maxLength`. The error did not
state 500. The schema already had `maxLength: 500`, but the field
description did not state it.

**Expected behavior**

State the limit in the field description and in the rejection. Accept
500 characters. Reject 501 characters before dispatch. Preserve a saved
monitor when a replacement is invalid.

**Steps to reproduce**

1. Supply a future `monitor.nextCheckAt` and 501 characters in
`monitor.notes`.
2. Read the native provider error.
3. Before this fix, it shows the constraint path without the maximum.

**Paperclip version or commit**

Reproduced on `7368a0b15c2f02107778a961144d86e2eb59a952` in a Linux
source checkout, with Node 25.9.0 and Rust 1.97.1.

## What Changed

- Use one local value for the published notes limit and its description.
The server test checks agreement with the persisted shared validator.
Keep the standalone Runner package boundary.
- Read the numeric maximum from the authorized JSON Schema validation
error. Keep submitted values private and preserve the 512-byte feedback
bound.
- Test the published schema and real bridge dispatch path at 500 and 501
characters. Test saved contents and rejection without monitor changes
with real PostgreSQL.
- Refresh generated contracts and document the limit.
- Sync two SDK proof lock identities after the reviewed master lock
refresh in #20615. This fixes the remaining CI proof mismatch. Keep
compiler, parser, Zod, and all rejection checks unchanged.

## Verification

- Final head: `bac60234ab3a8fc7043a90c7f9c81b02e250428b`.
- Red: the server regression failed because the field description
omitted `500`. The Rust regression failed because the error omitted
`maximum 500 characters`.
- Local passes after the final base update: 44 PostgreSQL
monitor/scheduler tests, 14 real-source Rust bridge tests, 11
cache/Docker guard tests, and 56 SDK proof tests. Runner TypeScript
build and generated contract checks pass.
- The focused Rust check compiles the actual source in a small Cargo
workspace. The full Rust CI suites also pass on Linux and Windows.
- Final-head [PR CI
run](https://github.com/paperclipai/paperclip/actions/runs/42881633978)
passes. This includes typecheck, build, Runner support, Runner Rust and
Vitest, server tests, serialized tests, and all eight e2e shards.
- Final-head [Windows Rust
run](https://github.com/paperclipai/paperclip/actions/runs/42881633419)
passes.
- Greptile: 5/5 on the final head, with no open findings. There are 61
successful checks and five conditional skips. The separate Paperclip
Review remains queued; its comment says work is paused. No CI or test
check is pending or failed.
- `pnpm verify` returned exit 2 and selected broader CI. The full local
suite was not run. Final-head CI supplies that coverage. Earlier failed
attempts were repaired and are not counted as passes.

## Risks

- Low risk. The existing 500-character bound, scheduling semantics, and
permissions stay in place.
- Length feedback uses only a schema path and numeric bound. It does not
print submitted text.
- The Runner binary and tool contracts must reach an instance for the
new guidance to apply. This PR does not deploy or merge.

## Model Used

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

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 13:59:58 -06:00
DottaandClaude Opus 5.5 38944cff1a fix(environments): retry and safely replace a reusable sandbox that will not resume (#20626)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A task that runs on a reusable (warm) cloud sandbox resumes the same
sandbox on the next wake
> - When a follow-up arrives while the provider is auto-stopping or
archiving the idle sandbox, the resume can fail
> - Each failed attempt waited the provider's whole five-minute start
deadline and then failed with `reusable_sandbox_resume_failed`, even
when the workspace was saved and nothing lived only on the sandbox
> - Two such runs in a row made a user wait for many minutes and
recorded two failed runs
> - This pull request bounds the resume, retries a state change inside
the run, and replaces the sandbox from the saved snapshot when that is
safe
> - The benefit is that recovery works automatically and the first turn
is not delayed by minutes

## Linked Issues or Issue Description

**What happened?**
Two consecutive runs failed before provider dispatch with: `Reusable
sandbox lease "<id>" could not be resumed (cause=provider_error); the
lease was preserved and no replacement was created.` Each run took about
5.5 minutes (320s and 330s) in preparation. The third run resumed the
same sandbox from `stopped` in 4.7 seconds after the provider settled.
The previous run had ended with a clean final save and a verified
harness backup.

**Steps to reproduce**
1. Let a task's reusable sandbox go idle so that the provider auto-stops
or archives it.
2. Send a follow-up while the provider still reports the sandbox as
changing state.
3. The Daytona worker waits in `ensureSandboxStarted` until its 300s
start deadline and throws "is still <state> at its start deadline". The
host does not classify that as transient and it has no safe fallback, so
the run fails.

**Expected behavior**
The run retries briefly. If the sandbox still cannot start and nothing
lives only on it, the run continues on a replacement sandbox that
restores the saved workspace.

**Root cause**
`server/src/services/environment-runtime.ts` (reusable resume block,
about line 1820): the resume deadline was the provider's full start
deadline (300s plus RPC margin), the transient classifier did not
recognize a state transition, and the catch always threw
`ReusableSandboxResumeError`. #20212 waits out a state change inside the
same 300s start deadline, so it covers the start but not the time budget
or the fallback.

## What Changed

- Add `server/src/services/reusable-sandbox-resume-replacement.ts`:
- `REUSABLE_SANDBOX_RESUME_BUDGET_MS` (90s): the total resume time for
one run, retries included.
- `sandboxStateTransitionFailure`: "state change in progress" and "is
still <state> at its start deadline" are transient.
- `retireUnresumableReusableSandboxLease`: replace only when the lease
is `released`/`retained` with no live owner, the task workspace is clean
with a committed saved snapshot and no other writer, and a
`paperclip_runner` session has a verified lease-bound harness backup.
Access refusals (401/403) and an unavailable worker stay strict. After
the replacement lease (which carries the harness backup) is recorded,
the old lease moves to `pending_cleanup` (reason `resume_failed`) in one
guarded write, so the existing cleanup sweep destroys the old sandbox
and no provider delete runs before the first turn. A failed creation or
a restart before that write leaves the old lease `retained` for the next
attempt.
- `environment-runtime.ts`: cap the resume deadline at the budget, treat
a state transition as transient, and call the replacement policy before
failing. A replacement records `sandboxLeaseAcquisition: { outcome:
"replacement", reason: "resume_failed" }`; workspace preparation then
restores the saved snapshot.
- `remote-workspace-transport.ts`: add the closed failure label
`sandbox_state_transition`, so a strict failure names the cause instead
of `provider_error`.
- `doc/execution-semantics.md`: document the budget, the in-run retry,
the replacement conditions, and the strict cases.
- Update the two slow-start tests: the resume RPC timeout is now 90s
instead of 330s/150s. A 61s start still resumes the same sandbox.

## Verification

- Red before the fix (new file
`server/src/__tests__/environment-runtime-reusable-resume-replacement.test.ts`,
staging shape: lease created by the real acquisition path, then retained
after a clean save with a backup stamp, 300s provider deadline, worker
refusal with no status): 3 tests failed with `could not be resumed
(cause=provider_error); the lease was preserved and no replacement was
created.`
- Green after the fix: 10/10 pass, including a case where the
replacement creation fails and the next run replaces the sandbox with
the backup carried forward. The safe case replaces the sandbox, keeps
every resume RPC at or below 90s, makes no synchronous destroy call, and
moves the old lease to `pending_cleanup`. A single state-change refusal
resumes the same sandbox inside the run. Six strict cases keep the lease
and fail with `cause=sandbox_state_transition`: unsaved work, no saved
version, an uncommitted saved version, a live owner, no verified backup,
and a 403.
- `pnpm exec vitest run src/__tests__/environment-runtime.test.ts` (197
pass), `heartbeat-retry-scheduling`, `legacy-execution-recovery`,
`remote-workspace-transport` (251 pass).
- `tsc -p server/tsconfig.json --noEmit` passes.
- `pnpm verify` exits 2 (a doc change is a global CI input). Required CI
supplies the broad coverage.
- Not run: a live Daytona run on staging. The provider message is kept
off the run record, so the exact transition state on staging (stopping
or archiving) is inferred from timing, not observed.

## Risks

- A strict resume now gives up after 90s instead of 300s. The provider
start continues in the background, and the bounded bootstrap retry
resumes the same sandbox. A stopped sandbox resumes in seconds and an
archived one in about 25s on Daytona, so 90s leaves headroom.
- A replacement leaves the old sandbox to the cleanup sweep. If the old
start finishes later, the sweep still destroys it. The guarded write
only claims a `released`/`retained` lease with no live owner.
- Upgrade safety: no new persisted fields are required. Leases saved by
older releases are read with the same rule; one without a verified
backup stays strict. The replacement reason `resume_failed` already
exists in the acquisition outcome.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:51:10 -06:00
DottaandClaude 070d6f83fd perf(realtime): share permission reads among queued live events (#20624)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A task page shows the output of a running agent as it is produced.
> - The server sends each output event to the browser on the company
WebSocket.
> - Before each send, the server checks permissions and redacts secrets.
Each event did its own sequential database reads.
> - When output arrives faster than those reads complete, the queue
grows. The text reaches the browser later and later, and the server then
closes the connection.
> - This pull request lets the events that are already in the queue
share one set of reads.
> - The benefit is that live output keeps pace with the run, and a
revoked permission still applies to the next event.

## Linked Issues or Issue Description

**What happened?**

On a hosted instance, the live output of a running task sometimes
appeared very slowly. A probe WebSocket on the company event stream
received events 0.5 seconds after their creation time at the start.
After 30 seconds the delay was 23 seconds. The server delivered about 6
events each second. Then it closed the socket with code 1013.

The delivery loop for a connection is serial. For each transcript event
it ran a membership read, a scope decision, a run lookup and a task read
decision. Native run events added a settings read and a secret registry
read. A burst of 200 events issued 1,401 SQL statements.

**Expected behavior**

Live output reaches the browser at the rate the run produces it. The
cost of the permission checks does not grow with the number of events in
a burst. A revoked permission stops delivery immediately.

**Steps to reproduce**

1. Use a deployment that authenticates users, with a database that has a
network round trip.
2. Start several agent runs that produce output at the same time.
3. Open the task page of one run and read the live output.
4. Compare the creation time of each `heartbeat.run.log` or
`heartbeat.run.event` message with its arrival time.
5. The delay grows continuously until the server closes the socket with
code 1013.

**Paperclip version or commit**

`master` at 79e28dc0d0.

## What Changed

- `live-event-delivery.ts` gives each queued event a sequence number. It
passes a shared read set to `authorize`. A set serves only the events
that were in the queue when the set opened. The delivery replaces the
set after one second and discards it when the queue is empty.
- `sharedDeliveryRead` returns the pending read for a key, or starts it.
It removes a read that fails, so the next event reads again.
- `live-events-ws.ts` uses the shared set for the membership or key
check, the transcript scope decision, the run binding and the task read
decision.
- `live-run-event-redaction.ts` uses the shared set for the username
setting and the registered secret values of the run.
`run-secret-redaction.ts` adds `redactorForRun`, which reads the values
one time and returns a redactor.
- `doc/live-transcript-delivery.md` describes the shared reads and their
limits.

## Verification

- `npx vitest run src/realtime/live-event-delivery.test.ts
src/realtime/live-event-delivery.cost.test.ts
src/__tests__/live-events-run-read.test.ts
src/__tests__/live-events-ws.test.ts
src/__tests__/live-events-toast.test.ts` in `server/`. All tests pass.
- New integration test against PostgreSQL: 200 transcript events in one
burst. All events arrive in order. The burst issues at most 40
statements. Without the shared reads the same test fails with 1,401
statements. After the grant is revoked, the next burst delivers nothing.
- New unit tests: 39 events that wait behind one read share the next
read. An event that arrives during a read starts a new read. A set is
replaced after one second. A failed read is not shared.
- The existing tests for revocation, deleted tasks, restricted agent
keys and live redaction pass without changes.
- Assumption tested: an event never uses a read that began before the
event entered the queue. The unit test holds a read open and adds an
event to prove it.
- Not verified: the delay on a hosted instance after deployment. The
measurement above is from before this change.

## Risks

- Permission freshness. Before, each event had its own check. Now a
check can serve events that were already in the queue, for at most one
second. A revocation applies to the first event published after it.
Events already in the queue at the time of the check can still be sent.
That window existed before between a check and its send.
- Secret values. A read set holds decrypted registry values while a
burst drains. The delivery discards the set when the queue is empty and
when the connection closes.
- Plugin run streams use the same redactor and get the same shared
reads.
- No schema, API or protocol change.

> 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, model ID `claude-fable-5-2`, in Claude Code with
tool use and code execution.

## Checklist

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 13:37:25 -06:00
DottaandPaperclip 89ec4e7614 feat(links): add cached GitHub pull request previews (#20593)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task conversations contain links to pull requests and other work.
> - A URL alone cannot tell the operator whether a PR is open, merged,
or blocked by conflicts.
> - Fetching every link at render time wastes API calls and can expose
private data through a shared cache.
> - This pull request adds a demand-driven links service and a GitHub PR
adapter.
> - Each viewer and company has a separate cache. Repeated links share
one request and result.
> - Operators get context when they hover or focus a link. Normal links
stay useful when a provider is unavailable.

## Linked Issues or Issue Description

**Subsystem affected**

Task Markdown links, shared link metadata, and the server GitHub API
boundary.

**Problem or motivation**

PR links have compact labels and neutral icons. They do not provide a
rich hover card or a common status source across repeated mentions. A
background resolver cannot borrow a person's GitHub connection because
it has no viewer identity.

**Proposed solution**

Recognize supported links locally. Request metadata after a delayed
hover or keyboard focus. Show a card at the first line of the link,
aligned with its icon. Share the result across all mentions in the
current viewer and company. Keep the exact original navigation URL.

The first adapter supports GitHub pull requests. Open is green, merged
is purple, and merge conflicts are orange. Draft, closed, unknown,
loading and unavailable states remain quiet.

**Alternatives considered**

Eager page scans would spend API quota on links the operator does not
inspect. Company-wide caching of personal GitHub results would cross the
credential boundary. This change uses neither approach.

**Roadmap alignment**

ROADMAP.md was checked. This extends existing task links and the GitHub
resolver. It adds no deployment adapter or new background worker.
Searches of open and closed PRs for link previews and PR hover cards
found no matching implementation.

## What Changed

- Add host-owned link recognition and a normalized preview contract. The
service selects an adapter by provider.
- Add a board-only company read endpoint. It checks company access
before cache reads and sends a private, no-store response.
- Use the current viewer's eligible GitHub connection. A configured
connection that cannot supply credentials fails closed. With no managed
connection, reuse existing company credentials or public access.
Instance credentials retain the public-repository check.
- Reuse the GitHub external-object resolver for API transport and
response parsing. Add title and mergeability to its normalized snapshot
without changing its existing state contract.
- Coalesce requests by company, viewer and canonical PR URL. Bound
cached entries and concurrent work. Cache success for one minute and
failures for a short period or the provider retry window.
- Add an abortable request deadline. Timed-out work stays in the
concurrency budget until it settles. No periodic worker or idle polling
is added.
- Add a shared browser query and anchored preview card. Repeated
Markdown links observe the same result. There are no automatic retries,
window-focus refreshes, toasts or connection prompts.
- Add ten production-component Storybook examples and real
pointer/keyboard browser checks. Document the API and verification
route.

## Verification

- Linux source checkout: 233 focused tests passed in nine suites. They
cover recognition, fixed API destinations, provider states, access
failures, credential selection, viewer/company isolation, request
coalescing, expiry, cache bounds, concurrency bounds and mounted OpenAPI
coverage.
- A live public GitHub smoke returned this PR's correct title and open
state. Two equivalent URLs shared one real network request. Personal
connection access was not part of that smoke.
- A controlled HTTP server stalls a real response body. The test proves
cancellation while another lookup completes.
- UI typecheck and the server layered typecheck passed. The UI
production build passed.
- Token gates and query policy gates passed. All 30 map/catalog support
checks passed. The three new server test files also passed a focused
TypeScript check.
- All 27 focused browser checks passed on the reviewed source tree. They
cover light/dark themes, narrow layouts,
open/merged/conflict/draft/closed/loading/unavailable/failed states,
fixed positioning during pointer movement, exact navigation and keyboard
access. A real browser HTTP interception counts one deferred lookup for
two equivalent PR links.
- The first browser attempt lacked Chromium. A later combined build/test
attempt exhausted the sandbox's 8 GiB memory limit and killed Storybook.
Those attempts supply no pass evidence. Chromium is installed, and the
final browser checks run with the build stopped. One initial Storybook
dependency reload interrupted a streaming check. All eight existing
inline-link cases passed on the warmed server, followed by a complete
27-case pass.
- Synthetic Storybook pointer events did not exercise the native hover
boundary. The assertions now run through real Playwright pointer and
keyboard actions. The fixture stories retain the production components.
- Review fixes preserve the Draft label when GitHub reports both draft
and conflicts. They also preserve neutral link colors in dark mode. The
25 affected service checks pass, including the added combined-state
regression. Browser checks compare unknown, draft and closed links with
the muted theme color.
- The first CI policy run rejected a literal retry opt-out. The code now
uses the existing explicit zero-retry helper. A new test-spy type error
was also corrected. These failures are fixed; their earlier runs remain
failed.
- `pnpm verify --explain` returned exit 2 because the browser test path
selects broad CI. No full local suite is claimed. The final CI run
supplies that coverage.
- The previous head `437913c797963aa113bb82e2ae8b836969317ef9` passed CI
and received a 5/5 Greptile score. All four review threads are resolved.
Master then advanced. The current head
`ae2a1c2adbd61a621cca9bdd160561952613df6e` resolves the source-manifest
conflict against master `9b21dcbdda01296349f3cfe0a3a6891e79df6130`.
Fresh CI and review are running.
- The previous CI run found a lost class on decorated PR links and a
stale SDK source manifest. Both are fixed. The existing CommentThread
integration test and 26 focused link service tests pass. All 45 SDK
source/privacy tests pass. All eight source-manifest helper tests pass.
The manifest helper changed only source identities and added the two new
shared files. Runtime, compiler, parser and library pins stay unchanged.
- A later review found that expired timeouts could reuse a settled
promise. The service now removes settled requests from its
shared-request map and counts unfinished provider work separately. The
new regression failed before the fix and passes after it. The
concurrency-bound and real stalled-HTTP tests also pass.
- UI typecheck and token gates pass after these fixes. The browser rerun
needed Chromium in the new session. That first attempt did not exercise
the UI. The complete 27-case browser rerun passed before the master
merge with retries disabled. All 27 browser cases also passed on the
merge revision with retries disabled.

- After the master merge, all 84 focused link, OpenAPI and integration
tests pass. All 55 SDK source/privacy/manifest tests pass. UI typecheck
and token gates pass again. The source manifest retains master's
reviewed runtime pins and includes both sets of source changes. GitHub
confirms the PR can merge cleanly. The preflight helper rebuilt the
shared package and plugin SDK; Cargo is unavailable locally. Final CI
supplies Rust coverage.

- The latest master update changed the reviewed report-lock policy but
left the SDK lock hash at its previous value. The SDK source/evidence
checks reproduced the mismatch. This change sets only the SDK lock hash
to the authenticated output of the existing master policy and patch.
Compiler, parser and Zod pins stay unchanged. All 56 SDK
source/evidence/manifest tests now pass. All 84 focused
link/API/integration tests, UI typecheck and token gates also pass on
the updated source.

## Risks

- This cache is process-local. Separate server replicas can make
separate GitHub requests.
- A cached result can remain visible for its short lifetime after GitHub
changes. A later hover refreshes expired data.
- GitHub can report unknown mergeability. The UI shows conflicts only
when GitHub explicitly reports them.
- Personal preview data stays in viewer/company caches. It is not
written to company external-object records or persisted browser query
storage.
- Stories simulate the API. They do not prove a live GitHub login or
private-repository access.
- No schema migration is required.

## Model Used

OpenAI Codex, based on GPT-6. The runtime does not expose the exact
serving model ID or context-window limit. Used reasoning, repository
inspection, source editing, shell execution and browser tools. No
subagents were 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 19:32:51 +00:00
DottaandClaude Opus 5.5 42d4499489 fix(daytona): force a stuck stop before starting a retained sandbox (#20609)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - On Daytona, a follow-up task resumes the agent's retained sandbox
before the agent starts
> - The host stops an idle sandbox gracefully. A process that ignores
SIGTERM can leave Daytona in `stopping` for many minutes
> - Resume waited for that state change until its 300 s start deadline.
It never escalated, so each attempt failed after five minutes, and the
bounded retries did the same
> - On staging, a user comment waited about 21 minutes before the agent
started. Two resume attempts each used 299.7 s in the plugin dispatch
> - This pull request forces a stuck stop once, then starts the sandbox
within the same deadline. It also traces the wait with the observed
state
> - The benefit is that a stuck stop costs about 30 s instead of failing
the run, and the next case is diagnosable from the trace

## Linked Issues or Issue Description

**What happened?**
A user commented on a task that used a retained Daytona sandbox. The
first run and its first retry each failed after about 5 min 20 s with
`reusable_sandbox_resume_failed (cause=provider_error)`. The Honeycomb
trace shows `execution.plugin_dispatch` at 299.7 s in both. Inside it
there is only `lease.lookup` (44 ms) and `lease.channel_cleanup`, and no
`lease.start`. So the whole budget went to `waitOutSandboxTransition`,
which polled a sandbox that stayed in a transition state. The later
attempt, after a control-plane restart, found the sandbox `stopped`. Its
`lease.start` took 1.3 s. A transition that ends in `stopped` is a stop.

**Expected behavior**
A sandbox that stays `stopping` for longer than the host's own
graceful-stop budget is forced to stop, then starts. The run does not
fail on the start deadline.

**Steps to reproduce**
1. Retain a Daytona sandbox whose graceful stop does not finish (Daytona
reports `stopping`).
2. Resume or realize the lease.
3. Before this change, the call polls until the 300 s deadline and fails
with "is still stopping at its start deadline".

## What Changed

- `packages/plugins/sandbox-providers/daytona/src/plugin.ts`:
`waitOutSandboxTransition` tracks how long the sandbox stays `stopping`.
After `STUCK_STOPPING_FORCE_AFTER_MS` (30 s) it calls
`sandbox.stop(seconds, true)` once, then keeps polling. A refused force
stop keeps the old wait within the same deadline.
- The wait is a `lease.wait_transition` span. The force stop is a
`lease.force_stop` child span.
- `lease-tracing.ts`: the closed `paperclip.sandbox.startup.state` label
also records `starting`, `stopping`, `restoring`, `archiving` and
`resizing`. Before, these were all `other`.
- `server/src/services/plugin-host-services.ts`: the host admits the two
new span names and the new closed state values.
- `doc/observability.md`: documents the new spans and states.

## Verification

- Red/green: with the plugin change stashed, the three new
stuck-`stopping` tests (realize, refused force stop, resume) fail by
hanging to the deadline. With the change they pass. The "settles on its
own" test confirms that no force stop happens for a normal stop.
- `vitest run` in `packages/plugins/sandbox-providers/daytona`: 14
files, 618 tests passed (8 skipped). `tsc --noEmit` passed.
- `server/src/__tests__/plugin-host-services-span.test.ts`: red before
the allowlist change, 25/25 after.
- `pnpm verify` exited 2 (the doc is a global CI input), so CI supplies
the broad coverage.
- Not verified on a live Daytona sandbox. Hosted acceptance needs the
change deployed to staging.

## Risks

- The force stop sends SIGKILL to processes in a sandbox that is already
stopping and is about to start. The disk persists across a stop, so the
retained workspace is kept. A graceful stop that is still running past
30 s is killed. The host's own graceful stop budget is 30 s, so the host
had already given up on it.
- Daytona can refuse a stop while a state change is in progress. Then
the code waits as before, so the result is no worse than today.
- The root cause is inferred from the trace (a transition that ends in
`stopped`). The new state label confirms or refutes it the next time it
happens.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:20:30 +00:00
DottaandClaude Opus 5.5 9b21dcbdda refactor: remove SPEC docs and repoint spec citations (#20565)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors and agents read the docs under `doc/` before they
change code
> - `doc/SPEC.md` and `doc/SPEC-implementation.md` were out of date.
`doc/SPEC-implementation.md` became an append-only log: in 60 days it
got about 1,645 new lines and lost 181, across 140 commits
> - #20557 changed `AGENTS.md` so the code and its tests are the source
of truth, and the specs are only background
> - This pull request removes the two spec docs. It moves only the
sections that live code comments cite into focused docs, and it repoints
all other references
> - The benefit is one less stale document that agents read and append
to

## Linked Issues or Issue Description

Refs #20557 (merged). This PR is the follow-up that removes the docs. It
is based on `master`, which already contains #20557.

**Issue type**
Outdated content. The docs are out of date and conflict with the code.

**Where is the issue?**
`doc/SPEC.md`, `doc/SPEC-implementation.md`, and the code comments and
docs that cite them.

**What's wrong?**
`doc/SPEC.md` and `doc/SPEC-implementation.md` are out of date, but
agents still read them and append a paragraph with each fix.

**Suggested fix**
Delete both files. Move the sections that code cites into focused docs.
Repoint the references.

## What Changed

- Deleted `doc/SPEC.md` and `doc/SPEC-implementation.md`.
- Moved these spec sections, because live code comments cite them:
- §9.3.1 "Shared default-open issue writes" → new section in
`doc/agent-permission-defaults.md`.
- §9.8.1 "Issue-thread interaction resolver contract" →
`doc/execution-semantics.md`, "Issue-thread interaction resolution".
That section already had most of the contract. I added only the missing
parts: the `supersedeOnUserComment` default, the canonical policy list,
the addressee and cap rules, the rule that surfaces show the server's
effective audience, and the cross-issue budget charge. The short policy
summary at the top of the section now points to the list, so the
definitions occur one time only.
- §9.9 "Task Watchdog Authority Contract" → new "Authority contract"
section in `doc/TASK-WATCHDOG.md`. It holds the allowed and disallowed
mutation lists and the deferred capabilities. The atomic recovery batch,
restoration verification, and interaction rules were already in
`doc/execution-semantics.md` §11, so the new section links there and
does not repeat them. I did not move the "Downstream acceptance
criteria" checklist or the sentence about the CTO and Security sign-off.
These were implementation-time notes; the tests now hold that coverage.
- §9.10 "Company Skill Policy Contract" → new
`doc/company-skill-policy.md`. No focused doc had this contract. I did
not move the "Required regression tests" phase checklist.
- Did not move any other part of the specs.
- Repointed code comments that cited spec sections:
- `packages/shared/src/issue-write-denial.ts` (§9.3) →
`doc/agent-permission-defaults.md`
- `server/src/routes/issues.ts` (§9.9) → `doc/TASK-WATCHDOG.md`,
"Authority contract"
- `server/src/routes/issues.ts` (§9.3, §9.8.1 cross-issue budget) →
`doc/agent-permission-defaults.md`
- `ui/src/lib/interaction-audience.ts` and
`ui/src/components/InteractionGovernancePanel.tsx` (§9.8.1) →
`doc/execution-semantics.md`
- `ui/src/lib/skill-policy-denial.ts`, `ui/src/pages/CompanySkills.tsx`,
`ui/src/pages/CompanySkills.production.tsx`,
`ui/src/pages/SkillStudio.tsx` (§9.10) → `doc/company-skill-policy.md`
- `server/src/services/recovery/ownership-invariant-report.ts` (monitor
deadline wait path) → `doc/execution-semantics.md` §8. That section
already lists the one-shot monitor as a valid wait path.
- Resynced the git blob pin for
`packages/shared/src/issue-write-denial.ts` in
`tests/runner-e2e/source-sdk-public-inputs.json`. The comment edit
changed the file's hash, and the Runner support suite checks that pin. I
used `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD>
--write`; only that one pin changed.
- Updated `AGENTS.md` §1 so it does not name the deleted files.
- Removed the links to the deleted files from
`doc/plugins/PLUGIN_SPEC.md`.
- Updated the `doc-maintenance` agent skill
(`.agents/skills/doc-maintenance/`) so it no longer audits or stages
`doc/SPEC.md`.
- Removed or replaced references in `maps/README.md`,
`maps/feature-map/README.md`, `maps/storybook-map/README.md`,
`doc/PRODUCT.md`, `doc/CLIPHUB.md`, `doc/DEPLOYMENT-MODES.md`,
`doc/DEVELOPING.md`, `doc/evals.md`, `doc/spec/agent-runs.md`,
`doc/AGENTCOMPANIES_SPEC_INVENTORY.md`,
`doc/connections/CONNECTOR-PLAYBOOK.md`,
`doc/plugins/ideas-from-opencode.md`, `doc/execution-semantics.md`,
`doc/TASK-WATCHDOG.md`, and `.devin/wiki.json`.
- In `doc/execution-semantics.md`, the "admission is independent of
cleanup and accounting" reference now states the rule inline: only a
hard stop, an explicit pause, or an inactive company refuses admission.
- Kept these references as they are: dated plans in `doc/plans/` and
`docs/plans/`, the dated log
`doc/logs/2026-05-24-cli-api-parity-e2e-log.md`, `releases/`, the
transcript fixture `ui/src/fixtures/runTranscriptFixtures.ts` (it uses
the filename only as sample text), and
`docs/specs/external-task-protocol.md` (it refers to the Symphony
project's own `SPEC.md`).

## Verification

- `git grep -n "SPEC-implementation\|SPEC\.md"` (without `releases/`,
and ignoring `PLUGIN_SPEC.md` citations) finds only the kept references
listed above.
- `grep -rn "§9\.\(3\|8\|9\|10\)" ui/src server/src packages/shared/src`
finds no results.
- `node scripts/check-maps.mjs`: "Maps aligned: 35 areas, 192 capability
IDs".
- `pnpm check:token-gates`: "All gates clean".
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/source-sdk-public-type.test.ts
tests/runner-e2e/source-sdk-evidence.test.ts`: 45 passed. These failed
in CI before the pin resync.
- `pnpm verify`: exit 2 ("Broader verification required") because
`AGENTS.md` is a global CI input. Required CI supplies the broad
coverage for this revision.
- All code changes are comments only. No runtime behavior changes. The
title uses `refactor:` because comments in source files changed.

## Risks

- Low risk. The change removes docs and edits comments only.
- A reader can lose context from spec sections that I did not move. The
text stays in git history. The code and its tests are the source of
truth.
- External links to the two deleted files will return 404.

> 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 edits). Standard context window.

## 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 (focused checks above; CI
supplies broad coverage)
- [x] I have added or updated tests where applicable (not applicable:
docs and comments only)
- [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 13:07:59 -06:00
DottaandClaude Opus 5.5 3bf7af6c77 perf(plugins): read the lifecycle inbox from per-resource cursors (#20576)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Plugins get agent, project and agent runtime changes from the
`resource_lifecycle_events` journal. Each plugin acknowledges events one
resource at a time.
> - #20405 made the inbox page read linear in the company journal size.
The journal has no retention, so each page still reads every event of
the company to find the unacknowledged ones: about 1.7 s per 1M events.
> - The delivery worker reads a page again after each acknowledgment.
The idle-sleep check also reads the whole journal when all events are
acknowledged. `acknowledge` checks every older event of the resource.
> - On a hosted instance, the page read ran 181 times in one window at
about 1 s each.
> - This pull request adds a per-plugin, per-resource cursor. Reads
start at the cursor, so they do not read acknowledged history again.
> - The benefit is that page, idle-check and acknowledgment cost follow
the number of resources and pending events, not the journal size.
Delivery order and results stay the same.

## Linked Issues or Issue Description

Refs #20405

**What happened?**
The plugin lifecycle inbox page query (`select ... from
"resource_lifecycle_events" ... DISTINCT ON ...`) ran 181 times at about
1 s each on a hosted database in one window. Each acknowledgment causes
another full read of the company journal. The idle-sleep check
(`hasPending`) reads the full journal when all events are acknowledged.
`acknowledge` reads the full history of the resource.

**Expected behavior**
A page read, the idle-sleep check and an acknowledgment must not read
acknowledged history again. Their cost must not grow with the journal
size.

**Steps to reproduce**
1. Give a company 400 agents with 2,500 `agent_runtime` events each (1M
events).
2. Acknowledge the first 2,000 events of each agent for one plugin.
3. Call `pluginLifecycleInbox(db, pluginId).list(companyId, 100,
undefined, ["agent", "project", "agent_runtime"])`.
4. The read takes about 1.7 s. `hasPending` takes about 240 ms.

**Paperclip version or commit**
`8d2bf78323` (master).

**Deployment mode**
Hosted (PostgreSQL). Local repro uses embedded PostgreSQL 18.

## What Changed

- `packages/db/src/schema/plugin_lifecycle_resource_cursors.ts`: new
table `plugin_lifecycle_resource_cursors`. It has one row per plugin,
company, resource type and resource id. `acknowledged_through_id` means:
every event of that resource at or below this id is acknowledged by the
plugin. Plugin and company deletion cascade.
- Migration `0462_plugin_lifecycle_resource_cursors`: creates the table
and carries existing acknowledgments forward (see Risks for the
previous-release story).
- `server/src/services/plugin-lifecycle-inbox.ts`:
- `list` and `hasPending` use a backward loose index scan on
`resource_lifecycle_events_resource_order_idx`. It visits each resource
stream once and gets its latest event id. A stream whose latest event is
at or below its cursor is settled and is not read again. For other
streams, the read gets one head: the unacknowledged creation, else the
first unacknowledged transition above the cursor. Then it applies
`afterId`, id order and the limit, as before.
- `acknowledge` reads only the events between the cursor and the
acknowledged event. It still writes the per-event
`plugin_lifecycle_acknowledgments` row. It moves the cursor only over a
contiguous acknowledged prefix. A creation recorded after a pending
transition does not move the cursor.
- An event counts as acknowledged when it is at or below the cursor, or
when it has an acknowledgment row. Thus rows written by an earlier
release, and gaps in old history, keep their current behavior.
- `server/src/__tests__/resource-lifecycle-events.test.ts`: new database
tests for the migration carry-forward, gaps, late creations, cursor
reads, the rollback rows and the plugin-delete cascade. All tests from
#20405 still pass without change.
- `scripts/benchmark-plugin-lifecycle-inbox.ts`: runs the migration
backfill on the fixture and times it. Adds an acknowledgment case. Runs
`VACUUM ANALYZE`, because hosted tables are autovacuumed.
- `doc/DATABASE.md`: describes the cursor, its read cost and the
migration.
- The delivery worker (`plugin-lifecycle-delivery.ts`) is not changed.

## Verification

Thesis: the cost is a read of the whole company journal (page, idle
check) or the whole resource history (acknowledgment). If a per-resource
cursor marks the acknowledged prefix, each read can skip that prefix.
Then the cost follows the number of resource streams and pending events.
The rows must stay equal.

Local SQL timings: embedded PostgreSQL 18.1 on Apple Silicon, synthetic
data, warm cache, one warmup, 7 samples, baseline and treatment order
alternate per sample. The machine was shared with other jobs (load
average about 35), so spread is wide. Values are median (min to max) in
ms. Rows are equal (`assert.deepEqual`) for every list and probe case.

Fixture A: 400 agents x 2,500 runtime events, 2,000 acknowledged per
agent. 1,020,440 events, 800,420 acknowledgments.

| Case | Before | After | Speedup |
| --- | --- | --- | --- |
| page of 100, all types | 1,700 (1,633-2,438) | 11.5 (9.5-12.2) | 148x
|
| page of 100, agent and project | 3.3 (2.5-5.4) | 4.2 (3.6-6.7) | 0.8x
(slower) |
| idle-sleep check | 238 (174-367) | 2.7 (2.1-3.4) | 88x |
| acknowledge one head | 15.3 (9.4-30.1) | 20.3 (12.9-29.9) | neutral
(noise) |
| migration backfill | - | 9.3 s | - |

Fixture B: 40 agents x 25,000 runtime events, 24,000 acknowledged per
agent (long history). 1,020,080 events.

| Case | Before | After | Speedup |
| --- | --- | --- | --- |
| page of 100, all types | 1,943 (1,642-4,979) | 4.4 (2.9-5.6) | 444x |
| page of 100, agent and project | 3.5 (2.1-5.7) | 4.6 (2.8-12.0) | 0.8x
(slower) |
| idle-sleep check | 234 (220-243) | 2.8 (1.9-6.5) | 85x |
| acknowledge one head | 64.6 (48.5-71.6) | 5.0 (2.6-7.1) | 13x |
| migration backfill | - | 4.6 s | - |

Fixture C: 5,000 agents x 200 runtime events, 190 acknowledged per agent
(many resources). 1,025,040 events.

| Case | Before | After | Speedup |
| --- | --- | --- | --- |
| page of 100, all types | 1,867 (1,649-1,975) | 76.9 (66.2-497) | 24x |
| page of 100, agent and project | 10.3 (8.7-12.4) | 21.6 (20.2-23.2) |
0.5x (slower) |
| idle-sleep check | 220 (201-298) | 2.8 (2.0-5.3) | 78x |
| acknowledge one head | 4.9 (3.4-8.6) | 6.0 (5.2-7.6) | neutral |
| migration backfill | - | 15.6 s | - |

`EXPLAIN (ANALYZE, BUFFERS)` for the all-types page on fixture A: before
2,488 ms, 13,597 shared hits plus temp files; after 7.0 ms, 7,926 shared
hits.

Commands:

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=8d2bf78323 --agents=400 --per-agent=2500 --acked=2000 --samples=7
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=8d2bf78323 --agents=40 --per-agent=25000 --acked=24000 --samples=7
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=8d2bf78323 --agents=5000 --per-agent=200 --acked=190 --samples=7
cd server && npx vitest run src/__tests__/resource-lifecycle-events.test.ts src/__tests__/plugin-lifecycle-delivery.test.ts src/__tests__/project-instance-secrets.test.ts src/__tests__/agent-runtime-hooks.test.ts
pnpm --filter @paperclipai/db check:migrations
```

- `resource-lifecycle-events.test.ts`,
`plugin-lifecycle-delivery.test.ts` and
`project-instance-secrets.test.ts`: 86 passed.
`agent-runtime-hooks.test.ts`: 22 passed.
- `tsc --noEmit -p server`: pass. `check:migrations`: pass.

## Risks

- **Schema change.** Migration `0462` adds one table and two foreign
keys to `plugins` and `companies`. It adds no index on an existing
table. The backfill reads the acknowledgment table once. It took 4.6 s
to 15.6 s on 1M-event fixtures with 0.8M to 1M acknowledgments.
- **Migration order.** The number `0462` is reserved after open PRs that
use `0459` to `0461` (#20571, #20568, #20573). Merge this PR after them.
Before merge, rebase onto master and chain the snapshot after the latest
migration.
- **Previous-release story.**
- Upgrade: the backfill stores, per plugin and resource, the end of the
contiguous acknowledged prefix. A cursor never passes an unacknowledged
event, so no event is lost. Acknowledged events above a gap keep their
rows, so no event is delivered again. A test runs the migration
statement on rows that only an earlier release wrote and checks that
`list` returns the same rows before and after.
- Rolling deploy: an earlier release that still runs writes only
acknowledgment rows. The new reads honor those rows.
- Rollback: the new code still writes every acknowledgment row, so an
earlier release sees the same progress.
- **Ordering assumption.** The cursor is safe because writers record a
resource's events while they hold that resource's row lock
(`paperclip_record_agent_runtime` locks the agent row; agent status and
project writers document the lock). Thus a later commit for the same
resource always has a higher id. A writer that breaks this contract
could commit an event below the cursor, and that event would not be
delivered. A lower id from a different resource is still delivered
(existing test).
- **Slower path.** A page that asks only for `agent` and `project`
streams is 1 to 11 ms slower. These streams are short, so the old full
read was cheap, and the new read visits each stream once. The cost is
bounded by the number of resources.
- **Many resources.** The page cost grows with the number of resource
streams of the requested types. At 5,000 agents a page is about 77 ms,
compared with about 1.9 s before.
- The hosted planner can choose a different plan. The hosted improvement
is not yet measured.

## Model Used

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

## 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 12:58:32 -06:00
DottaandClaude Opus 5.5 86cc3e3a11 fix(workspaces): save remote workspaces that contain outside symlinks (#20569)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents on a remote sandbox (for example Daytona) save their
workspace after each run, and recovery retries a failed save
> - The save refused the whole workspace when one symlink was absolute
or pointed out of the workspace
> - Ordinary agent work makes such links: a headless Chromium profile
links `SingletonSocket` by absolute path into `/tmp`
> - Recovery then spent its full budget on the same refusal, handed the
hold to the board, and the task stayed blocked
> - This pull request keeps confined links, records and omits outside
links, and retries saves that an older policy exhausted
> - The benefit is that these saves succeed and blocked tasks continue
without a person

## Linked Issues or Issue Description

**What happened?**
On staging, a native run (OpenCode on `paperclip_runner`, Daytona
sandbox) built Storybook and drove Chromium with
`--user-data-dir=<workspace>/.review-chrome`. Chromium wrote
`.review-chrome/SingletonSocket ->
/tmp/.org.chromium.Chromium.*/SingletonSocket`. The save failed three
times with `remote_workspace_archive_unsafe_symlink`
(`native_workspace_sync_out_retry_exhausted`). System recovery then
tried six more times. It recorded each failure as `export_interrupted`
with cause `provider_error`, became `exhausted`, and gave the hold to
the board. The task and its parent stayed blocked.

**Expected behavior**
The save succeeds for an ordinary workspace that contains symlinks.
Links inside the workspace stay links. Absolute and escaping links are
dropped and recorded, and are never followed. Holds that an earlier
release exhausted recover automatically after deploy.

**Steps to reproduce**
1. Create a workspace with `node_modules/.bin/tool -> ../tool/cli.js`,
an absolute link to a file outside the workspace, and a link `escape ->
../outside/file`.
2. Capture it with `captureRemoteWorkspaceArchive` (or the serialized
sandbox helper).
3. Before this change, the capture fails with
`remote_workspace_archive_unsafe_symlink`.

## What Changed

- `remote-workspace-snapshot-archive.ts`: capture records an absolute or
escaping symlink in the manifest `excluded` list and omits it, instead
of failing the save. It never follows a link. Confined links are kept.
Restore validation is unchanged and still refuses an archive that names
an outside link.
- Added `REMOTE_WORKSPACE_CAPTURE_POLICY_VERSION = 2`. Each new system
recovery generation records `capturePolicyVersion`. Generations without
the field count as version 1.
- New `workspace-recovery-capture-policy.ts`: an exhausted save whose
last failure was in `preserve_files` (`export_interrupted`) under an
older capture policy gets one new bounded generation. Restore refusals
(`archive_unsafe`) are not re-armed, because restore validation is
unchanged. It shares the run-over guard (`sourceRunProvablyOver`) with
the stop-proof re-arm. It writes a `capturePolicyRecovery` marker and
`issue.workspace_recovery_rearmed` activity with reason
`capture_policy_changed`. The new generation has the current version, so
the rule cannot repeat for it.
- The recovery worker discovers these holds
(`capturePolicyRearmPendingCondition` in `nextWorkspaceRecoveryAt` and
the obligations query).
- Docs: `doc/native-workspace-finalization-recovery.md` (symlink policy)
and `doc/execution-semantics.md` (capture-policy re-arm).

Previous-release story: archives saved by the previous release never
contain outside links, and restore of them is unchanged. Recovery state
saved by the previous release has no `capturePolicyVersion`; it is read
as version 1 and, if exhausted in `preserve_files`, is re-armed once on
the first worker pass after deploy. No migration is needed. A capture
already in flight at the cutover uses the helper that its operation
installed; the next attempt installs the new helper.

## Verification

Red before the fix (same tests, unfixed code):

- `remote-workspace-snapshot-archive.test.ts` "keeps confined links and
drops outside links" (local and serialized helper): failed with
`remote_workspace_archive_unsafe_symlink`.
- `remote-workspace-transport.test.ts` "saves a workspace with outside
links through the sandbox helper" (split and Daytona one-shot output):
failed with `remote_workspace_archive_unsafe_symlink`.
- `workspace-recovery-capture-policy.test.ts` "retries once under the
new policy": failed, because the worker did not discover the exhausted
hold (`nextWorkspaceRecoveryAt` returned null).

Green after the fix:

- `npx vitest run --root server
src/services/remote-workspace-snapshot-archive.test.ts
src/services/remote-workspace-transport.test.ts
src/services/workspace-recovery-capture-policy.test.ts`: 271 passed.
- The recovery test uses the exact staging evidence shape (6 attempts,
`export_interrupted`, `provider_error`, board owner, no policy version).
It ends `complete` and `recovered`, saves a snapshot, and leaves the
source untouched.
- Safety cases: the archive does not contain the outside bytes; restore
refuses a hand-made archive with an escaping link; a hold exhausted
under the current policy is not re-armed and no sandbox resume is
attempted.
- `npx tsc --noEmit -p server/tsconfig.json`: clean.
- `pnpm verify`: see CI for the broad selection.

## Risks

- A tracked symlink that is absolute or escaping is now omitted. After
restore it shows as a deletion in `git status`. Before, the whole save
failed.
- On deploy, every hold exhausted in `preserve_files` with
`export_interrupted` gets one more bounded recovery generation,
including holds that failed for provider reasons. This is one generation
per hold per policy version.
- The manifest exclusion label stays
`paperclip.remote-workspace-exclusions.v1`. Workspaces that the old
policy accepted produce the same manifest, so unchanged-snapshot reuse
still works.

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

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, 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
- [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 12:51:29 -06:00