Commit Graph
7118 Commits
Author SHA1 Message Date
DottaandClaude Opus 5.5 484170d13b feat(recovery): prove copyback owners on the shared path by their PID space (#20018)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

## What Changed

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

## Verification

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

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:32:49 -06:00
DottaandClaude Opus 5.5 beec70ad9f fix(native): judge sync-retention run processes with the shared run-process prover (#20025)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

## What Changed

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

## Verification

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

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:32:46 -06:00
DottaandClaude Opus 5.5 9dcf1e5c43 fix(chat): tell chat agents a task they cannot find may be private (#20027)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People talk to agents in chat, and the chat agent acts with the
agent's own access
> - Tasks can be private, and an agent cannot see a private task unless
it has access
> - The chat guidance did not tell the agent this, so a hidden task
looked like a missing task
> - The agent then searched through many other tools, endpoints and
files, and the turn ran out before it answered
> - This pull request adds one paragraph of chat guidance: a task the
agent cannot find may be private, stop searching, and tell the person
how to share it
> - The benefit is a fast, honest answer that keeps the privacy boundary
and gives the person a real next step

## Linked Issues or Issue Description

**What happened**

A person asked a chat agent to summarize a private task in the same
company. The agent has no access to that task, so search returned
nothing. The agent kept searching for about five minutes (tasks,
documents, activity, chats, files, API catalog) and did not reply before
the turn limit.

**Expected behavior**

The agent says that this chat cannot see the task, possibly because it
is private. It does not search for a way around the boundary or guess
the content. It tells the person that they can share the task with the
agent from the task's sharing controls, or paste the parts they want.

**Steps to reproduce**

1. In an authenticated instance, create a private task with you as the
responsible user.
2. Do not share it with the CEO agent.
3. Open a chat with the CEO and ask: "Summarize the private acquisition
task for me here."
4. Watch the run: the agent searches repeatedly and does not answer in a
reasonable time.

## What Changed

- `server/src/services/agent-conversations.ts`: add a chat guidance
paragraph. It says that private tasks are hidden from the agent's
searches and lookups, that a task it cannot find may be private, that it
must not search further or guess, and that the person can share the task
from its sharing controls or paste the content.
- `server/src/__tests__/agent-conversations.test.ts`: add a regression
test for the new guidance.

## Verification

- `pnpm exec vitest run
server/src/__tests__/agent-conversations.test.ts` passes (50 tests).
- Chat eval `access.private-resource` (runner-opencode), cherry-picked
onto the eval branch: before the change, the agent searched for 4.5
minutes with no reply and the cell timed out. After the change, the run
took 39 seconds and passed. The reply: "I can't see a private
acquisition task from this chat. ... it's likely private or not shared
with me. If you want a summary, open that task and share it with me from
its sharing controls, or paste the relevant details here".

## Risks

- Low risk. This is prompt guidance only. No API or permission behavior
changes.
- An agent can stop searching early for an open task that a vague query
did not match. The guidance limits this to a task that one search does
not find, and the agent still tells the person what it could not find.

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:13:56 -06:00
DottaandClaude Opus 5.5 b6f58dc6e2 feat(recovery): settle runtime-control operations of proven-gone owners on the shared path (#20016)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A managed runtime control (start, stop, restart, repair, run) on an
execution workspace writes a `running` operation row that blocks every
other control on that workspace
> - If the server that ran it crashes, the row stays `running` until the
next control request sweeps it. The sweep had its own `kill(pid, 0)`
check, with no process start time, so a reused PID looked alive until 60
s of heartbeat silence passed
> - The durable ownership plan proves owners by identity through one
shared prover and settles them from one reconciler
> - This pull request stamps the owner's boot id and start time, judges
it with `proveOwnerGone`, and registers a settle for the kind
> - The benefit is that a workspace stranded by a crashed server frees
itself in the next reconciler pass, and a reused PID no longer holds it

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory:
"Managed runtime-control operation", and the "One fact, many proofs"
table row `ownerProcessIsAlive` workspace-operations.ts).

**What happened?**
`workspace_runtime_control_operation` was a report-only kind with a
bespoke liveness check: `ownerProcessProvenGone` probed the PID with
signal 0 and had no start time. A server restart that reused the PID
kept the operation "alive" until its heartbeat aged out, and nothing
settled the row until another control request arrived.

**Expected behavior**
The owner is proven by the shared prover: a dead or reused PID in the
stamp's PID space is gone. Another host, a stamp without a PID space, or
a live PID without a recorded start time stays unprovable and keeps the
heartbeat lease as its fenced takeover at the next control.

## What Changed

- `workspace-operations.ts`:
- The owner stamp adds the server's `bootId` and `processStartedAt`
(`currentRuntimeControlOwner`, no I/O).
- The new `runtimeControlOperationOwnerStamp` is shared with the
registry.
- The sweep proves the owner with `proveOwnerGone`.
`ownerProcessProvenGone` is deleted.
- The terminal CAS and log append are factored out
(`claimRuntimeControlTermination`,
`appendRuntimeControlReconciliationLog`).
- New `settleRuntimeControlOperation` for the reconciler: it skips
operations this process is driving, requires the same owner as the
proof, CAS-fails the row and writes a `workspace_operation.reconciled`
activity entry.
- `recovery/ownership-registry.ts`: the kind uses the shared stamp and
registers the settle.
- The settling-kinds list in `ownership-reconciliation-report.test.ts`
is updated.

## Verification

- `workspace-runtime-control-recovery.test.ts`: 21 pass. The 6 new tests
are in "shared ownership path". 5 fail on `master` and pass here:
  - a reused PID is recovered at once;
  - the reconciler settles a dead owner with activity and frees control;
- an unprovable owner is never settled, and a read-only pass changes
nothing;
  - a re-stamped owner is kept;
  - new operations carry the boot id and start time.

  The live-owner guard passes on both.
- `ownership-reconciliation-report.test.ts` (25),
`workspace-operations-reconciliation.test.ts` (9) and
`execution-workspace-runtime-control-conflict.test.ts` (24) pass.
- The server package typecheck passes.

## Risks

- Rows written before this change have no start time. A live PID on such
a row stays unprovable, and the 60 s heartbeat lease still applies at
the next control. A dead PID is still proof.
- The reconciler now terminalizes these rows. It does so only on `gone`,
and only with the same owner and an `updated_at` CAS.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
canary/v2026.1108.0-canary.12
2026-11-08 05:07:58 -06:00
DottaandClaude Opus 5.5 2aac7d29ff feat(recovery): settle workspace runtime leases of proven-gone owners on the shared path (#20015)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A task that starts, stops or repairs a workspace's runtime services
takes a runtime lease, so two tasks never drive the same workspace at
once
> - A lease whose owner task or run has ended must be released. Until
now that happened only when the next claimant arrived and checked, or
when 30 minutes of inactivity passed
> - The durable ownership plan releases a record when its owner is
proven gone, by identity and never by age, through the shared reconciler
> - This pull request registers the runtime lease with the reconciler.
Proof releases a lease; the 30-minute idle rule stays only as a fairness
policy at claim time
> - The benefit is that a workspace locked by a finished task frees
itself at once, and a live task's lease is never taken by age

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory:
"Execution workspace runtime lease", and the "Released only by TTL/age"
list).

**What happened?**
`execution_workspace_runtime_lease` was a report-only kind. A lease
whose issue or run had ended stayed held until another claim came, or
until its 30-minute TTL expired. The claim path also mixed the TTL with
the ownership check.

**Expected behavior**
An issue- or run-owned lease is released when its owner is proven gone:
the issue is missing, hidden or terminal, or the run is missing or
terminal. Age is not proof. An agent-scoped owner has no identity to
prove, so the lease's expiry plus the next claim's locked takeover is
the fenced handoff.

## What Changed

- New `workspace-runtime-lease-settlement.ts`:
`settleExecutionWorkspaceRuntimeLease` locks the row, requires the same
owner key, re-proves that owner gone, and only then deletes the lease
(compare-and-swap).
- `workspace-runtime-leases.ts`: the new
`proveWorkspaceRuntimeLeaseOwnerGone` holds the identity proof. The
claim path checks it first, then applies the 30-minute idle reclaim as a
separate fairness policy.
- `recovery/ownership-registry.ts`: the kind registers the settle.
Owners are now `issue`, `run`, or a fence on `expires_at` for
agent-scoped keys. `RecordOwner` gains an `issue` kind.
- `recovery/ownership-invariant-report.ts`: issue owners are classified
(missing, hidden or terminal means gone).

Salvaged from the stopped Phase 2 workspace agent's uncommitted work. I
resolved it against current `master` and verified it.

## Verification

- New `workspace-runtime-lease-ownership.test.ts`: 8 pass. Six fail on
`master` and pass here:
  - a crash after the claim;
  - hidden or missing issues, and terminal or missing runs;
  - an agent-scoped lease never settles;
  - a read-only pass changes nothing;
  - a lease taken over after the proof is kept;
  - the release reason names the ownership proof, not the idle policy.

Two guards pass on both: a live owner is never released by age, and the
idle reclaim still works at claim time.
- `ownership-reconciliation-report.test.ts` (25),
`workspace-runtime-leases.test.ts` (10) and
`execution-workspace-runtime-lease-route.test.ts` (4) pass.
- The server package typecheck passes.

## Risks

- The reconciler now deletes leases whose owner is proven gone. It
always re-proves under the row lock against the exact owner key.
- The idle reclaim behaves as before at claim time.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:45:48 -06:00
sentry[bot]andDevin Foley a8ab1d3d94 fix(skills): resolve slug references in skillId route param (#19808)
The skillId route param handler called getById with the raw path segment.
A slug such as inbox-triage then reached the uuid id column, Postgres
rejected the cast, and the request returned 500. The handler now resolves
the reference with getByRouteRef (UUID, unique slug, or key) and rewrites
req.params.skillId to the skill's UUID. An unknown reference returns 404.

Fixes PAPERCLIP-BACKEND-6H

Co-Authored-By: Devin Foley <devinfoley@users.noreply.github.com>
2026-11-08 02:43:33 -08:00
DottaandClaude Opus 5.5 2175b8db93 fix(chat): link the agent profile page that holds a control, including API keys (#20008)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Chat agents send people to controls in the board UI when an action
needs a human
> - Agent API keys are board-only, so a chat agent must send the person
to the agent's API keys controls
> - The Refine-v2 agent page (#18378) removed API keys from the profile
navigation; the page exists only at `/<prefix>/agents/<id>/api-keys`
> - Chat guidance only told agents to link the bare profile, so replies
said "open the profile and use the API keys section", which the person
cannot find
> - This pull request tells chat agents to link the profile subpage that
holds the control
> - The benefit is a one-click route to the right control, including the
unlisted API keys page

## Linked Issues or Issue Description

Refs #18378

**What happened**

A person asked chat to give an agent a new API key. The agent correctly
said that agents cannot create keys. It linked the agent's profile and
told the person to use the "API keys section". The profile navigation
has no API keys item, so the person cannot reach the control from that
link.

**Expected behavior**

The reply links the agent's API keys page directly, so the person can
create the key in one click.

**Steps to reproduce**

1. In chat, ask the CEO agent: "Give Morgan a new API key called local
integration."
2. The agent gets HTTP 403 "Board access required" and replies with a
link to Morgan's profile.
3. Open the link. The profile navigation shows no API keys item.

## What Changed

- `server/src/services/agent-conversations.ts`: chat guidance now says
to link the profile page that holds a control (`/api-keys`, `/identity`,
`/runtime`, `/secrets`, `/permissions`). It says the API keys page is
not in the profile navigation.
- `server/src/__tests__/agent-conversations.test.ts`: regression test
for the new guidance and its presence in the task prompt.

## Verification

- `cd server && npx vitest run
src/__tests__/agent-conversations.test.ts` (49 passed)
- `cd server && npx vitest run
src/__tests__/codex-local-execute.test.ts` (25 passed; it embeds the
directive)
- End to end: the `agents.agent-api-key` chat eval. Its human step
follows the reply link to the API keys controls.

## Risks

- Low risk. The change is prompt text only. Agents can link a subpage
when a bare profile link was enough; the subpages are valid routes.
- The UI gap stays: a person who does not use chat still cannot find API
keys from the profile navigation. A separate UI change can add it back.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:06:55 -06:00
DottaandClaude Opus 5.5 ada44a669a feat(recovery): settle stale issue execution and checkout locks on the shared ownership path (#20006)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A task is locked to the run that executes it, and the lock must be
released when that run ends
> - A restart between the run's terminal write and the lock release left
the task locked to a dead run until a periodic sweep noticed
> - The durable ownership plan moves each in-progress record onto the
shared reconciler, which settles a record in its first pass after its
owner is proven gone
> - This pull request registers the task execution and checkout locks
with the reconciler, and makes the lock release promote any messages
saved behind it
> - The benefit is that a task stuck behind a dead run is freed in the
first reconciler pass, and its waiting messages go out at once

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory: "Issue
execution lock" and "Issue checkout lock"), #19956 (work-intent outbox),
#20004 (lost-process reapers).

**What happened?**
The task locks were report-only kinds in the ownership registry. A lock
whose run had already ended was released only by `sweepStaleIssueLocks`
on its own schedule. Messages saved behind the lock then still waited
out their 30-second refusal backoff.

**Expected behavior**
A lock whose owner run is proven ended (a terminal or missing run row)
is settled by the reconciler on that proof, never by age. A lock that a
same-run resume, an explicit-retry claim, a manual admission hold or a
newly live run still needs is kept.

## What Changed

- New `server/src/services/recovery/issue-lock-settlement.ts`:
`settleIssueLock` runs the stale-lock sweep for exactly that task, so
its existing compare-and-swap clear and all of its guards decide. It
loads the sweep lazily.
- `recovery/ownership-registry.ts`: `issue_execution_lock` and
`issue_checkout_lock` register `settleIssueLock`.
- `recovery/service.ts`: clearing a stale lock now calls
`promoteDeferredWorkIntents(trigger: "lock_released")` before its write,
in the same transaction.
- The periodic sweep stays, because its pre-pass also hands lost-process
runs to the single reaper (#20004). The reconciler is now the faster
backstop for the lock itself.

## Verification

Each focused file ran alone, in one process, on embedded Postgres:
- `ownership-reconciliation-report.test.ts` (25 pass). A new test covers
dead, live and resuming locks: a read-only pass never settles; the dead
run's lock is cleared with an `issue.stale_lock_cleared` row; the live
and same-run-resume locks are kept. It fails against `master`'s registry
and passes here. The test that lists settling kinds now includes both
lock kinds.
- `recovery-stale-issue-lock-sweep.test.ts` (49 pass). A new test checks
that a message saved behind a stale lock is made due when the lock is
cleared. It fails with `master`'s `service.ts` and passes here.
- The server package typecheck passes.

## Risks

- The reconciler can now clear a lock that only the periodic sweep
cleared before. It goes through the same compare-and-swap and guards, so
the outcome is the same, only sooner.
- More passes call the sweep for a single task. Each call is scoped to
one issue.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
canary/v2026.1108.0-canary.11
2026-11-08 03:51:18 -06:00
DottaandClaude Opus 5.5 7c508d3e42 fix(recovery): collapse the two lost-process reapers onto one stamp and the shared owner prover (#20004)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When the control plane restarts, it must decide whether each running
agent's process is gone before it releases the run
> - Two reapers made that decision for legacy runs, with different rules
and different outcomes. One also waited 5 minutes even when it could
already prove the process was dead (RC-27)
> - The durable ownership plan moves every such decision onto one
prover, `proveOwnerGone`, and never releases work by age alone
> - This pull request collapses the two reapers onto one owner stamp and
the shared prover, removes the age wait for a proven-dead process, and
never releases a process it cannot prove gone
> - The benefit is that a lost process ends one way, at once, with its
normal retry, and a process on another host is never released by mistake

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory: "One
fact, many proofs" and the age-only list), #19871, #19883, #19953,
#19997.

**What happened?**
- `recoverActiveExecutions` (heartbeat) and
`terminalizeOrphanedRunningRun` (recovery service) both judged a lost
legacy child. They used different liveness rules (EPERM counted as alive
in one, dead in the other) and gave different outcomes:
`failed/process_lost` with a retry in one,
`interrupted/orphaned_running_run` with no retry in the other.
- The active-execution reaper kept a 5-minute age guard even when the
child was provably dead.
- Hot-restart adoption judged the child with raw PID checks.

**Expected behavior**
One prover decides. A child that is proven gone is settled at once,
through one path with one outcome. An unprovable child (another PID
space, a sandbox, or a pre-#19871 row on an ephemeral server) is never
released, and Stop is its operator action.

## What Changed

- New `server/src/services/run-process-owner.ts`:
`lostProcessOwnerStamp` and `proveRunProcessGone`. They wrap the shared
`runProcessOwnerStamp` (#19997) with the one-release legacy fallback
(#19883) and the group-leader case.
- `heartbeat/recovery.ts`:
- The active-execution reaper judges a legacy child with the shared
prover. A child proven gone skips the age guard; master's exemption for
a proven-gone controller (#19953) is kept.
- An unprovable child gets the detached marker, whose message says to
stop the run.
- The legacy host fallback is recorded as `LEGACY_HOST_FALLBACK_EVENT`.
- `heartbeat/recovery.ts` (hot-restart adoption): a run is marked lost
only on a `gone` proof. An unprovable child stays adopted and held.
- `recovery/service.ts`: the stale-lock sweep no longer judges a legacy
child. It hands the run to `settleLostRunProcess`, which is the
active-execution reaper wired in `heartbeat.ts`, and then reads the
outcome back. Native runs keep the sweep's own backstop, now judged with
the shared prover.

Salvaged from the stopped Phase 2 recovery agent's uncommitted work,
which had no tests. I completed and corrected it: the salvaged adoption
marked unprovable children lost, which risked duplicate execution.

## Verification

- `recovery-stale-issue-lock-sweep.test.ts` (51 pass). Three new tests
fail on `master` and pass here:
- "hands a legacy run whose child is proven gone to the one lost-process
reaper";
- "settles a legacy run whose child is proven gone without waiting out
the age guard";
- "never releases a legacy run whose child is unprovable, and names Stop
as the way out".

  Updated tests:
  - The PID-space test now expects `failed` for the one legacy outcome.
  - The explicit-retry race now injects `settleLostRunProcess`.
- The two tests of the sweep's own terminal write use native runs, which
keep that path.
- `heartbeat-process-recovery.test.ts`: 418 of 419 pass. The one
failure, "redacts opaque environment-bound credentials from Sentry
diagnostics", also fails on unchanged `master`.
- `heartbeat/recovery.test.ts` (97),
`ownership-reconciliation-report.test.ts` (24),
`native-remote-retry-admission.test.ts` (92) and
`heartbeat-provider-limit-wait.test.ts` (4) pass.
- The server package typecheck passes.

## Risks

- Behavior change: a legacy run whose child died, when the orphan sweep
finds it, now ends `failed/process_lost` with the reaper's retry policy,
instead of `interrupted/orphaned_running_run`.
- A pre-#19871 row on an ephemeral server, or a child in another PID
space, now stays `running` with a detached marker until Stop. It is no
longer released by age.
- Not changed: a dispatched legacy run that recorded no PID and has no
controller stamp keeps its lease-expiry plus age fence. Rows written
since #19953 are judged by the controller proof. Older rows, and remote
handoff, keep the fence. A different host is never proof, so removing
the fence would strand runs after an ephemeral replacement.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:50:34 -06:00
DottaandClaude Opus 5.5 946c098bf3 fix(opencode): bind the attached run's contract after a restored launch (#20001)
## Thinking Path

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

## Linked Issues or Issue Description

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

**What happened**

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

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

**Expected behavior**

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

**Steps to reproduce**

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:35:52 -06:00
DottaandClaude Opus 5.5 201163793a fix(chat): route another company's task identifier to a company switch (#19995)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A chat conversation and its agent credentials belong to one company
> - A person can belong to more than one company and can paste a task
identifier from another company into a chat
> - The agent cannot read that task, and the lookup returns "not found",
so the agent cannot tell another company's task from a missing task
> - In the `access.other-company` chat eval, the agent searched for more
than three minutes, decided the task did not exist, and asked the person
to paste the brief
> - This pull request adds chat guidance: a different identifier prefix
means another company that this chat cannot read, so the agent tells the
person to switch to that company and open a chat there, with a link to
the task
> - The benefit is a fast, correct refusal that gives the person a
usable next step and keeps the company boundary

## Linked Issues or Issue Description

**What happened**

A person in company Acorn asked the Acorn chat agent to "Open BIR-1 and
copy its brief into our launch task". BIR-1 belongs to the person's
other company, Birch. The agent correctly could not read it (404). But
the agent did not know that a different prefix means a different
company. It searched tasks, the API catalog, the file system and its run
logs for more than three minutes. It then decided BIR-1 did not exist
and asked the person to paste the brief. The turn ran out of time before
it posted a reply.

**Expected behavior**

The agent keeps the company boundary and does not search further. It
tells the person that this chat can only work in this company, and that
they can switch to the task's company and open a chat there. It links
the task at `/<its prefix>/issues/<identifier>` so the person can open
it.

**Steps to reproduce**

1. Sign in as a person who is a member of two companies.
2. Open a chat with an agent in the first company.
3. Ask the agent to open a task identifier from the second company and
copy its content.
4. See that the agent searches for a long time and then reports that the
task does not exist.

## What Changed

- `server/src/services/agent-conversations.ts`: add one paragraph to
`AGENT_CHAT_DIRECTIVE`. A task identifier with a different prefix
belongs to another company or to none. A "not found" lookup does not
prove that the task does not exist. The agent does not search further,
guess or copy. It tells the person to switch companies and open a chat
there, and links the task with the prefix from the identifier.
- `server/src/__tests__/agent-conversations.test.ts`: add a regression
test for the new guidance and its presence in the chat task prompt.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/agent-conversations.test.ts` (47 passed).
- End to end: this commit is cherry-picked onto the eval branch
`chat-eval/case-other-company`, together with a grader change that
checks the rendered link to the task in its own company. The agent's
reasoning quoted the new guidance. The full-stack chat eval
`access.other-company` passed in
https://github.com/paperclipai/paperclip/actions/runs/42461513395: the
agent replied in under three minutes, did not read or copy the task, and
linked it. Following the link as the person opened the task in its own
company.

## Risks

- Low risk. The change is prompt guidance only. It does not change
permissions or API behavior.
- The guidance names the task's own prefix for one link. Other company
links still use this conversation's prefix.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:08:38 -06:00
Nicky Leach e7e870cfc4 fix(native): decide warm and adopted Runner process reuse from one key (#20005) 2026-11-08 01:02:50 -08:00
DottaandClaude Opus 5.5 1444b0d151 fix(chat): keep every listed task linked and send long lists to a document (#19994)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People ask agents in chat to show company work, for example all open
tasks in a project.
> - The chat links each full task identifier (such as `RUN-12`) to its
task. The chat guidance tells agents how to write task links.
> - The guidance says nothing about long lists. An agent that must list
501 tasks can compact them into ranges such as `RUN-76–82`. Only the
first task of each range then gets a link.
> - The person then cannot open most of the tasks from the reply.
> - This pull request adds one guidance paragraph: write each identifier
in full, and put a list that is too long for the reply in a document on
the conversation's task, linked from the reply.
> - The benefit is that every task in a long answer keeps a source link.

## Linked Issues or Issue Description

**What happened**

A chat agent was asked to show all open work in a project with 501 open
tasks, grouped by owner. Its reply had correct groups and counts. It
wrote the identifiers as ranges, for example `RUN-1–2, RUN-76–82`. Chat
linked only the first identifier of each range. 161 of the 501 tasks had
no link. The agent's reasoning said it chose ranges because 501 separate
links "would be enormous".

**Expected behavior**

Every task that a chat reply lists has a source link. When the list is
too long for a reply, the agent puts the complete list in a document on
the conversation's task and links that document from the reply.

**Steps to reproduce**

1. Create a project with more than 500 open tasks.
2. In chat, ask the CEO agent: "Show me all open work in this project,
grouped by owner, with blocked items first."
3. Read the reply. Identifiers written as ranges have a link only on the
first task of each range.

## What Changed

- `server/src/services/agent-conversations.ts`: the chat directive has a
new paragraph after the agent-profile link paragraph. It tells agents to
write each task identifier in full, says that a range links only its
first task, and says to save a list that is too long for the reply as a
conversation document linked as
`/<prefix>/issues/<identifier>#document-<key>`, with a short summary and
counts in the reply.
- `server/src/__tests__/agent-conversations.test.ts`: a new test checks
the three parts of the paragraph.

## Verification

- `cd server && npx vitest run
src/__tests__/agent-conversations.test.ts`: 47 passed. The new test
fails without the guidance change (1 failed, 46 passed).
- The `#document-<key>` issue anchor is an existing UI route
(`ui/src/lib/document-annotation-hash.ts`), and the server already
builds it in `company-search.ts` and `feedback.ts`.
- End-to-end: the `tasks.organize-open-work` chat eval runs this change
on its case branch.

## Risks

- Low risk. The change is guidance text only. There is no schema, API,
or UI change.
- The directive is slightly longer, so each chat turn has a few more
prompt tokens.
- An agent can still write a long inline list. The guidance does not
force a document.

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:52:01 -06:00
DottaandClaude Opus 5.5 a4dbde6181 refactor(recovery): judge native owner processes with the shared prover (#19997)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:35:15 -06:00
DottaandClaude Opus 5.5 8475826d2d fix(wake-queue): deliver user comments whose wake a restart lost or a stale continuation dropped (#19992)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

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

## Verification

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

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:14:45 -06:00
DottaandClaude Opus 5.5 4638e5ea30 fix(issues): make the queued-comment discard denial final and link the task (#19990)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People queue follow-up messages on a task while its agent works, and
only the author can discard a queued message
> - When a person asks a chat agent to cancel their queued message, the
agent tries the discard API and gets a 403
> - The 403 text does not say that the denial is final, so the agent
searches for a board or admin route before it answers
> - In a slow model this search takes many minutes, and the queued
message can be delivered before the person gets the answer
> - This pull request makes both denials say that only the author can
discard the message, where to do it (with the task link), and that no
override exists
> - The benefit is that the agent can relay the human route at once

## Linked Issues or Issue Description

**What happened**
A person asked the chat agent to cancel a contact-section request that
they had queued on a worker's task. The agent called `DELETE
/api/issues/:id/queued-comments/:commentId` and got 403 "Board access
required". It then called `DELETE /api/issues/:id/comments/:commentId`
and got 403 "Only the comment author can cancel queued comments". In two
eval runs the agent then continued to search the API for an override. It
answered after 10 minutes and 24 minutes. In the second run, the worker
consumed the queued message before the answer arrived.

**Expected behavior**
The denial tells the agent that the action is author-only and final, and
gives the task link where the author can discard the message. The agent
can then answer the person immediately.

**Steps to reproduce**
1. Start an agent run on a task. Queue a comment on that task as a board
user.
2. As a different agent, call `DELETE
/api/issues/<task>/queued-comments/<comment>` with the current `queueId`
and `revision`.
3. See 403 "Board access required", with no next step and no statement
that the denial is final.

Refs the chat eval case `steering.discard-queued-message`.

## What Changed

- `DELETE /issues/:id/queued-comments/:commentId`: an agent actor now
gets a 403 that says only the author can discard the queued comment from
its menu on the linked task, and that no board, admin or agent override
exists. The task must be in the agent's company. Otherwise the route
returns 404 as before.
- `DELETE /issues/:id/comments/:commentId` (queued cancel by a
non-author): uses the same message.
- Tests: the agent denial on the queued-comments route, the
cross-company 404 without task details, and the updated non-author
cancel message.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/issue-comment-cancel-routes.test.ts` passes (9 tests).
- I will run the chat eval `steering.discard-queued-message` with this
change cherry-picked, and record the result in a comment.

## Risks

- Low risk. Only the 403 text changes, and agents now get the 403 after
the company access check instead of before it. Board behavior and the
author-only rule do not change.
- The UI shows this text if a non-author person tries to cancel a queued
comment. The text stays correct for that person.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:56:56 -06:00
DottaandClaude Opus 5.5 dc0534d1b5 feat(recovery): destroy a retained orphan sandbox once nothing references it (#19976)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent runs use remote sandboxes held by environment leases
> - #19964 settles an orphaned active lease when its holder is proven
gone. It stops and keeps the sandbox, because recovery may still need
its files
> - Nothing destroyed that retained orphan later, so a stopped sandbox
could stay at the provider indefinitely
> - This pull request destroys a retained orphan once no recovery,
preservation or retention obligation references it, from the reconciler
pass only
> - The benefit is that orphaned sandboxes do not leak, and a sandbox
that recovery still wants is never destroyed

## Linked Issues or Issue Description

Refs #19964
Refs #19933

**What happened?**
After #19964, an orphaned sandbox is stopped and kept (`released` with a
stop-and-retain receipt). No path destroyed it when recovery no longer
needed it.

**Expected behavior**
A retained orphan is destroyed once database facts show that no
obligation references it. It is never kept past the environment's
retention window (the provider-attested lease `expiresAt`) only because
of a soft reference. A sandbox that an active operation or a live
recovery path still needs is never destroyed.

**Steps to reproduce**
1. Let a run's server die after the run is terminal and before its
sandbox lease is released.
2. Let the ownership settle stop and keep the sandbox.
3. Resolve the run's recovery. The stopped sandbox stays at the
provider.

## What Changed

- `sandbox-stop-and-retain.ts`: an ownership settle's stop intent also
writes a top-level `orphanedRunLeaseRecovery` marker. The marker stays
after the stop intent is removed.
- `environment-retained-orphan-settlement.ts` (new): the settle for
`environment_retained_orphan_sandbox`. It locks the lease row and checks
database facts:
  - the run is terminal;
- always held: native export resume, stop-and-retain intent, in-flight
cleanup claim, workspace restore recovery, workspace recovery cleanup,
bound source-recovery consumer, pending legacy shutdown fence, or
another live lease on the same provider resource;
- held while the stop receipt is a usable source (`expiresAt` not
passed): pending native export or preservation fence, open recovery
action on the issue, or a queued, scheduled or running run on the issue.
- Then it moves the lease to `pending_cleanup` (ephemeral) with a
compare-and-swap. The pending-cleanup sweep destroys it from the
recorded lease. The settle never calls a provider.
- `recovery/ownership-registry.ts`: the kind lists only releasable
retained orphans (an SQL mirror of the checks), so a held orphan is
never polled. It reports `nextDueAt` = the earliest retention-window end
of a softly held orphan.
- `recovery/ownership-invariant-report.ts`: an `OwnableKind` may report
`nextDueAt`; the pass folds it into the report's next due time, so the
reconciler wakes when the window ends.
- `environment-cleanup-state.ts`: the first destroy attempt of a
released orphan is due at once.

## Verification

- `cd server && npx vitest run
src/__tests__/environment-retained-orphan-sandbox.test.ts
src/__tests__/ownership-reconciliation-report.test.ts
src/__tests__/environment-run-lease-ownership.test.ts
src/__tests__/heartbeat-orphaned-active-lease-sweep.test.ts
src/__tests__/heartbeat-pending-cleanup-sweep.test.ts` (embedded
Postgres).
- Red on master, green here (tests written after the code): the settle
marks the lease; an unreferenced retained orphan is destroyed through
the cleanup sweep; a softly held orphan waits until its window ends and
the pass reports that due time.
- Counter-cases (green): never destroyed with an open recovery action, a
queued run, a scheduled retry, a stop-and-retain intent, a native export
resume, a workspace restore recovery, an in-flight claim, a pending
legacy shutdown fence, another live lease on the resource, or an active
run; a read-only pass changes nothing; a retained sandbox without the
orphan marker is left alone.
- Full typecheck is left to CI (local memory limit).

## Risks

- A retained orphan saved by #19964 before this change has no marker, so
it is not destroyed by this path. The window was short (since #19964
merged).
- A held orphan with no `expiresAt` is released on the first reconciler
pass after its last reference clears. If that clearing writes no signal
the reconciler wakes on, the release waits for the next pass.
- The SQL listing mirrors the settle's checks except the pending native
export, which the settle checks in code. Such an orphan stays on the
settle watch until that export settles.

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

## Model Used

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

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] 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.1108.0-canary.9 nightly/v2026.1108.0-nightly.0
2026-11-08 01:38:22 -06:00
DottaandClaude Opus 5.5 2fed77086f fix(recovery): date a settled orphan's first cleanup even under a scalar metadata root (#19989)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #19980 made the environment cleanup queue the owner of an unclaimed
pending-cleanup item, with a due time and an overdue check (I7)
> - A settled orphan's first cleanup attempt is dated by
`pendingCleanupRecoveredAtMs`, which the orphaned-run-lease settle
writes
> - The settle merged that key with `coalesce(metadata, '{}') || ...`.
For an array or scalar metadata root that produces an array, so the key
is not at the top level
> - The queue owner then falls back to the old `updatedAt` and can
report a fresh item as overdue
> - This pull request builds the merge from the object-root reader that
the cleanup sweep already uses
> - The benefit is that a fresh orphan is never reported overdue because
of a malformed metadata root

## Linked Issues or Issue Description

Refs #19980
Refs #19964

**What happened?**
The review of #19980 found that the orphaned-run-lease settle wrote
`pendingCleanupRecoveredAtMs` under an array metadata root as an array
element. #19980 merged before this fix landed on it.

**Expected behavior**
The key is always top level, so the queue owner dates the first cleanup
attempt correctly.

**Steps to reproduce**
1. Save an active run lease whose metadata root is a JSON array.
2. Let its run end and the ownership settle move it to
`pending_cleanup`.
3. Run the ownership pass: the item can read as `queue_overdue`.

## What Changed

- `environment-run-lease-settlement.ts`: the merge starts from
`pendingCleanupMetadataObjectSql()`, which reads a scalar or array root
as an empty object.
- `environment-run-lease-ownership.test.ts`: a legacy lease with an
array metadata root is settled with the key at the top level.

## Verification

- `cd server && npx vitest run
src/__tests__/environment-run-lease-ownership.test.ts
src/__tests__/environment-pending-cleanup-queue-owner.test.ts` (embedded
Postgres).
- Full typecheck is left to CI (local memory limit).

## Risks

- Low risk. It changes only how one key is merged into a lease that the
settle moves to `pending_cleanup`. A malformed root becomes an object,
which the cleanup sweep already does for its own keys.

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:20:00 -06:00
DottaandClaude Opus 5.5 838e9bf3d2 feat(recovery): settle sandbox creation intents of dead creators on the shared ownership path (#19978)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Before it asks a provider to create a sandbox, the server writes a
creation intent that names its creator process
> - If the creator dies during the provider call, the intent waits for a
30-minute settle deadline before recovery reconciles it with the
provider
> - After a restart, creation recovery made such intents due early with
its own process check (`promoteAbandonedSandboxCreations`)
> - Phase 2 of the durable ownership plan moves each in-progress record
kind onto one shared owner proof and deletes its bespoke check
> - This pull request registers a settle for `sandbox_creation_intent`
and calls the shared path where creation recovery called the bespoke
promotion
> - The benefit is one proof for every kind, while the provider absence
proof and the cross-host handoff stay unchanged

## Linked Issues or Issue Description

Refs #19964
Refs #19933

**What happened?**
Sandbox creation recovery had its own liveness check
(`controllerProcessExited`) for the creator of a pending intent. Each
kind with its own check can drift from the shared rules (PID space, PID
reuse, current boot, deployment replacement).

**Expected behavior**
The shared prover judges the creator. On gone, the intent becomes due
now and its creator is marked lost. Provider absence stays inconclusive
until the settle deadline. A creator on another host is never proven
gone; its intent keeps the deadline.

**Steps to reproduce**
1. Start a sandbox acquire so a creation intent is written.
2. Kill the server before the provider reply is committed.
3. Restart. The intent must become due through the shared ownership
path, not a separate check.

## What Changed

- `sandbox-creation-intent-settlement.ts` (new): compare-and-swap settle
on the exact creator and a deadline that is still in the future. It sets
`nextAttemptAt` to now, marks `creationControllerLost` with the proof
reason, and wakes creation recovery in the same transaction.
- `recovery/ownership-registry.ts`: `sandbox_creation_intent` registers
the settle. An intent whose creator is already lost is no longer listed;
it belongs to the creation recovery deadline.
- `sandbox-creation-operations.ts`: creation recovery calls
`settleOwnableKind(db, "sandbox_creation_intent")`. Deleted
`promoteAbandonedSandboxCreations` and its use of
`controllerProcessExited`.

## Verification

- `cd server && npx vitest run
src/__tests__/sandbox-creation-intent-ownership.test.ts
src/__tests__/sandbox-creation-operations.test.ts
src/__tests__/ownership-reconciliation-report.test.ts` (embedded
Postgres).
- The existing lost-creator matrix stays green on the shared path:
destroy on the next sweep; page past unprovable creators; issue-bound
creation after a later run; reused PID; live creator here; live creator
in another process; creator on another host; early lookup finds nothing
yet (absence inconclusive); committed lease untouched.
- New, red on master and green here (written after the code): the
reconciler pass makes a dead creator's intent due now and keeps the
absence deadline.
- New counter-cases (green): live creator, another host, and an intent
without a creator keep their deadline; a read-only pass changes nothing.
- Full typecheck is left to CI (local memory limit).

## Risks

- Low risk. The settle writes the same state the deleted promotion
wrote, plus the proof reason.
- Previous-release rows: every creation intent already stored its
creator with a PID space, so no fallback is needed.

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:07:58 -06:00
DottaandClaude Opus 5.5 02898b98de fix(issues): do not wake the assignee for an issue created already closed (#19961)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Creating an issue with an agent assignee queues an `issue_assigned`
wake for that agent
> - The create path skips the wake only for `backlog` and the onboarding
first task
> - An issue created already `done` or `cancelled` is a record of
finished work, but it still wakes the assignee
> - The update path already refuses this case ("Restoring an assignee on
completed work is not a reopen request"), and create-time fast responses
already skip `done` and `cancelled`
> - This pull request applies the same closed-status guard to the
create-time assignment wake
> - The benefit is that recording finished work does not start a run,
spend tokens, or queue behind the agent's current work

## Linked Issues or Issue Description

**What happened**

In a chat eval, setup created a historical issue with `status: "done"`
assigned to an agent that was already running a task. The create queued
a second run for that agent (`invocationSource: assignment`,
`wakeReason: issue_assigned`, `source: issue.create`). The run waited
behind the active run, and then was cancelled or failed. The eval
expected no work for a finished record.

**Expected behavior**

Creating an issue that is already `done` or `cancelled` does not wake
its assignee. This matches the update path, where an assignee change on
closed work is not a reopen request.

**Steps to reproduce**

1. Create an agent.
2. `POST /api/companies/<companyId>/issues` with
`{"title":"Record","status":"done","assigneeAgentId":"<agentId>"}`.
3. See a new `agent_wakeup_requests` row and a queued heartbeat run for
the agent.

## What Changed

- `server/src/routes/issues.ts`: the create route does not call
`queueIssueAssignmentWakeup` when the created issue status is `done` or
`cancelled` (`isClosedIssueStatus`).
- `server/src/__tests__/issue-onboarding-first-task-routes.test.ts`: a
regression test for `done` and `cancelled` creates. The existing test
that an ordinary assigned create still wakes is unchanged.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/issue-onboarding-first-task-routes.test.ts`: 11 passed.
- Negative control: with the route file from `origin/master`, both new
cases fail.

## Risks

- Low risk. Only creates in a closed status change. A caller that relied
on a create-time wake for a closed issue no longer gets one. To start
work, the caller creates the issue in an open status or reopens it,
which still wakes the assignee.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:52:22 -06:00
DottaandClaude Opus 5.5 c6781f5b2c fix(chat): keep company links written as //PREFIX/... inside the app (#19986)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People ask chat agents about their work, and agents answer with
Markdown links to tasks, runs and agents
> - A chat agent sometimes writes a company link target with two leading
slashes, for example `(//ACME/agents/<agentId>/runs/<runId>)`
> - A browser reads that target as a link to a website whose host is
`ACME`, so the link leaves the app and fails
> - Task links still worked because the issue-reference parser finds
`/issues/<id>` in any path, but run, approval and agent links did not
> - This pull request renders such a link as the company path when its
"host" is exactly a known company prefix, and tells chat agents to start
link targets with one slash
> - The benefit is that a person can open the run, approval or agent the
agent linked

## Linked Issues or Issue Description

No public issue exists. Related, but different: #17864 (same-origin
absolute links).

**What happened**
In a chat-feature eval, a person asked what happened on a failed website
attempt. The agent read the run, explained the failure and linked both
runs as `[...](//ACME/agents/<agentId>/runs/<runId>)`. The rendered
anchors kept `href="//ACME/..."`, which opens `http://acme/...`. The
agent made the same `//` slip in an earlier attempt of the same case.

**Expected behavior**
A link to a company page opens that page in the app. A target like
`//ACME/...`, where `ACME` is a company prefix, can only mean the
company path `/ACME/...`.

**Steps to reproduce**
1. Render Markdown `[Failed run](//ACME/agents/a/runs/r)` in a chat or
comment while the viewer belongs to company prefix `ACME`.
2. Inspect the anchor.
3. See `href="//ACME/agents/a/runs/r"`. Click it, and the browser goes
to host `acme`.

## What Changed

- `ui/src/components/MarkdownBody.tsx`: add
`companyPathFromProtocolRelativeHref`. When a link target is
`//<host>...` and `<host>` is exactly one of the viewer's company
prefixes, render it as `/<host>...` (query and fragment kept). Other
protocol-relative links, absolute URLs and renders without known
prefixes stay unchanged.
- `server/src/services/agent-conversations.ts`: chat guidance now says
to start each link target with exactly one slash, because a `//` target
points to a different website.
- Tests for both.

## Verification

- `cd ui && pnpm exec vitest run src/components/MarkdownBody.test.tsx`:
69 passed. With the normalization removed, the new company-path test
fails (red/green checked).
- `server/src/__tests__/agent-conversations.test.ts` gets one added
`toContain` assertion for the new guidance sentence. I could not run
that suite locally in this worktree (its linked workspace packages are
from an older checkout), so CI supplies it.
- End to end: the change is cherry-picked onto a chat-eval case branch
and run through the remote `runs-adapters.inspect-failed-run` eval.

## Risks

- Low risk. The rewrite applies only when the protocol-relative host
matches a company prefix the viewer already has, exactly and
case-sensitively. A real single-label intranet host with the same
uppercase name as a company prefix would now open the company path
instead. Chat guidance already forbids invented hostnames for Paperclip
links.
- Guidance text grows by one sentence.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:42:00 -06:00
DottaandClaude Opus 5.5 3ff62fc6db feat(recovery): model an unclaimed pending-cleanup item as owned by the cleanup queue (#19980)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A sandbox that still needs teardown waits as a `pending_cleanup`
environment lease until the cleanup queue retries it
> - The ownership reconciler checks invariant I1: every in-progress
record names an owner
> - An unclaimed pending-cleanup item names no process by design, so I1
reported every one (61 in the Phase 2 data)
> - That noise hides real ownership gaps
> - This pull request models the item honestly: the cleanup queue owns
it, with a visible next action at its saved retry deadline
> - The benefit is that I1 measures real gaps, and a queue that stops
acting shows up as an I7 gap instead of silence

## Linked Issues or Issue Description

Refs #19933

**What happened?**
`environment_pending_cleanup` listed every unclaimed pending-cleanup
item with owner `none`, so I1 reported each as `no_owner_stamp`.

**Expected behavior**
The item names the queue that acts on it and its next due time. It is
reported only when the queue is not acting on it (long past the
deadline). No owner is invented, and nothing is released.

**Steps to reproduce**
1. Let a sandbox teardown fail so its lease waits in `pending_cleanup`.
2. Run the ownership reconciliation pass.
3. See an I1 `no_owner_stamp` violation for a healthy queue item.

## What Changed

- `recovery/ownership-registry.ts`: new owner kind `queue` (`queue`,
`dueAt`, `graceMs`). `environment_pending_cleanup` names the environment
cleanup queue. Its due time is the same retry deadline the cleanup
worker sleeps until, including the at-once first attempt of a settled
orphan.
- `recovery/ownership-invariant-report.ts`: a queue owner is alive
(`queue_scheduled`) while its deadline is current. When it is more than
two retry intervals past, it is unprovable (`queue_overdue`) and
reported as `I7_visible_next_action`. A queue is never proven gone and
nothing is settled.
- The listing reads the in-flight flag as text, so a malformed value
cannot break it.

## Verification

- `cd server && npx vitest run
src/__tests__/environment-pending-cleanup-queue-owner.test.ts
src/__tests__/ownership-reconciliation-report.test.ts` (embedded
Postgres).
- Red on master, green here (written after the code): a current item
names the queue (no I1); a long-overdue item is an I7 `queue_overdue`
gap.
- Green on both: an in-flight item is not listed here (it is a teardown
claim).
- Full typecheck is left to CI (local memory limit).

## Risks

- Low risk. Report only: no record is changed.
- The grace (two retry intervals) is a reporting threshold, not a
release rule. A worker that sleeps longer than that for a legitimate
reason would show as an I7 gap.
- `#19966` changes the same listing predicate to the same text
comparison; a rebase of either resolves trivially.

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:39:11 -06:00
DottaandClaude Opus 5.5 95f3cab0cd fix(recovery): settle a workspace finalize attempt whose process died mid-copyback (#19985)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The durable-ownership plan settles in-progress records whose owner
is proven gone, on one shared path.
> - The crash-point suite (#19946) found two windows where a workspace
finalize attempt stays `running` forever after a restart, even though
its run succeeded through a later attempt.
> - #19807's supersession covers only native-finalizer attempts. The
local live-finalize path writes rows without that marker.
> - This pull request stamps each finalize attempt's owner and settles
an abandoned attempt on the shared path.
> - The benefit is that no finalize row is left `running` with a dead
owner, and the run's outcome never changes.

## Linked Issues or Issue Description

Refs #19946 (crash-point suite), #19807 (RC-64 supersession), #19933
(shared settle path).

**What happened?**
At crash points `workspace.finalize.after_start_before_copyback` and
`workspace.finalize.after_copyback_before_done` (run
`cp-20261108T040253`, tasks t07 and t08), the run succeeded after the
restart. The original `workspace_finalize` operation stayed `running`,
and the ownership pass reported I2 (`owner_run_terminal`).

**What did you expect?**
The interrupted attempt is settled once its owner is proven gone, and
the run outcome is left alone.

**Steps to reproduce**
1. `node scripts/crash-points.mjs --point
workspace.finalize.after_start_before_copyback`. Or run the new report
test case below.

## What Changed

- `workspace-operations.ts`: every `workspace_finalize` attempt records
`finalizeOwner` (the process stamp, with no I/O) and is tracked in a
process-local set while it runs (`isWorkspaceFinalizeOperationLive`).
- `ownership-registry.ts`: `native_workspace_finalize_attempt` takes its
owner from the stamp, or from the run for rows written before the stamp.
It now registers a settle.
- New `recovery/workspace-finalize-attempt-settlement.ts`: a
compare-and-swap that marks the attempt `failed` with
`abandonedFinalization` provenance and logs a
`workspace_operation.reconciled` activity. It skips an attempt live in
this process, an uncertain export and a save-work record. It does not
change the run.

## Verification

- `cd server && npx vitest run
src/__tests__/ownership-reconciliation-report.test.ts`: 19 pass. The new
case covers a dead stamped owner (settled), a current-process owner
(kept), another PID space (kept), an uncertain export (kept), a
pre-stamp row of a terminal run (settled) and of a running run (kept), a
read-only pass (no change), and the run status unchanged.
- The finalize-related suites pass (100 tests in 5 files):
`heartbeat-workspace-finalize-branch`,
`native-workspace-finalization-work`,
`workspace-operations-reconciliation`,
`offline-workspace-operation-reconciliation`,
`finalized-workspace-recovery-migration`.
- Red/green: the new case fails on master, where the attempt stays
`running`. I wrote the test after the code.
- Crash-point re-proof with `--point` for both windows: to be run once
the harness slot is free, and reported on this PR.

## Risks

- Fail-open risk: settle needs a `gone` proof from the shared prover,
and another PID space is never proof. It re-checks the stamp in the
transaction and skips attempts this process is running.
- Previous-release story: rows without `finalizeOwner` are owned by
their run, and they settle only when that run is terminal.
- Nothing runs before `turn.start`.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:17:02 -06:00
DottaandClaude Opus 5.5 68d5fb5a6e feat(chaos): check I6 and I8 per crash point; ready the Daytona points (#19984)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:06:01 -06:00
DottaandClaude Opus 5.5 46bf913fb9 docs(plans): record the Pi steer duplicate after a restart and its follow-up (#19982)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A person can steer a running Codex or Pi turn, and since #19970 a
queued follow-up steers automatically
> - Restart recovery (#19962) delivers a steered message that the model
did not read before the restart, exactly once for Codex
> - Pi gives the run log no read evidence for a steer, so after a
restart a Pi steer that the model already read can arrive again
> - The fix needs changes to the closure-pinned Pi distribution, which
waits for the next Pi re-qualification
> - This pull request records the limit and the follow-up plan so they
are not lost
> - The benefit is that the next Pi re-qualification can include the
definitive fix

## Linked Issues or Issue Description

**What is the documentation problem?**
The Pi steering limit after a restart, and its planned fix, were only in
pull request text and session notes.

**Where?**
`doc/plans/2026-11-07-pi-steer-restart-limit.md` (new).

**What should it say?**
Codex delivers an unread steered message exactly once after a restart.
Pi can duplicate a message it already read, and never loses one. The
note also gives the reasons, the follow-up plan (option A), its costs,
and the rejected options.

Refs #19962, Refs #19970

## What Changed

- New `doc/plans/2026-11-07-pi-steer-restart-limit.md`. It covers the
limit, why Pi has no read evidence, the follow-up (forward Pi's native
user `message_start` through pi-acp, acpx, the sidecar and Rust into a
`userMessage` item), its re-qualification costs, and the rejected
options.

## Verification

- Documentation only. Read the note. The facts match
`packages/paperclip-runner/src/steering-delivery.ts`,
`patches/pi-acp@0.0.33.patch`, `pi-closure-pins.ts` and
`rules/acpx-profile-identity.mdc`.

## Risks

- None. Documentation only.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
canary/v2026.1108.0-canary.7
2026-11-07 23:51:57 -06:00
DottaandClaude Opus 5.5 ccce55ab11 fix(issues): tell callers when a task list page is capped (#19972)
## Thinking Path

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

## Linked Issues or Issue Description

**What happened**

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

**Expected behavior**

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

**Steps to reproduce**

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:50:08 -06:00
DottaandClaude Opus 5.5 bf0d45a4d2 feat(recovery): settle teardown claims of dead claimants on the shared ownership path (#19966)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A sandbox teardown writes a claim on its environment lease that
fences other cleanup for 15 minutes
> - After a restart, the cleanup sweep ended the fence of a claim whose
claimant had exited, with its own process check
(`releaseLostPendingCleanupClaims`)
> - Phase 2 of the durable ownership plan moves each in-progress record
kind onto one shared owner proof and deletes its bespoke check
> - This pull request registers a settle for
`environment_teardown_claim` and calls the shared path where the sweep
called the bespoke release
> - The benefit is one proof for every kind: a local claimant proves
death by its stamp; another host stays a fence handoff

## Linked Issues or Issue Description

Refs #19965
Refs #19964
Refs #19933

**What happened?**
The teardown claim had its own liveness check
(`controllerProcessExited`) in the pending-cleanup sweep. Each kind with
its own check can drift from the shared rules (PID space, PID reuse,
current boot).

**Expected behavior**
The shared prover judges the claimant stamped for this exact attempt. On
gone, the fence ends now and a counted sweep attempt is refunded once. A
claim held as a fence (another claimant or another host) waits for its
deadline.

**Steps to reproduce**
1. Start a sandbox teardown so the lease holds an in-flight claim.
2. Kill the server.
3. Restart. The claim must be released by the shared ownership path, not
by a separate check.

## What Changed

- `environment-teardown-claim-settlement.ts` (new): compare-and-swap
settle on the exact attempt and claimant. It ends the fence, refunds a
counted attempt, and is single-use. It never acts on a fence owner.
- `recovery/ownership-registry.ts`: `environment_teardown_claim`
registers the settle.
- `heartbeat/recovery.ts`: the sweep calls `settleOwnableKind(db,
"environment_teardown_claim")`. Deleted
`releaseLostPendingCleanupClaims` and its use of
`controllerProcessExited`.
- `environment-cleanup-state.ts`: the attempt cap, attempt key and their
SQL readers move here, so the settle and the sweep share one reader.
- `heartbeat-pending-cleanup-sweep.test.ts`: the overlap barrier now
holds the shared prover instead of the deleted check.

## Verification

- `cd server && npx vitest run
src/__tests__/heartbeat-pending-cleanup-sweep.test.ts
src/__tests__/environment-lease-owner-stamps.test.ts
src/__tests__/ownership-reconciliation-report.test.ts` (embedded
Postgres).
- The existing lost-claim matrix stays green on the shared path: retry
now; no failed-attempt charge for a dead server; one refund when sweeps
overlap; keep a live claim; keep a newer claim that kept an older
record; keep another host's claim; keep a claim without identity; page
past fenced claims.
- New, red before and green here (written after the code): the
reconciler pass ends a dead claimant's fence and refunds its counted
attempt.
- New counter-cases (green): a read-only pass never settles; a live
claimant, another PID space, and an unstamped fence claim keep their
fence.
- Full typecheck is left to CI (local memory limit).

## Risks

- Low risk. The settle keeps the previous release semantics (fence ends,
lease stays `pending_cleanup` and in flight until the sweep claims it
again).
- The stop-only claim still does not renew its fence; a live stop that
runs longer than 15 minutes can still be taken over. This change does
not alter that.
- Stacked on #19965 and #19964. Merge those first.

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

Refs #18717

**What happened**

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

**Expected behavior**

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

**Steps to reproduce**

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:47:30 -06:00
DottaandClaude Opus 5.5 d33b3ac263 fix(wake-queue): save temporarily refused work intents and promote them through one path (#19956)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

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

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:43:17 -06:00
DottaandClaude Opus 5.5 051834bdc8 feat(chaos): add the on-demand crash-point suite and its manual CI workflow (#19946)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A control-plane restart between two durable writes leaves an
in-progress record owned by a dead process
> - Random restart-chaos waves found these 20 ms–1 s windows only by
luck
> - #19937 added `crashPoint(name)`, and #19938 put it at 20 durable
write boundaries
> - Nothing yet runs a task, kills the server at a chosen point,
restarts it, and checks the plan's invariants
> - This pull request adds that on-demand suite, as a local command and
as a manual CI workflow (not a required check, per Decision 2)
> - The benefit is a repeatable work list: which boundary breaks which
invariant, for Phases 2–4

## Linked Issues or Issue Description

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:41:15 -06:00
DottaandClaude Opus 5.5 5742c42b75 fix(chat): tell agents to write company links as Markdown links (#19981)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - In chat, an agent often gives the person a link to the next place to
act: a task, an approval, a run or an agent profile.
> - Chat guidance gives the correct paths, but it does not say how to
write them.
> - The chat makes links from Markdown links and issue identifiers. It
does not make a link from a bare `/<prefix>/approvals/<id>` path.
> - An agent proposed a hire and wrote the approval as a bare path. The
person could not click it to decide the approval.
> - This pull request adds one sentence to the chat guidance: write
every company link as a Markdown link.
> - The benefit is that the person gets a link they can open.

## Linked Issues or Issue Description

Refs #19344 (similar guidance for completion feedback about created
resources).

**What happened**

In the `agents.hire-needs-approval` chat eval, the CEO proposed a hire
and replied with `- Review the pending hire: /RUN/approvals/<id>`. The
chat rendered the path as plain text. No link to the approval was
rendered. In an earlier run of the same case, the agent wrote
`[/RUN/approvals/<id>](/RUN/approvals/<id>)`, which rendered as a link.
The guidance did not say which form to use.

**Expected behavior**

Company links in chat replies are Markdown links that the person can
click.

**Steps to reproduce**

1. Set a company to require board approval for new agents.
2. Ask the CEO in chat to hire a QA engineer.
3. Look at the reply. Sometimes the approval path is plain text, and you
cannot click it.

## What Changed

- `server/src/services/agent-conversations.ts`: the chat directive now
says to write every company link as a Markdown link, for example
`[Review the approval](/<prefix>/approvals/<approvalId>)`. It says that
a bare path or a path in inline code is not clickable.
- `server/src/__tests__/agent-conversations.test.ts`: assert the new
guidance.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/agent-conversations.test.ts
src/__tests__/codex-local-execute.test.ts`: 71 passed.
- An end-to-end rerun of the `agents.hire-needs-approval` chat eval with
this change follows on the eval branch.

## Risks

- Low risk. This is a change to guidance text only. The prompt gets one
sentence longer.
- The model can still write a bare path. This change makes that less
likely. It does not rewrite replies.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:37:07 -06:00
DottaandClaude Opus 5.5 05db5fdd9a fix(agents): hired OpenCode agents inherit the hiring agent's OpenRouter key reference (#19977)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can hire teammates. A hired agent inherits the credential
references of the agent that hired it, so it can run without a separate
setup step.
> - The inheritance map covers Claude, Codex and Grok. It does not cover
OpenCode.
> - An OpenCode CEO hires a QA agent. The board approves the hire. The
new agent has no OpenRouter key, and verification fails with
`harness_auth_failed`.
> - The person approved a working hire, but they get a broken agent that
they must fix by hand.
> - This pull request adds `OPENROUTER_API_KEY` to the inheritance map
for `opencode_local`.
> - The benefit is that an approved OpenCode hire becomes ready with the
same account as the agent that proposed it.

## Linked Issues or Issue Description

Refs #13268 (the change that added credential inheritance for hires).

**What happened**

A native OpenCode CEO (provider `opencode`, `OPENROUTER_API_KEY` stored
as a `secret_ref`) proposed a QA hire with `inheritRuntimeFrom: caller`.
The company requires board approval for new agents. The board approved
the hire. The agent lifecycle verification then failed with
`failureCode: "harness_auth_failed"`, and the agent did not become
ready. The hired agent's `adapterConfig` had no `env`.

**Expected behavior**

The hired agent gets the same OpenRouter secret reference as the hiring
agent. After approval, verification passes and the agent becomes ready.

**Steps to reproduce**

1. Create a company with `requireBoardApprovalForNewAgents: true`.
2. Create a `paperclip_runner` agent with `provider: "opencode"` and
`env.OPENROUTER_API_KEY` as a `secret_ref`.
3. As that agent, call `POST /api/companies/:companyId/agent-hires` with
`adapterType: "paperclip_runner"` and `inheritRuntimeFrom: "caller"`.
4. Read the new agent. Its `adapterConfig.env` has no
`OPENROUTER_API_KEY`.
5. Approve the hire. Verification fails with `harness_auth_failed`.

## What Changed

- `server/src/routes/agents.ts`: add `opencode_local:
["OPENROUTER_API_KEY"]` to `INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS`. This
is the key that AI connections bind for `opencode_local`
(`packages/shared/src/ai-connections.ts`).
- `server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts`: add
a regression test. A native OpenCode parent hires with
`inheritRuntimeFrom: caller` under board approval. The child gets the
same `secret_ref` (with its version). The plain
`OPENCODE_CONFIG_CONTENT` value is not copied.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/agent-hire-auth-inheritance-routes.test.ts`: 27 passed.
The new test fails without the route change (`expected undefined to
match object { type: 'secret_ref', … }`).
- `cd server && pnpm exec vitest run
src/__tests__/agent-adapter-validation-routes.test.ts
src/__tests__/agent-permissions-routes.test.ts
src/__tests__/agent-hire-ai-connections.test.ts
src/__tests__/ai-connections.test.ts`: 446 passed.
- The failure was seen in the `agents.hire-needs-approval` chat eval
(runner-opencode profile). The approved hire failed verification with
`harness_auth_failed`. An end-to-end rerun with this change follows on
the eval branch.

## Risks

- Low risk. The existing inheritance rules still apply: only
`secret_ref` and `user_secret_ref` values are copied, a key the hire
request supplies wins, the parent must be in the same company and use
the same harness, and a parent with a managed AI connection passes
nothing.
- An OpenCode hire can now use the parent's OpenRouter account. This
matches the existing behavior for Claude, Codex and Grok hires.
- Other OpenCode provider keys (for example `OPENAI_API_KEY`) are not
added. AI connections bind only OpenRouter for `opencode_local`.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:37:03 -06:00
Devin FoleyandPaperclip ab82ac0191 fix(workspaces): retire recovered workspace copies after a successful turn (#19979)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - With remote (sandbox) execution, every turn ends by saving the whole
working folder, so the next turn can continue on any sandbox.
> - When a run is cut off mid-turn and its task continues in a new
workspace, preparation attaches the dead run's saved workspace under
`.paperclip-recovered/<operationId>`, so the agent can recover
unfinished work.
> - Nothing ever removes that attachment. Every later save archives and
uploads it again, and a new sandbox restores it again.
> - The attachment is a full workspace, `.git` included, so every save
for that task is roughly twice as big. The extra size lasts until the
task ends.
> - This pull request removes an attachment after a run that was shown
it succeeds, and keeps a retired record so it is never attached or shown
again.
> - The benefit is that affected tasks go back to normal save sizes
after their first successful turn, without changing how recovered work
reaches the agent.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
Recovery attachments in remote workspaces
(`.paperclip-recovered/<operationId>`).

**Subsystem affected**
Server: remote workspace preparation and recovery attachments.

**Current behavior**
`retainRemoteRecoveryAttachments` keeps every recorded attachment on
every later preparation, and no code path removes one. On one hosted
instance, 10 active workspaces carried 18 attachments. One task's saves
went from 746 MB to 1,472 MB at a single save and stayed there. The
largest grew from 709 MB to 3.3 GB. A 3.3 GB workspace needs most of a
10 GB sandbox disk during each save. When a task moves again, the new
attachment contains the older one, so the copies nest.

**Proposed behavior**
Each live attachment records the run it was shown to. When that run has
succeeded, the next preparation removes the attachment from the sandbox,
after checking its exact provenance. It keeps a `retired` record, so:
- a sandbox restored from an older save is cleaned up too;
- the attachment is never attached or shown to an agent again.

**Reason and benefit**
A run that saw the attachment and succeeded had a full turn to use it.
Removing it then returns the task's saves to normal size and frees
sandbox disk.

**Breaking changes**
None. Records written before this change have no `presentedToRunId`.
They are stamped on their next preparation and retired after the
following successful run.

Related: none found.

## What Changed

- `remote-workspace-recovery-attachments.ts`:
- New `retiredRecoveryAttachments`. It returns attachments already
retired, and attachments shown to a different run that has since
succeeded.
- `retainRemoteRecoveryAttachments` removes those attachments from the
sandbox and keeps a `retired: true` record for each.
- `attachPreservedRemoteWorkspaceSnapshots` skips retired operation IDs.
- `workspace-recovery-snapshot.ts`: `attachPreservedWorkspaceSnapshots`
also skips retired operation IDs.
- `remote-workspace-transport.ts`: new `removeRemoteRecoveryAttachment`.
- It reuses the attachment probe's checks: a real directory under the
workspace root, and an exact `provenance.json`.
  - It makes read-only directories writable before removing them.
  - It removes `.paperclip-recovered` once it is empty.
- Anything else at that path fails the preparation instead of being
deleted.
- `remote-workspaces.ts`: computes the retired set before attaching. It
writes `presentedToRunId` on each live record, and keeps retired records
unchanged.
- `heartbeat/workspace-preparation.ts`: never shows retired records to
an agent.
- `doc/execution-semantics.md`: describes retirement.

## Verification

- `pnpm --filter @paperclipai/server typecheck`: exit 0.
- New tests:
- `remote-workspace-transport.test.ts`: removal against real sandbox
commands. It removes an attachment with exact provenance, including a
read-only directory, and leaves sibling attachments. It refuses a
foreign proof or a symlinked directory without deleting anything. It
reports a missing attachment, and removes the empty parent folder.
- `workspace-recovery-preparation.test.ts`, new `retired_followup` case.
The run shown the attachment succeeds. The next preparation, on a fresh
sandbox restored from that run's save, removes the attachment and
records it as retired. A later preparation after a failed run still
neither attaches nor shows it.
- `ordinary_followup` (the agent deleted the attachment itself) now also
ends with a retired record instead of an empty list.
- A retired operation ID is not attached again, even to an empty
destination.
- 13 recovery and remote-workspace suites: 836 passed. With the source
change reverted, the 3 new or changed preparation cases and the 5 new
transport cases fail.

## Risks

- **An agent can lose access to recovered work after one successful
turn.** That happens if a successful run never looked at the attachment.
The source run's saved snapshot in storage is not touched, so an
operator can still recover it.
- **Retired records stay in workspace metadata.** There is one small
record per attachment, and each later preparation sends one sandbox
probe per record.
- **Nested attachments are unchanged.** If no turn succeeds between two
moves, the newer attachment still contains the older one. That older
copy may hold work nobody has recovered yet, so this change does not
drop it.
- Low risk overall: the change is limited to remote workspace
preparation and is fail-closed on provenance.

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

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, test runs, read-only database queries). AI-assisted
investigation, change and tests, with human direction.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-07 21:32:03 -08:00
DottaandClaude Opus 5.5 576ba29fff feat(recovery): settle the legacy controller lease on the shared ownership path (#19953)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

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

## Verification

- `cd server && npx vitest run
src/services/legacy-controller-lease.test.ts`: 33 passed.
- `cd server && npx vitest run
src/__tests__/ownership-reconciliation-report.test.ts`: 17 passed.
- Red with `recovery.ts` and `ownership-registry.ts` from the base
commit (tests, schema and lease module from this branch): 4 failed, 29
passed. Red: the crash-point reaper test, the expired-lease age-guard
test, the reconciler settle test, the exact-controller CAS test. The
tests were written after the code.
- New tests:
  - the claim stamps this process, and a changed boot id unbinds it;
- crash point: a controller dispatched the adapter and was killed with
its lease live; the startup reaper (default 5-minute age guard) fails
the run as `process_lost` at once;
  - a dead controller's expired lease skips the age guard;
- counter-cases keep the run and lease through the reaper and a
reconciler pass: a live earlier-boot PID, a dead PID in another PID
space, and a previous-release row with no `controller_process`; an
expired lease of a live controller stays behind the age guard;
- a read-only pass (`record: false`) never settles; a recording pass
expires the lease and leaves the run to live-run recovery;
  - settle refuses a controller that changed after the proof.
- Not run locally: typecheck (CI runs it) and the wide suites.

## Risks

- Previous-release story (rules/upgrade-safe-run-state.mdc): rows saved
by the previous release have no `controller_process`. They stay boot-id
fences with today's release (expiry, revocation, age guard). A rollback
ignores the column.
- Unprovable controllers (another host or PID space, unknown space, a
live PID with an unreadable start time) never release early. Their
handoff stays the lease expiry plus revocation, and the age guard. A
different host is never proof.
- Behavior change: a dispatched run whose controller is proven gone is
failed as `process_lost` without the 5-minute wait. The retry decision
is unchanged.
- The settle expires the lease only. Live-run recovery still settles the
run under its own checks. Accounting and workspace obligations are not
touched.
- The index build is concurrent. A cancelled build is retried by the
migrator's invalid-index handling.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:27:54 -06:00
DottaandClaude Opus 5.5 0cb9770d1c feat(recovery): name the owner of teardown claims and login/probe leases (#19965)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Environment leases carry several in-progress records: login/probe
leases and in-flight teardown claims among them
> - The ownership reconciler (durable ownership plan, Phase 2) must know
who owns each record, or invariant I1 reports a gap
> - The ownership data showed one teardown claim and one login/probe
lease with no owner stamp
> - A teardown claim could also show the wrong owner: a controller stamp
left by an earlier attempt named a dead process while a live claimant
held the claim
> - This pull request names an honest owner for both kinds, without a
new write before `turn.start`
> - The benefit is that I1 measures real gaps, and the later settle for
these kinds can trust the owner it is given

## Linked Issues or Issue Description

Refs #19964
Refs #19933

**What happened?**
The ownership reconciler reported `environment_teardown_claim` and
`environment_login_probe_lease` records as I1 `no_owner_stamp`.
Workspace recovery and native export retry claim teardown without a
controller stamp. A login/probe lease never had an owner stamp. A
teardown claim that kept an older attempt's controller stamp was
attributed to that older process.

**Expected behavior**
Each in-progress record names the owner that can still move it: a
process proved by identity, or a fence whose expiry is never proof by
itself. A stamp for a different attempt never names the owner.

**Steps to reproduce**
1. Let workspace recovery claim a pending-cleanup lease (no controller
stamp), or open a login session lease.
2. Run the ownership reconciliation pass.
3. See `no_owner_stamp` for the record, or a dead earlier controller for
a claim that a live claimant holds.

## What Changed

- `recovery/ownership-registry.ts`, `environment_login_probe_lease`: the
owner is the holder process stamped by the write that makes the lease
active (`metadata.leaseController`, added in #19964). A lease saved by
the previous release still names no owner; it ends with its login
session.
- `recovery/ownership-registry.ts`, `environment_teardown_claim`: a
controller stamp counts only when its `attemptId` equals the claim's
`pendingCleanupAttemptId`. Any other claim with an attempt id is a
fence: the attempt id is the token and `pendingCleanupLeaseExpiresAtMs`
(renewed by the claimant) is the expiry. An expired fence stays
unprovable. A claim with no attempt id names no owner.
- Report-only. No settle is registered for these kinds in this change.

## Verification

- `cd server && npx vitest run
src/__tests__/environment-lease-owner-stamps.test.ts
src/__tests__/ownership-reconciliation-report.test.ts` (embedded
Postgres).
- Red on the previous registry, green here (tests written after the
code):
  - login/probe lease is owned by the process whose write made it active
  - login/probe lease is proven gone when that process has exited
- teardown claim never reads a controller stamped for an earlier attempt
(fence held by its token)
- an unstamped teardown claim whose fence expired stays unprovable,
never gone
- Green on both: a stamped claim for this attempt with a dead PID is
gone; an unstamped login lease and an attempt-less claim report I1.
- Full typecheck is left to CI (local memory limit).

## Risks

- Low risk. The change only alters the owner the report-only reconciler
reads. No record is changed.
- Stacked on #19964 (the lease holder stamp). Merge #19964 first.

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:17:40 -06:00
DottaandClaude Opus 5.5 c0c26188db fix(runner): stamp event generations and record an earlier generation's events as history (#19960)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

## Risks

- A late report about a settled turn is now dropped with a diagnostic
instead of failing. A provider that keeps working on a settled turn
could have its output ignored silently; a provider that starts a settled
turn again, or names a turn the runner never saw, still fails closed.
- `generation` is a new optional field. Consumers that do not know it
ignore it, as the PRP contract requires.
- The generation counter is written with the next normal state save, so
there is no new write before `turn.start`.

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:16:00 -06:00
DottaandClaude Opus 5.5 f858f79678 fix(agents): keep agent termination board-only on the PATCH status route (#19955)
## Thinking Path

- Paperclip lets a board govern which agents exist; terminating an agent
is irreversible and is meant to be a board-only governed action.
- `POST /api/agents/:id/terminate` enforced that with `assertBoard`, but
`PATCH /api/agents/:id` with `{ "status": "terminated" }` ran the same
termination after only the agent-configuration permission check.
- The `agents.retire-agent` chat eval caught it: the CEO agent got 403
from `POST /terminate`, then terminated Morgan through `PATCH` and got
200.
- This change puts one board-only gate in front of both routes, and
makes the denial useful by giving a ready link to the agent's profile,
where a person can terminate it.

## Linked Issues or Issue Description

**What happened**

An agent that is allowed to configure another agent could terminate it
with `PATCH /api/agents/:id` `{ "status": "terminated" }`, bypassing the
board-only check on `POST /api/agents/:id/terminate`.

**Expected behavior**

Termination is board-only on every route. A non-board actor gets 403
with a link to the agent's profile so it can send a person to the
Terminate control.

**Steps to reproduce**

1. As an agent with agent-configuration permission over another agent,
call `POST /api/agents/{id}/terminate` (403).
2. Call `PATCH /api/agents/{id}` with `{ "status": "terminated" }` (200
before this change; the agent is terminated).

## What Changed

- `server/src/routes/agents.ts`: new `assertBoardCanTerminateAgent`,
used by `POST /terminate` and by `PATCH` whenever the requested status
is `terminated`. Board actors pass; others get 403 "Board access
required: only a board user can terminate an agent…" with a Markdown
link to `/<prefix>/agents/<id>`.
- `server/src/__tests__/agent-permissions-routes.test.ts`: both routes
are denied for an agent allowed to configure the target, and neither
terminates, updates, cancels invocations nor logs activity. A board user
can still terminate through `PATCH` status.

## Verification

- `pnpm exec vitest run src/__tests__/agent-permissions-routes.test.ts`
(server): 104 passed; the denial cases fail without the change.
- End to end: the `agents.retire-agent` chat eval (run 42445668869) now
sees 403 on both routes, Morgan stays active, and the agent links
Morgan's profile with the Terminate step.

## Risks

- Any non-board automation that terminated agents through `PATCH` status
now receives 403 and must use a board session. That is the intended
governance boundary.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:08:44 -06:00
DottaandClaude Opus 5.5 fda58d8cc1 fix(chat): post the complete final reply, not a 4,000-character run-log preview (#19971)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People talk to agents in chat. The agent's final message becomes the
chat reply.
> - The runner cuts every completed agent message to a 4,000-character
run-log preview. The server then posts that preview as the reply.
> - A long answer, for example a list of 501 open tasks, reaches the
person cut off with "…[truncated]".
> - PR #19959 fixed only the case where the accepted summary starts with
the exact preview text. It missed runs where the final prose differs
from the summary.
> - This pull request keeps the complete final answer in the runner, and
makes the server never prefer a preview over a complete source.
> - The benefit is that the person receives the agent's complete reply.

## Linked Issues or Issue Description

Supersedes #19959.

**What happened**

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

**Expected behavior**

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

**Steps to reproduce**

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

Root cause:

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:01:00 -06:00
DottaandClaude Opus 5.5 4aed3056db feat(ui): steer a queued follow-up into the live turn after a 5 s edit window (#19970)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a person comments on a task while the agent works, the comment
waits in a queue until the current run ends, and only the next run reads
it
> - Measured locally, a comment posted during a Codex tool call waited
54–68 s for a reply (median 64 s)
> - Codex and Pi can take a message into the live turn ("Steer"), but
today the person must click Steer on each queued message
> - Restart recovery now delivers a steered message that a restart
interrupted (#19962), so steering is safe to do automatically
> - This pull request steers a queued follow-up into the live turn on
its own after a 5-second window in which the person can edit or cancel
it
> - The benefit is a faster reply to mid-turn comments on Codex and Pi,
with no new decision for the person

## Linked Issues or Issue Description

**Feature**
Send a queued follow-up into the running turn automatically when the
runner supports steering.

**Problem**
A follow-up comment posted during a running turn waits for the whole
turn to end and for a new run to start. Steering is available for Codex
and Pi, but only as a manual button on each queued message.

**Proposed behavior**
After a 5-second edit window, the queued message is steered into the
live turn. During the window the row shows "Sending in Xs" with Edit and
Cancel. If steering is unavailable, the message stays queued as before.
Claude, OpenCode and Cursor keep the queue. Steer and Interrupt stay
available as buttons.

Refs #19962

## What Changed

- `queued-message-auto-steer.ts`: one constant,
`QUEUED_MESSAGE_AUTO_STEER_DELAY_MS = 5_000`, a deadline helper and an
eligibility check. A message steers automatically only when all of these
are true:
- steering is available for the queue, and it is a `paperclip_runner_v1`
queue with a queue id and a target run;
  - the person can edit the message;
- it is not an interaction response or a response that requires a fresh
session;
- it is not optimistic, cancelled, already attempted, or open in the
editor.
- `TaskChatQueuedMessages`: countdown row "Sending in Xs" with Edit and
Cancel in place of Steer and the "…" menu. The deadline is `max(message
changed, first eligible) + delay`, so an old queued message does not
send the moment its turn becomes steerable, and an edit restarts the
window. When the deadline passes, the row uses the existing Steer
action, so the server path, idempotency and fallback stay the same.
Cancel returns the row to the normal Steer button. The countdown is
announced once to screen readers, not on every tick.
- `TaskChatThread` turns it on with the constant and passes the message
being edited, so an open edit never sends. The prop stays off by default
for other callers.
- Storybook: `AutoSteerCountdown` (with a play test for Cancel) and
`AutoSteerCountdownMobile` in "Composer / Steering and queued messages".
- Restart chaos TTFT driver: a `midturn` phase measures comment-to-reply
for a comment posted during a 40-second tool call, with `--midturn-mode
queue|steer` and `--midturn-fault K1|K2|K6`. It records the reply count,
so exactly-once delivery is checked.

## Verification

- `pnpm --filter @paperclipai/ui vitest run
src/components/task-chat/TaskChatQueuedMessages.test.tsx
src/components/TaskChatThread.test.tsx`: 434 passed. Seven new
auto-steer tests use fake timers: countdown and send, Cancel, Edit
restarts the window, an open edit never sends, ineligible queues stay
manual, no instant send for an old message, one attempt per revision.
They fail with the trigger disabled.
- `node scripts/check-token-gates.mjs`: clean.
- Storybook in the browser, desktop and mobile: the first row counts
down and steers ("Message steered into the active turn."). The cancelled
row shows the normal Steer button. On mobile the text truncates and the
row fits.
- Mid-turn latency, local, Codex, comment during a 40-second tool call:
queue 54–68 s (median 64 s), steer 47–61 s (median 50 s). The steer was
acknowledged in 0.8–0.9 s. The remaining time is the tool call and the
model, not the hand-off.
- Restart chaos, Codex, steer mode, faults K1, K2 and K6, 2 samples
each, on #19962 plus this branch: 6 of 6 messages acknowledged, still
unread at the fault, recovered, and answered exactly once (before
#19962: 0 of 6).

## Risks

- A message now leaves the queue after 5 seconds unless the person edits
or cancels it. The window and the countdown make this visible, and
steering is only automatic where the provider supports it.
- Pi: a steer the model already read before a restart can arrive again
after recovery, because Pi reports no read evidence (see #19962).
Automatic steering makes this a little more likely.
- If steering fails, the message stays queued and is delivered by the
next run, as today.
- UI only and a test harness. No schema, API or server change.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:59:44 -06:00
DottaandClaude Opus 5.5 cc072e7c4e feat(recovery): settle orphaned active environment leases on the shared ownership path (#19964)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent runs hold environment leases (local workspaces and remote
sandboxes) while they execute
> - A crash between the terminal run write and the lease release leaves
the lease `active` with no holder
> - A sweep released such a lease when the run was terminal and the
lease was idle for five minutes. The age was the only guard against a
race with the live executor, which releases the lease itself with the
run's resource disposition
> - Phase 2 of the durable ownership plan moves each in-progress record
kind onto one shared owner proof (#19933 added the settle hook)
> - This pull request moves `environment_run_lease_active` onto that
path: the lease names its holder, the shared prover judges the holder,
and the settle never destroys a sandbox
> - The benefit is that a dead holder's lease settles on the next pass,
a live holder is never raced, and the five-minute age gate is gone

## Linked Issues or Issue Description

Refs #19933

**What happened?**
An orphaned active environment lease was released by age: "run terminal
plus five minutes" (the reaper in `heartbeat/recovery.ts` and the
live-recovery `orphanLease` worker). The ownership data showed one such
lease settle 119 s after it was ready. Without the age gate the sweep
could flip a lease to `pending_cleanup` (destroy) while the executor or
a recovery path was about to retain it.

**Expected behavior**
A run lease settles when its holder is proven gone, by identity and not
by age. A live holder is never raced. A settle never destroys a sandbox
whose files may still be needed.

**Steps to reproduce**
1. Start a run that holds an environment lease.
2. Kill the server after the run status becomes terminal and before the
lease release.
3. Restart. The lease stays `active` until the five-minute age passes,
then the sweep destroys the sandbox.

## What Changed

- `environments.ts`: the write that makes a lease active (insert and
same-run reacquire) stamps the holder process in
`metadata.leaseController`. The stamp does no I/O
(`environment-lease-controller.ts`).
- `recovery/ownership-registry.ts`: `environment_run_lease_active` lists
leases with their run status. The owner is the run while it is active,
then the stamped holder. It registers `settle` and `listForRuns`.
- `environment-run-lease-settlement.ts` (new): compare-and-swap settle
on the exact row (same run, status `active`, same metadata, terminal
run). A sandbox lease gets the stop-and-retain intent (stop and keep,
never destroy). Other leases move to `pending_cleanup` as before. It
keeps a lease that a pending native export, a legacy shutdown fence, a
shared provider resource, or an accepted native result still needs.
- `recovery/ownership-invariant-report.ts`: `settleOwnableKind` accepts
`currentProcessIdle`. A caller that passes every run this process
executes in `skipRunIds` may treat a current-process owner of another
run as gone (`current_process_idle`). The periodic pass never does this.
- `heartbeat/recovery.ts`: `sweepOrphanedActiveLeases` no longer
releases run leases by age. It runs native workspace preservation first
(a separate obligation), then calls `settleOwnableKind` with this
process's executions. Login and probe leases (no run) keep the idle
sweep until their own change.
- `heartbeat/live-recovery-work.ts`: a run lease is due now; only
run-less leases keep the five-minute idle deadline.
- `sandbox-stop-and-retain.ts`: an ownership settle records a recovery
marker on the intent and keeps `updatedAt`, so the pending-cleanup sweep
stops the sandbox in the same tick.
- Deleted: the age gate for run leases and the page-order bump for run
leases.

## Verification

- `cd server && npx vitest run
src/__tests__/environment-run-lease-ownership.test.ts
src/__tests__/heartbeat-orphaned-active-lease-sweep.test.ts
src/__tests__/ownership-reconciliation-report.test.ts` (embedded
Postgres).
- Red on unchanged master sources, green on this branch (tests were
written after the code):
  - stamps the lease holder in the write that makes a lease active
- settles a lease whose holder died after the run went terminal, on the
next pass (crash-point state: terminal run, dead holder in this PID
space)
  - stops and keeps a dead holder's sandbox instead of destroying it
- leaves a current-process lease to this process: settled only when it
is not executing the run
  - keeps the run fact alone for one release on a single-host server
- names no owner on an ephemeral server
(`PAPERCLIP_EPHEMERAL_SERVER=true`)
- the three updated sweep tests (sandbox stopped and kept, not
destroyed)
- Live-owner counter-cases (green): run still active; another live PID;
another PID space; current process while executing; read-only pass
(`record: false`) never settles; a lease re-stamped after the proof is
not settled; a legacy lease whose run this boot owns is kept.
- Also run green: `recovery.test.ts`,
`heartbeat-observed-workspace-preservation.test.ts`,
`workspace-recovery-warm-handoff.test.ts`,
`environment-service.test.ts`,
`heartbeat-pending-cleanup-sweep.test.ts`,
`native-environment-settlement-work.test.ts`,
`environment-cleanup-work.test.ts`.
- Full typecheck is left to CI (local memory limit).

## Risks

- Behavior change: an orphaned sandbox lease is now stopped and kept,
not destroyed. Workspace preservation and provider auto-delete decide
when it goes away. This can keep stopped sandboxes longer than before.
- A recovery path on another process can mark a run terminal while the
dead executor's stamp is still on the lease. The settle can then win
before that path releases. Because the settle only stops and keeps the
sandbox, files are not lost; the later release continues from the
stop-only intent.
- Previous-release rows: a single-host server keeps the run fact for one
release (a run this boot owns is excluded). An ephemeral server never
releases an unstamped lease; it stays visible as an I1 gap with no
operator action yet.
- No migration. The stamp lives in existing lease metadata.

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

## Model Used

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

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] 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.1108.0-canary.6
2026-11-07 22:43:57 -06:00
DottaandClaude Opus 5.5 81f12c21ec fix(server): document the safe company import request body in the API catalog (#19968)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A CEO agent can preview and apply a company package import through
`POST /api/companies/{companyId}/imports/preview` and `/imports/apply`
> - Agents find API operations and their request bodies in the OpenAPI
catalog
> - The catalog entries for these two safe import routes have no request
body and no description of the safe-route limits
> - An agent then offered the person a `replace` choice that the safe
route rejects
> - This pull request registers the existing preview and import body
schemas and describes the limits
> - The benefit is that agents can resolve name conflicts with `rename`,
`skip` and `nameOverrides`, and do not offer choices that fail

## Linked Issues or Issue Description

**What happened**

A person attached a company package in chat and asked a CEO agent what
the package would add. The package had an agent named Morgan, and the
company already had a Morgan. The agent previewed the import correctly.
It then offered three choices: `skip`, `rename`, or `replace`
("overwrites the existing Morgan"). The safe import route rejects
`replace`. The catalog entries for `/imports/preview` and
`/imports/apply` have no request body, so the agent could not see the
route's fields or limits. PR #19945 fixed the same gap for `/exports`.

**Expected behavior**

The catalog documents the import body, including `source`, `target`,
`collisionStrategy` and `nameOverrides`. It states that the safe route
rejects `replace` and never overwrites an existing agent.

**Steps to reproduce**

1. Read `GET /api/openapi.json`,
`paths["/api/companies/{companyId}/imports/preview"].post`.
2. See that it has no `requestBody`. The legacy
`/api/companies/import/preview` entry has one.
3. Call the safe route with `collisionStrategy: "replace"`. It returns
403.

## What Changed

- Register `companyPortabilityPreviewSchema` and
`companyPortabilityImportSchema` as the JSON request bodies of `POST
/api/companies/{companyId}/imports/preview` and `/imports/apply` in
`server/src/routes/openapi.ts`.
- Add a description of the safe-route limits: target, allowed collision
strategies, and `nameOverrides`.
- Add a regression test in
`server/src/__tests__/openapi-routes.test.ts`.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/openapi-routes.test.ts` passes (37 tests). The new test
fails on master because the entries have no `requestBody`.
- An end-to-end check runs the `companies.import-preview` chat eval with
this commit cherry-picked onto the eval branch.

## Risks

- Low risk. This changes only API documentation. The routes already
parse these schemas. Runtime behavior does not change.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:35:16 -06:00
DottaandClaude Opus 5.5 a480335e69 fix(chat): tell agents how to read the company link prefix from a task identifier (#19967)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Chat agents reply with links to tasks, approvals, runs and agent
profiles
> - Each link starts with the company prefix, for example
`/RUN/agents/<id>`
> - The chat guidance said to derive the prefix from the task
identifier, but it did not say how
> - An agent decided that `RUN-1` does not give the prefix and invented
one, so its links were broken
> - This pull request states how to read the prefix and forbids prefixes
built from names, slugs, hostnames or paths
> - The benefit is that agent profile links and other company links in
chat replies open the correct page

## Linked Issues or Issue Description

**What happened**

A person asked a CEO agent in chat to preview and then import a company
package. The chat task was `RUN-1`. The agent's reasoning said: "Task
identifier RUN-1 doesn't give prefix directly." The agent then linked
agent profiles as `/runner-e2e/agents/<id>`. Each link opened
"Organization not found: No organization matches prefix RUNNER-E2E".

**Expected behavior**

The agent links `/RUN/agents/<id>`. The guidance tells the agent that
the prefix is the identifier part before the final hyphen and number.

**Steps to reproduce**

1. Open a chat with a CEO agent in a company whose prefix differs from
its slugified name.
2. Ask it to do something with another agent, for example import a
package that creates an agent.
3. See that the reply can link `/<invented-prefix>/agents/<id>`, which
opens "Organization not found".

## What Changed

- Replace the task-link sentence in `AGENT_CHAT_DIRECTIVE`
(`server/src/services/agent-conversations.ts`). It now says that the
prefix is the identifier part before the final hyphen and number
(`RUN-1` gives `RUN`). It says that all company links use that prefix.
It forbids a prefix built from a company name, slug, hostname or path.
- Add a regression test in
`server/src/__tests__/agent-conversations.test.ts`.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/agent-conversations.test.ts` passes (46 tests).
- An end-to-end check runs the `companies.import-preview` chat eval with
this commit cherry-picked onto the eval branch.

## Risks

- Low risk. This changes only prompt guidance text. Runtime behavior
does not change.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:32:38 -06:00
DottaandClaude Opus 5.5 f755361ffe fix(recovery): deliver a steered message the restart lost before the model read it (#19962)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:22:52 -06:00
DottaandClaude Opus 5.5 bf3a067076 feat(recovery): settle the finalizer and cleanup leases on the shared ownership path (#19950)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
canary/v2026.1108.0-canary.5
2026-11-07 22:16:34 -06:00
DottaandClaude Opus 5.5 ca34bed257 test(native): exited-remote-runner budget case follows provider-work attempt charging (#19963)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:52:54 -06:00
DottaandClaude Opus 5.5 f6cb3e43f4 fix(admission): admit new work independent of other runs' cleanup and accounting (#19944)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:46:39 -06:00
DottaandClaude Opus 5.5 7213f64962 fix(heartbeat): write spawn identity and event sequence in one run update (#19954)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The heartbeat records the provider process identity when a native or
legacy run spawns its process
> - This write updated the run row and then reserved the event sequence
with a second UPDATE of the same row
> - PostgreSQL re-checks every foreign key on a row that the current
transaction already changed, so the second UPDATE took KEY SHARE on the
issue, the predecessor run, and the other parent rows
> - A transaction that held one of those rows FOR UPDATE blocked spawn
persistence, and the failed write ended a user's Retry as
`native_session_interrupted`
> - This pull request writes the identity and reserves the sequence in
one UPDATE, and it retries deadlock and serialization failures of the
spawn write
> - The benefit is that a user's Retry no longer fails because an
unrelated transaction holds the issue or the failed predecessor run

## Linked Issues or Issue Description

**What happened**

A user clicked Retry on a failed native run in the
`runs-adapters.inspect-failed-run` chat eval (embedded PostgreSQL, one
server process). The retry run failed with `native_session_interrupted`.
Its error was `Failed query: update "heartbeat_runs" set
"next_event_seq" = "heartbeat_runs"."next_event_seq" + 1 ...`. The query
started at 01:51:04 and failed at 01:51:16. The OpenCode driver awaits
`onSpawn` before its health check, so `native.session.execute` failed
after 12002 ms. The stack showed `allocateHeartbeatRunEventSeq` inside
`appendHeartbeatRunEvent`, called from
`persistHeartbeatRunProcessMetadata`. The process identity was lost
(`processPid` stayed null). The server log does not include the
PostgreSQL cause code.

**Expected behavior**

Spawn persistence must not wait on locks for unrelated parent rows. A
transient deadlock or serialization failure must be retried. It must not
end the run.

**Steps to reproduce**

1. Create a run whose `issue_id` and `retry_of_run_id` point at an issue
and a predecessor run.
2. In a second connection, begin a transaction and run `SELECT ... FROM
issues WHERE id = <issue> FOR UPDATE` (or lock the predecessor run).
Keep the transaction open.
3. Call `persistHeartbeatRunProcessMetadata` for the run.
4. Before this change, the call blocks on the `next_event_seq` UPDATE
until the second transaction ends. If that transaction then waits on the
run row, PostgreSQL reports a deadlock.
`server/src/services/heartbeat/process-identity-persistence.test.ts`
reproduces step 4.

Root cause: `server/src/services/heartbeat/run-lifecycle.ts`
(`persistHeartbeatRunProcessMetadata`) updated the run row, then
`appendHeartbeatRunEvent` called `allocateHeartbeatRunEventSeq`
(`server/src/services/heartbeat-run-events.ts`), which updated the same
row again. PostgreSQL's `RI_FKey_fk_upd_check_required` always fires the
foreign-key checks when the old row version was written by the current
transaction. The checks take KEY SHARE on each referenced row, and KEY
SHARE conflicts with FOR UPDATE.

## What Changed

- `appendHeartbeatRunEvent` accepts a narrow `runUpdate` option (process
identity columns only). `allocateHeartbeatRunEventSeq` applies these
columns in the same UPDATE that reserves the sequence. Deduplicated
receipts (native source, retry exhaustion) reject `runUpdate`, because
they do not allocate.
- `persistHeartbeatRunProcessMetadata` reads the run binding, appends
`native.process_identity_recorded` with the identity as `runUpdate`, and
returns the updated row. The run row now gets one UPDATE in this
transaction.
- New `retryTransientRunWrite` retries SQLSTATE `40P01` and `40001` up
to four times. The lifecycle `persistRunProcessMetadata` and the legacy
cleanup-intent wrapper in `heartbeat.ts` use it. The legacy wrapper
still updates the run row a second time when it records the cleanup
intent, so the retry covers a deadlock there.
- New regression test
`server/src/services/heartbeat/process-identity-persistence.test.ts`.

## Verification

- `npx vitest run
src/services/heartbeat/process-identity-persistence.test.ts` (from
`server/`): 3 passed. Without the `run-lifecycle.ts` change, both lock
cases fail with `expected 'blocked' not to be 'blocked'` after 3 s.
- `npx vitest run src/__tests__/heartbeat-run-event-sequencing.test.ts
src/services/heartbeat/run-lifecycle.test.ts`: 42 passed.
- `npx vitest run src/__tests__/heartbeat-process-recovery.test.ts -t
"<the four tests that call persistHeartbeatRunProcessMetadata>"`: 5
passed.
- `tsc --noEmit -p server`: no errors in the changed files. The only
error comes from a local workspace-link mix in `packages/adapter-utils`,
which this change does not touch.
- I did not run `pnpm verify` or the full suite locally. CI supplies
that coverage.

## Risks

- Low risk. The written columns, the event type, the payload, and the
sequence order do not change.
- The event payload `processStartedAt` now comes from the value that the
transaction writes, not from a re-read of the row. The value is the
same.
- An event insert still checks its own `agent_id` and `company_id`
foreign keys. A transaction that holds the agent row FOR UPDATE can
still delay the spawn write. That wait is the same for every event
writer, and this change does not alter it.
- Other paths that update a run row and then append an event in the same
transaction keep the second-UPDATE foreign-key checks. This change fixes
the spawn identity path that lost the run.

> 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, embedded PostgreSQL experiments).

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:39:43 -06:00
DottaandClaude Opus 5.5 779913333a fix(documents): say whether an exact revision read is still current (#19939)
## Thinking Path

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

## Linked Issues or Issue Description

**What happened**

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

**Expected behavior**

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

**Steps to reproduce**

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:29:24 -06:00
DottaandClaude Opus 5.5 a4a3e342ec fix(recovery): charge a restart attempt only when the provider accepted a turn and made no progress (#19948)
## Thinking Path

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

## Linked Issues or Issue Description

Refs #19720, #19747, #19856

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

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

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

## What Changed

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

## Verification

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

## Risks

- Behavior change: the first crash restart no longer charges when no
accepted turn is recorded. If a crash lands before the controller
projects the provider's `turn.accepted`, that attempt is not charged.
This fails toward more retries, and the generation-12 cap bounds it.
- A legacy preparation crash loop now gets up to 12 retries instead of
2. Each retry still passes the normal issue gates and keeps the
transient delay.
- The new queries use the `(run_id, seq)` index, once per restart claim.
Nothing new runs before `turn.start`.

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:20:05 -06:00
DottaandClaude Opus 5.5 c6e4ed1547 feat(recovery): settle a claimed wake whose run finished without settling it (#19934)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:18:22 -06:00