Commit Graph
8152 Commits
Author SHA1 Message Date
DottaandClaude Opus 5.5 97ad08935d fix(status-cards): link listed cards and report the real refresh outcome in chat
The card list now carries each card's href, so an agent that reuses an
existing card can link it. The chat directive tells the agent to report what
the refresh response says (queued, already running, deferred by policy, or a
concurrent refresh that already finished) instead of always saying queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:45 -06:00
DottaandClaude Opus 5.5 b9f106f1b1 test(evals): count the company card list as refresh-card's read of the card's saved state
The list returns each card's state, failureReason and lastGeneratedAt, and the
lead answered the failed-refresh question from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:45 -06:00
DottaandClaude Opus 5.5 e4323ce47c test(evals): let the status-card prerequisite fixture read the lead's run allowance
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:45 -06:00
DottaandClaude Opus 5.5 c5f5570dc6 fix(status-cards): describe a concurrent finished refresh in the refresh catalog entry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:45 -06:00
DottaandClaude Opus 5.5 f5e02a6ea0 test(evals): give refresh-card's lead the four daily runs its four questions need
The shared chat fixture allows three lead runs a day. refresh-card asks four
questions, so the product correctly skipped the final question's wake and the
case timed out once the earlier turns became fast enough to reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:45 -06:00
DottaandClaude Opus 5.5 9862ffde3d test(evals): accept a company-wide blocked-work compile for create-card's one-project fixture
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:45 -06:00
DottaandClaude Opus 5.5 f65e73ba1a fix(status-cards): spell out the summary sentinels and the failure path in update tasks
An update task told any assigned summarizer to preserve the Summarizer's
STATUS and SUMMARY-DRAFT sentinels without defining them. A non-built-in
summarizer searched for the format for minutes, including other runs' local
files. State the exact lines, that the PUT body is plain Markdown, and that a
summarizer that cannot produce a summary comments and blocks the task so the
card keeps its previous summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:45 -06:00
DottaandClaude Opus 5.5 58e0ffb2ec test(evals): grade status-card create and refresh by saved state, links and the lead's own reads
create-card and refresh-card matched keywords in model output (the card's
interestPrompt, the summarizer's summary, the worker's blocked comment and
every chat reply). Grade the compiled watch scope, the saved card link, the
lead's refresh_requested receipt, the fingerprint of the changed source task,
and each later turn's own read of the card from its tool-call events instead.
Bump both oracle versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:44 -06:00
DottaandClaude Opus 5.5 fb39622dac fix(chat): tell chat agents what a status card is and to reply now after queuing one
While the Status Cards experiment is on, a standing chat now learns that a
status card is a saved feature on /<prefix>/status, how to create and refresh
one, and that both only queue the summarizer's run, so it replies now with the
card link instead of writing a document or polling the card. The create and
refresh catalog entries say the same, and the refresh response now carries the
card's href.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:26:44 -06:00
DottaandClaude Opus 5.5 6bf26629f4 fix(chat): name the managed-profile CLI and the dev-only labs in agent guidance
Chat agents told people to create a Claude Managed Agent profile "in the
Paperclip web app", which has no such page; the catalog never said profiles
are created with `paperclipai managed-agent setup`. Agents asked to open the
task-chat lab on a production instance probed its bundle in a headless browser
for dozens of steps because nothing said the labs exist only in a dev build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:51 -06:00
DottaandClaude Opus 5.5 524e2f3c76 test(evals): grade four chat cases by saved state and setup literals only
Remove the remaining reply-vocabulary checks. Reply meaning is read by a person;
the oracles keep:
- update-case-facts: saved browser tokens, plus a follow-up reply that names the
  case or its task and the setup's escalation literal;
- read-history: links to both tasks and the recorded blocked/failed and
  done/completed statuses;
- managed-profile: the 403s, no profile or workaround written, and a reference
  to the place the board acts (managed-agent setup or the profile route);
