Commit Graph
2602 Commits
Author SHA1 Message Date
DottaandClaude Opus 5.5 7086019d4b fix(api-catalog): say Agent Chat is always on instead of offering a retired toggle (#20169)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents learn what the API can do from the API catalog at
`/api/openapi.json`
> - PR #19360 made Agent Chat standard. The `enableAgentChat` key is now
retired: it always reads true, and the app ignores written values
> - The catalog still lists `enableAgentChat` as an ordinary boolean on
the experimental settings operations, with no note that it is retired
> - An agent that was asked to "Enable Agent Chat on this instance" read
the catalog and told the person to turn on a toggle in Instance Settings
→ Experimental. That toggle no longer exists
> - This pull request adds a note to both experimental settings
operations: Agent Chat is always on, the key is retired, and agents must
tell people it is already on
> - The benefit is that agents give a correct answer and do not send
people to a control that is not there

## Linked Issues or Issue Description

Bug report (no public issue exists).

**What happened**
An agent was asked "Enable Agent Chat on this instance." It read `PATCH
/api/instance/settings/experimental` in the API catalog and found
`enableAgentChat`. It replied that an admin must open Instance Settings
→ Experimental and turn on Enable Agent Chat. That toggle was removed in
#19360, and Agent Chat is already on for everyone.

**Expected behavior**
The agent says that Agent Chat is already on (the person is using it),
and that nothing must be enabled.

**Steps to reproduce**
1. Open a chat with an agent on any current instance.
2. Send "Enable Agent Chat on this instance."
3. The agent reads the experimental settings operation in the API
catalog and sends you to a settings toggle that does not exist.

Refs #19360

## What Changed

- `server/src/routes/openapi.ts`: the GET and PATCH
`/api/instance/settings/experimental` operations now say that Agent Chat
is always on, that `enableAgentChat` is a retired compatibility key that
always reads true and ignores written values, and that agents must tell
people it is already on instead of sending them to a toggle.
- `server/src/__tests__/openapi-routes.test.ts`: a regression test
checks this note on both operations.
- The note says Agent Chat "needs no setup" instead of "there is nothing
to enable", so it does not match unrelated keyword searches. The runner
API no-results test (`nothing-zzzzzzzzzz`) had matched the word
"nothing".

## Verification

- `cd server && npx vitest run src/__tests__/openapi-routes.test.ts
src/services/native-runtime/runner-api.test.ts` passes (2108 tests), and
the server `tsc --noEmit` passes.
- The chat eval `instance-operations.instance-settings` found this
defect. Its run log shows that the agent reasoned from the catalog
schema ("The setting is `enableAgentChat` on PATCH
/api/instance/settings/experimental").

## Risks

- Low risk. This changes only catalog description text. It does not
change behavior, validation or the schema.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:11:13 -06:00
DottaandClaude Opus 5.5 b07399c241 fix(authz): link the project's access settings when an agent cannot share a project (#20176)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Some actions are board-only. When an agent calls one, the 403 denial
tells it that the denial is final and, when known, links the board page
that holds the control
> - Project sharing (`/api/projects/:id/access-members`) had no known
page, so the denial told agents to link a page only if they already knew
its route
> - An agent asked to "Let Riley see the acquisition project" got this
denial. It told the person to look for "access/sharing settings" and
gave no link, so the person had to search for the control
> - This pull request maps project access-member routes to the project's
Configuration page, where the Manage access control is
> - The benefit is that agents give people a direct link to the control
that shares a private project

## Linked Issues or Issue Description

Bug report (no public issue exists).

**What happened**
An agent tried to add a person to a private project. `POST
/api/projects/:id/access-members` returned 403 "Board access required"
without a page link. The agent told the person to find the project's
access or sharing settings in the web app and gave no link.

**Expected behavior**
The denial links the project's Configuration page
(`/<prefix>/projects/<id>/configuration`), where Manage access is. The
agent gives that link to the person.

**Steps to reproduce**
1. Create a private project and give an agent access to it.
2. In chat, ask the agent to let another company member see the project.
3. The agent's access-member request is denied, and its reply has no
link to the sharing control.

Refs #20151

## What Changed

- `server/src/routes/authz.ts`: board-only denials for
`/projects/:id/access-members` (and its member subpaths) link "the
project's access settings" at `/<prefix>/projects/<id>/configuration`.
The link uses the resolved route project ID, so short references such as
`/api/projects/acquisition/access-members` also get the link. Without a
resolved UUID, the denial falls back to the generic instruction.
- `server/src/__tests__/board-access-denial.test.ts`: cases for add and
remove access-member denials, plus a short-reference case that goes
through a route param resolver.

## Verification

- `cd server && npx vitest run
src/__tests__/board-access-denial.test.ts` passes (17 tests). The
short-reference test fails on the previous matcher. The server `tsc
--noEmit` passes.
- The chat eval `projects.share-project` found this gap: in one run the
agent linked the project, and in the next run it gave no link.

## Risks

- Low risk. This changes only the remediation text in an agent's 403
response. It does not change authorization.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:11:08 -06:00
DottaandClaude Opus 5.5 2fc0293586 fix(api): tell agents only the person can revoke assistant connections, and where (#20170)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People connect external assistants to Paperclip, and each person
manages their own assistant connections in the browser
> - Chat agents find API routes through `search_api` and call them with
`call_api`
> - The catalog entries for `/api/mcp/connections` did not say who can
use them or which page holds the control
> - `call_api` refuses these routes as protocol endpoints, and its
message did not say the refusal is final
> - A chat agent asked to "disconnect my old assistant" searched for an
agent route for its whole turn and did not reply
> - This pull request names the person-only boundary and the page in the
catalog, and makes the refusal final
> - The benefit is that the agent replies at once with a link to the
page where the person revokes the connection

## Linked Issues or Issue Description

No public issue exists. This is a bug report.

**What happened**

A person asked the chat agent: "Disconnect my old assistant, but leave
the website task running." The agent found `DELETE
/api/mcp/connections/{id}`. `call_api` refused the list route with "This
endpoint requires its existing protocol client; call_api supports REST
only". The agent then searched the API for other routes (dot bindings,
agents, "mcp", "assistant") for two minutes. The turn timed out with no
reply. The agent wrote in its reasoning that there "must be a supported
path".

**Expected behavior**

The agent tells the person that only they can revoke the connection,
links the Assistant connections page, and says the delegated task keeps
running.

**Steps to reproduce**

1. Connect two assistants to a company as one person. Let the first
assistant create a task for an agent.
2. In chat, ask the CEO: "Disconnect my old assistant, but leave the
website task running."
3. See that the agent searches the API until the turn ends and does not
reply.

## What Changed

- `server/src/routes/openapi.ts`: the `GET /api/mcp/connections` and
`DELETE /api/mcp/connections/{id}` entries now have a description. It
says only the signed-in person can list or revoke their assistant
connections, links `[Assistant
connections](/<prefix>/apps/assistant-connection)`, notes that the
connected assistant itself can revoke its own token through OAuth token
revocation, says not to search for another route, and says that tasks
the assistant already created keep running.
- `server/src/services/native-runtime/runner-api-client.ts`: the
`call_api` protocol refusal now says no other API route substitutes for
the endpoint. It points to the catalog description only when that
operation has one, so undocumented protocol routes such as `POST
/api/mcp/project-tools` do not send the agent to an empty description.
- `server/src/__tests__/assistant-connections-guidance.test.ts`: new
regression test. It uses the exact search queries from the failed run
and covers the refusal with and without a description.
- `server/src/__tests__/public-mcp.test.ts`: agent and non-session board
actors get 401 on listing and revoking assistant connections.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/assistant-connections-guidance.test.ts
src/__tests__/primary-agent-guidance.test.ts
src/__tests__/openapi-routes.test.ts
src/services/native-runtime/runner-api.test.ts` passes (2111 tests).
After the review fixes,
`src/__tests__/assistant-connections-guidance.test.ts
src/__tests__/public-mcp.test.ts
src/services/native-runtime/runner-api.test.ts` passes (2157 tests), and
the server `tsc --noEmit` passes.
- The new test fails on `master` without the change (2 failures) and
passes with it.
- The end-to-end check is the
`assistant-connections.revoke-personal-grant` chat eval with this commit
cherry-picked.

## Risks

- Low risk. The change is catalog text and one error message. No route,
permission or data behavior changes.

## 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-09 00:08:31 -06:00
Devin FoleyandPaperclip 9b71d03215 fix: recover interrupted native preparations and unblock upgrades (#20177)
## Thinking Path

> - Paperclip manages AI-agent companies and their work.
> - A native run prepares its workspace before a runner PID or
checkpoint exists.
> - A server crash can interrupt that stage and leave its controller on
another host.
> - Recovery needs a durable owner proof, a delivered recovery wake, and
the saved execution contract.
> - The old paths missed controller lease registration and boot proof,
skipped checkpoint-free preparations, and counted retained work as live
upgrade blockers.
> - This PR connects preparation to the existing controller lease and
recovery paths, and separates retained work from the upgrade census.
> - The result is bounded recovery without a second executor or loss of
saved work.

## Linked Issues or Issue Description

**What happened?**

A native preparation interrupted by a server crash could remain running
indefinitely with no runner PID or checkpoint. Native controller stamps
did not wake the shared instance lease. That lease was disabled on
volume-backed Railway services, and native takeover omitted the boot
binding needed for its cross-host proof. Scoped recovery selected
checkpoint-backed runs but skipped these preparations. The upgrade probe
then counted the retained row as current execution and could block every
later upgrade.

**Expected behavior**

Track the preparation's controller before a runner starts. Recover it
through the normal classifier after proving its owner stopped or fencing
that boot. Keep live or unprovable owners held. Deliver recovery wakes
to preparations without checkpoints. Preserve unresolved historical work
and report it separately from current execution during upgrade
observations.

**Steps to reproduce**

1. Save an observed native preparation with no runner process, result,
provider-start evidence or checkpoint.
2. Replace its controller with a process in another PID space. Let its
owner lease expire and send a scoped recovery wake.
3. Before this fix, recovery skips the checkpoint-free preparation or
cannot prove its foreign controller stopped. Upgrade readiness continues
to count that old row as a live blocker.

Related work: #18037, #19651, #19868, and #20168. The foreign-PID guard
from #19868 remains. The queued first-turn input fix from #20168 is
included through the current base.

## What Changed

- Register the existing instance lease when native execution and restart
claims are written. Keep passive identity reads free of ownership
effects. Keep that lease enabled on volume-backed Railway services
unless explicitly disabled.
- Preserve the existing PID/start-time proof within the same readable
PID space. Bind foreign or unknown-space takeover to its saved boot and
last write. A live lease or grace period keeps the run held with a
scheduled check. Repeated evidence waits preserve their timestamp.
- Include explicitly fenced, checkpoint-free preparations in the
existing finite recovery candidate inventory. Normal launch, checkpoint,
provider evidence and admission checks still decide whether work may
resume.
- Expose the existing board stop-confirmation operation for historical
foreign-host owners. Bind confirmation to the exact run, boot, PID,
process start and host. Reject changed ownership.
- Report narrowly defined old, unowned preparations as
`retainedBlockedRuns` outside the live upgrade census. Local executions,
pending admission, drain state, truncation and positive checkpoint proof
retain their barriers.
- Update the execution contract and add PostgreSQL, real-route, race and
preservation regressions.

## Verification

- Genuine red/green regressions cover the old upgrade census, missing
native lease registration, the volume-backed default, foreign-host
confirmation, and scoped recovery skipping checkpoint-free preparation.
Test bodies ran against real PostgreSQL before their corresponding
production changes.
- Combined verification before the final idle correction (head
`055be6c84d9`): **459 passed, 22 skipped, 11 files passed**. Covers
controller lease/identity, native restart classification and
integration, host confirmation, heartbeat recovery, instance route
authorization, upgrade readiness, and queued first-turn input. The
focused command used `pnpm exec vitest run` with those 11 suites and
`--maxWorkers=1 --no-file-parallelism`. The 22 skipped cases require the
Rust debug fake-Codex fixture, which was not built locally; the
real-process CI coverage must pass.
- `pnpm --filter @paperclipai/server typecheck`: passed on head
`055be6c84d9b058064daad0af71bb5d893ab9529`.
- Actual route proof uses the real heartbeat service and PostgreSQL.
Unauthorized access returns 403. The retained run and owner records
remain unchanged. Recovery integration admits one bootstrap claim across
two replacement controllers, keeps a live owner held, and preserves
local execution.
- `pnpm verify`: exit 2, requests broad CI because the execution
contract changed. Full local suite not run. Current-head CI must supply
broad coverage.
- Local source checkout on macOS ARM64, Node v25.6.1, pnpm 9.15.4. Tests
use isolated PostgreSQL. No live provider restart is claimed by those
fixtures.
- CI found an idle-query regression in the first version of the lease
wiring. Two focused PostgreSQL regressions reproduced the misplaced
identity-read notification and the missing claim notification. The fix
moves registration to actual ownership writes. `env -u npm_execpath pnpm
exec vitest run server/src/__tests__/maintenance-server-routes.test.ts
--maxWorkers=1 --no-file-parallelism`: **17 passed**, including 90 real
seconds and two qualified hourly windows with no extra idle queries. An
initial local setup attempt inherited npm metadata and did not run
tests; clearing that metadata selected the correct pnpm helper. Three
focused controller/native suites also passed **168 tests, 22
fixture-gated skips**. Server typecheck passed after the correction on
head `4a527326dcd5b3a9e286f0e4c3e761d3056af4d9`.
- The older CI server shard also exposed a same-space graceful-restart
regression. The new boot fallback had overridden local process-exit
proof. Seven suspension cases and a focused unit regression reproduced
it; the fix limits boot fallback to foreign or unknown PID spaces. Fresh
verification on `acf1065d98150210c1e227c7f3b05a1eb489730b`: **251
passed, 22 fixture-gated skips, 4 files passed**, including all **40**
graceful-restart suspension tests. Server typecheck passed. The local
regression failed before this correction, and the same-space and
foreign-space paths pass together after it.
- Current-head CI completed successfully on
`acf1065d98150210c1e227c7f3b05a1eb489730b` (run 42568109784). Greptile
reviewed that exact head at 5/5 with no unresolved review threads.
- Secret and private-data scans passed. No workflow, lockfile or
migration changes.

## Risks

- The instance lease now applies to volume-backed Railway services. Its
existing 30-second renewal, quiescence, grace and self-fence behavior
applies while work is held. Idle servers retain the existing no-query
behavior. An explicit `PAPERCLIP_CONTROLLER_INSTANCE_LEASE=off` remains
available.
- A controller lease proves only the server owner's state. It does not
prove an agent process stopped or authorize replay, resource release or
volume removal. The existing classifier and provider/runner checks
remain responsible for those decisions.
- Previous-release records keep their shape and saved state. A missing
boot lease stays unprovable and requires the scoped stop-confirmation
path. A changed confirmation target is rejected.
- The upgrade observation does not recover a held task by itself. Source
tests do not qualify a hosted replacement. Deployment and the affected
task's useful result must be verified separately.
- This repairs recovery after a crash; it does not claim to identify or
fix every source of heap exhaustion.

## Model Used

OpenAI `gpt-6.1-sol`, xhigh reasoning effort, through Codex in T3 Code,
with repository, shell, and GitHub tools. Context window size is not
exposed by the runtime.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-08 21:53:13 -08:00
DottaandClaude Opus 5.5 c5c587f162 fix(issues): start a deferred children-completed wake the release drain missed (#20180)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A parent that delegates waits for its children, and is woken when
they finish (`issue_children_completed`)
> - Restart chaos t06 ended with both children done and the parent
blocked forever
> - The children finished while the parent's run was still going, so the
wake was deferred as a follow-up. That run then ended with the agent
setting the parent blocked, and the deferred wake was never started
> - The durable re-drivers for deferred wakes cover only
`issue_blockers_resolved`, so nothing else picks up a missed
`issue_children_completed`
> - This pull request makes both re-drivers cover children-completed
wakes too
> - The benefit is that a parent always hears that its children
finished, even when the release drain misses the deferred wake

## Linked Issues or Issue Description

Refs #19831 (durable ownership plan). Restart chaos wave
`w-20261109T041248-s9501`, task t06 (claude, M1, K2), Daytona per-turn,
master fa7950121d.

**Problem:** parent CHA-10 (all times UTC):
- 04:29:57: run 9cf49f52 started.
- 04:32:35 and 04:32:43: its children CHA-13 and CHA-14 finished.
- 04:32:43: `issue_children_completed` merged into the running turn.
- 04:32:53: that wake was deferred as a follow-up
(`issue_execution_deferred`, `deferred_issue_execution`, no run).
- 04:33:13: the turn ended with the agent setting the parent `blocked`,
a decision made before it saw the children's results.

The deferred wake stayed deferred for the rest of the wave, including
three later restarts. The parent sat blocked with no Retry. The
deferred-receipt worker (`deferred-receipt-work.ts`) and the
stranded-queue resume (`queue.ts`) select deferred dependency wakes with
`issueBlockersResolvedWakePayloadSql`, which matches only
`issue_blockers_resolved`.

## What Changed

- `server/src/services/issue-dependency-wakeups.ts`:
`dependencyResolvedWakePayloadSql` and `isDependencyResolvedWakePayload`
replace the blockers-only predicates. They match both
`issue_blockers_resolved` and `issue_children_completed` in
`_paperclipWakeContext.wakeReason`.
- The deferred-receipt worker and the stranded-queue resume use them, so
a deferred children-completed wake is started once the parent has no
live run, the same way a blockers-resolved one is.
- No code comments added.

## Verification

Red/green:
- `deferred-execution-receipt-work.test.ts` "starts a deferred
children-completed wake left behind when its parent's run blocked the
parent (restart chaos t06)". It sets up a blocked parent whose last run
succeeded, a done child, and the deferred `issue_children_completed`
receipt as t06 left it.
  - On master: the receipt stays `deferred_issue_execution` (red).
- With this PR: the receipt completes with exactly one
`issue_children_completed` run.
- `deferred-execution-receipt-work` and
`heartbeat-dependency-scheduling` pass (60 tests). `tsc --noEmit -p
server` is clean.

## Risks

- The workers now also select deferred children-completed wakes.
Admission is unchanged: they still pass the queue's own gates, and they
start only when the issue has no live run, the same as blockers-resolved
wakes.
- No schema change.

## 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 (#20069, #20108)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (no
user docs affected)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending)
- [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 23:14:47 -06:00
DottaandClaude Opus 5.5 5a51a7685d fix(budgets): raise_budget_and_resume continues the work its budget stopped, once (#20144)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A budget hard stop cancels the scope's running work; the board
resolves it with Raise budget and resume
> - In the restart-chaos H1 scenario, the cancelled tasks did not
continue after resume and each needed a manual Retry
> - Resume is the board's explicit decision to continue, so the work it
stopped should continue once, as if the board clicked Retry
> - This pull request files that Retry automatically, keyed by the
cancelled run, once the run's stop has settled
> - The benefit is that a budget raise restores the work without manual
Retries, without repeating completed work, and without charging the
run's attempt budget

## Linked Issues or Issue Description

Refs #20143 (bug B; this PR builds on it), #20113 (bug A), #20101
(superseded: split into #20143 and this PR)

**What happened?**
Restart-chaos waves w-20261108T195944-s42 and w-20261108T210118-s9802
(H1). After `raise_budget_and_resume`, CHA-3's budget-cancelled run
(enforcement stop) and CHA-1's run (agent lifecycle pause from the
budget hold) stayed cancelled until someone clicked Retry.

**Expected behavior**
Resuming continues each task its budget stopped, exactly once, with no
attempt charged, an activity entry, and no repeat of work that already
continued. A run a person stopped stays stopped.

**Steps to reproduce**
Set a budget below spend while a task runs; let the stop cancel the run;
resolve with Raise budget and resume. Before this change, no new run is
queued.

## What Changed

- New table `budget_resume_claims` (migration 0451). One row per
cancelled run, outside the run's result JSON, so a Stop that settles
during the resume cannot erase it.
- `markBudgetCancelledWorkForResume` (called by `resolveIncident` before
the incident closes): claims every task run its budget stopped since the
incident opened. That covers runs budget enforcement fenced, including
stops still in flight, and runs the agent lifecycle pause stopped for
this budget (`pauseStop`, carried from the lifecycle operation's
`pauseCause` so a resume before the Stop keeps the link). Runs a person
stopped are excluded. Two indexed passes led by `(company_id, status)`.
- `deliverBudgetResumeContinuations`: once a claimed run's stop settles,
files the resolving board member's Retry (`requestTaskRetry`,
idempotency key `budget-resume:<run>`, with the member's session source
and the chat Retry callback). The Retry path checks, under the task
lock, that the run is still the task's latest and the task is open, so
continued or completed work is never repeated. Claim settlement and its
activity entry commit together; retryable refusals and errors retry.
- Delivery runs in the always-on budget-enforcement worker after each
pass's Stop delivery. Its schedule includes deliverable claims and the
earliest stale-claim expiry. The execution-continuation worker is a
backstop.
- `agent.paused` / `agent.resumed` logging is in #20113.
`budget.cancelled_work_resume_requested` (incident) and
`budget.cancelled_work_continued` / `_not_continued` (task) are recorded
here.

## Verification

- New `budget-resume-continuation.test.ts` (Postgres, real
`resolveIncident` → claims → `requestTaskRetry` → real `resumeTaskRetry`
into a queued run):
- each task continues once, including a blocked task; repeat delivery or
resolve saves nothing new;
- a stop still in flight at resume, whose terminal write replaces the
result JSON, is still continued;
  - a 30-task scope is fully continued;
- a lifecycle-pause stop caused by the budget is continued, and one
caused by a manual pause is not;
- continued work, done tasks, a run that finished before its stop, and
person-stopped runs (including ones enforcement also fenced) are not
continued.
- `lifecycle.test.ts`: the pause records its cause on the operation and
on stopped runs, including when the budget is resumed before the pause's
Stop. `budget-enforcement-worker.test.ts`: delivery after Stop delivery,
and a wake at claim expiry.
- 287 tests across the affected suites passed. Server typecheck
(`typecheck-layers`, low-memory): no errors.
- Not run: the H1 chaos rerun (Phase 3 runs it after merge).

## Risks

- Resume now continues budget-stopped tasks automatically. A board that
wants a task stopped can Stop it again; person-stopped runs are never
continued.
- A new table and one index (migration 0451).

> 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-08 22:56:28 -06:00
DottaandClaude Opus 5.5 b54317d3fb fix(native): give a preparing run's first turn the user's queued messages instead of interrupting it (#20168)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A Paperclip Runner (native) run prepares its workspace and
environment before it starts its first provider turn. This can take
about a minute.
> - A user's message that arrives during that time waits as a deferred
wake behind the run. The Interrupt control then cancelled the run before
any provider turn.
> - The cancel threw away the whole preparation. A new run had to
prepare again, so the user waited longer for the first token.
> - This pull request gives the run's first turn the queued messages.
The run seals its first-turn input immediately before `turn.start`, so
the messages are part of the prompt that the turn starts with.
> - The benefit is that the user sees the message handled in the same
run, with no cancel and no second preparation.

## Linked Issues or Issue Description

**What happened?**
A Paperclip Runner run took about 54 s to prepare (environment startup,
workspace, runtime selection). The user sent a second message 32 s into
that time and clicked Interrupt 21 s later. Interrupt cancelled the run
as `operator_interrupted` ("Interrupted to send queued messages") before
any provider turn. A new run then prepared again from the start. The
runtime-selection transaction that froze the run's input completed about
5 s before the click, and the coordinator claim came 4 s after it, so
the message could not be given to the first turn at any point in the old
code.

**Expected behavior**
When a user message arrives for a task whose current run has not started
its first provider turn, the run does not stop. The message goes into
that run's first turn. A run that is in a turn is interrupted as before.

**Steps to reproduce**
1. Assign a task to a Paperclip Runner agent with a sandbox environment,
so preparation takes a long time.
2. While the run prepares, post a second comment on the task.
3. Click Interrupt on the queued message before the run starts its
provider turn.
4. See that the run is cancelled and a new run prepares again.

**Paperclip version or commit**
Staging revision b43cccbb0.

## What Changed

- `server/src/services/native-runtime/first-turn-queued-input.ts` (new):
`claimFirstTurnQueuedInput` seals a run's first-turn input in one
transaction. It locks the task, the deferred wakes, and the run in the
same order as the queue routes. It takes the responsible user's plain
queued comments in queue order (not interaction answers, interrupt
intents, independent continuations, conversations, or other users'
messages). It renders them with source-trust sanitizing and secret
redaction, then writes a `firstTurnQueuedInput` receipt
(`paperclip.first-turn-queued-input.v1`) into the run context. A second
call for the same run returns the same messages.
`runAcceptsFirstTurnQueuedInput` is true for a running native (or
preparing native) run that has no receipt and no accepted provider turn.
- `packages/paperclip-runner/src/native-session-runtime.ts`: new
`getFirstTurnQueuedInput` option. The runner calls it immediately before
the first `session.startTurn` and appends the result to `task.prompt`
(or to `events` for a resumed-session continuation envelope). It is not
called for the disposition-only recovery turn.
- `native-session-executor.ts`: passes the claim to the runner. A failed
seal commits nothing, so the executor logs it and starts the turn
without the messages. The messages stay queued.
- `queued-comment-delivery.ts`: a message in a run's receipt counts as
delivered only after the provider accepted that run's turn
(`turn.accepted` or the provider's `turn.started`), and only if the
message was not edited after the seal. Release promotion, Send, and
later first turns use this rule. `explicit-native-continuation.ts` uses
the same rule for stopped native runs.
- `routes/issues.ts` Interrupt: when the target run has not sealed its
first turn and has no accepted turn, the route does not record interrupt
intent and does not cancel the run. The response has
`executionWait.reason = "first_turn_pending"`. Other runs are
interrupted as before.
- Queue views (`routes/issues.ts` and the wake-queue adapter) hide
messages that the active run took into its first turn. Queue edits keep
those ids on the wake, so a run that fails before acceptance still
leaves them queued.
- `run-state.ts`: the issue-control run projection includes the receipt,
so the queue poll can hide taken messages.
- `doc/execution-semantics.md`: new section "Queued messages before a
native run's first turn".

## Verification

- Red before the fix, green after:
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/issue-queued-comments-routes.test.ts -t "first
turn|already started"`: Interrupt on a preparing run and on a claimed
native run before `turn.start` does not stop the run, and the first turn
gets both messages in order. Before the fix, both cases cancelled the
run.
- `pnpm --filter @paperclipai/paperclip-runner exec vitest run
src/native-session-runtime.test.ts -t "first-turn queued input"`: the
claim runs immediately before `turn.start`, and the first turn's prompt
ends with the queued messages. Before the fix, the claim was never
called.
- `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/first-turn-queued-input.test.ts`: a run that
fails before `turn.start` leaves the message queued. The next run
delivers it, and after that no later run or copied retry context takes
it again. Before the delivery rule, the "exactly once" and "accepted
then failed" cases failed.
- Safety cases that pass before and after: Interrupt still cancels a run
whose provider accepted a turn (including a run saved by the previous
release with no receipt) and a run whose first-turn input is already
sealed. The existing "explicit interrupt fallback" cases for preparing
and native runs without a responsible user still pass.
- Speed: the seal is one short transaction before `turn.start`. In the
embedded-Postgres test it took 5.5 ms. It removes a full second
preparation (54 s on staging) from the message-to-first-token time.
- Related suites: `src/modules/wake-queue`,
`explicit-native-continuation`, `issue-queued-comment-queue`,
`issue-queued-comments-routes`, `queued-comment-consumption`,
`heartbeat/run-state` (888 tests), `native-session-executor.test.ts`
(1306 tests), and the full `native-session-runtime.test.ts` (238 tests)
pass. `pnpm --filter @paperclipai/server typecheck` passes.
- Not done: no live run against a real provider or sandbox. Staging was
read only.

## Risks

- Previous-release state: a deferred wake saved by the previous release
has the same shape and is taken normally. A run saved by the previous
release has no receipt. If it is resumed before its first turn, it seals
on the new release. If its provider already accepted a turn, Interrupt
stops it as before. A run saved by the previous release while it was in
`turn.start` but before acceptance counts as unsealed, so in that short
window an Interrupt does not stop it and the message is delivered at the
turn boundary.
- Rollback: if the server rolls back after a run sealed and its turn was
accepted, the old release does not read the receipt and can deliver
those messages a second time.
- A first turn that does not use the fresh-turn path (restart
continuation, disposition-only recovery) does not call the claim. Its
messages are delivered at the turn boundary as before.
- A message that arrives after the seal waits for the turn boundary.
Interrupt stops the run as before.
- No schema migration. The receipt is a key in the run context.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:44:18 -06:00
Devin FoleyandClaude Opus 5.5 32b3648758 fix(storage): back off and reduce parallel parts when S3 asks to slow down (#20165)
A workspace snapshot upload sends up to eight file-backed parts at once. The SDK never retries a part because its body is a stream, so a part that received 503 SlowDown had only the uploader's three fixed-delay attempts (0.5 s and 1 s). Under a request-rate limit the save failed and the run failed in setup.

A rate-limited part now retries with exponential full-jitter backoff (up to eight attempts, at most 20 s per wait), and the upload halves the parts it admits, once per round of throttled requests. Other part failures keep the existing three attempts. Tests cover a real-client SlowDown recovery, the single halving, the attempt bound, cancellation during a wait, and the backoff schedule through its ceiling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 20:42:12 -08:00
DottaandClaude Opus 5.5 8e5959c759 fix(agents): keep pause board-only and resume grant-gated on the PATCH status route (#20164)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board governs the agent lifecycle: pause, resume and terminate
> - The V1 permission matrix says agents may never pause an agent, and
may resume one only with a direct `agents:configure` change grant
> - `POST /api/agents/:id/pause` and `POST /api/agents/:id/resume`
enforce this, but `PATCH /api/agents/:id` with a `status` ran the same
lifecycle change after only the agent-configuration check
> - The `agents.pause-agent` chat eval caught it: the CEO agent paused
Morgan itself through `PATCH`, although the case (and the spec) expect a
board user to do it
> - This pull request applies the dedicated routes' checks to `PATCH`
status changes, as #19955 did for termination
> - The benefit is one lifecycle authority, with no second route around
it

## Linked Issues or Issue Description

Refs #19955

**What happened**

An agent that may configure another agent could pause it with `PATCH
/api/agents/{id}` `{ "status": "paused" }`, although `POST
/api/agents/{id}/pause` is board-only. The same `PATCH` with `{
"status": "idle" }` resumed an agent without the change-grant decision
and without the invalid-org-chain guard that `POST /resume` applies. In
a chat eval run, the CEO agent paused Morgan this way and reported
"Morgan is paused".

**Expected behavior**

Pause is board-only on every route (`doc/SPEC-implementation.md` section
9.3: "Pause/resume agent ... pause: no; resume: direct
`agents:configure` grant only"; "Pause, clear-error, terminate,
approval, and key-management routes remain board-only"). An agent gets
the standard final "Board access required" denial with a link to the
agent's profile. Resume through `PATCH` needs the same grant and
org-chain checks as `POST /resume`.

**Steps to reproduce**

1. As an agent with agent-configuration permission over another agent,
call `POST /api/agents/{id}/pause`. The result is 403.
2. Call `PATCH /api/agents/{id}` with `{ "status": "paused" }`. Before
this change the result is 200 and the agent is paused.

## What Changed

- `server/src/routes/agents.ts` (`PATCH /agents/:id`): `status:
"paused"` now calls `assertBoard`, the same check as `POST /pause`.
Agents get the structured `board_access_required` denial that says the
denial is final and links `/<prefix>/agents/<id>`.
- `status: "idle"` now calls `assertCanResumeAgent` (the
`agent_config:update` decision with `requiresChangeGrant`) and returns
409 with the repair guidance when the org chain is invalid, the same as
`POST /resume`.
- `server/src/__tests__/agent-permissions-routes.test.ts`: pause is
denied through both routes for an agent allowed to configure the target,
with no lifecycle change, run cancel or activity. A board user can still
pause through `PATCH`. `PATCH` idle applies the change-grant decision,
allows a granted agent, and keeps the org-chain guard.

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/agent-permissions-routes.test.ts
src/__tests__/agent-cross-tenant-authz-routes.test.ts
src/__tests__/board-access-denial.test.ts`: 147 passed.
- Without the route change, three new tests fail (PATCH pause by an
agent, PATCH idle without the change grant, PATCH idle with an invalid
org chain).
- Policy evidence: `doc/SPEC-implementation.md` section 9.3 and the
`agents.pause-agent` scenario ("Pausing an agent requires board
control").

## Risks

- Non-board automation that paused agents through `PATCH` status now
gets 403. That is the intended governance boundary. Board users are not
affected.
- Agents that resumed agents through `PATCH` status now need the same
change grant as `POST /resume`. Standard agents get `agents:configure`
on creation, so ordinary behavior does not change.
- `PATCH` idle now returns 409 for an agent with an invalid org chain,
the same as `POST /resume`.

## 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-08 22:01:11 -06:00
DottaandClaude Opus 5.5 f89813d005 fix(api-catalog): describe the real backup and restore paths for agents (#20159)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Instance admins and operators own database backups; a chat agent
cannot create one
> - When a person asks an agent to back up the instance and check a
restore, the agent must say who can do it and how
> - The agent reads the API catalog to find the operator path, but the
backup entry had only the summary "Trigger a database backup"
> - With no real path in the catalog, the agent told the person to use
an "Instance settings > Backups" page that does not exist
> - This pull request describes the real paths in that catalog entry:
the endpoint, the CLI, the retention setting, the result file, and how
to check a restore safely
> - The benefit is that the agent sends the person to paths that exist
and keeps a restore check off the live database

## Linked Issues or Issue Description

**What happened**

A person asked the CEO chat agent: "Back up this test instance and check
that we can restore it." The agent got 403 on `POST
/api/instance/database-backups`, which is correct. It searched the
catalog for backup and restore operations and found only that endpoint
with no description. Its reply then said: "Create a backup in Instance
settings → Backups". The web UI has no such page or control.

**Expected behavior**

The agent says that an instance admin or operator must do this. It names
real paths: the endpoint for an instance admin, `paperclipai db:backup`
on the server host, and the Backup retention setting for automatic
backups. For the restore check, it says to restore into a separate empty
database, not over the live one.

**Steps to reproduce**

1. Start a local instance and open a chat with the CEO agent.
2. Ask: "Back up this test instance and check that we can restore it."
3. Read the reply: it names a backup page in Instance settings. Look for
it in the UI: there is none.

## What Changed

- `server/src/routes/openapi.ts`: add a description to `POST
/api/instance/database-backups`. It says that only instance admins can
call it (agents get 403, cloud-managed instances run backups for you),
that automatic backups run about hourly with retention set on the
company-prefixed /company/settings page, that the UI has no create or
restore control, that an operator can run `paperclipai db:backup`, what
the result file is, and that a restore check uses psql into a separate
empty database, never over the live one.
- `server/src/routes/openapi.ts`: register the success status as `201`,
which is what the route returns, instead of `200`. The published
document already showed `201` through the created-operations fixup, so
this makes the source match it.
- `server/src/__tests__/database-backup-catalog-guidance.test.ts`: new
regression test. It runs the agent's catalog searches from the failed
run and checks the description. It also checks that the published
document shows `201` and not `200` for a successful backup.

## Verification

- `pnpm exec vitest run
src/__tests__/database-backup-catalog-guidance.test.ts
src/__tests__/openapi-routes.test.ts` (from `server/`): 40 tests pass.
Without the change, the 2 search tests fail. The status test guards the
published document, which the fixup already kept correct.
- `pnpm exec vitest run
src/__tests__/instance-database-backups-routes.test.ts`: 7 tests pass.
The server typecheck passes.
- Chat eval `instance-operations.backup-and-restore` (runner-opencode),
with this commit cherry-picked onto the eval branch: before the change,
the reply named an "Instance settings → Backups" page that does not
exist. After the change (GitHub Actions run 42552777052), the eval
passed. The first reply said the agent got 403, named `POST
/api/instance/database-backups` for an instance admin and `paperclipai
db:backup --json` for an operator, linked Backup retention on the
company settings page, and said to restore with psql into a separate,
empty database, never over the live one. The second reply reported the
operator's restore receipt and its limits correctly.

## Risks

- Low risk. The change adds catalog text and makes the registered
success status match the published 201. It does not change any API or
permission behavior.
- If a backup page or restore command is added later, this text must
change. The regression test names these facts, so such a change fails
the test and shows the text to update.

> 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 22:01:06 -06:00
DottaandClaude Opus 5.5 3f12046576 fix(tool-access): an unscoped tools:use grant does not turn on actions a connection left off (#20160)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People connect apps and choose which actions of each app agents may
call; the choice is saved as the connection's tool profile
> - Every new standard agent also gets an unscoped `tools:use` grant,
which means "this agent may use tools"
> - The access policy checked explicit grants before profiles, so this
default grant allowed every action of every installed connection,
including actions the person left off
> - The agent's effective profile showed the action off, but the gateway
and the test-as-agent summary allowed it
> - This pull request makes an unscoped grant stop at a connection
profile: when a profile governs the tool's connection, only a grant
scoped to the action, connection or application allows an action the
profile leaves off
> - The benefit is that the actions a person turns off for a connection
stay off for new agents

## Linked Issues or Issue Description

**What happened**

A person connected an app with one read action and one destructive write
action. When they finished the connection, they enabled only the read
action. They installed the connection for a new agent, Morgan. Morgan's
effective profile (`GET
/api/companies/{companyId}/tools/profiles/effective/agents/{agentId}`)
allowed only the read action. But `GET
/api/tool-connections/{connectionId}/test-agents/{agentId}/access`
reported both actions as `allowed` with reason `allow_explicit_grant`,
and a chat agent told the person that Morgan could also run the
destructive write. The same policy decision admits gateway calls, so
Morgan could call the write.

**Expected behavior**

An action that the connection's profile leaves off is off for the agent.
The access summary, the effective profile and the gateway agree. A grant
that a person scopes to that action, connection or application still
allows it.

**Steps to reproduce**

1. Connect an MCP app that has a read tool and a destructive tool. In
the finish step, enable only the read tool.
2. Create a new agent through the API (it gets the default unscoped
`tools:use` grant) and install the connection for it.
3. Call `GET
/api/tool-connections/{connectionId}/test-agents/{agentId}/access`: the
destructive tool shows `allowed`, reason `allow_explicit_grant`.

## What Changed

- `server/src/services/tool-access-policy.ts`: for an agent, a tool's
connection is governed by a connection profile when the agent's
effective profiles include the connection's own access profile (the app
wizard's `app:<connectionId>` profile or the agent's connection-intent
profile, even with no entries), or any entry that names this connection
or selects this action (including application, tool-name and risk-level
selectors). For a governed connection, `explicitGrant` counts only
grants whose scope names an action, connection or application (`allow`,
`toolName`, `catalogEntryId`, `connectionId`, `applicationId`,
`applicationKey`). A grant scoped only by context (for example an agent
or project) still covers every tool, so it does not override the
profile. Board users keep their operator grant.
- `server/src/services/tool-profile-binding-precedence.ts`: export the
existing connection-profile identity check as
`isConnectionAccessProfile`, so the policy and binding precedence use
one definition.
- `server/src/__tests__/generic-mcp-connection.test.ts`: regression
tests through the gateway's access summary and the direct policy check:
- only the read action enabled: the write stays off with the unscoped
grant, and a grant scoped to the write allows it
- every action turned off (both lists empty): both stay off; a
context-only scope stays off; a connection-scoped or application-scoped
grant allows them
- an agent the connection's profile does not cover keeps the unscoped
grant
- a general profile with an application-selector exclude (no connection
id) is not overridden by the unscoped grant
- with company-wide access, the direct policy check denies the off
action for an agent and still allows it for a board user with the
unscoped grant

## Verification

- New tests: all pass with the change. On `master`, 4 of 5 fail. On the
first revision of this PR, the all-off, application-exclude and
board-user tests fail.
- Regression suites, all pass: `generic-mcp-connection`,
`tool-access-policy-service`, `tool-profile-matching`,
`tool-profile-entry-order`, `connection-management.integration` (238
tests), and `tool-access-service`, `tool-gateway-service`,
`tool-gateway`, `tool-profile-review-service` (799 tests).
- Every enforcement path uses the one `decide` function in
`tool-access-policy.ts`, and `explicitGrant` is called only there. That
covers the gateway tool listing and calls, the access summary, delegated
and Test tab calls, the connection-intent access check, the GitHub
review bot tool, and the example smoke check.
- Chat eval `app-permissions.test-as-agent` (runner-opencode). Before
the change (GitHub Actions run 42550085273), the access summary for
Morgan reported the write as `allowed` by `allow_explicit_grant`. With
the first commit cherry-picked onto the eval branch, GitHub Actions run
42552819589 passed. The summary reported `launch_read` allowed
(`allow_profile`) and `launch_update` off (`deny_default`), which
matches Morgan's effective profile, and the agent's reply said the same.

## Risks

- Medium risk. This changes an access decision. An agent that used a
connection action only through its default unscoped grant, while the
connection's profile or an explicit exclude left that action off, loses
that action. That is the intended fix. An operator who relied on it must
enable the action in the connection or give the agent a grant that names
the action, connection or application.
- A grant scoped only by context (for example `{ projectId }`) no longer
overrides a connection profile. Risk-level and provider-type scopes are
not treated as naming an action.
- Tools that no connection profile governs (for example Paperclip's own
tools), agents without a connection profile, and board users keep the
current behavior.

> 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

Related: #17884 (scoped `tools:use` grants at hire time).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:58:29 -06:00
DottaandClaude Opus 5.5 28e070f8dc feat(skills): show a line diff for a pending tracked-source update (#20155)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Skills can come from a tracked GitHub source, and an update moves
the installed copy to a newer upstream revision
> - Before an update, a person asks the agent "what changed in the new
version?"
> - The product has no diff for a pending update. The agent previews two
full SKILL.md snapshots and compares them by eye
> - In two eval runs the agent reversed the same changed line. In a
third run it fetched the files with curl and ran `diff` itself, and the
turn took too long
> - This pull request adds a read-only endpoint that returns the line
diff between the installed and the latest tracked revision
> - The benefit is that review answers come from an exact diff, and they
come faster

## Linked Issues or Issue Description

No public issue exists. Feature description:

**What happened**
An agent asked to show what changed in a pending skill update had only
`POST /skill-sources/preview`, which returns one file at one commit. It
previewed both commits and compared about 9 KB of text by eye. It
reported one changed line in the wrong direction ("every new build" ->
"every build or release" instead of the reverse), and later retracted a
real change.

**Expected behavior**
The agent can request the exact changes of a pending tracked-source
update and report them with the correct direction.

**Steps to reproduce**
1. Install a skill from a GitHub source at an old commit and set the
tracking reference to a newer commit (automatic updates off).
2. Ask the chat agent: "Show me what changed in the new accessibility
skill version before we update it."
3. See that the agent can only preview full files at each commit and
must compare them itself.

## What Changed

- Add `GET /api/companies/{companyId}/skills/{skillId}/update-diff`. For
a skill from a tracked GitHub source, it compares the installed source
revision (`currentRef`) with the latest tracked revision (`latestRef`).
For each changed package file it returns `change` (added, removed,
modified, or `not_compared`), `binary`, `executableChanged`, and a
unified line diff (`-` installed, `+` latest), or `diffUnavailable`
(`binary`, `too_large`, `omitted`, `budget`) that says why there is no
diff. The endpoint is read-only. The `skillId` parameter handler checks
company access and skill visibility, the same checks other skill reads
use.
- `hasUpdate` is false and `files` is empty when the installed revision
is already the latest one. That case reads only the latest commit and
scans nothing.
- Return `supported: false` with a reason for: a skill without a tracked
source, a disconnected source, an unknown installed revision, and a
repository package that a scan could include only in part (a subset, or
skill folders only). In the last case, files that a scan limit left out
would otherwise look removed.
- A path the scan left out of a revision (too large, a symlink, or
rejected by the audit) is listed as `not_compared` instead of added or
removed.
- One response shares a work budget (24M LCS cells), a diff-text budget
(1M characters) and a file-count budget (1,000 files), and sets
`truncated` when it reaches one. The GitHub read and both scans stop
when the caller disconnects.
- Add `server/src/services/skill-update-diff.ts` with a bounded line
diff (shared head and tail trimmed, LCS size capped). An absent side has
no lines, a final newline is not a line, and a missing final newline
shows the unified-diff marker. A replaced line reads `-old` then `+new`.
- Add the shared types `SkillSourceUpdateDiff` and
`SkillSourceUpdateDiffFile`.
- Document the endpoint in the API catalog (`openapi.ts`) so agents find
it with `search_api`.

## Verification

- `cd server && npx vitest run src/__tests__/skill-update-diff.test.ts`
(10 tests):
  - direction of `-`/`+` lines, and separate hunks with bounded context
  - exact diffs for added and removed files, and final-newline handling
  - a change to only the executable bit, and paths left out of a scan
  - the response budgets
- the snapshot comparison for the selected skill path, with the abort
signal passed to both scans
  - a commit mismatch
  - refusal of partial repository scans
- `cd server && npx vitest run
src/__tests__/skill-update-diff-routes.test.ts` (6 tests):
  - the diff response
  - no pending update (no scan)
- a caller from another company (board user and agent) gets 403 before
any lookup
  - a skill the caller cannot read gets 404 before any source read
  - every unsupported case
  - a client disconnect aborts the scans
- `company-skills-routes.test.ts` and `openapi-routes.test.ts` pass (119
tests across the four files).
- `npx tsc --noEmit -p server`: no errors from this change.
- End to end: the `skills.review-update` chat eval exercises this path.
Its grader checks the quoted hunks against the real pinned diff.

## Risks

- Low risk. The endpoint is new and read-only. Existing routes do not
change.
- When an update is pending, each call scans the source twice (installed
and latest commit). It uses the same GitHub reader, scan limits and scan
slots as preview and refresh. The diff work and response size are capped
per response.
- `/update-status` and this route both read the latest commit of the
tracking ref. They are kept separate because `/update-status` compares
with the source's last scan, while this route compares with the
installed revision.
- No UI uses the endpoint yet.

## 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 21:55:52 -06:00
DottaandClaude Opus 5.5 4afdf0493d fix(api-catalog): name the Connections page for company model service settings (#20152)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A company can turn on model services such as Fast response, Decision
model and Prompt Injection Prevention
> - Only people who manage connections can change these services, so a
chat agent must send the person to the right page
> - The agent finds these operations through the API catalog search, but
their catalog entries named no page
> - The agent searched for the settings page, found nothing, and linked
the general /company/settings page, which does not have the control
> - This pull request adds a description to these catalog entries that
names the section and the company-prefixed /company/settings/connections
page
> - The benefit is that the agent links the page that actually holds the
control

## Linked Issues or Issue Description

**What happened**

A person asked the CEO chat agent: "Help me enable the company's Fast
response feature so chat acknowledges my requests faster." The agent
correctly got 403 on the fast-response API. It then searched the API
catalog for "company settings page fast responses web UI" and found no
page. It linked the general company settings page
(`/<prefix>/company/settings`) and told the person to find Fast response
there. That page has no Fast response section. The section is on
/company/settings/connections.

**Expected behavior**

The agent links /<prefix>/company/settings/connections and tells the
person to use the Fast response section there. If the section is hidden,
the agent can say that an instance admin turns the experiment on.

**Steps to reproduce**

1. Turn on the fast responses experiment. Create a shared OpenRouter API
connection.
2. Open a chat with the CEO agent and ask it to enable Fast response.
3. Read the reply: it links /<prefix>/company/settings. Open that link:
the page has no Fast response control.

## What Changed

- `server/src/routes/openapi.ts`: add one shared description to the
fast-response, decision-model and prompt-injection-prevention catalog
entries. It names the section, the company-prefixed
/company/settings/connections page, the Markdown link form, and that
agent credentials cannot change or test the service. For Fast response,
it also names the instance experiment that hides the section.
-
`server/src/__tests__/company-model-service-settings-guidance.test.ts`:
new regression test. It runs the agent's catalog search (including the
query from the failed run) and checks that the results name the
Connections page and section. It also requires every page the
descriptions name to be a route that `ui/src/App.tsx` mounts, so
renaming a settings route fails CI.

## Verification

- `pnpm exec vitest run
src/__tests__/company-model-service-settings-guidance.test.ts` (from
`server/`): 6 tests pass. Without the catalog change, the 5 search tests
fail. If the Connections route in `ui/src/App.tsx` is renamed, the route
test fails.
- `pnpm exec vitest run src/__tests__/openapi-routes.test.ts`: 37 tests
pass.
- Chat eval `companies.fast-responses` (runner-opencode), with this
commit cherry-picked onto the eval branch: before the change, the reply
linked the general settings page, which has no Fast response control.
After the change (GitHub Actions run 42549422681), the eval passed. The
reply linked the Experimental settings page and the Connections page and
said to use the Fast response section there. The scripted person clicked
the reply's links and found the Fast response control on the Connections
page, then saved and tested it.

## Risks

- Low risk. The change adds catalog text only. It does not change any
API or permission behavior.
- If the settings page moves, these descriptions become wrong. The
regression test compares every page the descriptions name with the
routes in `ui/src/App.tsx`, so a route change that does not also update
the description fails CI.

> 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 21:55:48 -06:00
DottaandClaude Opus 5.5 43fe87795a fix(authz): make board-only denials final and actionable for agents (#20151)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People talk to their agents in Agent Chat, and the agents act
through the Paperclip API
> - Many API routes are board-only. The shared `assertBoard` check
returns 403 `"Board access required"` to an agent, with no other detail
> - The chat guidance also told the agent to "use permitted discovery to
identify the supported operator path" after such a denial
> - So the agent keeps searching the API, `call_api` and the shell for a
second route. On a slow model, this search uses the whole reply window
and the person gets no answer
> - Earlier single-route fixes (#19990, #20041, #20008) showed the
remedy: a denial that says it is final and names where the person acts.
Reply time fell from 10-24 minutes to about 2 minutes
> - This pull request applies that remedy once, in the shared board-only
check and in the chat guidance
> - The benefit is a prompt, correct handoff for every board-only action
that an agent cannot do

## Linked Issues or Issue Description

Refs #19990, #20008, #20041 (same remedy for single routes).

**What happened**
In about 30 Agent Chat evaluation cases on a slower model, the agent got
403 "Board access required" (or read `authorization.actor: "board"` in
the API catalog). Then it searched for another route until the attempt
deadline. Examples: archive a company, set up a managed agent profile,
add a company secret. In one recorded run, the agent got the 403 at
minute 1, then tried PATCH, environment variables, the CLI and UI paths
for more than 3 more minutes, and never replied.

**Expected behavior**
A board-only denial is final. The agent stops, tells the person that a
board user must do the action, links the page in the Paperclip web app
where they can do it, and ends its reply.

**Steps to reproduce**
1. Open Agent Chat with a CEO agent.
2. Ask it to archive the company (or to add a company secret).
3. The agent calls `POST /api/companies/{id}/archive` and gets `{
"error": "Board access required" }`.
4. The body says nothing about finality or where the person acts. The
agent searches for other routes and replies late or not at all.

## What Changed

- `server/src/routes/authz.ts`: `assertBoard` now throws
`boardAccessRequired(req)`. For an agent actor, the 403 body adds `code:
"board_access_required"` and a `remediation`. The remediation says the
action is board-only by design, the denial is final, and no other route
can do it. When the API path maps to a known board page, it links that
page with a `<prefix>` placeholder: company settings, company secrets,
budgets, members, tool gateways, connection settings (decision model,
prompt injection prevention), an approval, or the agent profile tab that
holds the control (API keys, permissions, budget, identity, harness
settings). Otherwise it says to link a page only when the route is
already known, and not to search for one. Board and anonymous actors get
the same body as before.
- `environments.ts`, `instance-settings.ts` and the issue force-release
route used their own copy of the check. They now call `assertBoard`, so
they return the same denial. Status and error text do not change.
- `server/src/middleware/error-handler.ts`: for this code, the handler
does not repeat `code` and `remediation` inside `details`. They are
already top-level, and the copy only made the agent's context longer.
- `AGENT_CHAT_DIRECTIVE`: the board-identity paragraph now says that a
board-only operation (catalog actor `board`, or a 403 "Board access
required") is final. The agent must stop searching, tell the person
which control in the web app to use, and link it when it knows the
route. The old instruction to "discover the operator path" is removed.
- Claude Managed Agent setup: the Paperclip Runner configuration doc and
the `POST /api/companies/{companyId}/managed-agent-profiles` catalog
entry now say that a board operator creates and qualifies the profile
(limited environment, retention acknowledgement) and that agents cannot.
Before, an agent asked for an "Anthropic managed agent" explored hiring
and adapter docs for its whole reply window.
- Tests: new `board-access-denial.test.ts` covers the agent body for
each mapped page, the agent profile tabs, the general fallback, the
unmapped fast-response route, the org-access guard, the unchanged
non-agent body, the guidance and the managed profile catalog entry. Two
exact-body tests now also expect the new code.

## Verification

- `cd server && npx vitest run src/__tests__/board-access-denial.test.ts
src/__tests__/costs-service.test.ts
src/__tests__/browser-use-route-scope.test.ts
src/__tests__/primary-agent-guidance.test.ts
src/__tests__/agent-cross-tenant-authz-routes.test.ts
src/__tests__/company-portability-routes.test.ts
src/__tests__/issue-comment-cancel-routes.test.ts
src/__tests__/error-handler.test.ts
src/__tests__/managed-agent-profile-routes-authz.test.ts
src/__tests__/companies-route-cross-company-authz.test.ts
src/__tests__/environment-routes.test.ts
src/__tests__/environment-instance-routes.test.ts`: all pass.
- Real chat journeys on the chat evaluation branch (runner-opencode,
`deepseek-v4.1-flash`). Before this change, each case below got no reply
before its deadline:
- `companies.archive-company`: passed. Reply 47 s after the request
(before: none in 270 s). The agent read the remediation and linked
`/COM/company/settings`.
- `companies.configure-decision-model`: passed. Reply 68 s after the
request (before: none in 389 s). It linked
`/COM/company/settings/connections`.
- `companies.prompt-injection-guard`: reply 85 s after the request
(before: none in 390 s), with the correct handoff and link. The case
still fails later, in the scripted settings step: the saved model is the
default, not the model the script typed. That is a separate defect.
- `navigation-preferences.sidebar-order`: reply 99 s after the request
(before: none in 150 s). The case still fails in its scripted sidebar
step (a locator was not found).
- `secrets.configure-company-secret`: reply 6.5 min after the request
(before: none in 7.5 min). The reply is correct, but a grader keyword
check reads "chat is not a safe place to send it" as a request for the
secret.
- `agents.managed-profile`: still no reply in its 150 s window. After
the catalog change, the agent found the board-only profile setup within
30 s, but then spent about 90 s deciding what "Anthropic managed agent"
means.

## Risks

- Low risk. The HTTP status and the `error` text stay the same for every
actor. Board users and anonymous callers get the same body as before.
- Agent actors now get two more top-level fields (`code`,
`remediation`). A client that compares the whole 403 body of an agent
call exactly will see a change. Two server tests did this and are
updated.
- The page map uses the API path. A wrong map entry gives a less useful
link but does not change authorization.

## 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 21:28:52 -06:00
DottaandClaude Opus 5.5 c0fc7608ee fix(agents): honor an explicit hiring denial on CEO agents (#20041)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board controls which agents can hire, with the "Can create
agents" permission on each agent
> - A CEO agent with that permission turned off could still hire,
because the CEO role allowed hiring before the server read the saved
permission
> - In chat, a CEO agent asked to hire then created the agent through
the `hire_agent` tool, against the board's policy
> - This pull request makes the saved permission decide, and uses the
CEO role only for old rows that never saved a value
> - It also tells a denied agent that the denial is final and where a
person can act
> - The benefit is that the permissions page and the server now agree,
and the board can trust a hiring denial

## Linked Issues or Issue Description

**What happened**
A board user turned off "Can create agents" for a CEO agent. Then the
user asked the CEO in chat to hire a QA engineer. The CEO called
`hire_agent` (`POST /api/companies/{companyId}/agent-hires`). The server
returned 201 and created the agent. The legacy creator rule allowed any
agent with `role: "ceo"` before it read `permissions.canCreateAgents`.

**Expected behavior**
An explicit `canCreateAgents: false` denies hiring for every role,
including CEO. The agent gets a 403 that says the denial is final, and
it sends the person to the hire form or to its permissions page.

**Steps to reproduce**
1. Create a company with a CEO agent.
2. On the CEO's permissions page, turn off "Can create agents".
3. In chat with the CEO, send "Hire a QA engineer named Morgan."
4. See that a new agent named Morgan exists.

Found by the chat eval case `agents.hire-denied`. Related: #19614 (chat
eval harness).

## What Changed

- `canCreateAgentsLegacy` (authorization) uses the role-aware permission
normalization. A CEO keeps hiring unless the board recorded a hiring
denial.
- New `ceoHiringDeniedBy` record in `permissions` (`{ actorType,
actorId, at }`). `PATCH /agents/:id/permissions` writes it when an
authorized actor saves a CEO's hiring as off (an earlier record is
kept). The page shows the effective value, so a saved off is the board's
choice. Turning the toggle off also removes an explicit `agents:create`
grant. A saved `canCreateAgents: false` restricts a CEO only together
with this record.
- Reads report the effective value. A CEO row with no value, or with an
unrecorded false, shows hiring on. A CEO with a recorded denial shows it
off.
- Create and hire inputs cannot supply the record. Configuration updates
keep an existing record unless they turn hiring on. Company export keeps
a CEO's recorded denial, and import restores it, stamped by the
importing user.
- `PATCH /agents/:id/permissions`: an agent actor needs `agents:create`
to change `canCreateAgents`. A denied CEO cannot restore hiring for
itself or grant it to another agent.
- `assertCanCreateAgentsForCompany` adds a final, actionable sentence to
an agent's `agents:create` denial on hiring routes only (`POST /agents`,
`POST /agent-hires`, the hiring toggle on `PATCH /permissions`).
- `POST /api/companies/:companyId/imports/apply` (CEO-safe import): an
agent import that would create agents needs an `agents:create` decision
before any write. Before, any CEO agent could create agents this way.
Import also removes an existing `agents:create` grant when it restores a
CEO's denial.
- Chat guidance tells the agent to link `/<prefix>/agents/new` and its
own `/permissions` page when it cannot hire.
- Regression tests: authorization (a recorded denial is honored; an
unrecorded false keeps CEO hiring), normalization, route tests (denial
text, self-escalation, the record is written only when a CEO is turned
off), service tests for the permission and configuration write paths,
chat guidance text.

### Why a recorded denial and not a plain false

These normal paths save `canCreateAgents: false` on a CEO without a
board choice:

- The permissions page posts the full object. Before this change, a
legacy CEO row with `{}` was read as false, so saving any other toggle
stored false.
- A company import that updates an existing agent normalizes the
manifest permissions in the "stored" context. A manifest without the
field stores false. Export drops false values, so this is common.
- Low-trust creation stores false. If the trust preset later changes to
standard, the false remains.

These paths store true or nothing for a CEO: onboarding seed
(`requestHire`, create context: true), teams-catalog and import creation
(create context: true; the bundled catalog sets true only for a CTO),
built-in agents (no CEO roles), migrations and seed data (no writes to
this field).

A plain-false rule would remove hiring from those CEOs. With the record,
existing data keeps today's behavior, and no data migration is
necessary.

## Verification

- `cd server && npx vitest run
src/__tests__/agent-permissions-service.test.ts
src/__tests__/authorization-service.test.ts
src/__tests__/agent-permissions-routes.test.ts
src/__tests__/agent-conversations.test.ts` passes (244 tests).
- `teams-catalog-service`, `company-portability` and `built-in-agents`
tests pass (189 tests).
- Red check: the new authorization test fails without the
`authorization.ts` change.
- `npx tsc --noEmit -p server` passes.
- End to end: the chat eval `agents.hire-denied` on the OpenCode runner.
The baseline run (Actions run 42478870236) created Morgan through
`hire_agent`. With this fix (Actions run 42480936881), the agent got the
403, created no agent, and linked the hire form and its permissions
page. The eval clicked the rendered hire-form link.

## Risks

- Behavior change: a CEO whose hiring the board turns off after this
change can no longer hire.
- A CEO that already has a saved false keeps hiring until a board user
turns it off again on the permissions page. The page now shows such a
CEO as on, so the board can see and change it.
- A request that creates a CEO with `canCreateAgents: false` does not
restrict it. The board must turn hiring off on the permissions page.
- A CEO agent whose hiring is denied can no longer import a package that
creates agents. A board user can still import it.
- Other CEO special cases are not hiring routes: `issues.ts` lets a CEO
link approvals to issues, and `agents.ts` gives a CEO task assignment.
`teams-catalog.ts` and `llms.ts` read the normalized permissions, so
they follow the same effective value.
- The denial text is longer. No existing test compares this route's
exact error text.

## 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 20:05:15 -06:00
Devin FoleyandPaperclip 0e49576fd9 fix(github): default repository owner to the only installed organization (#20141)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents use a managed GitHub connection to create repositories and to
fork repositories
> - The Paperclip GitHub App can be installed only on an organization,
and not on the user's personal account
> - Then `create_repository` and `fork_repository` fail when
`organization` is empty, because GitHub sends them to the personal
account
> - Agents, approval cards, and connection tests all fail on their first
attempt with `403 Resource not accessible by integration`
> - This pull request fills an empty `organization` with the only
organization where the App is installed
> - The benefit is that repository creation works on the first attempt
for these connections

## Linked Issues or Issue Description

No public issue exists. Refs #19655 (GitHub repair guidance for the same
error).

**What happened?**
A user connected GitHub through the managed connection. The Paperclip
GitHub App was installed on one organization and not on the user's
personal account. An agent called `create_repository` without
`organization`. GitHub returned `POST https://api.github.com/user/repos:
403 Resource not accessible by integration`. The test button on the
connection page gave the same error. The agent then stopped and asked
the user to create the repository manually.

**Expected behavior**
When the App can create repositories only in one organization, an empty
`organization` uses that organization. The approval card and the test
result show that owner.

**Steps to reproduce**
1. Install the Paperclip GitHub App on one organization only. Do not
install it on the personal account.
2. Connect GitHub with the managed connection.
3. Run `create_repository` with only `name`, from an agent or from the
connection test dialog.
4. GitHub returns 403.

## What Changed

- Added `server/src/services/tool-access/github-default-owner.ts`. It
finds the default organization from the grant's access snapshot. It
returns an organization only when there is exactly one installation, and
the snapshot marks that owner's repositories as organization
repositories. Older snapshots without account types get no default until
access is refreshed.
- `governedToolArguments` in `tool-gateway.ts` fills an empty
`organization` for `create_repository` and `fork_repository` on managed
GitHub connections. The change occurs before policy, approval signing,
previews, and audit. Thus agent calls, approval cards, and connection
tests use the same owner.
- The grant lookup runs only for these two tools, and only when
`organization` is empty. If the credential is unavailable, the call does
not change and fails with its own error at dispatch.
- An explicit owner does not change. A fork into its own owner does not
change. Token (PAT) connections, personal installations, and multiple
organizations keep GitHub's behavior.
- `projectedConnectionToolInputSchema` describes the default on the
`organization` argument. Agents and the connection test form see where
the repository goes.
- Updated `doc/connections/GITHUB.md`.

## Verification

- `pnpm --filter @paperclipai/server typecheck` passes.
- `npx vitest run
src/services/tool-access/github-default-owner.test.ts`: new unit tests
for the owner rules (single organization, personal installation, several
organizations, another person's account, older snapshots, blank values,
forks into the same owner, schema text).
- New gateway test in `src/__tests__/tool-gateway.test.ts`: "defaults
GitHub repository creation to the only installed organization for agents
and connection tests". It covers an agent call, an explicit owner, a
connection test call, and a personal installation. It fails without the
gateway change.
- Related suites pass, 393 tests in 9 files: `tool-gateway.test.ts`,
`tool-gateway-service.test.ts`, `connection-instructions.test.ts`,
`github-access-errors.test.ts`, `github-default-owner.test.ts`,
`tool-gateway-discovery-http.test.ts`,
`github-operation-credentials.test.ts`,
`project-git-authorization.test.ts`, `prompt-injection-gateway.test.ts`.
- Not tested against a live GitHub App installation.

## Risks

- Low risk. The default applies only when the grant snapshot shows one
organization installation and no personal installation. Without the
change, these calls fail with 403.
- A stale access snapshot can choose the organization after a user
installs the App on the personal account. Installation events and
**Refresh access** update the snapshot.
- An approval that was created before this change, without
`organization`, no longer matches the projected arguments. That approval
cannot run and must be requested again. Without the change, it fails
with 403.
- `gh repo create` and `gh repo fork` in agent shells do not use the
gateway, so they keep GitHub's personal default.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (code reading, shell commands, and 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-08 17:08:13 -08:00
DottaandClaude Opus 5.5 c49ab6b9ee feat(issues): wake the parent once when a child or blocker stalls on the board (#20098)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A parent task delegates to children and waits on blockers. Its agent
learns of progress only through wakes
> - Today only completion (`issue_children_completed`) and resolution
(`issue_blockers_resolved`) wake a waiting issue
> - In restart chaos `w-20261108T181313-s9701` t05, a blocker stopped on
a recovery action that needs the board, and its parent sat silently
blocked for 50+ minutes
> - This pull request wakes the waiting agent once per such stall, tells
it what it can do, and shows the stall on the parent's page with the
child's Retry
> - The benefit is that a stuck child or blocker no longer silently
strands its parent

## Linked Issues or Issue Description

Refs #19831 (durable ownership plan; restart chaos campaign). Restart
chaos wave `w-20261108T181313-s9701`, task t05 (M1, claude).

**Problem**

From the kept database:
- CHA-8 `blocks` CHA-5.
- CHA-8's board-owned recovery action (`native_session_retry_exhausted`)
was created at 18:44:02.909 and resolved with outcome `blocked` at
18:44:08, when CHA-8 moved to `blocked` for the board.
- CHA-5's only wake in the whole wave was its `issue_assigned` at
18:20:48. Its agent never heard about CHA-8.

**Expected behavior**
- When a child or blocker stalls on the board, the waiting agent is
woken once. It can then re-delegate, escalate, or block with a reason.
- The board sees the stall on the waiting task, with the child's Retry.

## What Changed

- **Shared:**
- `ISSUE_BLOCKER_STALLED_WAKE_REASON` (`issue_blocker_stalled`),
`ISSUE_BLOCKER_STALL_RELATIONS`, and the types
`IssueBlockerStallRelation` and `IssueBlockerStalledWakeContext`;
  - `Issue.stalledDependencies`.
- **Server, `services/recovery/blocker-stall-wakeups.ts`:**
- A stall is an issue that is not done or cancelled, whose latest
recovery action needs the board. That means either open
(`active`/`escalated`) and board-owned or escalated, or board-owned and
resolved as `blocked` while the issue is still `blocked`. The second
case is what t05 needed: recovery resolves its action that way when it
hands the issue to the board.
- Waiters are the parent, and every issue the stalled one `blocks`, when
they are open, visible and agent-assigned.
- Each waiter gets one wake per stall. The idempotency key
`issue_blocker_stalled:{waiter}:{stalled}:{recoveryActionId}` is checked
through the indexed `(company_id, payload->>'issueId')` path. A later
stall has a new action id, so it wakes again.
  - Each wake writes activity `issue.blocker_stalled_wake`.
- The pass runs from the recovery-action sweep
(`reconcileActiveRecoveryActions`) and from the per-issue blocked sweep
(`reconcileResolvedDependencyWakeBackstop`, which visits each blocked
issue). No new worker.
- A waiter's agent that cannot read the stalled issue (`issue:read`,
which honors private visibility) is woken without its title, identifier
or recovery details; the activity entry withholds them too.
- **Database:** migration `0450` adds the partial unique index
`agent_wakeup_requests_issue_blocker_stalled_uq` on `(company_id,
idempotency_key)` for `issue_blocker_stalled:` keys in delivered or
pending statuses, so two concurrent sweeps cannot both deliver a stall.
It is built `CONCURRENTLY IF NOT EXISTS` outside a transaction (listed
in `MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS`).
- **Queue:** `allowsDependencyBlockedWake` (in
`run-dispatch/domain/wake-context.ts`) lets a stall wake run while the
waiter's blockers are unresolved, at both dependency gates. Without it
the wake was parked until the stalled blocker resolved, which is never.
Pause holds are unchanged.
- **Agent guidance:** the wake payload carries `blockerStall`, and
`renderPaperclipWakePrompt` adds a "Stalled dependency" section. It
names the task and its recovery action and the board's next action, then
the three choices: re-delegate, escalate with a comment, or block with a
reason naming the stalled task.
- **Other reason lists:** `issue_blocker_stalled` joins the
responsible-user wake reasons, the conversation execution-wake reasons
(it never drives a chat turn) and the wake diagnostic's known reasons.
- **Board surface:**
- `GET /issues/:id` returns `stalledDependencies`, readable issues only
(`readableIssueEdgeIds`).
- `StalledDependencyNotice` shows on the issue's chat notices, next to
`IssueBlockedNotice`: each stalled child or blocker, its board next
action, a link, and the child's own Retry (`useTaskRetry`), offered only
when the child's interruption allows it.

## Verification

Red/green:
- `server/src/__tests__/issue-blocker-stall-wakeups.test.ts` (14 tests).
  - On master: no `issue_blocker_stalled` wake is raised.
- With this PR: the parent is woken once per stall, and re-woken after a
new stall. A blocker's dependent is woken.
- The t05 shape (an action resolved as `blocked`) wakes once, and
nothing more after a Retry.
- No wake for an agent-owned action, a done, unassigned or hidden
waiter, or an action resolved another way.
  - `listStalledDependencies` lists children and blockers.
- `heartbeat-dependency-scheduling.test.ts` "admits an
issue_blocker_stalled wake while a blocker is unresolved". On master the
wake was parked (`null`); once admitted, the run-start gate cancelled
it. With this PR it runs with `dependencyBlockedInteraction`. The file:
14 passed.
- `packages/adapter-utils` `server-utils.test.ts`: the new prompt test
failed on master (no `blockerStall`). The file: 141 passed.
- `ui/src/components/StalledDependencyNotice.test.tsx`: 3 passed.
`IssueChatThread.test.tsx`: 109 passed.

Regression and static checks:
- Nearby server suites (recovery durable wait, dependency wake routes,
recovery actions, run-preparation): 149 passed.
- `tsc --noEmit` passes for `packages/shared`, `server` and `ui/src`.
- `pnpm check:token-gates` is clean.

Review round 1 (Greptile, Superagent), each with a regression:
- A backlog waiter is never woken. The waiter's status and assignee are
guarded at enqueue.
- One stall per run: no coalescing, so a merged run cannot keep only the
last stall's context.
- Only a delivered or pending wake ends a stall. A refused one
(wake-on-demand off) is retried after 10 minutes, and an agent that
takes no wakes is skipped without writing a row.
- Company-wide passes resume after the last stall a capped pass reached.
The per-issue sweep also checks the issue's own children and blockers,
which covers an unassigned stalled blocker.
- The dependency gates no longer trust the reason string.
`verifyBlockerStallWake` requires a system request, the stalled issue's
current stall action, and a real child or blocker edge. An
agent-requested wake with the same context is parked (tested).
- The notice shows a refused Retry's reason, and a lost response as
"Taking longer than usual…", with Retry still available (tested).

Review round 2 (Greptile), each with a regression:
- Race: two passes claiming one stall leave one delivered row (unique
index; the loser's insert is skipped).
- The company recovery sweep selects companies whose only stall is a
resolved-`blocked` board action, so an unassigned stalled blocker with a
todo waiter is reached.
- Private stalled issue: the waiter's wake and activity carry no title,
identifier or recovery details.
- `GET /issues/:id` always returns `stalledDependencies`, so a cleared
stall replaces the cached list.
- The notice shows a failed child read with a Reload button (tested).
- `pnpm --filter @paperclipai/db check:migrations` passes.

Review round 3 (Greptile), each with a regression:
- A stall wake that fails to enqueue is retried: the recovery-action
pass reports `blockerStallWakeFailures` and the dependency pass counts
it in `enqueueFailed`, so the worker throws and retries at its backoff
(`issue-recovery-work-lifecycle.test.ts`).
- The notice drops a stall once the child's refreshed status has moved
on (`StalledDependencyNotice.test.tsx`, 7 tests).

## Risks

- A deploy wakes the waiters of every issue that is stalled today, once
each. That is intended (they are stranded now), and it's bounded at 200
stalls per pass.
- A stall wake may run while blockers are unresolved, like a comment
wake. It is bounded to one per stall, and pause holds still apply.
- The agent skill (`skills/paperclip`, CODEOWNER-owned) does not yet
list the new reason. The wake prompt carries the guidance.
- One additive index, built concurrently; no persisted run state change.
Existing rows cannot violate it before deploy, since no
`issue_blocker_stalled:` keys exist yet.

## 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 18:26:07 -06:00
DottaandClaude Opus 5.5 4420a5adcf fix(issues): a Retry held by a blocked subtask names it instead of failing silently (#20109)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board's Retry is the one action a person takes when a task
stops; it must either start work or say why it can't yet
> - Restart chaos t05 left a parent task blocked on a blocked subtask.
The board clicked Retry twice; each was accepted and nothing happened
> - The Retry was saved and the queue deferred it behind the unresolved
blocker, which is correct. But the task still looked failed with Retry
offered, so the wait was invisible
> - This pull request presents a saved Retry that waits only on
unfinished blockers as waiting, names the blocked subtask and links it
> - The benefit is that the board sees what to act on (the subtask's own
Retry) instead of clicking a Retry that cannot run

## Linked Issues or Issue Description

Refs #19831 (durable ownership plan). Restart chaos wave
`w-20261108T200620-s9701`, task t05 (claude, M1, K4).

**Problem:** parent CHA-5 was blocked by subtask CHA-10, which was
itself blocked on a board recovery action. The board pressed Retry on
CHA-5 at 20:27:46 and 20:40:46. Each `POST /issues/:id/retry` returned
202, and the saved Retry stayed `deferred_issue_execution` behind
CHA-10, so no run started. The task's interruption presentation kept
saying `failed` with `canRetry: true` and gave no reason.

## What Changed

- `server/src/services/task-interruption-presentation.ts`:
- For tasks with a saved (deferred, user-requested) Retry, the loader
reads the queue's own dependency readiness (`listDependencyReadiness`).
That counts cancelled blockers and done blockers whose workspace has not
finalized, like the queue does.
- When the saved Retry for the latest run is held only by unfinished
blockers (no execution wait, no preparation), the task is presented as
`working` with `canRetry: false` and `retryWait: { reason:
"dependency_blocked", message }`.
  - The message has three forms:
    - a blocked blocker: "Retry or unblock it";
- a cancelled blocker: "Remove or replace that dependency to continue",
because the queue keeps it unresolved;
    - otherwise: waiting for the blockers to finish.
- The presentation carries no blocker names or ids. Issue reads return
it to restricted agents too.
- The Retry response (`POST /issues/:id/retry`) already returns this
presentation, so the API says the Retry is waiting on dependencies as
soon as it is saved. The blockers themselves are in the issue's
`blockedBy`, which issue reads already filter per viewer.
- `ui/src/components/ExecutionBlockerNotice.tsx` takes `blockers` (the
issue's `blockedBy`, from `IssueDetail`). For a `dependency_blocked`
wait it lists each readable unfinished blocker with a link and what it
needs: "Blocked: open it to Retry" or "Cancelled: remove or replace it".
Tokens only; `pnpm check:token-gates` is clean.
- No shared type change, and no code comments added.

## Verification

Red/green:
- `server/src/__tests__/task-interruption-query.test.ts` "explains a
saved Retry held by unfinished blockers instead of offering Retry again
(restart chaos t05)". It sets up a parent with a failed run, a saved
Retry deferred, and a blocked subtask that blocks it.
  - On master: `{ state: "failed", canRetry: true }`, no reason.
- With this PR: `working`, `canRetry: false`, `dependency_blocked`. The
presentation has no blocker title or identifier.
- A cancelled blocker gets the remove-or-replace message. Once the
blocker is done, the dependency wait is gone.
- `ui/src/components/ExecutionBlockerNotice.test.tsx`: the notice links
the blocked and the cancelled blockers with their actions, skips the
done one, and offers no second Retry.

Regression: `task-interruption-query` (25 tests) and
`ExecutionBlockerNotice` (16 tests) pass, as do the presentation unit
tests, workspace-recovery admission, gates and copyback tests, and
`heartbeat-dependency-scheduling`. `tsc --noEmit` is clean for `server`
and `ui`.

## Risks

- One more batched read (dependency readiness plus blocker statuses),
only for tasks in the page that have a saved Retry.
- Presentation only. Admission is unchanged: the queue still runs the
saved Retry when the blockers are done.
- A Retry saved before a blocker was added shows the same wait, which is
accurate, because the queue holds it too.

## 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 (#20069)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (code
comments; no user docs affected)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending)
- [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 17:18:55 -06:00
DottaandClaude Opus 5.5 77168d3367 fix(recovery): settle a dead controller's result lease for a run a pause cancelled (#20113)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A budget hard stop pauses the agent, which cancels its running work
and waits until every execution has provably stopped
> - The shared ownership reconciler (#19933) settles an accepted
result's executor lease when its controller is proven dead, so
finalization does not wait out the 20-minute lease
> - In the restart-chaos H1 wave, the pause cancelled a run 0.36 s after
its result was accepted, and a hard kill then took the controller
> - The settle only acted on runs that were still `running`, so the dead
lease held finalization, the agent stayed in `pausing`, and every later
task for it was stranded
> - This pull request settles that lease for a cancelled run too, and
records the budget pause with its reason
> - The benefit is that a pause crossed by a restart completes at once
and resume continues normally

## Linked Issues or Issue Description

Refs #19933, #20101

**What happened?**
Restart-chaos wave w-20261108T210118-s9802 (scenario H1). An agent-scope
budget hard stop paused agent "Chaos claude". The pause cancelled run
`dfc22298` at 21:02:27.818, 0.36 s after its result was accepted
(finalization phase `workspace_finalizing`, executor lease until
21:21:55, controller PID 78157). A K4 SIGKILL followed. After the
board's `raise_budget_and_resume`, the agent stayed `status=paused`,
`lifecycle_state=pausing`, `lifecycle_holds=[]`, with its lifecycle
operation at `attempts: 90`, `hostComplete: false` and no error. The
pause host step (`stopInvocationsForAgents` →
`agentExecutionsHaveStopped`) returns `pending` while any coordinator
holds an owner lease with an accepted result.
`settleNativeAcceptedResultLease` required `heartbeat_runs.status =
'running'`, so it returned `lease_no_longer_held` for the cancelled run.
The pause also wrote no activity entry.

**Expected behavior**
A proven-dead controller's accepted-result lease is settled whatever
stopped the run, so finalization finishes and the pause completes. The
budget pause is logged with its reason.

**Steps to reproduce**
Pause an agent right after its run's result is accepted, then SIGKILL
the server. After restart, the agent stays in `pausing` until the lease
expires (up to 20 minutes).

## What Changed

- `native-accepted-result-lease-settlement.ts`: settle the lease for a
`running` or `cancelled` run. All other guards are unchanged: exact
owner compare-and-set, owner proven gone by `proveOwnerGone`, remote
copyback excluded, another host never proof.
- `agent-lifecycle.ts` `applyAgentBudgetHoldInTransaction`: when the
budget hold is added or removed, records `agent.paused` /
`agent.resumed` with `reason: "budget"`, the lifecycle state and the
holds, in the transaction that applies the hold. Applying the same hold
again records nothing.
- `budgets-service.test.ts`: its activity-log mock now also stubs
`persistActivity`.

Not changed, by design: resume still waits for an incomplete pause to
finish before resuming. The pause's host step proves the agent's
executions stopped before new work starts. In H1 it was blocked only by
the dead lease, which is now settled at restart.

## Verification

- `heartbeat-native-restart-suspension.test.ts`: new "finalizes an
accepted result whose run a pause cancelled before a hard kill took its
controller". It fails on master (lease still 20 minutes out) and passes
with the fix (lease owner cleared, finalization past
`workspace_finalizing`). Whole file: 41 passed.
- New `agent-budget-hold-activity.test.ts`: the pause and release
entries, with the reason, written once each.
- `budgets-service.test.ts` (19), `budgets-history.test.ts`,
`company-portability-routes.test.ts`,
`agent-lifecycle/__tests__/lifecycle.test.ts`: passed.
- `server` typecheck (`scripts/typecheck-layers.mjs`, low-memory): no
errors.
- Not run: the H1 chaos rerun (Phase 3 schedules it after merge).

## Risks

- A cancelled run's accepted result is now finalized promptly after a
hard kill instead of after the lease term. The finalizer already handles
a run cancelled during finalization; the settle only shortens the wait.
- New activity entries on budget pause and release. Run-log and activity
only; no Telemetry.

> 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-08 17:06:13 -06:00
DottaandClaude Opus 5.5 f789f3e2af fix(environments): a consented environment delete releases restore-source protection so it never stays blocked (#20099)
## Thinking Path

- Deleting an environment with consent destroys its kept sandboxes.
#20085 added failed runs' restore-source sandboxes (`retain_on_failure`)
to that teardown, and the delete dialog now tells the operator that the
consented delete destroys those files.
- But every teardown path refuses a lease whose sandbox carries a
`workspaceRestoreRecovery` marker
(`legacyShutdownWorkspaceResourceProtected`). That is correct for
ordinary cleanup, since the files may be the only copy.
- So the consented delete claimed the restore sources for cleanup, every
teardown returned without destroying, and the delete returned 409
`pending_sandbox_cleanup` forever: a refusal the user cannot act on.
- The operator's consent is exactly the decision the protection waits
for, so the delete releases it explicitly and audits who did, then
proceeds.

## Linked Issues or Issue Description

Refs #20048, follow-up to #20085

**What happened?**
In Daytona warm wave w-20261108T192459-s9501, the harness's consented
environment delete returned 409 `pending_sandbox_cleanup` for minutes
(it retried every 3 s). Two restore-source leases (8b26e044, bcb897c9)
had been claimed for cleanup with `environment_deleted`. The activity
log shows a cleanup attempt on every retry, each returning without
destroying. On the kept database copy,
`legacyShutdownWorkspaceResourceProtected` returns `true` for both,
because of their `workspaceRestoreRecovery` marker. Their restore
recovery had already succeeded (workspace sync finalized). Phase 3
deleted the two sandboxes by hand.

**Steps to reproduce**
1. A failed run leaves a `retain_on_failure` lease with
`workspaceRestoreRecovery` metadata (sandbox stopped).
2. `DELETE /api/environments/:id?destroyReusableSandboxLeases=true`.
3. The lease is claimed (`pending_cleanup`), the teardown refuses it,
and every retry of the delete returns 409.

**Expected behavior**
The consented delete destroys the restore source (the operator was told
the files go with it) and the environment is deleted. No 409 the user
can't act on.

## What Changed

- `server/src/services/environment-runtime.ts`: new
`releaseRestoreSourcesForEnvironmentDelete({ environmentId, consentedBy
})`. For each of the environment's restore-source sandboxes
(`retain_on_failure` with `workspaceRestoreRecovery`, any non-terminal
status including an earlier `pending_cleanup` claim), it removes the
marker from every lease row on that sandbox and records
`restoreSourceReleasedByEnvironmentDelete: { consentedBy, releasedAt }`.
`destroyReusableSandboxLeasesForEnvironment` takes `consentedBy` and
releases first.
- `server/src/routes/environments.ts`: a consented delete whose only
blockers are pending cleanup or kept sandboxes releases restore
protection before the pending-cleanup retry, so leases an earlier
attempt left claimed are freed too. It then passes `consentedBy` to the
teardown.
- Tests:
- A restore source now carries the real `workspaceRestoreRecovery`
marker, and the consented teardown destroys it. This failed before the
fix (`{ destroyed: 0, failed: 1 }`), matching the wave.
- The route test asserts the release runs before the pending-cleanup
retry.

## Verification

- `cd server && npx vitest run src/__tests__/environment-runtime.test.ts
src/__tests__/environment-service.test.ts
src/__tests__/environment-routes.test.ts`: 347/347 pass.
- The restore-source test fails on master with `failed: 1`.

## Risks

- **Losing restore files without consent.** The release runs only on the
explicit consented delete path (`destroyReusableSandboxLeases=true`),
and only when the remaining blockers are pending cleanup or kept
sandboxes. It is recorded on every released row. Leases with a pending
native export are still skipped by the teardown.
- **Ordinary cleanup.** Unchanged: restore protection still holds
everywhere else.

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool
use and code execution, via Claude Code.

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:27:00 -06:00
DottaandClaude Opus 5.5 1b7254206f fix(recovery): replay a preparation a restart interrupted once proof shows no provider work started (#20072)
## Thinking Path

- Restarts are product failures: a task interrupted by a restart must
resume on its own, never wait for a user Retry.
- A run interrupted during preparation, before any provider work, is
safe to replay; the agent never touched the workspace. Paperclip already
has the proof (`isInterruptedNativePreparation`) and the settle
(`settleInterruptedNativeBootstrap`) that replay such a run in the
uncharged interrupted-preparation lane.
- But a graceful shutdown terminalizes that run inside the draining
process, where the proof cannot hold yet: the owning controller is still
that process, and its sandbox lease may still be cleaning up. So
recovery records `preserve_without_replay_v1` with replay blocked.
- When the proof becomes available (the next process boots, and the
lease is released with a confirmed stop), nothing re-judges the hold.
The only re-evaluation point runs after a pending-cleanup sweep, so the
task waits for a manual Retry.
- The coordinator set the rule: replay automatically with durable proof
of no provider work; anything uncertain stays held. This PR adds the
re-evaluation point and no new proof logic.

## Linked Issues or Issue Description

Refs #20048

**What happened?**
In Daytona warm wave w-20261108T142906-s9501, task t05: scheduled retry
c99b3a2e was 2 s into preparation (legacy, stage `preparing`, no runtime
mode, no events, no finalization) when the K2 graceful shutdown
interrupted it. Terminalization in the draining process recorded
`legacy_execution_requires_reconciliation` with
`preserve_without_replay_v1` and replay blocked, while its lease was
still `pending_cleanup`. The lease was released with a stop receipt 2 s
later, and the next process started, but the hold was never re-judged.
The harness clicked Retry at 14:44:27.

**Steps to reproduce**
1. Start a run that is still in preparation (legacy, `preparing`,
adapter `paperclip_runner`).
2. Gracefully shut down the server; terminalization records a
preserve-without-replay hold.
3. Let its lease release with a confirmed stop, and restart the server.
4. The hold stays `blocked`; the task needs a manual Retry.

**Expected behavior**
Once the owning controller is proven gone and every lease is released
with a confirmed stop, the ownership reconciler re-judges the hold with
the existing proof. With no provider work and every cleanup confirmed,
the hold is settled and the preparation is replayed with no attempt
charged. Possible provider work, a live controller, or an unfinished
cleanup keeps the hold.

## What Changed

- New ownership kind `legacy_interrupted_preparation_hold`, in
`server/src/services/interrupted-preparation-hold-settlement.ts` and
registered in `recovery/ownership-registry.ts`.
- **Listing:** legacy runs that are `interrupted` or `failed`, in stage
`preparing` with no `runtimeModeResolvedAt`. Each must have a
system-recorded `legacy-execution:<run>` hold: resolved/blocked,
`preserve_without_replay_v1`, replay blocked, no owning user, and none
of `ownerGoneAttestation`, `explicitUserContinuation` or
`executionReconciliation`. Every lease must be released with no failed
cleanup, so a lease that settles later is listed on the next pass.
- **Owner:** the run's recorded controller
(`legacyControllerOwnerStamp`). The shared reconciler settles only after
`proveOwnerGone` proves it gone. A missing stamp, or one from another
host or PID space, is unprovable and stays held. There is no age
fallback.
- **Settle:** re-checks the existing durable proof
(`isInterruptedNativePreparation`: no coordinator, result or execution
events; leases released with stop receipts; workspace repair when
needed). It then resolves the hold through
`settleInterruptedNativeBootstrap` as `false_positive` with replay
allowed and continuation pending, delivered through the existing
workspace-recovery queue in the uncharged interrupted-preparation lane.
Its row locks make the transition happen once; the settle reports
settled only when it resolved holds.
- `server/src/services/legacy-execution-recovery.ts`:
`settleInterruptedNativeBootstrap` takes an optional `proof`, recorded
in the `issue.recovery_action_resolved` activity details as
`ownerProof`, and an `onHoldsResolved` callback.
- `server/src/__tests__/interrupted-preparation-hold-settle.test.ts`,
K2-during-preparation shape, run through the reconciliation pass:
- settled with a dead controller in this PID space, with the audit entry
naming `pid_dead`;
- kept on a `turn.started` event, a stamp from another process space, a
missing stamp, the current process, a lease still cleaning up, an
operator owner-gone attestation, and a user continuation;
  - two concurrent settles produce one resolution.
- `ownership-reconciliation-report.test.ts`: adds the kind to the list
of settling kinds.

**Previous-release state:** holds written by the previous release
(`preserve_without_replay_v1`, replay `blocked`) are what this
re-judges, through the same proof. A run without a recorded controller
stamp stays held. No saved state changes shape.

## Verification

- `cd server && npx vitest run
src/__tests__/interrupted-preparation-hold-settle.test.ts`: 9/9 pass.
Removing the kind registration makes the settle case fail.
- The ownership, retained-orphan and recovery suites (19 files) pass,
after adding the kind to the report's settling-kinds list.
- Not yet covered: the Daytona warm wave rerun, on measure/remote-warm
with this change.

## Risks

- **Replaying work that already ran.** Settlement requires both the
shared owner proof and the existing durable no-provider-work proof.
Anything unprovable stays held.
- **Overriding an operator.** Only holds the system recorded are listed;
attested, continued or owned holds are never touched.
- **Load.** The listing is a filtered query on the 30 s reconciliation
pass, gated so a run with an unsettled lease is not polled.

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool
use and code execution, via Claude Code.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:21:20 -06:00
DottaandClaude Opus 5.5 015d3d0a7c fix(environments): tear down retain-on-failure sandboxes when their environment is deleted (#20085)
## Thinking Path

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

## Linked Issues or Issue Description

Refs #20048

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool
use and code execution, via Claude Code.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:32:38 -06:00
DottaandClaude Opus 5.5 4f7eee10c2 feat(recovery): let the board confirm an unrecorded owner stopped for every settling process kind (#20084)
## Thinking Path

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

## Linked Issues or Issue Description

Refs #20067, #20054

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

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:16:07 -06:00
DottaandClaude Opus 5.5 e8a3f1f6f2 feat(recovery): prove a replaced container's records gone through a fenced controller epoch (#20054)
## Thinking Path

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

## Linked Issues or Issue Description

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

**What happened?**

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

**Expected behavior**

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

**Steps to reproduce**

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

**Paperclip version or commit**

`master` before this PR.

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:06:32 -06:00
DottaandClaude Opus 5.5 0a64c9b4ab fix(recovery): treat a warm sandbox as abandoned only on positive evidence its task moved (#20070)
## Thinking Path

- #20065 made idle warm sandboxes owned records on the shared
retained-orphan kind. A warm sandbox no turn can resume is stopped if
running, then destroyed.
- One branch of that predicate decides that a task "moved" off a
workspace. As merged, it was true whenever no task was bound to the
lease's workspace. That includes a task whose binding is null: finished,
or between bindings.
- A null binding is not proof of a move. The warm sandbox may still be
resumable, and for a remote workspace it can hold the files. Phase 2's
review flagged this as blocking, so this follow-up requires positive
evidence.

## Linked Issues or Issue Description

Refs #20048, follow-up to #20065

**What happened?**
`taskMovedToAnotherWorkspace()` listed a warm lease as abandoned when
its task had no bound workspace. The pending-export check was then the
only thing preventing the destroy of a sandbox that could still be
resumed.

**Steps to reproduce**
1. Create a warm (`reuse_by_environment`) lease, released with a
confirmed stop, bound to workspace W.
2. Clear the task's `execution_workspace_id` (task finished, or between
bindings).
3. Run an ownership reconciliation pass: on master the lease is settled
and queued for destruction.

**Expected behavior**
Only positive evidence counts as a move: the lease's own task is bound
to a different, non-null workspace, and no task is bound to W. An
unbound task's warm sandbox is kept; a done or cancelled task's sandbox
goes through the existing retention paths.

## What Changed

- `server/src/services/environment-retained-orphan-settlement.ts`:
- `taskMovedToAnotherWorkspace()` now requires that the lease's own task
is bound to a non-null workspace different from the lease's, and that no
task in the company is bound to the lease's workspace.
- `newestRowForSandbox()` breaks `created_at` ties by id, so two rows
written in the same millisecond can't both count as newest.
- `server/src/__tests__/environment-retained-orphan-sandbox.test.ts`,
two keep cases:
- the task has no bound workspace, for both a released and a retained
warm lease;
  - two rows share the newest `created_at`.

## Verification

- `cd server && npx vitest run
src/__tests__/environment-retained-orphan-sandbox.test.ts`: 32/32 pass.
- With master's settlement file, the null-binding case fails, because
the lease is settled. The tie case also passes on master, since the
settle's existing shared-resource hold protects it; it stays as a
regression test for the listing.
- The full chain (retained, stop confirmed, listed, handed to
destruction) is covered in one test from #20065.

## Risks

- **Fewer sandboxes cleaned.** A warm sandbox whose task finished
unbound now waits for the existing retention paths instead of this
branch. That is the conservative direction, so files are never lost.
- **Scope.** No change to the holds or the settle contract.

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool
use and code execution, via Claude Code.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:56:32 -06:00
DottaandClaude Opus 5.5 a9cf4ee731 fix(outbox): deliver a user comment whose wake was lost on a task its agent then closed (#20066)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A user's comment on a task with a live run must reach the agent: it
is steered into the run or delivered in a follow-up run. Per-comment
exactly-once delivery is plan invariant 4
> - A comment commits before its wake is written. A control-plane
restart in between loses the wake, and the reconciler's lost-wake rule
(chaos s9401/t69) saves the missing outbox entry
> - That rule only looked at `todo` and `in_progress` tasks. In the
restart chaos Daytona wave s9501, task t06 got a comment 10 ms before a
K1 kill. The live run then finished, its agent closed the task, and the
comment was never read
> - This pull request extends the rule to a task its agent closed, and
reopens that task for the user's message, as the release drain already
does
> - The benefit is that a message sent while the agent works is never
silently dropped by a restart

## Linked Issues or Issue Description

Refs #19831 (plan, section E: work-intent outbox, invariant 4), and
#19992 (the s9401/t69 and t72 rules this extends). Found by the restart
chaos campaign, wave w-20261108T142906-s9501, task t06:

- The comment was saved at 14:40:17.048.
- The K1 kill came at .058.
- No wake request ever carried the comment, and no run event shows it
steered in.
- The run that started before the comment finished at 14:44:22 and
closed the task.

## What Changed

- `recovery/ownership-invariant-report.ts`
(`checkUserCommentsWithoutWake`): also reports a comment on a `done`
task its agent closed (`taskClosedByAgentSql`), when no wake carries it,
no run started after it, and no run of the task logged it as a steered
`userMessage` item.
- `work-intent-outbox.ts` (`saveUndeliveredUserComments`): a new
`reopenAgentClosedDone` option. In the same transaction that locks the
issue, re-checks the agent close and saves the receipt, it reopens the
task to `todo` through the issues service. It also logs `issue.updated`
(reopened from done, source `undelivered_comment_backstop`).
- `native-finalization-reconciler.ts` (`applyOwnershipReportBackstops`):
the lost-wake backstop allows `done` and passes `reopenAgentClosedDone`.
The receipt keeps its `wake_lost` / `reconciler_backstop` wait, so the
existing release path delivers it once.

## Verification

- `heartbeat-held-comment-wake.test.ts`, new test "delivers once a
comment whose wake a restart lost mid-run on a task the run then
closed". It fails on `master` and passes here. The violation is found,
the backstop saves and reopens, the comment is delivered exactly once,
and a second pass and backstop deliver nothing more. All 25 tests in the
file pass.
- `ownership-reconciliation-report.test.ts`, new test "finds a user
comment that a restart left without a wake on a task its agent then
closed". It fails on `master` and passes here: only the lost comment is
reported, not one steered into the run and not one on a task a person
closed. All 26 pass.
- The server package typecheck passes.

## Risks

- **Reopening a done task.** This only happens when the latest recorded
close was not made by a person, the comment is a direct human comment,
and no run received it or had it steered in. That's the same rule the
t72 stale-continuation path and the release drain already apply.
- **The steer check** joins the task's runs to their `item.started`
events by comment ID. It runs only for candidate comments within the
lookback window.

## 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 10:08:00 -06:00
DottaandClaude Opus 5.5 3448c7f9ba fix(recovery): own abandoned warm sandboxes on the retained-orphan path (#20065)
## Thinking Path

- Paperclip runs agent tasks in remote sandboxes (Daytona). With a warm
lifecycle, a sandbox outlives its turn so the next turn on the same
workspace resumes it instead of provisioning and uploading again.
- #20048 makes warm the default on remote sandboxes. Its merge
condition: an idle warm sandbox must be an owned record, adopted by its
successor or destroyed or suspended once its owner is proven gone, with
zero orphans in a Daytona chaos wave.
- That wave (warm environment, K1/K2/K4 faults) left sandboxes alive
behind dead lease rows. Reading the wave's database shows three
warm-specific shapes that nothing ever tears down:
1. **A Retry moved the task to a new workspace.**
`retainedIssueWorkspaceForRetry` reuses only local leases, so a remote
task always gets a fresh workspace. Reuse matches only the exact
workspace, so the old sandbox is unreachable: `released` and stopped
(wave t05), or still `retained` and running.
2. **A failed warm lease.** The plugin stopped the sandbox for reuse,
but reuse never resumes a `failed` row (wave t12).
3. **A warm sandbox kept running (`retained`).** Its idle timer lived
only in the process that kept it. If that task moves on, nothing stops
the sandbox, even after a restart.
- The shared ownership path already has a kind for exactly this
lifecycle: `environment_retained_orphan_sandbox` destroys a stopped,
retained sandbox once nothing references it, but it skipped warm leases
on purpose. This PR extends that kind instead of adding a new sweep.

## Linked Issues or Issue Description

Refs #20048

**What happened?**
In Daytona warm wave w-20261108T142906-s9501:
- t05: run 8758207a's sandbox was resumed by c99b3a2e and released
stopped in workspace 35a678f0. The Retry kept a fresh workspace
(8ac860c3) and provisioned a new sandbox, and the old one stayed
forever.
- t12: run f2bf2f05's lease on sandbox 6d4c91fb was released as `failed`
with the sandbox stopped, and the next run on the same workspace
provisioned a new one.
Both sandboxes were still alive when the wave ended.

**Steps to reproduce**
1. Use a sandbox environment with warm reuse (`reuseLease: true`).
2. Complete a turn so the warm lease is kept, retained or released and
stopped.
3. Make the next run fail so the Retry keeps a fresh workspace (always
the case for a remote task), or so the lease is released as `failed`.
4. The next run provisions a new sandbox; the old one is never resumed
or destroyed.

**Expected behavior**
A warm sandbox that no turn can resume is an owned record. Once its
owning run is terminal and nothing references it, the ownership
reconciler stops it if it is still running, then destroys it. A sandbox
its task can still resume on the same workspace is left for reuse.

## What Changed

- `server/src/services/environment-retained-orphan-settlement.ts`: the
listing (`retainedOrphanSandboxCondition`) now has two warm branches,
each on the newest lease row for its sandbox:
- `abandonedWarmSandboxCondition`: a `reuse_by_environment` lease with a
confirmed `stopped` receipt for this lease and run, either `failed`, or
`released` while the task's bound workspace differs from the lease's.
The settle hands it to the cleanup sweep through the existing
stopped-cleanup contract, so the old run's successors are never blocked
on the destroy.
- `supersededRunningWarmSandboxCondition`: a `retained` (still running)
warm lease whose task moved workspace. The settle queues a stop-only
intent. When the provider confirms the stop, the row becomes `released`
with a receipt, and the first branch destroys it on a later pass.
- The settle re-checks the same predicate under the row lock. Every
existing hold still applies: the run still active, open runs or recovery
actions on the issue, pending exports, stop or restore intents, or
another live lease on the same sandbox.
- `server/src/services/sandbox-stop-and-retain.ts`: new
`prepareSupersededWarmSandboxStop`, which prepares the existing
stop-only intent from `retained`. The intent carries the recovery
marker, so the sweep dispatches its first attempt at once.
- `server/src/__tests__/environment-retained-orphan-sandbox.test.ts`,
new tests:
  - a superseded released lease is destroyed;
  - a failed warm lease is destroyed;
  - a running superseded lease is stopped, then destroyed;
- keep cases: the task can still resume it (released, and retained after
a restart), no stop receipt, a newer row on the same sandbox, a run in
flight on the task.
  The positive tests fail on master and pass with this change.

**Previous-release state:** the conditions read existing columns only
(status, lease policy, workspace binding, stop receipt). Rows the
previous release saved are judged as-is. The tests build them in exactly
the shape that release writes (no new marker), so old-saved state is
handled by the new code without migration. Rows without a confirmed stop
are never listed.

## Verification

- `cd server && npx vitest run
src/__tests__/environment-retained-orphan-sandbox.test.ts`: 29/29 pass.
With the two service files reverted to master, the 3 positive tests fail
and the other 26 pass.
- Neighbouring suites `environment-run-lease-ownership`,
`heartbeat-pending-cleanup-sweep`, `ownership-reconciliation-report`,
`heartbeat-orphaned-active-lease-sweep`,
`environment-pending-cleanup-queue-owner` and
`retained-native-session-cleanup-work`: 150/150 pass.
- The wave's lease rows were read from the run's kept database copy to
confirm the shapes above.
- Not yet covered: the same Daytona warm wave, rerun with this fix.
#20048 merges only at ≥11/12 with zero orphans.

## Risks

- **Destroying a sandbox someone can still use.** Only the newest row
for a sandbox is judged. `released` and `retained` rows qualify only
when the task's bound workspace differs from the lease's, which is the
exact rule reuse matches on. All existing holds still apply, and a stop
must be provider-confirmed before any destroy.
- **Unsaved work.** Pending native exports and stop or restore intents
hold the sandbox. A stopped sandbox is destroyed only after its owning
run is terminal and nothing on the task is still running.
- **Blocking a successor.** The destroy uses the stopped-cleanup
contract (`workspaceRecoveryCleanup` with the stop receipt), which
`hasPendingRunCleanup` treats as resource cleanup. This is covered by a
test.
- **Out of scope.** A `retain_on_failure` lease kept for workspace
restore (wave t12, a095e0b5) stays with restore recovery, which owns its
own cleanup.

## Model Used

Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), 1M context window, tool
use and code execution, via Claude Code.

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

**What happened?**

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

**Expected behavior**

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

**Steps to reproduce**

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

**Paperclip version or commit**

`master` before this PR.

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 09:46:36 -06:00
Nicky Leach 17ad23aafe fix(workspaces): back off failed terminal cleanup and retire abandoned reservations through remove (#20020) 2026-11-08 07:12:48 -08:00
DottaandClaude Opus 5.5 cf9c81d9a7 perf(run-log): record named preparation phases before dispatch as run-log spans (#20046)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Every native run records performance spans in its run log, the
instance-local `heartbeat_run_events` table, so slow turns can be
diagnosed without any external service
> - The steps between environment acquisition and dispatch are timed by
named preparation phases, but those timings reach only OpenTelemetry,
which is off unless an operator configures an endpoint
> - On a Daytona follow-up in a paperclip-sized workspace, 17–30 s sat
in one opaque span, `heartbeat.prepare_after_environment`
> - This pull request also records the named preparation phases, and
their nested steps, as run-log spans
> - The benefit is that the next slow step shows up by name in the run
log; this change found a full workspace re-upload on every remote turn

## Linked Issues or Issue Description

**What happened?**
The run log showed `heartbeat.prepare_after_environment` at 17–30 s on
Daytona follow-ups, with nothing inside it. The phases that would
explain it (`heartbeat.prepare.*`, `runtime_selection.*`,
`workspace_sync.*`) were recorded only as OpenTelemetry spans, and those
are a no-op without an OTLP endpoint.

**Steps to reproduce**
1. Run a Codex task on a Daytona environment with a large repository as
the project workspace.
2. Post a follow-up comment.
3. The run log shows one large `heartbeat.prepare_after_environment`
span with no children.

**Expected behavior**
The run log names the steps that make up the wait.

## What Changed

- `execution-preparation-timing.ts`: every named preparation phase
(`measure`) and nested run phase (`measureRunPhase`) records its start,
end and outcome in a per-run in-memory list.
- Bounds: phases under 50 ms are skipped, at most 200 per run, at most
256 runs held.
- Without a tracer, the run scope is still set, so nested phases reach
the run.
  - OpenTelemetry behavior is unchanged.
- `runtime-execution.ts`: at native dispatch, the recorded phases are
drained into the run's preparation spans under `task.prepare`.
- `runtime-selection.ts`: the remote Codex CLI version read and the
runtime context build get their own phases,
`runtime_selection.codex_cli_version` and
`runtime_selection.runtime_context`.

## Verification

- `execution-preparation-phases.test.ts`: a slow phase is recorded and a
fast one skipped, a failure is marked, draining clears the run, runs are
kept apart, and a nested `measureRunPhase` step is recorded without a
tracer.
- `database-operation-attribution`, `company-skills-observability` and
`github-catalog-timing`: pass (15 tests in total).
- Daytona Codex run in a fresh paperclip clone: the follow-up's 17.8 s
gap now reads `heartbeat.prepare.runtime_selection` 15.7 s, then
`runtime_selection.workspace_sync` 15.4 s, then
`workspace_sync.transfer` 13.3 s.

## Risks

- This is a run-log change only, not Telemetry: the data stays in the
instance database. It adds roughly 10–40 spans per run, bounded as
above.
- The recorder is in memory and per process. A run that never dispatches
is evicted when the cap of 256 runs is reached.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:18:16 -06:00
DottaandClaude Opus 5.5 96badeaa42 fix(lifecycle): never repeat a passed harness verification; settle a dead verifier's lease at once (#20030)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

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

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:40 -06:00
DottaandClaude Opus 5.5 d5cbe70636 fix(workspaces): create git worktrees one at a time per repository; retry another process's config lock (#20039)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Tasks in a project with git_worktree isolation each get their own
worktree, created with `git worktree add -b <branch> <path> <base>`
> - That command writes the repository's shared `.git/config` to record
the new branch's upstream
> - When two tasks start at once on one repository, both write
`.git/config`, and one fails with "could not lock config file"
> - The failed attempt already created its branch, so the retry attached
that branch without upstream tracking and without runtime ownership
> - This pull request serializes worktree creation per repository and
retries another process's brief config lock cleanly
> - The benefit is that simultaneous tasks on one repository start
without a failed attempt or a degraded branch

## Linked Issues or Issue Description

**What happened?**
In a TTFT measurement with a fresh clone of the paperclip repository as
the project workspace, two agents started tasks at the same time. The
Codex task's `git worktree add -b` failed twice ("error: could not lock
config file .git/config: File exists"). It then succeeded by attaching
the branch the failed attempt had created.

**Steps to reproduce**
1. Use a project whose primary workspace is a git clone, with
git_worktree isolation and a remote-tracking base ref.
2. Start several tasks in that project at the same moment.
3. Some worktree creations fail with "could not lock config file". The
task falls back to attaching the existing branch, without upstream or
branch ownership.

**Expected behavior**
Every simultaneous task gets a fresh worktree on its own new branch,
with upstream tracking, owned by the runtime.

**Root cause**
`git worktree add -b` writes `.git/config`, which git locks with
`config.lock`. Concurrent creates on one repository contend for that
lock. On a lock failure, git has already created the branch but no
worktree.

## What Changed

- `executeGitCommand` (in `workspace-runtime.ts`) runs every `git
worktree add` from `recordGitOperation`. The recorded and unrecorded
paths both use it.
- Creates in this process run one at a time per repository, keyed by the
realpath of `git rev-parse --git-common-dir`. Linked worktrees and the
primary checkout share one key.
- A create that still meets a config lock held by another process
retries after 100, 250, 500, 1000 and 2000 ms. Before each retry it
removes the branch that same `-b` command created. Git refuses an
existing branch with "already exists" before it touches config, so a
branch present after a config-lock failure is one this command made. The
retry then creates the branch again with its upstream.

## Verification

- `workspace-worktree-concurrency.test.ts`:
- Three rounds of ten simultaneous `realizeExecutionWorkspace` calls on
a fresh clone with base `origin/main`. Every workspace is `created:
true` and `branchCreatedByRuntime: true`, on its own branch. Without the
serialization it failed 3 of 3 runs.
- A create that meets `.git/config.lock`, held for 300 ms as another
process would, succeeds with the branch's upstream recorded.
- `workspace-runtime.test.ts`: 179 tests pass.

## Risks

- Worktree creation on one repository is now serial within a process.
Each create takes about 0.1–1 s, so ten simultaneous tasks add a few
seconds at most. Creates on different repositories still run in
parallel.
- The branch deletion runs only after a config-lock failure of a `-b`
create, when the branch is provably new.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:44:27 -06:00
DottaandClaude Opus 5.5 ba867dea39 fix(recovery): keep the orphan marker for a stop intent saved before #19976 (#20043)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a restart, the control plane stops and keeps the sandbox of an
orphaned run lease, then destroys it once nothing references it
> - #19964 marked such a sandbox only inside its stop intent; #19976
added the top-level marker the destroy looks for
> - A stop still pending when #19976 shipped lost the intent on success,
so the destroy never saw that sandbox
> - This pull request carries the orphan marker forward when a stop
intent that says `recoveredOrphanedRunLease` is confirmed
> - The benefit is that those sandboxes are destroyed like every other
retained orphan

## Linked Issues or Issue Description

Refs #19964 and #19976. This is a follow-up gap from the Phase 2
ownership migration. No duplicate PR was found.

**What happened?**

Between #19964 and #19976, the shipped builds were
`canary/v2026.1108.0-canary.6` and `canary.7`. In that window the
orphaned-run-lease settle wrote `recoveredOrphanedRunLease: true` only
on the `sandboxStopAndRetain` intent. `settleStopOnlyCleanup` removes
the intent when the stop is confirmed, and the retained-orphan kind
lists only leases with the top-level `orphanedRunLeaseRecovery` marker.
So an orphan whose stop was still pending at upgrade stays `released`
with a stopped sandbox forever.

**Expected behavior**

Once its stop is confirmed and nothing references it, the orphan is
destroyed through the retained-orphan settle, whichever build wrote the
intent.

**Steps to reproduce**

1. Write a `pending_cleanup` lease with a `sandboxStopAndRetain` intent
that has `recoveredOrphanedRunLease: true` and no top-level marker (what
canary.6/.7 wrote).
2. Confirm the stop through `settleStopOnlyCleanup`.
3. Run an ownership reconciliation pass. Before this PR the
retained-orphan kind never lists the lease.

**Paperclip version or commit**

`master` before this PR. Rows written by `canary/v2026.1108.0-canary.6`
and `canary.7`.

## What Changed

- `settleStopOnlyCleanup` writes the top-level
`orphanedRunLeaseRecovery` marker (`fromStopIntent: true`) when a
confirmed stop's intent carries `recoveredOrphanedRunLease: true` and
the lease has no marker yet.
- An ordinary stop-and-retain (no recovery flag) is not marked.

## Verification

- Red, before the change: "keeps the orphan marker for a stop intent
saved before the top-level marker existed" failed.
- Green: `environment-retained-orphan-sandbox` and
`environment-run-lease-ownership` 36/36; `environment-runtime` and
`heartbeat-pending-cleanup-sweep` 246/246.
- Counter-case: an ordinary stop-and-retain intent stays unmarked, and
the retained-orphan pass does not settle it.

## Risks

- Not covered: an orphan whose stop was confirmed *while* canary.6/.7
ran kept no durable marker at all. Its intent was removed and the
receipt never carried the flag, so it cannot be told apart from an
intentional stop-and-retain. Those rows exist only on instances that ran
those two canaries during about three hours, and the provider's own
auto-delete bounds them.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:22:16 -06:00
DottaandClaude Opus 5.5 cefd17a82b fix(environments): renew the stop-only cleanup claim while the provider stop runs (#20042)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a run ends with a "stop and retain" disposition, the control
plane stops the agent's sandbox and keeps it for later recovery
> - The release claims the lease with a 15-minute in-flight fence, so no
cleanup sweep acts on it at the same time
> - The stop-only path never renewed that fence; a provider stop that
outlives 15 minutes lets a sweep take the lease over and stop it again
mid-flight
> - This pull request renews the stop-only claim every 30 s while the
provider stop runs, like the destroy claim and the pending-cleanup sweep
> - The benefit is one owner per cleanup attempt, however slow the
provider is

## Linked Issues or Issue Description

Follow-up gap from the Phase 2 ownership migration (durable ownership
and reconciliation plan). Related: #19966 (teardown claim on the shared
path), #19980 (pending-cleanup queue owner). No duplicate PR was found.

**What happened?**

`releaseRunLeases` with `providerResourceDisposition: "stop_and_retain"`
claims the lease (`pendingCleanupInFlight: true`,
`pendingCleanupLeaseExpiresAtMs: now + 15 min`) and calls the provider
stop. Nothing renewed the fence while the stop ran. The destroy claim
and the pending-cleanup sweep both renew theirs every 30 s.

**Expected behavior**

While this process is still running the provider stop, its claim stays
fenced, so no other sweep starts a second attempt.

**Steps to reproduce**

1. Release a run lease with `stop_and_retain` against a provider whose
stop RPC blocks.
2. Let more than 15 minutes pass.
3. See that the fence expired while the first stop is still in flight.

**Paperclip version or commit**

`master` before this PR.

## What Changed

- New `renewCleanupClaim(leaseId, attemptId, failure)` in
`environment-runtime.ts`. It renews `pendingCleanupLeaseExpiresAtMs`
every 30 s, only while the row still names this attempt and is in
flight.
- The destroy claim now uses the helper; its behaviour is unchanged.
- The stop-only claim starts the renewal right after the claim and stops
it before it settles the attempt, on success, on failure and in
`finally`.

## Verification

- Red, before the change: `environment-runtime.test.ts` "renews the
stop-only cleanup claim while the provider stop is still running"
failed. The fence stayed at its aged value.
- Green: `pnpm exec vitest run src/__tests__/environment-runtime.test.ts
src/__tests__/heartbeat-pending-cleanup-sweep.test.ts`, 247/247.

## Risks

- Low. One extra metadata write every 30 s per in-flight stop, the same
as for the destroy claim. The renewal is fenced on the attempt id, so it
never extends another attempt's claim.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:53:40 -06:00
DottaandClaude Opus 5.5 c966334ec3 fix(recovery): settle a run-less lease once its stamped holder is proven gone (#20026)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The control plane recovers in-progress records after a server
restart through a shared ownership path
> - Run-less sandbox leases (login, probe, harness verification) were
released only by a five-minute idle sweep
> - That sweep releases by age: it leaves a dead holder's sandbox for
minutes and takes a live login session's sandbox
> - This pull request settles a run-less lease only when its stamped
holder is proven gone
> - The benefit is no stuck sandbox after a restart, and no live session
loses its sandbox

## Linked Issues or Issue Description

Related: #19965 (named the owner of login/probe leases), #19964 (the
run-lease settle this mirrors). No duplicate PR was found.

**What happened?**

The final Daytona chaos wave `w-20261108T083036-s9401` recorded I2
`environment_login_probe_lease` (`pid_dead`) on 2 records. These were
the codex agent's harness-verification leases. A restart killed their
holder. The leases stayed `active` until the five-minute idle sweep
released them. The same sweep releases any run-less lease after five
idle minutes, also when its holder is alive.

**Expected behavior**

A run-less lease is released as soon as its stamped holder is proven
gone. A lease whose holder is alive is never released, however long it
is idle.

**Steps to reproduce**

1. Start a Daytona wave: `node cli/node_modules/tsx/dist/cli.mjs
tests/restart-chaos/main.ts --env daytona --daytona-image <image>`
(K1/K4 faults).
2. Let a fault kill the server while an agent's harness verification
holds its sandbox lease.
3. See the reconciler report I2 `environment_login_probe_lease` until
the idle sweep releases it.

**Paperclip version or commit**

`master` at `8475826d2d`.

## What Changed

- New `settleEnvironmentLoginProbeLease`, registered on
`environment_login_probe_lease`. It runs only for a stamped holder that
is proven gone, and compares and swaps on that exact stamp.
- It moves the lease to `pending_cleanup` with the recovered-orphan
reason, so the cleanup sweep destroys the sandbox in its next tick.
- It leaves the lease alone when a legacy shutdown fence applies, or
when another live lease holds the same provider resource.
- The idle sweep for run-less leases now covers only legacy rows saved
before holders were stamped (no `leaseController` key). Stamped rows are
never released by age.
- `holderUnchanged` is now exported from the run-lease settle so both
settles share it.
- The controller stamp (`leaseController`, and every other
`ControllerProcess` stamp) now records the platform deployment when the
platform names one (Railway: project, environment, service, deployment,
volume). `controllerStamp` passes it to `proveOwnerGone`. A container
redeploy changes the PID space, and the old stamp would read
`different_process_space` (unprovable) forever. Now the existing
`deployment_replaced` proof shows the old holder gone. This also covers
`environment_run_lease_active` and the other kinds built on
`controllerStamp`.
- `currentDeploymentIdentity` in `owner-proof.ts` now delegates to
`currentControllerDeployment`, so there is one parser.
- Data doc: added the final Daytona wave, the six Daytona-only crash
points (4 pass, 2 not reached, no violations), and the cause of the
roughly 450 s codex TTFT. Runs waited behind the agent's harness
verification, which restarted after each fault; boot itself took about
32 s.
- Restart-chaos README: corrected which Daytona crash points a plain
task reaches.

## Verification

- Red, before the change:
`environment-login-probe-lease-ownership.test.ts` failed 3 of 6. No
settle happened, and the idle sweep released 3 live holders' leases.
- Green: `pnpm exec vitest run` passes for
`environment-login-probe-lease-ownership`,
`environment-run-lease-ownership` and
`heartbeat-orphaned-active-lease-sweep` (45/45), and for
`environment-lease-owner-stamps`,
`heartbeat-observed-workspace-preservation`,
`ownership-reconciliation-report`, `heartbeat/recovery` and
`workspace-recovery-warm-handoff`.
- Redeploy: a stamp from a replaced Railway deployment settles with
`deployment_replaced`. A stamp from the same deployment in another PID
space stays `active`. This was red when `controllerStamp` dropped the
deployment.
- Live-owner counter-cases: the current process, another live PID,
another PID space, and a lease re-stamped after the proof all stay
`active`. A read-only pass never settles.
- Not re-run live: the Daytona wave and crash points ran before this
fix. The next Daytona wave should show no
`environment_login_probe_lease` I2.

## Risks

- A stamped run-less lease whose live holder never releases it, because
of a bug in that process, is no longer released after five idle minutes.
It is released once that process exits. This is the intended trade:
never take a live holder's sandbox.
- On an ephemeral server without a platform deployment identity (not
Railway), a stamp from a replaced container is still unprovable. Such a
run-less lease stays `active` until the provider expires the sandbox,
and the reconciler reports it as unprovable. Before this PR, the
five-minute sweep released it by age. This is the same open gap as
unstamped legacy leases on an ephemeral server: there is no operator
action yet. It is listed as a Phase 2 follow-up.
- Legacy unstamped run-less leases keep the age fallback. These are rows
written before #19965, and no current code writes them.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:43:45 -06:00
DottaandClaude Opus 5.5 20cc6faca3 feat(recovery): settle legacy shutdown fences of proven-gone servers on the shared path (#20017)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a server shuts down gracefully during a sandbox run, it commits
a fence on the run's lease so no cleanup worker destroys the sandbox
before its files are exported
> - The fence names its server only by boot id. It is released when its
owner settles, or when its 30 s deadline (renewed every 10 s) passes.
After a restart, the sweep still waits for that deadline even though the
old server is gone
> - The durable ownership plan proves owners by identity through one
shared prover and settles them from one reconciler
> - This pull request stamps the committing process on the fence and
settles it when the shared prover shows that process gone. The outcome
is the same safe one an expired deadline gives: the source is preserved
and the restore failure recorded
> - The benefit is that the fence releases on proof, not age, and a
fence whose owner may still be alive (another host, or an older marker)
keeps its deadline

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory: "Legacy
graceful-shutdown workspace settlement fence", and "A boot-id mismatch
alone does not release").

**What happened?**
`legacy_shutdown_settlement_fence` was report-only, and its owner stamp
had only a boot id. A fence left by a crashed or replaced server could
be released only by its deadline.

**Expected behavior**
A fence whose committing process is proven gone (a dead or reused PID in
the same PID space) settles from the recovery sweep and from the shared
reconciler. An unprovable owner keeps the renewed deadline as its fenced
takeover.

## What Changed

- `legacy-shutdown-workspace-settlement.ts`:
- `beginLegacyShutdownWorkspaceSettlement` stamps `controller`
(`currentControllerProcessStamp`, no I/O).
  - New `legacyShutdownSettlementOwnerStamp`.
- `allowLegacyShutdownWorkspaceCleanup` takes `ownerProvenGone`, which
replaces only the deadline check, and only while the marker still names
that process. Every other guard is unchanged: owner stop, competing
allocations, in-flight cleanup.
- The sweep also proves the owners of fences from other boots that are
still before their deadline.
  - New `settleLegacyShutdownSettlementFence` for the reconciler.
- `recovery/ownership-registry.ts`: the kind uses the full stamp when
present and registers the settle.
- The settling-kinds list in `ownership-reconciliation-report.test.ts`
is updated.

## Verification

- `heartbeat/recovery.test.ts`: 100 pass. Three new tests fail on
`master` and pass here:
  - the fence stamps the committing process;
- a previous server proven gone settles before the deadline (restore
failure recorded);
- the reconciler settles only the proven-gone owner (a read-only pass
changes nothing). Another host and a boot-id-only marker stay pending
until their deadline, then expire as before.
- `ownership-reconciliation-report.test.ts` (25) and
`environment-retained-orphan-sandbox.test.ts` (19) pass.
- The server package typecheck passes.

## Risks

- A proven-gone owner now expires the fence up to 30 s earlier than
before. Expiry preserves the source (stop-and-retain or a
`retain_on_failure` hold) and never destroys provider files.
- Markers written before this change have no `controller` and behave
exactly as before.

## 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 06:15:36 -06:00
DottaandClaude Opus 5.5 e3a941372d fix(api): tell agents how a person connects their terminal (#20029)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People talk to agents in chat. Agents find API operations with the
`search_api` tool, which reads the OpenAPI catalog.
> - A person asked a chat agent to connect their terminal to Paperclip
as themselves.
> - The agent found the CLI auth operations, but their summaries did not
say who starts the flow or which command to run.
> - The agent then read source code for six minutes and did not answer
in time.
> - This pull request adds descriptions to the CLI auth operations. The
descriptions tell the agent to give the person the `npx paperclipai auth
login` command, and to not create or approve a challenge, share an agent
key, or ask for a password.
> - The benefit is a fast, safe answer that uses the supported browser
approval flow.

## Linked Issues or Issue Description

**What happened**
A chat agent got "Connect my terminal to Paperclip as me." It searched
the API catalog and found `POST /api/cli-auth/challenges`, approve, `me`
and `revoke-current`. These operations had only short summaries. The
agent did not know that the person's own CLI starts the flow. It
searched the repository source for the command and used its full
420-second turn without a reply.

**Expected behavior**
The agent tells the person to run `npx paperclipai auth login --api-base
<instance URL>` in their own terminal and approve the printed link in
their browser. The agent does not create or approve a challenge, share
an agent key, or ask for a password.

**Steps to reproduce**
1. Open a chat with an agent that uses the native runner.
2. Send "Connect my terminal to Paperclip as me."
3. Look at the run: the agent calls `search_api` for CLI and terminal
words, finds the cli-auth operations with no usage guidance, and then
reads source files.

## What Changed

- `server/src/routes/openapi.ts`: add descriptions to the four CLI auth
operations. Give `POST /api/cli-auth/challenges` a summary that names
terminal connection, so a plain request finds it.
- The challenge description also tells agents that the URL is the
address the person already uses in their browser. An agent must not look
it up, because a runner-local API address can be unreachable from the
person's machine.
- `server/src/__tests__/cli-auth-terminal-guidance.test.ts`: a
regression test. It runs `searchRunnerApi` with the user's words and two
queries from the failed run. It checks that the challenge operation is
in the top five results and that its description gives the command and
the safety rules.

## Verification

- `pnpm exec vitest run
src/__tests__/cli-auth-terminal-guidance.test.ts` (in `server/`): 4
passed. On `master` without the fix, the same 4 tests fail.
- `pnpm exec vitest run src/__tests__/openapi-routes.test.ts
src/services/native-runtime/runner-api.test.ts`: 2103 passed.
- `npx tsc --noEmit -p server`: clean.
- Chat eval `access.cli-authorization` with the first commit
cherry-picked passed (run 42475844547). The agent found the guidance in
its first `search_api` call and gave the correct command. It then used
about four minutes to look for the instance URL. The second commit
removes that search; its rerun is run 42477301708.

## Risks

- Low risk. The change adds text to API documentation only. Routes,
permissions and responses do not change.
- The new summary changes search ranking for words like "connect",
"terminal" and "Paperclip". Other operations can move down by one place
for those queries.

## 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 06:02:07 -06:00
DottaandClaude Opus 5.5 82a1fda9b1 feat(recovery): settle observed-workspace preservation fences on the shared path (#20022)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a native run on a remote sandbox fails mid-turn with unsaved
files, Paperclip pins an observed-workspace preservation fence. The
sandbox is stopped and retained, never deleted, and the run is settled
only after the provider confirms the stop
> - The fence is held by the run's expired coordinator lease. Its
release proof is the provider's stop receipt, and only the
environment-settlement worker acted on it
> - The ownership reconciler could not see these fences at all: they sit
on the preserved-workspace recovery action, which is resolved (no
replay) while the fence still waits
> - The durable ownership plan has every in-progress record visible to
the one reconciler, and settled from it once its owner is proven gone
> - This pull request makes the fence visible, proves it by the provider
stop, and settles it through the same function the worker already uses
> - The benefit is that a fence whose sandbox stopped can no longer sit
unseen, and expiry alone never releases one

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C), #19838 (inventory:
"Observed-run workspace preservation fence").

**What happened?**
`recoveryActionKind` lists only unresolved recovery actions, so
`observed_workspace_preservation_fence` reported nothing: its fence
lives on a resolved action. The kind registered no settle, and its fence
owner never carried the provider proof.

**Expected behavior**
Every `stop_pending` fence is listed. The fence is `gone`
(`provider_stopped`) once its run's one remote lease has a termination
receipt and a confirmed stop-and-retain. The reconciler then settles it
with `settleStoppedObservedWorkspace`, which re-checks every fence field
under its own locks. An expired coordinator lease without that receipt
is `unprovable` and is never released.

## What Changed

- `recovery/ownership-registry.ts`:
- `recoveryActionKind` takes `includeResolved`, which only the observed
fence uses.
- `resolveFenceHolders` adds `provider: "stopped"` for observed fences
whose run lease carries the stop proof.
- The kind registers a settle that calls
`settleStoppedObservedWorkspace`. A source-busy lock conflict is kept
for a later pass.
- `native-observed-workspace-preservation.ts`: pinning a fence wakes the
ownership worker in the same transaction.
- The settling-kinds list in `ownership-reconciliation-report.test.ts`
is updated.

## Verification

- `heartbeat-observed-workspace-preservation.test.ts`: 29 pass. The new
test "settles the fence from the shared reconciler once the provider
stop is proven, never before" fails on `master` and passes here:
- a pinned fence with an expired lease is
`fence_expired_without_provider_proof` and is not settled;
- after the provider stop, a read-only pass sees `provider_stopped` and
changes nothing;
- the settle moves the fence to `stopped` and the coordinator to
`terminal_failure`, and the saved files are untouched.

All 15 existing "keeps unsafe observed source held" cases and the
stale-evidence cases still pass.
- `ownership-reconciliation-report.test.ts` (25) passes.
- The server package typecheck passes.

## Risks

- The kind's listing now includes resolved recovery actions that carry a
`stop_pending` fence. Like every recovery-action kind, it is an
evidence-predicate scan; no new index is added.
- The settle is the existing worker's function with all its fence
checks. The reconciler is a backstop for that worker, not a new release
rule.

## 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:56:08 -06:00
DottaandClaude Opus 5.5 7c56147bb3 fix(workspaces): a busy-workspace holder whose controller lease is held is live, however silent (#20023)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Two runs must never write one workspace at once. A run that finds a
workspace held (a remote workspace's writer run, or a running holder of
a shared local tree) waits behind it
> - The holder counted as live only while it produced output: after 60
minutes of silence it was "stale"
> - For the shared local tree, that meant the waiter went ahead while
the silent holder might still be working, which is a second writer.
>   - For a remote workspace, the waiter failed instead of waiting.
> - Silence is not proof a run stopped. A long tool call can be silent
and alive
> - The durable ownership plan proves ownership by identity or by a held
fence, never by age
> - This pull request treats a holder whose controller still renews its
lease (the legacy controller lease or the native coordinator lease) as
live, however silent. The 60-minute rule applies only once that fence
lapses
> - The benefit is that a silent but live holder is never overtaken. A
dead holder is still settled by the run reapers, which wake the waiters

## Linked Issues or Issue Description

Refs #19831 (plan, Phase 2 sections A and C, and Phase 3 E), #19838
(inventory: "Remote workspace writer lane", and the "Released only by
TTL/age" list: "Remote writer lane: 60 min").

**What happened?**
`WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS` (60 min of output silence)
decided whether a running holder still held a workspace. This applied in
four places:
- the remote reservation deferral;
- the parked busy-workspace wait;
- `findSharedWorkspaceHolder`, which stopped counting the holder at all;
- `findExecutionWorkspaceHolder`, which flagged it stale.

**Expected behavior**
A running holder whose controller lease is held is live by fence. Only a
holder with no held fence is judged by the silence cutoff, which keeps
the visible failure instead of parking forever. The check is
database-only (`runOwnerFenceHeldSql`): no process probe on the
workspace-preparation path before a turn starts.

## What Changed

- `remote-workspace-writer-hold.ts`: new `runOwnerFenceHeldSql`. It is
true while `heartbeat_runs.controller_lease_expires_at > now()` or the
run's native coordinator lease is held.
`RemoteWorkspaceWriter.ownerFenceHeld` is reported for a running writer.
- `heartbeat/workspace-preparation.ts`: the reservation keeps deferring
behind a silent writer whose fence is held.
- `run-dispatch/adapters/workspace-busy-wait.ts`: a parked wait behind a
running holder with a held fence stays parked and re-checks at the
parked cadence.
- `heartbeat/retries.ts`: `findSharedWorkspaceHolder` counts a silent
holder whose fence is held. `findExecutionWorkspaceHolder` flags a
holder stale only when it is silent and its fence lapsed.

## Verification

- `heartbeat/workspace-preparation.test.ts`: 138 pass. The extended
"waits for the live writer of an inherited remote … at reservation"
(both modes) fails on `master` and passes here: a silent writer with a
held lease defers, and once the lease lapses it is the 409 as before.
- `heartbeat-workspace-busy.test.ts`: 41 pass. The new "still defers
behind a silent holder whose controller renews its lease" fails on
`master` and passes here. The existing "does not defer when the only
holder has been silent past the staleness threshold" (no lease) still
passes.
- The server package typecheck passes.

## Risks

- A silent holder whose controller is alive now blocks waiters for as
long as it holds its lease. That is the safe direction. A hung but live
run is the active-run silence watchdog's to handle, and a dead
controller's lease lapses within a minute.
- The extra predicate is an indexed lookup of the run's own row and its
coordinator row.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:50:33 -06:00
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>
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>
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 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
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