- gated-lab: the route literal and no link to an unrelated task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:51 -06:00
DottaandClaude Opus 5.5 1b48ecd285 test(evals): accept a local dev build or dev harness as gated-lab's checkout path
Run 42596676991 said the route is registered only under import.meta.env.DEV and
needs a local Vite dev build of the UI, and offered to start the UI dev
harness; v2 accepted only checkout/clone/pnpm dev/dev-server wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:51 -06:00
DottaandClaude Opus 5.5 0c27bb5c26 test(evals): accept the managed-profile policy literal and attestation
Run 42594125035 named the catalog's environmentPolicy literal
limited_no_hosts_no_packages and "attest" (the catalog's word for
qualification) and failed only on those words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:50 -06:00
DottaandClaude Opus 5.5 5f5ff0a4fe test(evals): ignore quoted tool output when reading read-history outcomes
Run 42594125035 replied correctly but quoted the provider's `success: false`
on the failed task's line, which the v3 oracle read as a success claim. Quoted
spans are cited data, not the reply's own outcome (task ids in quotes are kept).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:50 -06:00
DottaandClaude Opus 5.5 6226046ada test(evals): grade gated-lab by route literal, development-only fact and non-claims
Replace the narrow dev-only keyword gate with: the setup route literal, a
statement tying the route to a development build (the UI registers it only
under import.meta.env.DEV), an owned-checkout path, no claim that the lab is
open or enabled, and no link to an unrelated task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:50 -06:00
DottaandClaude Opus 5.5 e857a76d45 test(evals): classify settled oracle check failures as candidate failures
"Settled scenario failed: <check ids>" was matched against infrastructure
words, so the check id specific-operator-qualification-next-step made a grader
rejection permanent_infrastructure (and ids naming a provider timeout would be
retried as transient). A settled grade is always the candidate's outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:50 -06:00
DottaandClaude Opus 5.5 28258fde7b test(evals): accept the catalog's no-hosts/no-packages environment limit in managed-profile
The reply restated the API catalog's "limited environment (no hosts, no
packages)" as "environment with no hosts and no packages" and failed only on a
locked|restricted|limited regex. Accept the limit or its concrete content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:50 -06:00
DottaandClaude Opus 5.5 7f04708687 test(evals): grade read-history per task against its recorded outcome
The reply check required outcome keywords in a sentence naming each task, so a
correct "COM-2 published successfully and is done" failed while a reply that
swapped the outcomes passed. Attribute reply lines to exactly one linked task,
pair each with the status the product records (blocked/failed vs
done/completed), and reject a unit that gives a task the other outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:50 -06:00
DottaandClaude Opus 5.5 b3291f6af0 test(evals): grade update-case-facts by the saved browser value and a non-contradicting reply
The saved field was matched by keywords anywhere in the JSON ("only" in any
field satisfied it), so a correct "iOS Safari" failed and a desktop-and-mobile
value passed. Grade the setup's browser field by normalized tokens (Safari plus
a mobile qualifier and nothing else). Grade the follow-up reply (text after the
first checkpoint) by the case reference, the setup-chosen escalation literal and
non-contradiction instead of a keyword sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:35:50 -06:00
DottaandClaude Opus 5.5 9227344705 fix(native): require publication for files named under a Deliverable label
A delegated brief often lists its output under "Deliverable:" rather than with
a creation verb. The completion gate missed those briefs, and .html/.css names,
so a worker could finish with the website only in its workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:17:25 -06:00
DottaandClaude Opus 5.5 3a8822988a test(evals): bind interrupt-with-direction's delivery to the successor's website-delivery revision, not a copied plan marker
The successor saved website-delivery in its own words. Requiring the plan's
setup marker inside it graded copying, not delivery. The page content is
graded from the registered website.html.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:17:25 -06:00
DottaandClaude Opus 5.5 4979ab4593 fix(heartbeat): let recovery settle a held turn presented by its own card after merging #20204
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:11:42 -06:00
Dotta 59c64f78de Merge remote-tracking branch 'origin/master' into chat-eval/master-candidate
* origin/master: (21 commits)
  fix(secrets): delete a secret that a binding proposal referenced (#20193)
  perf(runner): save provider state once per poll instead of once per frame (#20217)
  fix(native): an inherited checkpoint with a pending runtime request is evidence (#20226)
  fix(chat): post a turn's answer when a card it did not create is pending (#20220)
  fix(native): roll back a warm rotation cut off before its new control plane once its runner is proven dead (#20218)
  fix(native): restart an unclaimed remote follow-up that carries only its inherited checkpoint (#20214)
  fix(native-runtime): a stopped run cannot close a task another run continues; deliver a pre-restart comment at once (#20216)
  fix(daytona): wait out a sandbox state change before starting it for workspace realize (#20212)
  fix(authz): require board authority for agent budget changes on every route (#20213)
  fix(native): retire a finished remote owner whose suspend never finished once its sandbox leases ended (#20211)
  fix(guidance): tell agents who acts and which page to link for person-only steps (#20206)
  fix(runner): keep discarded OpenCode token deltas from delaying tool calls (#20209)
  fix(authz): say every plugin management step is board-only in plugin denials (#20207)
  perf(budgets): judge Paperclip's share of restart recovery; report the provider's session load (#20183)
  fix(runner): keep a restart continuation when the lost turn's terminal is delivered again (#20196)
  fix(chat): recover a held conversation turn's answer after a restart (#20204)
  test(chaos): honor 429 retry delays on harness reads (#20194)
  perf(environments): release a stopped plugin task once (#20203)
  perf(native-runtime): claim a parked Runner while the follow-up run prepares (#20195)
  perf(plugins): read runtime identity in one statement without a row lock (#20192)
  ...

# Conflicts:
#	maps/agent-access-map/agents.md
#	server/src/__tests__/agent-budget-configuration.test.ts
#	server/src/__tests__/agent-permissions-routes.test.ts
#	server/src/__tests__/built-in-agent-routes.test.ts
#	server/src/routes/agents.ts
#	server/src/routes/authz.ts
#	server/src/routes/built-in-agents.ts
#	server/src/routes/openapi.ts
#	server/src/services/heartbeat/run-completion.ts
#	server/src/services/native-runtime/agent-chat-response-recovery.test.ts
#	server/src/services/native-runtime/native-run-finalizer.ts
2026-11-09 04:06:37 -06:00
DottaandClaude Opus 5.5 8a1bea59ac fix(api-catalog): avoid the word 'nothing' in the unchecked-connection description
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:57:46 -06:00
DottaandClaude Opus 5.5 d2fe85e57c fix(secrets): delete a secret that a binding proposal referenced (#20193)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents can propose a secret binding. A person approves or rejects it
on a review card.
> - Each binding proposal row refers to one source: a secret or a
pending secret proposal. A check constraint enforces this.
> - The secret reference used `ON DELETE SET NULL`. When a person
deletes the secret, Postgres sets the reference to null. The check
constraint then rejects the row.
> - So a person cannot delete any secret that a binding proposal ever
referenced. The API returns 500.
> - This pull request deletes the proposal rows together with the
secret, as the other source reference already does. Before the delete,
it rejects pending proposals so their cards close with a reason.
> - The benefit is that secret deletion works again and no review card
points at a removed proposal.

## Linked Issues or Issue Description

Refs #15644 (related: block secret deletion while bindings still exist).

**What happened**
`DELETE /api/secrets/:id` returned 500 for a secret that a binding
proposal referenced. The server log showed `new row for relation
"company_secret_proposals" violates check constraint
"company_secret_proposals_shape_check"`. This happened for pending,
rejected and approved proposals. It also happened after the person
removed the agent's binding.

**Expected behavior**
The person can delete the secret. Proposals that referenced it go with
it. A pending review card closes as rejected and tells why.

**Steps to reproduce**
1. An agent proposes a binding for an existing company secret (`POST
/api/agents/me/secret-proposals` with `secretId`).
2. Approve or reject the proposal, or leave it pending.
3. Delete the secret with `DELETE /api/secrets/:id`.
4. The response is 500 and the secret stays.

## What Changed

- `packages/db`: `company_secret_proposals.secret_id` now uses `ON
DELETE CASCADE`, the same as `secret_proposal_id`. Migration
`0452_secret_proposal_secret_cascade.sql` replaces the foreign key
(`DROP CONSTRAINT IF EXISTS`, then `ADD CONSTRAINT`).
- `server/src/services/secrets.ts`: `remove` accepts an optional
`beforeDelete` hook. The hook runs after the secret is marked deleted
and before its row is removed.
- `server/src/services/secret-proposals.ts`: new
`rejectPendingBindingsForSecret`. It rejects pending binding proposals
for a secret with a reason and returns them. This closes their review
cards and writes the normal activity entries. `createBinding` now holds
the secret row `FOR SHARE` in its insert transaction and refuses a
deleted secret. So no new pending proposal can start after the delete
marks the secret deleted.
- `server/src/routes/secrets.ts`: the delete route rejects pending
proposals in `beforeDelete`. For each one it sends the normal rejection
notice and wake (`notifySecretProposalResolution`), so the proposing
agent continues.
- `server/src/__tests__/secret-proposals-routes.test.ts`: a regression
test deletes a secret with a pending, rejected and approved binding
proposal through the route. It checks the secret and proposal are gone,
the card status is correct, and only the pending case sends one
rejection notice and one wake. A second test checks that a binding
proposal for a secret that the delete already marked deleted fails with
404.

## Verification

- Red first: the new test failed on `origin/master` with the shape-check
violation for all three cases.
- `cd server && vitest run --config vitest.config.ts
src/__tests__/secret-proposals-routes.test.ts`: 47 passed.
- `vitest run src/__tests__/chat-run-milestone-governed-work.test.ts
src/__tests__/agent-secrets-routes.test.ts
src/__tests__/secrets-service.test.ts`: 112 passed.
- `tsc --noEmit` for `server` and `packages/db`: pass. `pnpm --filter
@paperclipai/db run check:migrations`: pass.
- Found by the `secrets.secret-proposal` chat evaluation. Its cleanup
deletes the fixture secret and failed on this error in runs 42563093640
and 42571127911.

## Risks

- The migration only replaces one foreign key. It does not rewrite rows.
Existing rows already have a non-null `secret_id` or
`secret_proposal_id`, so nothing else changes.
- Deleting a secret now also deletes its binding proposal rows. The
activity log keeps the proposal history (`secret.proposal.*` and
`secret.deleted`). Secret-kind proposal rows that created the secret
keep `created_secret_id ON DELETE SET NULL`, so their history stays.
- Other paths that delete secrets (company deletion, user secret
cleanup) now cascade instead of failing on the same constraint. They do
not reject pending cards first.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:54:07 -06:00
DottaandClaude Opus 5.5 d7d0f96f3f perf(runner): save provider state once per poll instead of once per frame (#20217)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native OpenCode, Codex and Cursor Cloud chat turns run through
runnerd. runnerd reads provider frames in order and keeps the run's
provider state in a durable file
> - For every frame that it reads, runnerd rewrote the whole provider
state file and called `fsync` on the file and on its directory. The
state holds every tool result kept in the turn, so each save got more
expensive as the turn went on
> - A tool call request is just the next frame. It waited behind one
full save for every frame before it. #20209 measured tool-call
turnaround that grew from about 1.4 s to about 15 s in one turn
> - #20209 stopped the OpenCode proxy from sending frames that runnerd
discards. Every frame that runnerd still reads still paid a full save,
and other proxies (for example Cursor Cloud) still send such frames
> - This pull request makes runnerd save the frames of one poll once,
before anything derived from them leaves runnerd. A save that would
write the bytes already on disk is skipped
> - The benefit is that the number of saves before a tool call no longer
grows with the number of streamed frames. In a local measurement, a tool
call after 400 frames with 830 KB of kept state took 0.57 s instead of
more than 30 s

## Linked Issues or Issue Description

Refs #20209
Related: #17514 (the same per-event full-state rewrite, on the server
control-plane side)

**What happened**

During a native OpenCode chat turn, tool-call turnaround grew from about
1.4 s to about 15 s as the turn went on. runnerd saved its complete
durable provider state, with `fsync`, after every provider frame. That
state includes the tool results kept so far in the turn. A tool call
request waited behind one full save for each earlier frame.

**Expected behavior**

The time to deliver a tool call must not grow with the number of
assistant-text, reasoning or item frames that came before it. A crash or
restart must still never lose state that a resumed run needs.

**Steps to reproduce**

1. Start a native OpenCode (or Codex app-server or Cursor Cloud) turn
that keeps a few tool results and then streams many text or reasoning
frames before its next tool call.
2. Measure the time from the tool call frame to the
`semantic_tool.input` event.
3. See that the time grows with the number of earlier frames and with
the size of the kept state. Locally, `cargo test -p
paperclip-runner-core --lib measure_streamed_frame -- --ignored
--nocapture` on `master` reports 102 saves and 9.3 s for 100 frames with
830 KB of kept state, and more than 30 s for 400 frames.

## What Changed

-
`packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs`
and `codex_provider.rs`:
- The provider frame loop moves into `poll_provider_frames`. An ordinary
notification frame and a descendant notification frame no longer save
the state. They mark it as unsaved.
- After the loop, `poll_current_provider_with_control` saves the unsaved
state once. It also saves when a frame fails, before it returns the
error.
- Frames that already saved at once still do so: turn terminals, Cursor
Cloud terminal deliveries, `reconciliation_required`, protocol failures,
MCP permission and runtime requests, process exit, and new tool calls.
- A tool call that runnerd answers at once (an exact completed replay or
the receipt-limit stop) saves unsaved state before it writes to the
provider. A rejected tool call already saved first.
- `retained_events`, `execute` and `host_park_ready` save unsaved state
first, so no event, command result or park decision can expose state
that is not on disk.
- Courtesy rejections that `CodexProvider::poll` writes by itself
(unsupported requests, descendant requests, delayed requests after a
terminal, full runtime-request capacity) are held while runnerd reads a
batch (`hold_courtesy_responses`). runnerd sends them only after it
saves the state that covers every earlier frame
(`save_before_provider_writes`). A terminal frame also saves and
releases them before runnerd asks the provider for goal state or drains
the accounting tail. Released courtesy replies are best-effort, as
courtesy replies were before: a provider that already closed stdin
cannot turn a received terminal into a polling failure. The
receipt-limit stop sends held cancellations before its `turn/interrupt`
request, so their order to the provider does not change.
- The trace records for runnerd's normalization
(`rust_durable_normalization`), which name emitted event ids, are kept
until the state that holds those ids is saved. The trace therefore only
names saved events. Saved mappings are written before an exited or
failed provider is dropped, so they are not lost. Raw frame records
still go to the trace when a frame is read; they name no event ids.
- `save_unsaved_frame_state` does not write when the serialized state is
byte-for-byte the same as the saved file. All other saves write as
before.
- Serialization moves to `serialize_provider_state`, and the file write
moves to `write_state_bytes`. The write path (private temporary file,
`fsync`, rename, directory `fsync`, `0600`) does not change.
- New tests in
`packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_frame_save_batching.rs`:
- `streamed_frames_do_not_multiply_durable_saves_before_a_tool_call`: 8
and 48 streamed frame pairs before a tool call need the same number of
saves (1). On `master` they need 18 and 98.
-
`batched_frame_state_is_saved_before_events_leave_and_survives_a_restart`:
at every poll, the saved file is equal to the in-memory state and holds
exactly the delivered events. A new executor loads the file after the
old one stops and gets the same state, the same unacknowledged events
and the pending tool call.
- `batched_frame_state_is_saved_when_a_later_frame_fails`: a frame that
fails after 20 batched frames still leaves those 20 frames on disk.
- `previous_release_state_is_rewritten_by_the_first_batched_save`: a
state file in an older shape (compact JSON without newer default fields)
loads, and the first batched save rewrites it. Unchanged state is not
rewritten. Changed state is.
-
`courtesy_rejections_reach_the_provider_only_after_earlier_frames_are_saved`:
when the courtesy rejection reaches the provider, the saved file (what a
restarted runnerd would load) already holds the 20 earlier frames and
equals the in-memory state. It fails if the hold is removed.
- `trace_interpretations_name_only_saved_events`: every event id that a
trace record names is in saved state at the moment the record is
written. It fails if records are written before the save.
-
`a_crash_inside_an_unsaved_batch_leaves_saved_state_and_provider_output_unchanged`:
after 122 frames are read and not yet saved, the saved file is
byte-for-byte unchanged and runnerd has written nothing to the provider.
A reload gets the pre-batch event sequence and no pending events.
-
`a_held_cancellation_that_cannot_reach_the_provider_keeps_the_completed_turn`:
the provider closes stdin and then completes the turn with a tool call
still pending. The held cancellation cannot be written, and the turn
still completes and is saved.
- `trace_interpretations_survive_a_provider_exit_in_the_same_poll`: the
trace keeps the mappings for frames that arrived in the same poll as the
provider exit.
- `measure_streamed_frame_cost_before_a_tool_call` (ignored): prints
saves and tool-call latency for different frame counts and kept-state
sizes.

## Verification

- `cargo test --locked -p paperclip-runner-core` (debug, macOS arm64):
library 595 passed, 1 failed; all 35 integration test binaries passed
(`--no-fail-fast`). The one library failure is
`task_memory::tests::monitor_bounds_notices`. It also fails when run
alone, takes about 9 minutes, and waits on file-watch timing in
`task_memory.rs`, which this PR does not touch. In one earlier run, two
ACPX park tests in `acpx_park_preparation` failed. The same binary also
failed on unchanged `master` (with a different test), and it passed in
the final run.
- `cargo test -p paperclip-runner-core --lib measure_streamed_frame --
--ignored --nocapture`, debug build, macOS APFS (`sync_all` is
`F_FULLFSYNC`). Each row is one turn: frames streamed, then one tool
call.

| Kept state | Frames | Saves (master) | Tool-call latency (master) |
Saves (this PR) | Tool-call latency (this PR) |
| --- | --- | --- | --- | --- | --- |
| 4 KB | 100 | 102 | 946 ms | 1 | 18 ms |
| 4 KB | 400 | 405 | 3,791 ms | 7 | 70 ms |
| 211 KB | 100 | 102 | 3,207 ms | 1 | 37 ms |
| 211 KB | 400 | 405 | 12,803 ms | 7 | 193 ms |
| 833 KB | 100 | 102 | 9,251 ms | 1 | 93 ms |
| 833 KB | 400 | about 405 | more than 30 s (test deadline) | 7 | 567 ms
|

With 400 frames, the remaining saves are one per poll of up to 128
frames and one per controller acknowledgement.
- With the debug `paperclip-runnerd` and `fake-codex-app-server`
binaries from this branch: `npx vitest run` on
`src/live/runnerd-opencode-streaming-tools`,
`runnerd-opencode-assigned-tools`, `runnerd-codex-transport`,
`runnerd-codex-transport-settlement`,
`runnerd-codex-transport-cold-restore`, `runnerd-restart-continuation`,
`runnerd-saved-continuation`, `runnerd-detach-durability`, and
`src/cli/opencode-proxy-output`, `opencode-proxy-events`,
`opencode-app-server-proxy`: 11 files, 353 tests passed.

## Risks

- Durability. The rule is: nothing derived from a frame leaves runnerd
before the state that holds that frame is on disk. That covers delivered
events, command results, park decisions, tool results and rejections,
courtesy rejections, Cursor Cloud terminal acknowledgements, and trace
records that name event ids. The tests above check each of these.
- The unsaved window is at most one poll (128 frames). Every frame in it
is undelivered: no event from it reached the controller, and runnerd
wrote nothing to the provider while it was unsaved.
`a_crash_inside_an_unsaved_batch_leaves_saved_state_and_provider_output_unchanged`
proves this. A crash in the window therefore leaves the saved state and
everything the controller and provider have seen exactly as a crash just
before those frames were read. The resumed run starts from the same
event sequence, so it cannot deliver an event id twice. Its normal
recovery (thread resume and `thread/read` reconciliation) handles those
frames in the same way as frames still in the provider pipe. On
`master`, a crash could already lose the one frame being processed.
- One provider write can still happen in the window: `thread/read`,
which `CodexProvider::poll` sends to check the lineage of an unknown
helper thread. It is a read-only query that waits for its answer, and it
changes nothing on the provider side.
- Courtesy rejections now reach the provider at the end of the poll
instead of during it. The delay is one batch. The provider gets the same
replies.
- Previous-release story: the saved file shape does not change, and no
new field is saved. A file from the previous release loads as before.
The skip check compares exact bytes, so an older file is never treated
as already saved. The first save after an upgrade rewrites it in the
current shape. A deploy that lands mid-turn sees a file that the
previous release saved after each frame. That file is valid input for
this code.
- The ACPX backend has its own frame loop and is not changed here.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:43:27 -06:00
DottaandClaude Opus 5.5 c5a0d8eae3 test(evals): stop parsing numbers out of the long-thread interpretation
A correct interpretation rounded (~470 comments, ~0.27s), proposed other
sizes (50 comments) and failed the number checks. The grade now covers the
measured report and the attributed reply; the reviewer reads its meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:43:11 -06:00
DottaandClaude Opus 5.5 ae20a63031 test(evals): identify reject-and-revise's draft by structure, not the setup's scope tag
The draft is introduction copy; whether it repeats the plan's internal
"Acorn research launch <nonce>" tag is the model's choice, and a correct run
left it out. The draft is now a new document separate from the plan, written
by the run and linked from the reply; the opened link must show that saved
document's first line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:43:11 -06:00
DottaandClaude Opus 5.5 61264662c6 test(evals): accept first-work's CEO owning and running the linked website task itself
The expectation is linked work under the available execution policy, not
delegation to Morgan. A run where the CEO created COM-2 for itself, ran it in
a separate task run and delivered index.html was rejected only for the
assignee. The task's assignee (the CEO or Morgan) must now be the agent that
uploaded the file from a run bound to the task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:43:11 -06:00
Dotta 9375ac95cc fix(ui): publish long-thread metrics after StrictMode cleanup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
(cherry picked from commit 3807ee526d)
2026-11-09 03:43:11 -06:00
DottaandClaude Opus 5.5 8ce4371cd2 test(evals): grade the long-thread interpretation by its numbers, not keywords
Keyword checks on the reply (synthetic, production, negations) are dropped.
Code now rejects only stated timings, commit counts, data sizes or rates that
contradict the measured report; the meaning is read by the reviewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:43:10 -06:00
DottaandClaude Opus 5.5 e689d13800 test(evals): accept the requested-changes receipt for a rejected plan decision
Both the classic receipt ("Requested changes to Plan v1") and the
Coordinator receipt ("Changes requested · v1") render a rejected plan-target
confirmation as requested changes; the saved status stays rejected and is
graded separately. Draft and reply keyword checks are dropped; the new
attributed document must carry the setup's scope marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:43:10 -06:00
DottaandClaude Opus 5.5 fff6524bac test(evals): follow the reply's task link to Interrupt and match the page's identifier routes
The task page posts accept and interrupt to /api/issues/COM-1/..., so the
response waits keyed on the issue UUID timed out after a successful
approval. They now accept the id or the identifier. The reply keyword checks
are replaced: the scripted human clicks the reply's rendered task link to
reach the Interrupt control, and the original run must still be running
after the reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:43:10 -06:00
DottaandClaude Opus 5.5 a78ff18841 test(evals): grade first-work's rendered site by visible heading and business name, not hero heading text
The worker put "Acorn Notes" in the brand link and a value proposition in
the h1, so the heading-name locator failed on a correct site. The scripted
human now checks for a visible heading and the setup's business name
anywhere visible. The task title keyword checks are dropped; the delivered
HTML proves the work is the business website.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:43:10 -06:00
DottaandClaude Opus 5.5 df4562ab38 fix(native): an inherited checkpoint with a pending runtime request is evidence (#20226)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #20153 and #20214 let restart recovery set aside a remote
follow-up's admission-rebound checkpoint when the checkpoint is blank
and nothing of the run reached a runner. Recovery then restarts the
bootstrap instead of blocking
> - "Blank" checked the cursor, terminal turns, active turn, semantic
result and terminal, but not pending runtime requests
> - A rebound checkpoint that still carried an outstanding runtime
request would therefore be treated as no evidence. The run would restart
as a fresh bootstrap and drop that request
> - This pull request also requires no pending runtime requests before
the checkpoint counts as a blank admission checkpoint
> - The benefit is that an outstanding request always keeps the run on
its reconciling path

## Linked Issues or Issue Description

Refs #20214

**What happened?**
After #20214 merged, ponytail review noted that
`admissionCheckpointOnly` (shared by #20153's
`remoteRunnerNeverReceivedCommands` and #20214's
`inheritedCheckpointOnly`) did not inspect `pendingRuntimeRequests`. A
rebound checkpoint with a pending request would be demoted to "no
evidence", and the run restarted as `remote_execution_never_claimed`,
dropping the request.

**Expected behavior**
A checkpoint with any pending runtime request is evidence. The run is
not restarted as a fresh bootstrap.

**Steps to reproduce**
1. Claim restart recovery for an unclaimed remote follow-up whose
rebound checkpoint carries a pending runtime request and no other state.
2. Before this change, recovery claims `bootstrap_incomplete`.

**Paperclip version or commit**
Current master after #20214.

## What Changed

- `native-restart-recovery.ts`: `admissionCheckpointOnly` additionally
requires `pendingRuntimeRequests` to be absent or empty.
- `native-runner-restart-recovery.integration.test.ts`: a held case, an
unclaimed remote follow-up whose inherited checkpoint carries a pending
runtime request.

## Verification

- Red/green: on master the held case is restarted as
`bootstrap_incomplete`. With the change it is not, and the other cases
are unchanged.
- `vitest run` of `native-runner-restart-recovery.integration.test.ts`
and `native-restart-recovery.test.ts`: 2 files, 201 tests passed (14
skipped).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` (`server`): passed.

## Risks

- Minimal. The change only narrows when a checkpoint counts as blank.
Every run it affects keeps the existing, more conservative
classification.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:31:35 -06:00
DottaandClaude Opus 5.5 04de249df4 test(evals): check duplicate-answer's answered card on either the classic or Coordinator receipt
With the Coordinator workspace (on by default) an answered question renders
as an "Answers recorded" link that jumps to the human answer in the thread,
not the classic expandable receipt. The scripted human checks whichever one
the page shows and confirms the answer there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:22:10 -06:00
DottaandClaude Opus 5.5 77f7c991df test(evals): grade switch-company's handoff from saved state, not reply keywords
The standing conversation must stay with Acorn and its agent, and Birch's
activity must show no agent action; the person's own switch is proven by the
browser journey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:22:10 -06:00
DottaandClaude Opus 5.5 8525bb534c fix(chat): post a turn's answer when a card it did not create is pending (#20220)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People talk to agents in Agent Chat. The agent's final message
becomes the chat reply.
> - A chat thread can hold a pending card that the board addressed to
another person, for example a human-only confirmation for Riley.
> - When Alex asks the agent to act on that card, the agent correctly
gets `403 interaction_human_only` and writes a final answer that
explains the denial.
> - The shared answered rule (`conversationTurnAnswered()`, from #20179
and #20204) counted any pending human-only card in the thread as the
turn's own response. So the answer was suppressed, and the chat stayed
`active` with no reply.
> - This pull request limits that rule to cards that the turn's own run
created. Other cards still hold the task status.
> - The benefit is that the person always gets the turn's answer, unless
the turn itself asked a question with a card. Live completion and
restart recovery stay consistent.

## Linked Issues or Issue Description

Refs #20179, #20204 (the shared answered rule this PR corrects).

**What happened**

In an Agent Chat, the board created a pending `request_confirmation`
card with `resolverPolicy: human_only`, addressed to another user
(Riley). The card has no source run. Alex wrote "I approve Riley's
request." The agent tried to accept the card and got `403
interaction_human_only`. It then wrote a correct final answer: the card
needs Riley. The run succeeded with a `yielded` result and a
`response_wake` continuation. Run completion recorded
`presentationDecision: none`. No comment was created. The conversation
stayed `active`, so Alex never got a reply.

Cause: `conversationTurnAnswered()` reused the status-gate query
`pendingNativeInteraction()`. In conversation mode, that query excludes
another run's card only when it is an ordinary question or an `anyone`
confirmation. A human-only card addressed to someone else is kept. The
rule then treated that card as the card that "presents the turn",
although this run did not create it.

**Expected behavior**

A successful chat turn posts its final answer exactly once, and the
conversation moves to `waiting`. Only a pending question or confirmation
card that the turn's own run created presents the turn in place of its
prose. A card from an earlier turn, or one the board addressed to
another person, can hold the task status, but it does not suppress the
answer.

**Steps to reproduce**

1. Open an Agent Chat as Alex.
2. As the board, create a pending `request_confirmation` card in the
chat with `resolverPolicy: human_only` and `addresseeUserId` set to
another person.
3. Send "I approve Riley's request."
4. Let the agent try to accept the card, get `403
interaction_human_only`, and end its turn with a final answer.
5. See that no reply appears, `presentationDecision.commentAction` is
`none`, and `conversationState` stays `active`.

## What Changed

- `server/src/services/native-runtime/native-governance.ts`:
`conversationTurnAnswered()` now checks only pending cards whose
`sourceRunId` is the turn's run. `pendingNativeInteraction()` gets an
optional `createdByRun` filter for this. The status gate
(`pendingNativeGovernance()`) does not change, so another person's card
still holds the task.
- Both callers get the fix through the one shared rule
`conversationTurnPresentsAnswer()`: live run completion
(`run-completion.ts`) and background presentation recovery
(`recoverCommittedNativeChatResponse`).
- `server/src/services/heartbeat/run-completion.test.ts`: new live cases
"someone else's confirmation card" (board-created, human-only, addressed
to another user, no source run) and "earlier turn's question card"
(created by an earlier run of the same agent). Each case now also checks
that the conversation moves to `waiting`.
-
`server/src/services/native-runtime/agent-chat-response-recovery.test.ts`:
new restart case for the board-created card, plus a `waiting` check
after recovery.
- `doc/execution-semantics.md`: state that only the turn's own card
presents the turn.

## Verification

- Thesis: the reply is suppressed because the answered rule counts any
pending human-only card in the thread, not only this run's card. If this
is true, a turn with only a board-created card addressed to someone else
posts no comment on master, and restricting the rule to `sourceRunId =
runId` posts exactly one comment and settles the conversation to
`waiting`, with the own-card cases unchanged.
- Failing first: without the fix, the two new live cases failed
(`expected [] to deeply equal [answer]`). The new recovery case also
failed (no comment).
- After the fix:
- `cd server && npx vitest run
src/services/heartbeat/run-completion.test.ts -t "posts a waiting
conversation turn"`: 9 passed (includes the two own-card cases, which
still post nothing).
- `cd server && npx vitest run
src/services/native-runtime/agent-chat-response-recovery.test.ts`: 62
passed.
- Both suites use a real embedded PostgreSQL database.
- Turn start: no change. The rule runs only after the provider turn
ends.

## Risks

- Low. Only the presentation rule changes. The status gate, the
conversation settle rule (it already required `sourceRunId = run.id`)
and the external-chat path do not change.
- Behavior shift: a turn now posts its answer while another run's or the
board's card is pending. Before, the answer was lost. A card that the
turn itself created still presents the turn, as before.
- Previous-release state: no persisted shape change. A run that the
previous release already recorded as `none` keeps that decision.
Recovery does not re-present it.

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

## Model Used

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

## Checklist

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

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

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

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

## Linked Issues or Issue Description

Refs #20146

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

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

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

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

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

## Linked Issues or Issue Description

Refs #20153

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:10:23 -06:00
DottaandClaude Opus 5.5 98e0f1fc0d fix(native-runtime): a stopped run cannot close a task another run continues; deliver a pre-restart comment at once (#20216)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When the board stops a run and then posts a comment asking the agent
to continue, that comment starts a new run, and only that new run should
decide when the task is done
> - In restart chaos wave `w-20261109T081456-s9901`, t16 (claude S1, K1)
closed the task as done without the line the user asked for, and t10
(codex S1, K6) never woke a run for the continue comment, so it needed a
Retry
> - In t16, the stopped run's saved "done" result was accepted 10 s
after the continue run started. In t10, a restart killed the server 21
ms after the comment committed, before its wake was written, and the
comment backstop then waited out its 30 s grace
> - This pull request stops a run from closing a task that another run
now holds, and makes the backstop deliver at once a comment that
predates the server process
> - The benefit is that a user's latest direction after a Stop is
neither overridden by the stopped run nor lost to a restart

## Linked Issues or Issue Description

Refs #19831. Restart chaos wave `w-20261109T081456-s9901` (master
`09e24dd12c`), tasks t16 and t10.

**t16, from the kept database (CHA-20):**
- 08:29:19: the board stopped run 1 (`6e33d123`). Its saved result
reported done, but finalization failed on the stop
(`finalization_failed_claim_preserved`, a retryable failure).
- 08:29:41: the user's continue comment started run 2 (`2cb71c5b`). Run
2 held the task's execution and saw the comment.
- 08:29:51: the finalization reconciler retried run 1's saved result and
committed `completion_claim_policy_accepted` for run 1. The task closed
as done.
- From then on, every Paperclip tool call from run 2 was refused
(`paperclip_runner_tool_binding_not_authorized`), so it could not add
the `CONTINUE-…` line. Its result (blocked) was then preserved as
`terminal_status_preserved`.
- Run 2 ending `succeeded` with `native_session_lease_lost` follows from
this: its result was accepted before the lease was lost. The comment was
delivered; the model saw it.

**t10 (CHA-14):**
- The Stop moved the task to blocked. The user's comment reopened it to
todo at 08:26:28.633, and K6 killed the server at 08:26:28.654, before
the route wrote the comment's wake. No wake request exists for that
comment.
- This is not caused by the Stop, #20066 or #20180: the wake is written
after the comment commits, and the kill landed in between.
- The I4 backstop ("a user comment without a wake") matched the comment,
but treated it as possibly still getting its normal wake for 30 s. The
pass interval then pushed the check to 02:27:16, and the harness clicked
Retry at 02:26:59.

## What Changed

- `status-arbiter.ts`:
  - New `heldByAnotherRun(issue, runId)`.
- `supersedeStaleOwnerCompletion` also keeps status, with reason
`completion_run_superseded`, when the issue's `executionRunId` is a
different run. A run that was reassigned away still gets
`completion_owner_superseded`.
- `native-run-finalizer.ts` and `native-status-reassessment.ts` pass the
run id to that guard.
- `status-decision-committer.ts` rechecks the same rule under the issue
lock: a done decision for a non-conversation issue held by another run
raises `NativeStatusRaceError`. The finalizer then re-arbitrates and
keeps status.
- `ownership-invariant-report.ts`: the comment-without-wake grace now
only applies to comments created after this server process started
(`getServerProcessStartedAt`). Separate live server replicas are not
supported, and the wake is written by the process that accepted the
comment, so an older comment is owed at once. The startup pass now
delivers it. `runOwnershipReconciliationPass` takes an optional
`processStartedAt`.

## Verification

Red/green:
- `native-stale-owner-completion.test.ts`: three new tests.
- The unit policy: another run holding the task keeps status; the same
run, or none, still closes it.
- "does not let a stopped run's saved done result close the task a newer
run is continuing" (the t16 shape).
- "does not close the task when a newer run takes it after arbitration
but before the status commit" (only the committer recheck covers this).
  - On master: 3 failed. With this PR: 13 passed.
- `ownership-reconciliation-report.test.ts`: "finds at once a young user
comment whose wake a restart lost after the board stopped the run" (the
t10 shape). It fails on master and passes with this PR; the same comment
in a process that accepted it still waits for its grace deadline. The
file: 27 passed.

Regression and static checks:
- 16 nearby native finalization, status projection, workspace finalizer
and arbiter suites: 439 passed.
- `tsc --noEmit` passes for `server`, and for the two test files.

## Risks

- A done result now waits when a different run holds the task. That run
reaches its own decision; the earlier result stays as history. If the
holder is stale, the existing execution-lock recovery clears it, and a
later reassessment can then close the task.
- After a restart, a user comment newer than the task's last run, with
no wake, is delivered at the startup pass rather than 30 s later. A
replica that is still live and writing the same wake is not a supported
topology.
- No schema change and no change to persisted run state.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:03:15 -06:00
DottaandClaude Opus 5.5 088bdf7cfa fix(daytona): wait out a sandbox state change before starting it for workspace realize (#20212)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - On Daytona, every run realizes its workspace in the leased sandbox.
Realizing it first makes sure the sandbox is started
> - Right after a control-plane restart, the sandbox can still be
mid-transition, for example stopping or starting from the previous
lifecycle step
> - The plugin called `sandbox.start` straight away. Daytona rejects
that with "Sandbox state change in progress", so the run failed
`setup_failed` and needed a Retry
> - This pull request waits out a sandbox's state change before starting
it, and retries a start the provider rejected for that reason, within
the existing start deadline
> - The benefit is that a run whose sandbox is briefly transitioning
starts on its own

## Linked Issues or Issue Description

Refs #20211

**What happened?**
Daytona per-turn restart-chaos K13 storm w-20261109T074128-s9503
(revision 9046884811), t07 (claude A1): run `5aabeb55` failed at
07:45:27 with `setup_failed` and "Failed to realize workspace for
environment … (sandbox): Sandbox state change in progress". The sandbox
was mid-transition after the restart, and realize neither waited nor
retried. A Retry succeeded.

**Expected behavior**
Realize waits for a transitional sandbox state to settle, then starts
the sandbox. If the provider still reports the state change, it retries,
all within the existing start deadline. Any other start error fails at
once, and a transition that outlasts the deadline still fails.

**Steps to reproduce**
1. Realize a workspace while the Daytona sandbox is `stopping` or
`starting`, or while `start` returns "Sandbox state change in progress".
2. Before this change, realize fails at once with that error.

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

## What Changed

- `packages/plugins/sandbox-providers/daytona/src/plugin.ts`:
`ensureSandboxStarted` now polls `refreshData` while the sandbox is in a
transitional state.
- The transitional states are `starting`, `stopping`, `restoring`,
`archiving`, `creating`, `pending_build`, `pulling_snapshot`,
`building_snapshot` and `resizing`.
- It polls every second, until the start deadline from `timeoutMs`. Each
read keeps the existing liveness bound.
- If the start fails with "state change in progress", it refreshes and
tries again, within the same deadline.
- The wait, every retry and the start share one deadline. Each read is
bounded by the time left, and the start receives only the remaining
seconds.
- As soon as any state other than `started` is observed, the lease's
stored session is dropped before waiting, so a restart that happened
outside the plugin never reuses a dead session.
  - Starting a settled sandbox (`startSettledSandbox`) is unchanged.
- `plugin.test.ts`:
  - realize waits for a stopping sandbox, then starts it;
  - realize retries a start that was rejected for a state change;
  - realize gives up once the transition outlasts the deadline;
  - another start error fails at once;
  - the start receives only the budget the wait left;
- a restart outside the plugin that the wait sees through opens a fresh
session.
  - The tests stub the API key with `vi.stubEnv`.

## Verification

- Red/green: on master, the wait and the retry cases fail with "Sandbox
state change in progress". With the change they pass, and the deadline
and other-error cases hold.
- `vitest run` in `packages/plugins/sandbox-providers/daytona`: 14
files, 614 tests passed (8 skipped).
- `tsc --noEmit` for the Daytona plugin package: passed.

## Risks

- Low risk. The wait is bounded by the start deadline the plugin already
used. Only transitional states and the provider's state-change rejection
are waited out, and every other error behaves as before.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
canary/v2026.1109.0-canary.9
2026-11-09 03:00:14 -06:00
DottaandClaude Opus 5.5 6383eb8033 fix(authz): require board authority for agent budget changes on every route (#20213)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Budgets stop agent spending. The budget hard stop is a control-plane
invariant
> - The budget routes (`PATCH /api/agents/:id/budgets`, `PATCH
/api/companies/:id/budgets`) are board-only
> - `PATCH /api/agents/:id` also accepts `budgetMonthlyCents`, and the
configuration service writes the agent budget policy from it. That route
had no board check for the field
> - So an agent, such as a CEO, a manager or the agent itself, can
change a monthly budget without a board user. This is a second route
around a board-only control, like the pause bypass that #20164 fixed
> - This pull request applies the budget route authority to every route
that writes an agent budget, and gives agents the final board-only
denial with a link to the budget page (#20151)
> - The benefit is that only the board changes budgets, and an agent
that asks gets a final answer that it can pass to the person

## Linked Issues or Issue Description

Refs #17857 (earlier fix, now in conflict with master; this PR rebases
it and adds the budget page link). Refs #20164 and #20151 (same pattern
for PATCH status and board-only denials).

**What happened**

An agent can send `PATCH /api/agents/{id}` with `budgetMonthlyCents`.
The route accepts it, and the configuration service writes the agent
budget policy. This works for the agent's own budget and, with update
permission, for other agents. The same field also reaches budget policy
through a configuration rollback, direct agent creation, an immediate
hire and a built-in agent provision. A chat eval
(`budgets-costs.resolve-budget`) found the route.

**Expected behavior**

Only a board user can change an active budget, as on the dedicated
budget routes. An agent gets `403` with code `board_access_required`.
The denial says it is final and links the agent's budget tab
(`/<prefix>/agents/<id>/budgets`) or the company budgets page
(`/<prefix>/activity/budgets`). An agent can still propose a budget in a
hire that waits for board approval. The approving board user activates
it.

**Steps to reproduce**

1. On master, authenticate as an agent of a company (for example the
CEO).
2. Send `PATCH /api/agents/{agentId}` with body `{"budgetMonthlyCents":
5000}`, for its own id or for another agent.
3. See `200`. The agent budget policy changes with no board action.
4. The new route tests reproduce this: on master source they fail with
`expected 200 to be 403`.

## What Changed

- `PATCH /api/agents/:id`: a changed `budgetMonthlyCents` requires a
board actor. An unchanged amount with other edits still works.
- Configuration rollback: a rollback that changes the budget requires a
board actor.
- `POST /api/companies/:id/agents` and an immediate `agent-hires`: a
positive budget requires a board actor. A hire that needs board approval
keeps the proposed budget and does not activate it before approval.
- Built-in agent provision: a budget override requires a board actor.
- `prepareAgentConfiguration` rejects an agent-originated budget change
again (defense in depth). An agent rollback with an unchanged amount
does not rewrite the policy.
- Board writes keep the actual operator identity. The initial hire
budget is saved in the hire transaction.
- `assertBoard` and `boardAccessRequired` take an optional board page.
Budget denials link the agent's budget tab or the company budgets page,
not the general agent profile.
- Agent access map updated.
- This includes the four commits of #17857 (f970f6b01f, 8c06aba41c,
e5682d22fa, b6cd49d4b6), rebased onto master with the conflicts
resolved, then squashed.

## Verification

- Red on master source, same tests: 12 budget tests fail. The PATCH
tests fail with `expected 200 to be 403` for the agent's own budget and
for a CEO on another agent. Built-in provision fails with `expected 200
to be 403`. Creation and immediate hire fail with `expected 201 to be
403`.
- Green with the fix: `cd server && npx vitest run
src/__tests__/agent-permissions-routes.test.ts
src/__tests__/built-in-agent-routes.test.ts
src/__tests__/built-in-agents.test.ts
src/__tests__/agent-hire-idempotency-routes.test.ts
src/__tests__/board-access-denial.test.ts
src/__tests__/agent-budget-configuration.test.ts`: 222 passed.
- `src/modules/agent-lifecycle/__tests__/lifecycle.test.ts` (embedded
Postgres): 76 passed.
- `agent-skills-routes`, `agent-adapter-validation-routes`,
`openapi-routes`, `agent-cross-tenant-authz-routes`: 275 passed.
- `node scripts/check-maps.mjs`: passed. `git diff --check`: passed.
- Server `tsc --noEmit`: no errors in changed files. One error in
`native-session-executor.ts` comes from a linked runner build in the
local worktree, not from this change. CI runs the full typecheck.

## Risks

- Agent clients that set an active budget directly now get `403`. They
must propose the budget in an approval-gated hire, or ask a board user.
- Agent creation with no budget or a zero budget is not changed. Pending
hire budgets do not activate before approval.
- Trusted internal plugin provisioning keeps its prior behavior and gets
no board identity.
- No schema or migration change.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code
execution, in Claude Code. The rebased commits from #17857 were first
written with OpenAI `gpt-6.1-sol` through Codex.

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

Refs #20140

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

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

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:40:13 -06:00
DottaandClaude Opus 5.5 fc5c79a327 fix(guidance): tell agents who acts and which page to link for person-only steps (#20206)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents in chat read the API catalog (`search_api`) and
`connections_search` to learn what they can do and who must act
> - Some requests need a step that only a person can take: connect an
assistant, allow a quarantined app action, turn on an experiment, enter
an API key, or finish their own connection request
> - For these steps, no catalog entry or search result said who acts or
which page to link, and none of them returns a board-only 403, so the
denial guidance from #20151 and #20182 does not reach them
> - The agent knew a person had to act, searched for the page until its
turn ran out, and never replied
> - This pull request puts the fact and the exact page link where the
agent already looks, following #20152 and #20170
> - The benefit is that the agent replies at once with the right page
instead of timing out

## Linked Issues or Issue Description

Refs #20151, #20152, #20170, #20182

**What happened**

In chat, five requests ended with no reply. The agent searched the API
until the turn deadline:
- "Connect my assistant so it can see our tasks." `GET /api/mcp/setup`
is refused for agents. Its catalog entry had no description, so the
refusal text "Its catalog description says who can use it" pointed at
nothing.
- "Use the app's new export feature." The new action was quarantined. No
catalog entry said where a person reviews it.
- "Let me chat with Morgan over iMessage." `connections_search` hides
chat channels while the Chat connectors experiment is off, so the agent
could not find iMessage or the setting that hides it. `POST
/api/companies/{companyId}/chat-endpoints` also accepted a draft
endpoint for the turned-off provider.
- "Connect my account using an API key." The service was the person's
own MCP server. `connections_search` returned no result and did not name
the secure "Connect your own MCP server" page.
- "I will finish Alex's connection request for them." No catalog entry
said that only the addressee can resolve a connection request card.

**Expected behavior**

The agent says who must act, links the exact page, and replies in the
same turn. It does not search further, does not ask for a key in chat,
and does not create another request.

**Steps to reproduce**

1. Ask a chat agent one of the five requests above, in the matching
state (for example, Chat connectors off, or a quarantined catalog
entry).
2. Watch the run events. The agent calls `search_api` and `call_api`
until the turn deadline and posts no reply.

## What Changed

- `server/src/routes/openapi.ts`: add catalog descriptions that say who
acts, link the page, and say not to search further:
- `GET /api/mcp/setup` and `POST /api/mcp/requests/{id}/consent` link
[Assistant connections](/<prefix>/apps/assistant-connection) and say the
consent screen offers only access within the person's own role.
- `GET /api/tool-connections/{connectionId}/catalog`,
`.../review-counts` and `GET
/api/companies/{companyId}/tools/apps/attention` say a quarantined
action waits for a person on
`/<prefix>/apps/<connectionId>/permissions`.
- The four `/api/connection-intents/{interactionId}/...` routes say only
the addressee can resolve the card, and link the task that holds it.
- `server/src/services/connection-intents.ts`:
- While Chat connectors is off, a search that names a chat-only channel
(for example iMessage) returns it as `unavailable`, with a reason that
links `/<prefix>/company/settings/instance/experimental`. Channels that
also offer tools, such as Slack, keep their current result.
- A single `unavailable` result now always gets the "explain the reason,
do not bypass" instruction.
- When no listed service matches and the person gave the service's own
server URL, the instruction links `/<prefix>/apps/byo`, the secure
"Connect your own MCP server" page, and says never to ask for a key in
chat.
- `server/src/routes/chat-channels.ts`: `POST
/api/companies/{companyId}/chat-endpoints` returns 403 with the
Experimental settings page while the provider's experiment is off (Chat
connectors, or GitHub review bots for GitHub). AgentMail is unchanged.
The catalog entry says the same. The UI already hides the setup pages in
this state.
- `GET /api/issues/{id}/interactions` says only the addressee resolves a
connection request card.
- A hidden channel that an aggregator can serve (for example Discord)
keeps its aggregator routes instead of the unavailable result.
- Tests: `person-only-page-guidance.test.ts` checks the catalog text and
that every named page is a route that `ui/src/App.tsx` mounts.
`connection-intents-service.test.ts` covers the hidden channel (off and
on) and the own-server page.

## Verification

- `cd server && npx vitest run
src/__tests__/person-only-page-guidance.test.ts
src/__tests__/connection-intents-service.test.ts` passes.
`chat-channels.integration.test.ts` passes with `-t` for every test that
posts to the create route.
- These related suites pass: `openapi-routes`,
`connection-aggregator-fallback`, `generic-mcp-connection`,
`assistant-connections-guidance`,
`company-model-service-settings-guidance`, `board-access-denial`,
`email-channels.integration`, and the eight suites that import
`runner-api-catalog`.
- `npx tsc --noEmit -p server` shows no errors in the changed files.
- End to end: the full-stack chat evals for the five requests ran on a
branch with this change. The run results are in the comments below.

## Risks

- Low risk. The change is text in the API catalog and in
`connections_search` results, plus one result for a channel that was
hidden before.
- A hidden channel now shows in search as `unavailable` with no methods.
It cannot be set up from the result, so the gate stays in place.
- Chat endpoint creation is now refused while the experiment is off.
Callers that created endpoints with the setting off now get 403. Three
integration tests that relied on that now turn the setting on first, and
a new test covers the refusal.
- The single-`unavailable` instruction now also applies in suggestion
mode. Before, that mode told the agent to "respect unavailable states"
with no reason.

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:36:57 -06:00
DottaandClaude Opus 5.5 582685e11b test(evals): wait for secure-key's rendered setup link before following it
Run 42583237179 replied with [Connect your own MCP server](/COM/apps/byo),
but act read the comment anchors once and compared the origin to the raw
baseURL. It now polls for the rendered link for up to 30s and compares
normalized origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:25:27 -06:00
DottaandClaude Opus 5.5 dc36aee6e1 fix(api-catalog): send an own MCP server's API key to the secure setup page
An agent asked to connect a person's own MCP server with an API key saw
the connections_search instruction but kept searching the API for a way
to create the connection itself. The two connection-creation entries it
finds now say the person enters the key on Connect your own MCP server,
and that the agent links that page and replies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:25:27 -06:00
DottaandClaude Opus 5.5 c77024412d fix(guidance): keep aggregator routes for hidden channels and check search links
- A chat channel that an aggregator can serve is no longer replaced by
  the unavailable result, so its routes stay offered.
- Test that every page connections_search links is a mounted UI route,
  and cover the partial-name and ranked own-server instructions.
- Drop the unreachable unavailable branch and the added code comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:25:08 -06:00
DottaandClaude Opus 5.5 c5402bd33a test(evals): find the waiting connection card by either card's test id
The Notion request renders through ConnectionIntentInteractionBody, whose
waiting state uses connection-intent-waiting; the grader only looked for
connection-authorization-waiting and failed although Waiting for Alex was
on screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:25:08 -06:00
DottaandClaude Opus 5.5 47302becaf fix(chat): refuse chat endpoints while their experiment is off, and say so in search
A run asked to chat over iMessage while Chat connectors was off. Search
did not surface iMessage for a longer query, and POST chat-endpoints
created a draft endpoint for a provider the instance has turned off.

- Chat endpoint creation now returns 403 with the Experimental settings
  page while the provider's experiment (Chat connectors, or GitHub review
  bots for GitHub) is off. AgentMail is unchanged.
- Hidden chat channels match on their description too, so worded
  requests find the unavailable result.
- The own-server instruction says the person enters the key and the
  agent must not create the connection itself.
- The interaction listing says only the addressee resolves a connection
  request card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:25:07 -06:00