Commit Graph
2740 Commits
Author SHA1 Message Date
DottaandClaude Opus 5.5 d79b7f29f2 fix(heartbeat): bind queued card-answer interrupts to the delivered answer (#20698)
## Thinking Path

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

## Linked Issues or Issue Description

Refs #14043, #14510

**What happened**

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

**Expected behavior**

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

**Steps to reproduce**

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

## What Changed

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

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

Refs #16355

**What happened?**

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

**Expected behavior**

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

**Steps to reproduce**

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

No public issue exists. This is the bug description.

**What happened**

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

**Expected behavior**

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

**Steps to reproduce**

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

**What happened**

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

**Expected behavior**

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

**Steps to reproduce**

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

## What Changed

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

## Verification

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

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

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

## Risks

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:35:39 -06:00
DottaandClaude Opus 5.5 c444da7d05 perf(run-events): stop blocking checkpoint FK checks during event appends (#20670)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native run sends a stream of runner events. The server appends
each event to the run log. The runner controller then saves a checkpoint
for recovery.
> - Each checkpoint save inserts the new `event:N` row and updates the
`cursor` row in `native_runner_checkpoints`. The new row has a foreign
key to the run row in `heartbeat_runs`.
> - The event append locks the same run row `FOR UPDATE`. That lock
blocks the `FOR KEY SHARE` lock that the foreign key check takes. So
each checkpoint save waits for the event append that is in progress.
> - On a hosted database, the two-row checkpoint upsert had the largest
total SQL time. The one-row (cursor-only) upsert, which has no foreign
key check, was much cheaper.
> - This pull request changes the append lock to `FOR NO KEY UPDATE`.
Appenders still serialize. Child foreign key checks no longer wait.
> - The benefit is shorter checkpoint saves, shorter waits before each
runner ACK, and less lock wait on the database.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The controller checkpoint save for native runs
(`nativeRunnerCheckpointWriter`), and the native run-event append
(`appendHeartbeatRunEvent`).

**Subsystem affected**
Server run-event log and native runtime checkpoint persistence.

**Current behavior**
On one hosted database, Neon query performance showed this statement as
the largest total: `insert into native_runner_checkpoints ... values
(...), (...) on conflict (run_id, part) do update`. It ran 6,582 times
at 62.6 ms (412 s) in the first 38 minutes after a compute restart, and
9,435 times at 31.8 ms (300 s) about two hours later. An earlier day
showed 100,650 calls at 102 ms. The one-row form of the same upsert ran
965 times at 13.4 ms.
The runner already writes only changed records (`captureCheckpoint`
compares plaintext), so unchanged material is not rewritten. The two-row
form is a new event row plus the cursor. Only the new row fires the
foreign key check. `appendHeartbeatRunEvent` locks the run row `FOR
UPDATE` for each native event, in a transaction with several statements.
`FOR UPDATE` conflicts with `FOR KEY SHARE`, so the check waits for that
transaction.

**Proposed behavior**
`appendHeartbeatRunEvent` locks the run row `FOR NO KEY UPDATE`. This
mode still conflicts with other appenders, `FOR SHARE`, `FOR UPDATE`,
deletes and updates. It does not conflict with `FOR KEY SHARE`. The
append never changes key columns: `runUpdate` is limited to process
fields, and the native and retry-exhaustion paths reject it. The fast
path already uses a plain `UPDATE`, which takes the same lock mode. The
execution-admission adapter already locks this row `FOR NO KEY UPDATE`.

**Reason and benefit**
The wait goes in both directions. A checkpoint save waits for an append.
An append also waits for an open checkpoint transaction that holds `FOR
KEY SHARE`. Both waits stop. The controller sends each ACK only after
its checkpoint commits, so shorter saves also shorten the event loop.

## What Changed

- `server/src/services/heartbeat-run-events.ts`: lock the run row `FOR
NO KEY UPDATE` instead of `FOR UPDATE` in the transactional append path.
Add a comment that names the contract.
- `heartbeat-run-event-atomic-append.test.ts`: add a regression test. An
append transaction stays open. A checkpoint row insert for the same run
must finish under a 2 s `lock_timeout`. A second appender must still
wait. After release, the sequence is 1, 2 with no gap and no duplicate.
The existing test with 40 concurrent appenders on two connections still
proves contiguous sequence numbers. Update the existing lock-statement
assertion to the new clause.
- Add `scripts/benchmark-run-event-checkpoint-lock.ts`. It replays a
native event stream with the real append and the real checkpoint writer
on separate connections. It times each checkpoint save for a baseline
append module and the current one.

## Verification

- Red: with the old `FOR UPDATE` clause, the new test fails with
`canceling statement due to lock timeout`. With the change it passes.
- `npx vitest run
src/__tests__/heartbeat-run-event-atomic-append.test.ts
src/__tests__/heartbeat-run-event-sequencing.test.ts
src/services/native-runtime/native-runner-checkpoint-authority.integration.test.ts
src/__tests__/native-recovery-notice-work.test.ts` (server): 4 files, 42
tests passed.
- `pnpm --filter @paperclipai/server typecheck`: passed.
- `pnpm verify` exits 2, because a new script under `scripts/` is a
global CI input. Required CI supplies the broad coverage.

Local benchmark. Embedded PostgreSQL 18.1 on a laptop. 400 events per
pass, 2 KiB event payload, 6 KiB header, real encryption. Order:
warm-up, baseline, current, baseline, current. `HOLD_MS` keeps each
append transaction open longer. It stands in for hosted round trips.

| Hold | Pass | Median save | p90 | Max | Saves > 2 ms | Total save time
|
| --- | --- | --- | --- | --- | --- | --- |
| 5 ms | Baseline (1) | 8.16 ms | 9.50 ms | 129.48 ms | 396 / 400 |
3,551 ms |
| 5 ms | Current (2) | 1.44 ms | 2.08 ms | 12.66 ms | 44 / 400 | 677 ms
|
| 5 ms | Baseline (3) | 8.12 ms | 9.64 ms | 22.08 ms | 398 / 400 | 3,453
ms |
| 5 ms | Current (4) | 1.50 ms | 2.37 ms | 10.54 ms | 80 / 400 | 700 ms
|
| 0 ms | Baseline (1) | 1.71 ms | 2.50 ms | 11.05 ms | 105 / 400 | 780
ms |
| 0 ms | Current (2) | 0.97 ms | 1.42 ms | 10.28 ms | 22 / 400 | 458 ms
|
| 0 ms | Baseline (3) | 1.49 ms | 2.02 ms | 6.45 ms | 41 / 400 | 667 ms
|
| 0 ms | Current (4) | 0.91 ms | 1.24 ms | 9.80 ms | 19 / 400 | 420 ms |

- Event order: the controller frame chain awaits each event's append and
its local commit, but not its checkpoint save. The ACK goes through
`#sendJson`, which queues the save and does not block the next frame.
Only semantic inputs and command results await the save. So the save for
event N overlaps the append of event N+1. Streaming events
(`item.delta`) also go through the same locked append, with no
checkpoint save, so a save can also overlap them. The benchmark uses
this order.
- Control without overlap (`SERIAL=1`, each save finishes before the
next append, 5 ms hold): baseline median 1.15 / 1.07 ms, current 1.03 /
1.00 ms. There is no material difference. The gain comes only from the
removed lock wait.
- Results are the same in every pass. The script asserts that all event
rows, the header and the cursor are stored, and that the run-log
sequence is contiguous.
- Command: see the script header. It needs a baseline copy of
`heartbeat-run-events.ts` from the base revision.

## Risks

- Low risk. The SQL change is one lock clause. Appenders still serialize
with each other, with `FOR UPDATE` and `FOR SHARE` lockers, and with
updates and deletes of the run row. Run deletion and key changes take
`FOR UPDATE`, so they still wait for an append, and an append still
waits for them.
- The only new concurrency is with `FOR KEY SHARE` lockers. In this code
base those are foreign key checks of child inserts. The one explicit
`FOR KEY SHARE` read is on `issues`, not on `heartbeat_runs`. A foreign
key check only needs the run row and its key to exist. The append does
not delete the row or change its key.
- The hosted cause is inferred. Neon shows calls and mean time only. We
did not read lock waits on the hosted database. The local proof shows
the mechanism and the size of the effect. A new query-performance window
after deployment must confirm the result.
- Persisted run state: no saved run, session, event or checkpoint shape
changes. State saved by the previous release carries forward unchanged.
The lock mode applies only inside one transaction, so a deploy during a
run needs no special case.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:27:58 -06:00
DottaandClaude Opus 5.5 81c52e3666 perf(chat): find terminal GitHub responses by issue run indexes (#20671)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The GitHub chat channel posts a terminal response when an agent run
for a GitHub request stops. A background sweep finds acknowledged
response comments (`processed`, not final) whose issue is blocked or
whose latest run is terminal
> - For each candidate, the sweep reads the status of the latest run of
the assigned agent for the issue. It matches runs with
`coalesce(native_issue_id::text, context_snapshot->>'issueId')`
> - No index has that expression. So PostgreSQL reads every run of the
agent for each candidate, and it decompresses the large legacy
`context_snapshot` of each run
> - On a hosted test database, this statement ran 13 times at a mean of
11 s in a 38-minute window (149 s in total), with single runs of 16 s
and 41 s
> - This pull request splits the run match into its two disjoint
branches. Each branch uses an existing issue index
> - The benefit is that each candidate reads a few index entries instead
of the agent's full run history. The returned rows do not change, and no
migration is necessary

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The discovery page query in `registerGitHubTerminalResponseWork` in
`server/src/services/chat-channels/github/terminal-response-work.ts`.
The query runs when the sweep reconstructs its candidates, and once or
twice for each candidate check.

**Subsystem affected**
GitHub chat channel terminal-response recovery.

**Current behavior**
The `WHERE` clause has `issues.status = 'blocked' or (select r.status
from heartbeat_runs r where r.company_id = ... and r.agent_id = ... and
coalesce(r.native_issue_id::text, r.context_snapshot->>'issueId') =
<issue id> order by r.created_at desc, r.id desc limit 1) in (<terminal
statuses>)`. The planner can only use
`heartbeat_runs_company_agent_started_idx`. It reads all runs of the
agent, then evaluates the `coalesce` on each row. That decompresses the
context of each legacy run. The cost is the number of candidates times
the number of agent runs.

**Proposed behavior**
The subquery takes the latest run from two branches and then the latest
of the two:

- runs with `native_issue_id = <issue id>`, through
`heartbeat_runs_company_native_issue_contract_id_uq`
- runs with `native_issue_id is null and context_snapshot->>'issueId' =
<issue id>`, through `heartbeat_runs_company_ctx_issue_created_idx`

The two branches are disjoint, and together they are exactly the rows
where the `coalesce` is equal to the issue ID. Both branches and the
outer query use the same order (`created_at desc, id desc`). So the
selected run is the same, including ties.

**Reason and benefit**
This statement was the largest single-statement cost in the hosted
window after the earlier database fixes. A local reproduction is about
490x faster on the default fixture, and the gain grows with the agent's
run history.

**Breaking changes**
None. The API, the candidate rules and the results do not change.

## What Changed

- Rewrite the latest-run status subquery in the GitHub terminal-response
discovery page as two index-backed branches (native binding, and legacy
context without a native binding).
- Add two integration tests for the sweep. In the first, newer runs of
the same agent that do not bind the issue (one with a native binding to
another issue, but a legacy context that names this issue) must not hide
the stopped run. In the second, a newer active run with a native binding
to the issue keeps the request waiting.
- Add `scripts/benchmark-github-terminal-response-sweep.ts`. It captures
the SQL that the real worker generates at a baseline commit and at
`HEAD`, compares all rows, and measures both.

## Verification

Cause and thesis. The cost comes from the `coalesce` run match, which
has no index and makes each candidate scan the agent's run history.
Thesis: if each precedence branch uses its own issue index, the cost of
the page no longer depends on the agent's run count, and the rows stay
identical. The benchmark confirms the thesis.

Local SQL benchmark:

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-github-terminal-response-sweep.ts --baseline-ref=7b33384446
```

Fixture (synthetic): two companies, each with one GitHub endpoint and
one assigned agent. Each issue has one GitHub response comment. The
first N comments are acknowledged and not final; the others are final.
Every tenth issue is blocked. Runs alternate between native binding and
legacy context, and each run has a 4 KiB incompressible context. The
fixture also has these edge cases: newer active runs with a native
binding to another issue but a legacy context that names this issue,
active runs that tie on `created_at` with the latest run (between native
and legacy runs), and newer active runs of another agent. The benchmark
compares every column of every candidate row (the page limit is lifted
for this check). Embedded PostgreSQL 18.1 on an Apple laptop. The hosted
database is Neon with different compute, so absolute times are local SQL
measurements only. Each size has 2 warmups and then alternates the
baseline and the treatment.

| Size | Runs (both companies) | Candidate rows equal | Baseline median
(range) | Treatment median (range) | Speedup |
| --- | --- | --- | --- | --- | --- |
| Small: 200 issues, 3 runs each, 20 candidates, 7 samples | 1,372 | 26
of 26 | 18.7 ms (17.7–20.8) | 3.8 ms (3.3–4.3) | 4.9x |
| Default: 1,500 issues, 8 runs each, 150 candidates, 7 samples | 25,300
(156 MB) | 200 of 200 | 3,697 ms (2,875–4,032) | 7.6 ms (6.6–9.6) | 490x
|
| Large: 3,000 issues, 10 runs each, 400 candidates, 5 samples | 62,600
(382 MB) | 538 of 538 | 57,055 ms (48,343–66,079) | 31 ms (16–79) |
1,800x |

Sizes are set with `BENCH_ISSUES`, `BENCH_RUNS_PER_ISSUE`,
`BENCH_CANDIDATES` and `BENCH_ITERATIONS`. The timed query is the real
first page (limit 100).

Plan (default size): the baseline runs `SubPlan` 270 times. Each run is
a `Bitmap Heap Scan` on `heartbeat_runs_company_agent_started_idx` that
removes about 12,000 rows by filter, for about 8.2 million buffer reads
in total. The treatment runs an `Append` of two `Limit` index scans
(`heartbeat_runs_company_native_issue_contract_id_uq` and
`heartbeat_runs_company_ctx_issue_created_idx`), for about 3,800 buffer
reads in total.

Tests:

- `cd server && npx vitest run
src/__tests__/chat-channels.integration.test.ts -t "terminal GitHub"`:
13 passed, including the 2 new tests.
- Mutation check: when the legacy branch does not exclude runs with a
native binding, the first new test fails. It passes with the fix.
- `cd server && npx tsc --noEmit`: passed.

## Risks

- Low risk. The change is one SQL subquery. The match is the same set of
runs: `native_issue_id::text = <uuid text>` is equal to `native_issue_id
= <uuid>`, and a null `native_issue_id` makes the `coalesce` use the
legacy context.
- The candidate set itself is not bounded by this change. Acknowledged
responses that never become final stay in the candidate set. Each
candidate is now cheap, but the count still grows with that history.
- `readGitHubResponseRun` in `response-run-state.ts` uses the same
`coalesce` match. The sweep calls it once for each candidate row, and
other GitHub paths also call it. Each call is a separate, smaller
statement. This pull request does not change it.

## Model Used

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

## Checklist

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

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

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

## Thinking Path

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

## Linked Issues or Issue Description

Refs #20631

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

Refs #20625, #20474

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 17:43:46 -06:00
DottaandPaperclip aa2a7be8b4 fix: recognize provider-owned Runner workspace storage (#20663)
## Thinking Path

> - Paperclip manages agent work and preserves its execution state.
> - A server replacement must retain every workspace that depends on the
old server disk.
> - Runner task environments can own their workspace outside the Core
server.
> - These environments save an adapter-managed workspace with a
runnerTaskWorkspaceOwner binding.
> - Readiness and maintenance treated that provider path as a Core
filesystem dependency.
> - This change validates the saved owner and shares that classification
between both paths.
> - Local, malformed, and foreign workspace bindings still block
enrollment.

## Linked Issues or Issue Description

Related enrollment safeguards: #18835. A GitHub search found no
duplicate fix for this provider-owned class.

**What happened?**

Volume-free readiness returned workspaces_require_migration for an
adapter-managed Runner workspace owned by a plugin task environment.
Maintenance also selected it and failed with
workspace_provider_unsupported. The provider owned the path, which did
not exist on the Core server.

**Expected behavior**

A valid provider-owned workspace does not require migration from the
Core server disk. Ordinary local workspaces and invalid ownership
bindings remain blockers.

**Steps to reproduce**

1. Run a paperclip_runner agent in a plugin task environment.
2. Retain its adapter-managed workspace and complete
runnerTaskWorkspaceOwner binding.
3. Read startup eligibility for volume-free enrollment.
4. Observe the workspace blocker before this fix.
5. Run workspace maintenance and observe workspace_provider_unsupported
for the same row.

**Paperclip version or commit**

Reproduced on 353945c86d. The regression
also fails on the branch base a3b34a9d88.

**Deployment mode**

Docker with external PostgreSQL. Local regression coverage uses
disposable PostgreSQL.

## What Changed

- Share one metadata predicate between startup eligibility and offline
workspace migration selection.
- Validate a complete saved owner against a same-company
paperclip_runner agent and a plugin environment with matching nonempty
keys. Per-run environment overrides remain supported.
- Keep ordinary local workspaces and invalid ownership bindings in the
migration set.
- Add real PostgreSQL coverage for provider-owned readiness, zero-import
maintenance selection, unchanged retained rows, and
local/foreign/malformed ownership controls.

## Verification

- Readiness RED: the provider-owned fixture failed with
workspaces_require_migration on unchanged source.
- Maintenance RED: the provider-owned fixture failed with
workspace_provider_unsupported before the shared selector was applied.
- GREEN: 29 selected tests passed across three files. This covers 8
eligibility/configuration tests and 21 migration/offline-admission
cases, including eleven invalid/local owner controls inside the
eligibility test. The affected migration file has 207 cases; broad
coverage remains with required CI.
- Commands: `pnpm exec vitest run
server/src/__tests__/ephemeral-server-data.test.ts
server/src/__tests__/ephemeral-server-contract.test.ts` plus focused
selections in `server/src/services/remote-workspace-migration.test.ts`
for provider ownership, offline admission, local imports,
interrupted-batch resume, and unresolved workspace semantics.
- Server layered typecheck passed after the final source change.
- Final head `2fb98dbc664da14e6f60d812e1e10877b2b7f00a`: [required
CI](https://github.com/paperclipai/paperclip/actions/runs/42920262773)
completed with 55 successful checks, 1 neutral review, and 4 intended
skips. Greptile and Paperclip Review both rated this head 5/5. No
unresolved review threads remain.
- No hosted data migration or metadata rewrite was performed.

## Risks

- This changes a readiness classification. Incomplete, foreign,
non-Runner, or mismatched environment bindings remain conservative
blockers.
- The check validates saved ownership through current database bindings.
It does not inspect provider files or grant execution authority.
- Existing saved rows retain their shape. No migration or archive format
changes are required.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, code
editing, and real PostgreSQL tests. The host does not expose the exact
model ID or context window size.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 17:22:01 -06:00
Nicky Leach 0d4b1d8c89 Gate agent plugin tabs by execution environment (#20657) 2026-11-10 15:04:44 -08:00
Devin FoleyandPaperclip e9343d8501 fix(heartbeat): release task Retries held by a drain as soon as it ends (#20640)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a person clicks Retry on a stopped task, Paperclip saves the
request as a held wake and dispatches it right away, unless admission is
held. Admission is held during a task drain, for example while a
deployment restarts the server
> - A Retry held by a drain is released later by the deferred-receipt
worker. That worker shares one queue with every other held wake: comment
waits, execution waits, and interrupted queues
> - On a busy instance that queue holds hundreds of passive waits that
come due every 30 seconds. A Retry is checked in due-time order behind
all of them. Ending a drain does not wake the worker either
> - In a real case, a Retry clicked during a deployment drain was still
held 40 minutes later, after the drain expired and the server restarted.
It ran only when the person clicked Retry again
> - This pull request checks operator Retries ahead of passive waits,
and wakes the worker as soon as a drain stops or expires
> - The benefit is that a Retry held by a drain starts within one worker
pass after the drain ends

## Linked Issues or Issue Description

**What happened?**

A person clicked Retry on a blocked task 14 seconds after a deployment's
task drain started. The Retry was saved as `deferred_issue_execution`,
and the immediate dispatch returned early because of the drain. The
drain had a 20-minute TTL, and the deployment restarted the server about
6 minutes in. The held Retry still did not start, and its `updated_at`
never changed. Forty minutes later the person clicked Retry again, and
the new request started a run and merged with the held one.

That instance had 390 held wakes: 271 execution waits and 116 others,
against 6 task Retries. The execution waits come due every 30 seconds.
Measured on the instance, the deferred-receipt worker took about 7
minutes to get through that backlog once.

**Expected behavior**

A Retry that a drain held starts soon after the drain stops or expires,
or after the server restarts, whatever the size of the passive backlog.

**Steps to reproduce**

1. Start a non-idle task drain (`POST /instance/task-drain`).
2. Click Retry on a stopped task. The receipt is
`deferred_issue_execution`.
3. Stop the drain. On `master`, the deferred-receipt worker only notices
on its next 30-second re-check. It then checks the Retry in due-time
order behind every passive wait that came due before it.

**Paperclip version or commit**

`master` at 8148fc0b1e.

## What Changed

- `server/src/services/scoped-deadline-work.ts`:
- New optional `priority(entry)`. Due entries are sorted by priority
first, then by due time and id as before.
- New `refresh(hint)`. It clears a scope's reconstruction delay and
invalidates it. Failure backoff is kept.
- New optional `onStop` hook, called when the coordinator stops the
worker.
- `server/src/services/heartbeat/deferred-receipt-work.ts`:
- A `task_retry` receipt that a person requested is an operator Retry,
with priority 1. Passive receipts keep priority 0, so their order does
not change.
- Each deferred scan loads operator Retries first, with their own query,
then pages by id as before. A Retry is therefore in memory in the first
pass even beyond the first 100-row page. A receipt loaded twice merges
by id.
- New optional `owner.subscribeRefresh`. The work calls `refresh()` on
all three of its queues when it fires, and releases the subscription
from `onStop`, so the coordinator's own stop removes it.
- `server/src/services/heartbeat.ts`: passes `subscribeRefresh` that
listens to task-drain changes and expiry. Before this change, a deploy
drain left the worker waiting for its 30-second re-check, behind the
scan's reconstruction delay.
- `server/src/__tests__/deferred-execution-receipt-work.test.ts`, four
new tests:
- A real Retry, requested during a deploy drain, starts a run (to
`succeeded`) within 5 seconds after the drain stops.
- An operator Retry whose id sorts after 120 passive execution waits
(more than one load page) is resumed first, in the first worker pass.
- A held Retry inserted with no notification right after a full scan is
resumed as soon as the drain refresh fires.
  - Stopping only the coordinator removes the drain listener.

## Verification

- `npx vitest run src/__tests__/deferred-execution-receipt-work.test.ts
-t "held by a deploy drain|ahead of a backlog|ordinary committed task
Retry"`: 3 pass. The third is the existing ordinary Retry dispatch test.
- Red checks, removing one piece at a time:
- Without the drain subscription, the drain test fails: the Retry is
still `deferred_issue_execution` with no run after 5 seconds.
- Without the priority, or without the prioritized load, the backlog
test fails: the first resumed request is the passive `00000000-…` wake.
- With `invalidate()` instead of `refresh()`, the post-scan test fails
after 5 seconds.
  - Without the `onStop` release, the listener test fails.
- Full `deferred-execution-receipt-work.test.ts`: 50 pass.
- `chat-channels.integration.test.ts -t "still batches several replies
in one finding thread"`: it timed out once on CI and passes 2 of 2 times
locally with this change.
- `scoped-deadline-work`, `heartbeat-task-drain`,
`heartbeat-task-drain-admission-release`, `cloud-control-task-drain`,
`task-admission-deadline`, `onboarding-first-task-admission` and
`delivery-work-coordinator` suites: 164 pass.
- `pnpm run typecheck` in `server` (layered): clean.

## Risks

- Operator Retries are a small set; there were 6 on the instance above.
They now take the first slots of a worker pass, so passive waits can
move by at most that many places. Passive waits keep their own order.
- A Retry that keeps failing its check still gets the existing failure
backoff, so it cannot take every pass.
- A drain start also wakes the worker. That pass ends at once, because
the worker's run gate still reads the drain.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) via Claude Code, with tool use
(shell, file editing, vitest runs against embedded PostgreSQL, read-only
production log and database analysis).

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 15:00:50 -08:00
DottaandPaperclip a3b34a9d88 fix(ui): allow queued message deletion before queue acknowledgement (#20553)
## Thinking Path

Paperclip lets a user queue a message while an agent works. The user
must be able to remove that message before delivery starts. A new local
message can clear the displayed queue ID until the server accepts it.
This also disables trash for older saved messages. The page rejects the
action before it sends a request.

This change keeps trash available when the server says the user can
discard the message. When the queue ID is absent, the page uses the
existing comment cancellation route. The server finds the queue and
checks the author and dispatch state in the same transaction.

## Linked Issues or Issue Description

**What happened?**

Trash stays disabled for a saved queued message while another message
waits for queue acknowledgement. The page handler also throws before it
sends a cancellation request.

**Expected behavior**

Trash removes the selected queued message before dispatch. A message
that has started dispatch stays visible with an error. Other queued
messages stay queued.

**Steps to reproduce**

1. Send a follow-up while an agent works.
2. Send another follow-up before the queue read includes it.
3. Try to discard the first saved message.

**Paperclip version or commit**

Reproduced from source at `0e3fdebea825`.

**Deployment mode**

Source checkout on Linux. UI tests use jsdom. Route tests use isolated
embedded PostgreSQL.

Related work: #15865 changes mixed-queue interrupt handling. #15939
changes recovery after a queued message is discarded. #15465 changes
discard permissions for agent-authored entries. This change fixes trash
for a user's own saved messages while queue acknowledgement is pending.

## What Changed

- Keep trash enabled for a saved message with discard permission when
the displayed queue ID is absent.
- Use the existing author-only cancellation route when queue identity is
pending. Keep the revision-checked route when queue identity is present.
- Keep failed cancellations visible. Refresh the queue after
cancellation and remove successful deletions from comment caches.
- Cover deletion, dispatch conflicts, permission failures, network
failures, history reloads, final-message cleanup, and concurrent
dispatch.

## Verification

- Red: three regression cases failed on the original production code.
The trash button was disabled. Both page cases made no cancellation
request.
- Green: five complete UI suites pass, with 767 tests and one existing
skipped test. These cover the page, queue component, thread, queue
merge, and optimistic comments.
- Green: 11 focused PostgreSQL route cases pass. They cover both routes,
queue promotion, final-message cleanup, authorship, dispatch conflicts,
and a concurrent dispatch claim.
- `pnpm check:token-gates` and `git diff --check` pass.
- `pnpm --filter @paperclipai/ui typecheck` passes.
- The GitHub tree is `d4f0d61cbef2620c1fd62e6988ad80ccb1ba6173`. It is
identical to the tested local tree after merging master `902f3767960d`.
- `pnpm verify` selected more than 1,500 server test files. That broad
local run was stopped without a pass claim. CI will supply broad build
and test coverage. The preflight helper could not build Rust because
Cargo is absent.
- [CI
Build](https://github.com/paperclipai/paperclip/actions/runs/42835584447/job/129897150252)
and [browser
E2E](https://github.com/paperclipai/paperclip/actions/runs/42835584447/job/129901676175)
pass for head `44dea22faea51a6664a6f45852a42f62f5c72bbd`.
- [Greptile
review](https://github.com/paperclipai/paperclip/pull/20553#issuecomment-6770202777)
gives the current head 5/5 with zero comments and no unresolved review
threads.
- [Final CI
run](https://github.com/paperclipai/paperclip/actions/runs/42835584447)
passes for head `44dea22faea51a6664a6f45852a42f62f5c72bbd`: 55 check
runs passed and 4 were skipped. No failures or pending checks remain.
Typecheck, Build, all general test shards, serialized suites, Runner
checks, and browser E2E passed. Snyk also passed.

## Risks

The fallback has no client queue revision. The existing server
transaction finds the queue for the selected comment and checks
ownership and dispatch state. Tests cover rejection after dispatch and
when another user owns the message. No API contract or schema changes
are required. No visual tokens change. Existing user documentation
remains accurate.

## Model Used

OpenAI GPT-6 in Codex. The runtime does not expose a more specific
provider revision or context window size. Used reasoning, repository
tools, code edits, and test execution.

## Checklist

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

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 16:38:58 -06:00
Nicky LeachandPaperclip f30ac226d6 Keep project environment mappings independent of shared defaults (#20651)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 14:31:48 -08:00
DottaandClaude Opus 5.5 358977454b test(native): cover unknown-outcome recovery and hold settlement without a recorded PID space (#20649)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - #20628 settles a host-tool callback that a server restart orphaned,
and settles the no-replay holds that earlier releases left for a
completed turn
> - Review of #20628 asked for two more cases. That PR merged before the
commit with those cases reached it
> - This pull request adds only those tests
> - The benefit is that the two edge rules of #20628 stay covered

## Linked Issues or Issue Description

Refs #20628

**What happened?**
The review of #20628 named two untested rules. First, a recovery
authority that throws `semantic_tool_outcome_unknown` must settle only
for a callback from an earlier controller, and must rethrow for this
controller's own callback. Second, a coordinator row saved before
`controller_host_hash` existed must still settle its holds through the
stopped controller boot.

**Expected behavior**
Both rules have regression tests.

**Steps to reproduce**
1. Read `recoverToolCallback` and `native_completed_turn_cleanup_hold`
on master.
2. Search the tests for a thrown `semantic_tool_outcome_unknown`, or for
a row with a null `controllerHostHash`. Neither exists.

**Paperclip version or commit**
master at 2150820bd8.

## What Changed

- `durable-prp-control-plane.test.ts`: an authority that throws
`semantic_tool_outcome_unknown` settles as
`tool_call_interrupted_by_restart` for `earlier_controller`, and
rethrows for `this_controller`. Any other rejection rethrows.
- `completed-turn-cleanup-hold-settle.test.ts`: a coordinator row
without `controllerHostHash`, whose server boot is recorded stopped in
`controller_instances`, settles the run's holds through the
controller-instance proof. No attestation is needed.

## Verification

- `npx vitest run src/control-plane/durable-prp-control-plane.test.ts -t
"orphaned by a server restart"` in `packages/paperclip-runner`: 7
passed.
- `npx vitest run
src/__tests__/completed-turn-cleanup-hold-settle.test.ts` in `server`: 5
passed.
- `tsc --noEmit -p packages/paperclip-runner/tsconfig.test.json` and
`tsc --noEmit -p server/tsconfig.json` pass.

## Risks

- Low risk. Test-only change. No production code changes.

## Model Used

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

## Checklist

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

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

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

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

## Linked Issues or Issue Description

Refs #20572, #20578

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 15:20:42 -06:00
DottaandClaude Opus 5.5 353945c86d fix(tool-gateway): let agents create gateway tokens without receiving the value (#20613)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Tool gateways let external MCP clients use a company's tools with a
gateway client token
> - Since delegated connection management (#17737), an agent can create
and revoke gateway client tokens for its responsible person
> - #20581 stopped agents from getting the one-time token value, but it
did this by making token creation person-only
> - The product decision is that agents keep their delegated authority
to create and revoke tokens
> - This pull request restores agent token creation, but the agent never
receives the token value: the person reveals the value once on the
gateway's Tokens page
> - The benefit is that an agent can do the rotation work for the
person, and the secret still never enters chat, run events, logs or tool
output

## Linked Issues or Issue Description

Refs #17737
Refs #20581

**What happened**
After #20581, `POST /api/tool-gateway/gateways/{gatewayId}/tokens`
returns 403 `gateway_token_requires_person` to every agent caller.
Agents lost the delegated authority to create gateway client tokens that
#17737 gave them. Before #20581, an agent that created a token received
the plaintext value in its tool result and could repeat it in chat.

**Expected behavior**
An agent with delegated connection management can create and revoke
gateway client tokens. The agent never receives a token value. The
responsible person gets the value once, on the gateway's Tokens page.
Person callers keep today's behavior.

**Steps to reproduce**
1. Give an agent delegated connection management for a responsible
person with `tools:admin`.
2. As the agent, call `POST
/api/tool-gateway/gateways/{gatewayId}/tokens` (for example through
`call_api`).
3. See 403 `gateway_token_requires_person`.

## What Changed

- Agent create: `POST /api/tool-gateway/gateways/:gatewayId/tokens` from
an agent with delegated authority returns 201 with token metadata only
(`id`, `tokenPrefix`, `expiresAt`, `pendingReveal: true`), plus
`tokensPath`, a `reveal_credential` `requiredUserAction` for the
responsible person, and a `nextStep` instruction. The saved row has no
usable value: its stored hash is of a value that is generated and then
discarded. Gateway authentication also rejects `pendingReveal` rows.
- Person reveal: new person-only `POST
/api/tool-gateway/gateway-tokens/:tokenId/reveal`. It generates the
value, stores its hash, clears `pendingReveal` and returns the value
once. A conditional update makes a second or concurrent reveal return
409. Only the token's responsible person can reveal it (403
`gateway_token_reveal_requires_responsible_person`). Agent callers get
403 `gateway_token_requires_person`. A token revoked before reveal
cannot be revealed.
- Why a reveal that generates the value: no stored plaintext existed to
reveal, and storing one (even encrypted) for later display adds a secret
at rest. Generating the value at reveal time keeps the existing one-time
display and stores only a hash.
- Schema: `tool_mcp_gateway_tokens.pending_reveal boolean not null
default false` (migration `0460_gateway_token_pending_reveal`). Shared
`ToolMcpGatewayToken.pendingReveal`, and `ConnectionRequiredUserAction`
gets `reveal_credential` / `gateway_token`.
- UI: the Tokens page shows a "Waiting for you to reveal" section for
pending tokens with a "Reveal token" button. The value appears in the
existing "New token — copy now" one-time block. Pending tokens show the
status "Not revealed".
- Agent gateway create: the 201 for agent callers also includes
`gatewayPath`, `tokensPath` and a `nextStep` that tells the agent to
link the saved gateway.
- The agent create-token `nextStep` and the catalog say not to revoke
the old token in the same turn: its client keeps working on it until the
person installs the new value.
- The reveal update and its `tool_gateway.token_revealed` activity entry
commit in one transaction. The Tokens page offers Reveal only for tokens
that act for the signed-in person.
- API reference: new `docs/api/tool-gateway-tokens.md` (create, reveal,
revoke) in the docs nav.
- Revoke stays allowed for agents with delegated authority (unchanged
route).
- API catalog: create-gateway, create-token, reveal and revoke
descriptions now say who acts, that agents never receive values, the
page paths, and that the old token is revoked only after the client
works with the new one.
- Agent access map: create-token policy note and a new reveal entry.
- Evals `gateways-profiles.create-gateway` and
`gateways-profiles.rotate-token`:
- Remove the keyword grader (`human|operator|admin|manage`) and the
handoff text checks. Grade saved state, rendered links and secret
absence. A plain reply or a question card is accepted.
- Remove the setup probes that called the create routes as the agent.
Under delegated authority, those probes created a real gateway
("Forbidden agent gateway"), which failed create-gateway before the
agent ran.
- create-gateway: exactly one gateway for the intended profile and
company, created by the agent (or by the person after a handoff); the
person opens it from a rendered link; attribution matches the creator;
any agent-created token is still pending reveal; no token value in chat,
cards or run events.
- rotate-token: after the agent's turn, both setup tokens still work and
any replacement the agent created is pending the person's reveal; the
person follows the rendered Tokens page link and reveals (or issues) the
value in a private browser; the new value works before the old token is
revoked; the unrelated client keeps access; no token value appears
anywhere, including a regex scan for any full `pcgw_<id>.<secret>`
value.
- The changed scenario context and outcomes are recorded in
`chat-full-catalog-testops-manifest.json` `changedFields` and pinned in
the manifest test. The inventory digest, descriptors (`oracleVersion`
v2, `sourceFiles`) and SDK public input pins are updated. The unused
`external-gateway-handoff.ts` module is removed.
- Fixture realism: the create-gateway profile now allows a real read
tool (an empty profile made the agent ask which apps to add instead of
finishing), and the rotation tokens get a neutral owner note instead of
"Disposable rotation fixture" (the agent read that note as an eval
hint).
- Shared private-browser helpers (`external-private-browser.ts`,
`external-private-browser-worker.ts`) get one optional `pendingTokenId`
input and one `revealedPending` receipt field.

## Verification

- Red: on `master` route code, the new integration tests `lets agents
create and revoke gateway client tokens without ever receiving a token
value` and `reveals an agent-created gateway token value only to its
responsible person` fail (403 `gateway_token_requires_person`).
- Green: `cd server && npx vitest run
src/__tests__/connection-management.integration.test.ts -t gateway` (25
passed). The test checks that the agent `call_api` result has no `token`
field and no token-shaped value, that the agent cannot reveal, that the
person reveals once (second reveal 409) and the value authenticates,
that agent revoke works and stops the value, that a token revoked before
reveal never gets a value, and that person create still returns the
value.
- `npx vitest run src/__tests__/tool-gateway.test.ts
src/__tests__/openapi-routes.test.ts
src/services/native-runtime/runner-api.test.ts` (2247 passed).
- `cd ui && npx vitest run
src/pages/apps/gateways/panels/TokensPanel.test.tsx
src/pages/apps/gateways/gateway-helpers.test.ts` (16 passed); `pnpm
check:token-gates` clean.
- Eval graders and catalog: `pnpm exec vitest run --config
tests/runner-e2e/vitest.config.ts` on the gateway oracle tests,
`external.test.ts`, catalog consistency/contract, TestOps
export/retained, phase/clock callers and SDK input/type tests (all
pass); `node --test` on the TestOps manifest, adapter, contract,
streaming and reconcile tests (all pass); `node
tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`
(unchanged).
- `node scripts/check-maps.mjs` passes. `tsc --noEmit` for `server`,
`ui` and `tests/runner-e2e`: no errors in changed files.
- Paid end-to-end run of both eval cases, run
[42872921890](https://github.com/paperclipai/paperclip/actions/runs/42872921890)
on 39f38a7f6c (before the last three commits): both FAILED, for
agent-behavior reasons the later commits address. Not rerun yet.
- rotate-token: the agent created the replacement (pending reveal, no
value), then revoked the old token in the same turn, despite the create
response saying to wait. Its reasoning cited the eval-looking fixture
note. The grader rejected the premature revocation. The reply linked the
Tokens page correctly.
- create-gateway: the agent created the gateway and one pending token,
then asked a question card about which apps to add to the empty profile,
with no link to the gateway. The grader rejected the missing link.
- Token values: a scan of all evidence files of both cells found no full
`pcgw_<id>.<secret>` value and no `pcgw_<uuid>` form. Agent `call_api`
payloads in run events are redacted; the integration test proves the
agent tool result has no value.

## Risks

- Behavior change for agents: token create returns metadata and a Tokens
page path, not a value. Agents that expect `token` in the response must
send the person to the Tokens page.
- Migration adds one boolean column with a default. Existing tokens get
`false` and keep working.
- A pending token that is never revealed stays as an unusable row until
someone revokes it. It cannot authenticate.

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

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

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

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:39:45 -06:00
DottaandClaude Opus 5.5 38944cff1a fix(environments): retry and safely replace a reusable sandbox that will not resume (#20626)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

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

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

## Linked Issues or Issue Description

**What happened?**

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

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

**Expected behavior**

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

**Steps to reproduce**

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

**Paperclip version or commit**

`master` at 79e28dc0d0.

## What Changed

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

## Verification

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

## Risks

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

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

## Model Used

- Anthropic Claude, model ID `claude-fable-5-2`, in Claude Code with
tool use and code execution.

## Checklist

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

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

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

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

## Linked Issues or Issue Description

**Subsystem affected**

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

**Problem or motivation**

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

**Proposed solution**

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

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

**Alternatives considered**

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

**Roadmap alignment**

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

## What Changed

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

## Verification

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

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

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

## Risks

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

## Model Used

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

## Checklist

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

---------

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

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

## Linked Issues or Issue Description

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:20:30 +00:00
DottaandClaude Opus 5.5 3bf7af6c77 perf(plugins): read the lifecycle inbox from per-resource cursors (#20576)
## Thinking Path

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

## Linked Issues or Issue Description

Refs #20405

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

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

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

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

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

## What Changed

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

## Verification

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

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

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

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

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

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

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

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

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

Commands:

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

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:58:32 -06:00
DottaandClaude Opus 5.5 8e4c5e81b9 perf(issues): read created-from tasks by index and inbox review waits by page (#20573)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task page shows "Created from this task". The inbox shows
blocked-inbox attention for a page of tasks.
> - On a hosted database, the created-from list ran about 4,000 times in
one afternoon at about 499 ms each. The task page sends it each time a
task opens.
> - Its filter was an `OR` of three provenance paths over the list's own
rows. PostgreSQL could not use an index for it. It read every task of
the company and tested each one, with the list's other per-row
conditions (such as the chat conversation state subquery).
> - The blocked-inbox attention map also read external review waits for
every in-review task of the company, not only for the tasks of the page.
> - This pull request reads created-from tasks from three index probes,
and reads review waits only for the in-review tasks that can change a
finding of the page.
> - The benefit is less database work on each task open and each inbox
page. The responses stay the same.

## Linked Issues or Issue Description

Refs #20400 (shares review-wait reads between the attention maps of one
request).
Refs #20551 (authorizes the review waits of a page with one read; this
change narrows the waits that the page asks for, and adds a test that
pins the batched read).
Refs #13241 (adds the created-from list).

**What happened**

- `GET /api/companies/:id/issues?createdFromIssueId=…` used
`createdFromIssueCondition`. Its filter was `origin_issue_id = source OR
(origin_issue_id IS NULL AND origin_run_id IN source runs) OR (… AND
EXISTS creation activity of a source run)`. The `OR` stops index use, so
the statement read every task of the company. The list's other
conditions, which include the chat conversation state subquery, ran for
each task before the task was excluded.
- `listIssueBlockedInboxAttentionMap` read review waits for every
in-review task of the company graph. The caller uses the findings of the
page rows only.

**Expected behavior**

The created-from list reads only the created tasks. The blocked-inbox
map reads review waits only for in-review tasks that the liveness
classifier can read for a page row. The rows, the order, and the
attention values do not change.

**Steps to reproduce**

1. Use a company with tens of thousands of tasks, runs, and activity
rows.
2. Open a task page. Look at the created-from list statement: it reads
the full task set of the company.
3. Open an inbox page with blocked-inbox attention. Look at the
review-wait read: it gets the ID of every in-review task of the company.

## What Changed

- `server/src/services/issue-creation-origin.ts`:
`createdFromIssueCondition` is now `issues.id <> source AND issues.id IN
(three branches)`. Each branch starts from an index:
  - recorded origin task: `issues_company_origin_issue_idx`;
- legacy origin run: the source task's runs
(`heartbeat_runs_company_origin_issue_idx`), then the new partial index
on `issues (company_id, origin_run_id)`;
- creation activity: `activity_log_run_id_idx` for the source runs, then
the task by primary key.
The precedence does not change: a recorded origin task wins, the run
path applies only without one, and the activity path applies only
without both. The activity path matches only an `entity_id` that is the
canonical text of the task ID, as before (it also compares `id::text =
entity_id`).
- `packages/db`: add `issues_company_legacy_origin_run_idx` on `issues
(company_id, origin_run_id) WHERE origin_issue_id IS NULL AND
origin_run_id IS NOT NULL`. Migration `0461` creates it with `CREATE
INDEX CONCURRENTLY IF NOT EXISTS` and is registered with the other
concurrent migrations. `doc/DATABASE.md` documents the index and the
benchmark command.
- `server/src/services/issues.ts`: add `blockedInboxReviewWaitIssueIds`.
It returns the in-review tasks in the blocker closure of the page rows.
The blocked-inbox map reads review waits only for these tasks. The
liveness classifier reads a wait only for a row or for a blocker that it
reaches from a row, and the caller reads only the findings of the rows.
The classifier does not change.
- Tests:
- `issue-created-from-routes.test.ts`: new cases for precedence in both
directions, a source-run comment, an activity row whose task has another
origin run, a non-canonical and a non-UUID activity entity ID, the
source's own creation activity, and other-company rows.
- `blocked-inbox-review-wait-scope.test.ts`: the narrowed set for
chains, cycles, shared blockers and off-page tasks. The rows get the
same findings with the narrowed waits as with the waits of every
in-review task, and each narrowed wait is needed.
- `issue-list-review-wait-authorization.integration.test.ts`: on real
PostgreSQL with two authorized waits, `listExternalReviewWaits` and one
issue-list request with blocked-inbox attention each send one bound-run
message-link statement. With the per-wait authorization from before
#20551, both tests fail (2 statements).
- `scripts/benchmark-created-from-issues.ts`: local benchmark. It loads
the baseline `issues.ts` and `issue-creation-origin.ts` from a pinned
commit, asserts identical complete row payloads, and reports alternating
timings and `EXPLAIN (ANALYZE, BUFFERS)`.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/issue-created-from-routes.test.ts
src/__tests__/blocked-inbox-review-wait-scope.test.ts
src/__tests__/issue-list-review-wait-authorization.integration.test.ts
src/__tests__/agent-task-hierarchy-depth.test.ts
src/__tests__/issue-blocker-attention.test.ts
src/__tests__/issue-list-assignee-filter-routes.test.ts
src/__tests__/issues-parent-id-alias.test.ts
src/__tests__/batched-chat-authorization.integration.test.ts
src/__tests__/issue-liveness.test.ts` passes (126 tests). One timing
test in `issue-list-assignee-filter-routes` failed one time under
machine load and passed on rerun.
- `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter
@paperclipai/db check:migrations` pass.
- `pnpm verify` asks for broader verification because the change adds a
file under `scripts/`. Required CI supplies that coverage.
- Benchmark: `pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-created-from-issues.ts --baseline-ref=bd30ef8510`

Fixture (synthetic, local PostgreSQL 18.1): one company with 30,000
tasks (10,628 with a recorded origin task, 10,445 with only a legacy
origin run), 60,030 runs with wide context snapshots (145 MB), and
261,079 activity rows; a second company with 10,000 tasks and 20,000
runs. The busy source task has 63 created tasks through all three paths,
plus decoys. The quiet source task has 3. The UI's page: `sortField=id`,
`asc`, `limit=500`, routine executions included. Both versions return
identical complete payloads. 9 alternating pairs after 2 warm-up pairs:

| Case | List statement before | List statement after | List call
before, median (min to max) | List call after, median (min to max) |
| --- | --- | --- | --- | --- |
| Busy source (63 tasks) | 234.9 ms, 47,437 buffers | 0.90 ms, 437
buffers | 156.0 ms (114.5 to 480.7) | 31.6 ms (19.9 to 62.7) |
| Quiet source (3 tasks) | 90.7 ms, 47,413 buffers | 0.25 ms, 46 buffers
| 126.1 ms (90.6 to 300.4) | 10.5 ms (7.2 to 26.1) |

The list call time includes the enrichment reads (labels, runs,
attention), which do not change.

Inbox page (50 open tasks, blocked-inbox attention): the review-wait
read goes from 2 statements with 1,215 parameters (every in-review task
of the company) to 1 statement with 31 parameters. The page time does
not change in this fixture (median 801 ms before, 873 ms after, both
with a wide spread), because the fixture has no review waits and the
other graph reads of the map are most of the time. With real waits, the
server does an inherited GitHub review read for each wait it finds, so
the gain grows with the number of off-page waits.

Migration timing (local PostgreSQL 18.1): `0461` (measured as the same
statement under its earlier number) on 500,000 tasks (846 MB, 20
companies, one third with a legacy origin run) takes 1.6 s. The index is
12 MB.

## Risks

- The new index adds a small write cost to tasks with only an origin
run. New tasks with a source task record `origin_issue_id`, so the index
holds mostly older tasks. The migration does not lock writes.
- The created-from rewrite must keep the same rows. The new tests and
the benchmark payload check cover precedence, company scope, and ID text
forms. Visibility and read filters are the list's other conditions and
do not change.
- The review-wait narrowing depends on the liveness classifier reading
waits only for rows and their blocker chains. A unit test compares the
findings of the rows with full and narrowed waits. A future classifier
change that reads waits of other tasks must update
`blockedInboxReviewWaitIssueIds`. The function comment says this.
- The local timings are synthetic. Hosted acceptance needs a fresh
query-statistics window after deployment.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:38:51 -06:00
DottaandPaperclip 12f70d0d4c Keep Slack form custody and receipt transfer durable (#19825)
## Thinking Path

> - Paperclip manages work for AI-agent companies.
> - Chat connections deliver provider messages and user answers.
> - Slack requires a response within its original three-second deadline.
> - Forms and their delivery receipts must survive retries and tenant
restarts.
> - This change uses the existing connector queue and durable form
owners.
> - Current authorization, exact receipt checks and normal ACK rules
stay in force.
> - The tenant can settle useful work while keeping the connection
enabled.

## Linked Issues or Issue Description

Refs #19019 and #19348. Companion Cloud PR:
paperclipai/paperclip-cloud#1250.

## What Changed

- Preserve form grants, sessions, encrypted evidence, terminal receipts
and company-authorized Retry.
- Check initial form enrollment before the provider send. Keep a typed
preparation wait only while an exact current enrollment obligation
exists.
- Refund the attempt only when the claimed attempt and credential lease
still match and provider invocation has not started. Possible or
accepted sends retain uncertain-delivery custody. An ordinary error with
the same text still uses the normal five-attempt limit.
- Notify the existing publication worker when normal enrollment commits.
Reconstruct pending work from durable state after a lost reply or owner
restart.
- Page settled history before proving idle safety. Keep unfinished,
malformed and foreign receipts visible beyond the first 1,000 rows.
- Keep Slack callback, answer, receipt and ACK rules in their provider
owners. Support body-bound Cloud idle capabilities when a complete
catalog exceeds the header budget.
- Add focused regression coverage, an enabled quiet/reactivation recipe
and explicit fixture port checks.
- Keep SDK evidence tests tied to a normally built reviewed reference,
with a separate strict refusal for actual unreviewed CI outputs.

## Verification

App head: `6284d6881b5dc38a9ee149bcbde35e69a67dd8ca`, including actual
master `dff8bed007e01c32f3826144b893ec80603d1335`. Companion Cloud head:
`f83b38187dc7e6b2e219fe865dc16d268cc66fdb`, including actual Cloud
master `b1dcc3421a91efffee631a61536cf48f357c22fc`. Cloud #1250 is merged
at `1d07d81f23d4627ab7b6dcf49176b4128a2f8f9b` after exact-head passing
checks and complete Greptile 5/5. Current exact-head CI typecheck,
build, tests and aggregate verification are green; the complete edited
Greptile review is 5/5 and all 13 review threads and nested comment
pages are resolved.

- Both latest enrollment findings were reproduced against the preceding
product. The pure readiness control returned no wait for the first
enrollment. The real-PostgreSQL control observed a provider call while
that enrollment was still staged.
- The repaired source passes three whole files: 21 readiness controls,
80 real-PostgreSQL milestone controls and 37 tenant-actor middleware
controls. All 138 pass with zero excluded or TODO cases. The SQL case
reconstructs the worker through seven waits, preserves the attempt
budget, recovers a controlled lost enrollment reply, and commits one
provider-port call and one exact grant. Replaying the selected attempt
does not duplicate them. Authorization, SDK and enrollment-response
ports are controlled in that SQL case.
- The same files cover ordinary five-attempt exhaustion, accepted and
possible sends, stale ownership, malformed bindings, attention states
and current actor restrictions. One synthetic mismatched-binding
parameter uses generation 2 against current generation 1; it is not
evidence for an older-generation control.
- The normal server-package typecheck passes at the measured source. It
runs the normal Runner TypeScript, protocol, SDK and declaration
prerequisites and all seven main App programs. The separate declaration
configuration is also recorded. A worker union-narrowing error was
found, fixed with an explicit callable/null branch, and verified by that
normal compiler. No cast or test relaxation was used.
- The normal merge of current master preserves all 78 owned paths and
15,783 unrelated master entries and modes. The three measured test files
and their changed feature paths are unchanged. New upstream DB code adds
only the normal nonunique concurrent native-session index and its
migration classification. Shared and adapter-utils changes alter
unrelated prompt or denial wording. The 138-case proof carries for those
unchanged behaviors; it is not reported as a rerun after the merge. The
normal Shared/SDK ensure, DB build, adapter-utils build and complete
server-package check also pass at the current merged head. Their fresh
inventory contains 8,143 outputs; canonical SQL and journal copies
match. This build did not apply the new index migration.
- Earlier real-PostgreSQL retained-history coverage reproduced the
missing proof with 1,005 settled rows. Its repair retains the existing
two-second/64-query inspection budget, current endpoint bound and exact
owner snapshot. The 46 source-bound controls cover unfinished and
invalid tails, exact cross-page action references, Retry, rollback and
lost receipt responses.
- Earlier enabled quiet/reactivation coverage produced ordinary useful
work, kept configuration enabled, accepted fresh signed work plus a
duplicate, and produced exactly one second useful reply. Its two
App-worker windows lasted at least 30 seconds with zero measured PG
writes, connection or outbound activity after settlement. That evidence
stays tied to its measured revision. A missing worker EXIT marker limits
that earlier proof; parent evidence does not replace it.
- Earlier three-second callback, terminal ACK, current-question race and
complete body-size controls remain source-bound. The
500-owner/1,000-binding body measured 313,875 bytes with a 618-byte JWT;
signature tampering failed closed.
- Final managed-provider acceptance now passes all five fresh Node
boots: A/A, A/AB, B/A rejection, B/AB recovery, B/B final. Two
independently configured, challenged, verified, linked and enrolled
Slack identities exercise ACK and retirement debt separately. Normal
provider factory/materialization delivers the configuration through a
controlled external stack-provider boundary. The registered opening
returns HTTP 200 in 21 ms within the original 3-second deadline. Two
answers persist, normal ACK is accepted, exact retirement and lost
acceptance/GC replies recover, and the original canonical snapshot
remains unchanged without duplicate provider sends or submissions.
- The separate complete committed-preflight case passes. A committed
`update_attempted` response is lost before SDK entry; normal
service-owner reconstruction after the original lease/deadline recovers
the same useful update and one answer without a second trigger. This
reconstructs service owners within one App process; the separate five
fresh Node boots cover process restart and managed signing-key rotation.
- Assertion-time PG socket reports balance 15 opens/15 closes across the
five boots and 5/5 in preflight, peak 2 and zero live sockets or
unexpected attempts. Worker terminal EXIT markers were missing and are
not replaced by parent EXIT markers. These are domain/recovery and
assertion-time connection proofs, not a new whole-worker terminal quiet
measurement. All known owners/groups closed; independent SQL audits were
empty; only successful fixture databases were removed. Earlier failed
fixture databases/logs remain retained. No product guard, deadline or
assertion was weakened. Exact runtime source and normal outputs are
unchanged by the three test files in the current follow-up, so these
source-bound proofs carry without repeating those journeys.
- The preceding App CI ran 11,312 support cases: 11,289 passed, three
SDK-evidence cases failed, and 20 external cases skipped. Normal CI
dependency setup resolved six stale Slack patch-hash references and
produced an unreviewed lock. The public SDK proof correctly refused that
lock; the tests incorrectly assumed the CI output was a reviewed
reference. The three-file test repair keeps the protected lock policy,
source/compiler/parser/Zod pins, scanner and redaction rules unchanged.
It builds an owned reviewed reference using the existing normal
Shared/SDK producers for the positive seal test, while a separate
negative requires actual unreviewed-build privacy refusal and no
retained SDK bytes. It does not relabel the CI build as certified or
admit a new lock hash.
- All four whole SDK support files pass locally: public type 30,
evidence 16, public-input synchronizer 8 and lock preparation 10,
totaling 64 with zero excluded/TODO cases in 28.73 seconds. The genuine
fresh reference build/positive seal and separate invalidated-reference
privacy refusal passed within their original 120-second bounds. Original
installed dependencies, SDK outputs/cache and runtime source stayed
unchanged; normal global setup produced only its own Runner JavaScript
outputs. PostgreSQL was denied throughout, all four worker markers were
observed, and the owned process group/private test root closed. The
local canonical-lock negative mutates only its own reference; fresh
remote CI also passes the actual resolved-CI-lock branch: all 16
evidence and 30 public-type tests pass, including the genuine
reviewed-reference positive and the strict actual-unreviewed-build
refusal. The upstream synchronizer file runs its current 10 cases in CI.
- The first complete CI attempt on this head also exposed two separate
30-second timeouts: one real reporter-artifact test and the
`agent-test-environment` module-loading hook. Full original logs remain
preserved. Their exact test, sanitizer and evidence source blobs are
unchanged from the preceding head; inspection found no deterministic
setup cause. After the original run finished, one retry of only the
failed jobs passed on the same synthetic merge
`168851e210671ebe97e7a778e8cf891807e80502`: Runner support reports
11,304 passes and 20 external skips; serialized shard 6 reports all 395
passes, including all 67 route bodies. No source, timeout or assertion
changed. This proves recovery; the original causes remain unexplained.
- The final pre-merge tree audit uses actual master
`17136a6c25ef6cb7c816a29140a4c48c2c75fb31` (refreshed after the earlier
`85dec046` audit). The expected normal squash preserves all 81 owned
paths and 15,816 unrelated master entries and modes. Only OpenAPI has
edits from both sides, and its complete content composition preserves
the new project route and token help alongside this PR's API entries.
The shared inbound selection changes preserve the same candidate set;
new concurrent indexes, Native/workspace behavior and shutdown changes
remain upstream behavior. The scoped form proof uses process agents and
does not qualify the new Native/sandbox branches. The latest
question-schema change still accepts the explicit version-1 payload
exercised here. The last master delta also changes managed AI/Native
credential refresh and writeback; those branches are outside the
measured process-agent form paths. Existing 8,143 local outputs remain
tied to their measured revision; no new whole-tree output equality or
deployed suspension is claimed.
- Reproduce the public path with
`doc/testing/slack-cloud-qualification.md`, the companion Cloud
checkout, Node 24/22 and fresh owned fixture databases. Aggregate local
`pnpm -r typecheck`, `pnpm test:run` and `pnpm build` were not rerun as
one command set. Current exact-head required CI and the complete
Greptile 5/5 review now pass; the full edited summaries and all
paginated threads were read.

Local worker quiet does not prove deployed host or Neon suspension.
Photon and Discord remain separate work; this PR completes the described
Slack slice.

## Risks

Use compatible App and Cloud readers before transferring custody.
Signatures prove authenticity; current authorization and exact ownership
still control execution. Preserve old targets, secrets and terminal
facts until their normal owners release them. Keep the managed seal
identity stable while custody remains. The required Slack adapter patch
remains pinned. Upstream's concurrent index uses the normal top-level
migration path.

## Model Used

OpenAI `gpt-6.1-sol` implemented and tested the changes. A GPT-6-based
Codex managing agent reviewed and integrated them. The host does not
expose the managing agent's exact model ID or context-window sizes. The
agents used code execution and test tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used, its version and tool capabilities
- [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 linked public related issues or PRs
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [x] I have run the local tests listed above and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green for the current head
- [x] Greptile is 5/5 with no open findings for the current head
- [x] I will address all reviewer comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 12:17:50 -06:00
DottaandClaude Opus 5.5 1805a1d247 perf(workspaces): probe workspace run bindings through indexes (#20568)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The terminal workspace sweep checks each Git workspace for recent
activity and for live consumers before it expires or moves the workspace
> - These checks find the runs of a workspace with one SQL scope,
`expirationScope`. The scope used one `OR` over all run bindings, and
one branch decoded the context JSON of each run
> - PostgreSQL could not use an index for that `OR`. Each check read
every run of the company and its large context from TOAST. On a hosted
database, each call took 2 to 5 seconds, about 5,200 s in total in one
window
> - This pull request splits the scope into one index probe per binding
and adds one concurrent expression index for the legacy
`executionWorkspaceId` context key
> - The benefit is that each check reads a few index entries instead of
all company runs. The cleanup decisions do not change

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The workspace activity and cleanup-safety checks:
`gitWorkspaceLastActivityAt`, `isGitWorkspaceExpirationEligible`,
`workspaceMoveEligible` and the orphan-folder consumer check. All of
them use `expirationScope` in
`server/src/services/git-workspace-expiration.ts`.

**Subsystem affected**
Execution workspace cleanup in the server
(`git-workspace-expiration.ts`, `isolated-repository-rehome.ts`,
`isolated-task-directory-retention.ts` through the shared scope) and the
`heartbeat_runs` schema in `packages/db`.

**Current behavior**
The `relevant_runs` CTE selects company runs where any of these is true:
`issue_id` or `native_issue_id` is in the issue tree or the bound
issues, the run is a bound issue's checkout or execution run, or the
context JSON has a matching `issueId`, `taskId` or
`executionWorkspaceId`. The planner does one sequential scan of the
company runs and reads each context from TOAST. The cost grows with the
run history of the company, not with the size of the workspace.

**Proposed behavior**
Each binding is a separate index probe. The `UNION` of run IDs goes to a
primary-key probe. The SQL gives the same rows.

**Reason and benefit**
Local SQL measurements show a 34x to 169x decrease in time. The decrease
grows with the run history (see Verification).

## What Changed

- `expirationScope` builds `relevant_runs` from a `UNION` of index
probes, one for each binding. The key sets go in as arrays (`=
ANY(ARRAY(...))`). With `IN (subquery)`, the planner chose a hash join
over a full scan, because it does not count the cost of reading TOASTed
contexts.
- The legacy context keys use `context_snapshot ->> 'key'`. This
expression matches the existing indexes
`heartbeat_runs_company_ctx_issue_created_idx` and
`heartbeat_runs_company_ctx_task_created_idx`. For a non-object context,
`->>` gives `NULL`, as the strict JSONPath decode did. Such a run can
still match through a native binding.
- New partial index `heartbeat_runs_company_ctx_execution_workspace_idx`
on `(company_id, context_snapshot ->> 'executionWorkspaceId') WHERE
context_snapshot ? 'executionWorkspaceId'`. The query repeats the
predicate. For an array context, `?` can be true, but `->>` gives
`NULL`, so the result does not change. The index is partial so that it
serves only this probe. A full index that starts with `company_id` made
the planner choose it for an unrelated company-scoped query. Migration
0460 builds it with `CREATE INDEX CONCURRENTLY IF NOT EXISTS`. The
migration is in the list of migrations that run outside a transaction.
- New tests: a legacy `issueId` or `executionWorkspaceId` binding on a
run that is bound to a different issue still blocks cleanup. A plan test
with `enable_seqscan = off` checks that no binding scans
`heartbeat_runs` and that the three context indexes are used.
- The benchmark script `scripts/benchmark-workspace-activity.ts` gets a
`--runs-per-workspace` option. Unrelated fixture runs now have distinct
context IDs, as real history does.
- `doc/DATABASE.md` describes the probes and the new index.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/git-workspace-activity.test.ts
src/__tests__/native-harness-backup-expiration.test.ts`: 132 of 132
tests pass. The second file checks that the native session index stays
the planner's choice for its query. These include the existing route
matrix (root, child, bound, native, context issue, context task, context
workspace, checkout, execution run), exactly-once counts, malformed and
uppercase contexts, company isolation, controller leases and lease
vetoes.
- `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter
@paperclipai/db check:migrations` pass.
- Benchmark (local SQL measurement, PostgreSQL 18.1, synthetic fixture,
8 workspaces per sample, before and after alternate in each iteration).
The script compares the complete ordered caller results and the complete
`relevant_runs` multisets of the baseline and treatment. They are equal
in every run.

`pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-workspace-activity.ts --baseline-ref=origin/master
--runs-per-workspace=<N>`

| Fixture | Check | Before median (min–max) | After median (min–max) |
Speedup |
  | --- | --- | --- | --- | --- |
| 4,800 runs, 330 MB | last activity | 523 ms (460–598) | 12 ms (8–15) |
44x |
| 4,800 runs, 330 MB | eligibility | 452 ms (432–473) | 13 ms (11–15) |
34x |
| 4,800 runs, 330 MB | idle eligibility | 465 ms (442–573) | 12 ms
(10–13) | 40x |
| 24,000 runs, 1.7 GB | last activity | 2,240 ms (2,214–2,454) | 13 ms
(11–14) | 169x |
| 24,000 runs, 1.7 GB | eligibility | 2,245 ms (2,097–2,377) | 17 ms
(14–19) | 133x |
| 24,000 runs, 1.7 GB | idle eligibility | 2,196 ms (2,179–2,381) | 15
ms (13–18) | 143x |

Before: one `Seq Scan` on `heartbeat_runs`. After:
`heartbeat_runs_company_issue_created_idx`,
`heartbeat_runs_company_native_issue_contract_id_uq`, the three context
indexes and `heartbeat_runs_pkey`.
- Index build time: on a synthetic table with 200,000 runs and 8 KiB
contexts (1.8 GB), the full form of this index took 3.7 s to build
concurrently and used 15 MB. The partial index reads the same rows and
holds only runs that have the key.

## Risks

- Cleanup safety: a wrong "allowed" result could delete an active
workspace. The scope keeps every binding of the old query. The benchmark
checks exact equality of the run sets, and the existing tests check each
binding route and each veto.
- Migration: the index build reads every run context once. It runs
concurrently and does not block writes. Until migration 0460 builds the
index, the new query is still correct but the `executionWorkspaceId`
probe scans.
- Plan choice: the array form makes index probes the cheapest plan for a
small tree. A very large issue tree gives a large array, but each
element is still one index probe.
- The sweep frequency does not change. This pull request reduces only
the cost of each check.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:17:46 -06:00
Nicky Leach 17136a6c25 fix(runtime): persist managed Runner credential refreshes across execution targets (#20580) 2026-11-10 10:15:29 -08:00
DottaandClaude Opus 5.5 1b7ec13acb perf(native): default remote sandbox runs to a warm, reusable sandbox for measured harnesses (#20048)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can run in remote sandboxes (Daytona), where each turn needs
the task's workspace and a runner inside the sandbox
> - With a default environment, every remote turn got a brand-new
sandbox: the whole workspace was uploaded again and a new runner
started, even for a follow-up comment
> - In a paperclip-sized workspace that made a follow-up take 30 s to
first token, against 8–10 s with a reusable warm sandbox
> - This pull request makes an agent that never chose a lifecycle run
warm on a sandbox for the measured harnesses (Codex, OpenCode, Claude),
so a follow-up keeps its sandbox, runner and workspace
> - The default applies only where warm can work: a sandbox provider
that keeps reusable leases, and a managed AI account that supports a
warm session. Everywhere else the run is per-turn, exactly as before
> - The benefit is fast remote follow-ups by default, without
re-uploading a large repository every turn

## Linked Issues or Issue Description

**What happened?**
On Daytona, with a fresh paperclip clone as the project workspace, a
follow-up comment took 30 s to its first token. The run log showed
`environment: fresh/created` on every follow-up. It spent 13.3 s in
`workspace_sync.transfer`, a full upload into the new sandbox, and 8–9 s
in `runner.session.resume`.

**Steps to reproduce**
1. Create a Daytona environment with the defaults: lifecycle inherited,
lease reuse off.
2. Run a Codex task in a large repository, then post a follow-up.
3. The follow-up gets a new sandbox, uploads the whole workspace again
and starts a new runner.

**Expected behavior**
A follow-up on the same task reuses the live sandbox, its runner and its
workspace (plan rule 2: no full re-upload per turn).

## What Changed

- **One rule for the default.** `defaultsRemoteSandboxRunToWarm`
(`native-runtime/native-warm-capability.ts`) decides the remote-sandbox
warm default for both the lease (environment view) and the runner
(native session policy), in one vocabulary (runtime backend plus ACPX
agent). It is applied only when no lifecycle was chosen anywhere:
admitted session, environment `runnerLifecycleMode`, or agent
`lifecycleMode`. It holds only when:
  1. the harness is measured (Codex, OpenCode, ACPX Claude);
2. the sandbox provider keeps reusable leases (Daytona, CreateOS and
boxd declare them; E2B, Modal and the built-in `fake` do not); and
3. with a managed AI account, that harness supports a managed warm
session (`supportsManagedNativeWarmSession`: Codex).
- **The harness resolves as the runtime resolves it.**
`resolveWarmProviderSelection` uses
`resolvePaperclipRunnerProviderProfile`, so an omitted `provider` is
Codex and ACPX without an agent is Claude, in both decisions. A config
the runtime would refuse gets no default.
- **Lease side** (`runner-environment-lifecycle.ts`,
`environment-run-orchestrator.ts`): for an eligible run only, the
orchestrator asks the provider before acquisition
(`providerSupportsReusableLeases` in `environment-runtime.ts`: the
acquisition rule, one registry lookup for a plugin provider, no
worker-ready wait). With support, the run-scoped view sets `reuseLease:
true` and forwards the warm idle window to the provider's auto-stop. The
shared environment is never rewritten. Every other run resolves exactly
as on master, without the probe.
- **Runner side** (`native-lifecycle.ts`, `runtime-selection.ts`): the
native policy applies the same rule to the acquired lease
(`reusableLeaseConfigured && effectiveCapabilities.reusableLeases`). If
acquisition downgrades the lease anyway, the run falls back to per-turn;
a default never produces
`runner_warm_environment_requires_reusable_lease`.
- **Idle window.** The remote default uses the window the environment
view forwards to the provider: an inheriting environment's
`runnerIdleTimeoutMs`, then the agent's `idleTimeoutMs`, then 5 minutes.
The sandbox execution target now carries the environment's window
(`runnerIdleTimeoutMs`, an optional field in `adapter-utils`'s execution
target) so the runner and the provider auto-stop agree. An explicit warm
choice and the local default are unchanged.
- Unchanged: explicit agent choices, environments that pin a lifecycle,
plugin-task environments, admitted sessions (`persistedPolicy`), and
local behavior.
- `tests/restart-chaos`: the vCPU budget check counts `--daytona-inherit
--lifecycle default` sandboxes as retained, because they now default to
warm.
- `doc/plans/2026-11-08-large-workspace-speed.md`: the measurements, the
ranked waits, and the gate.

## Verification

- Unit and integration tests, one per rule:
- Rule 1 (provider gate): `native-lifecycle.test.ts` (ephemeral lease →
per-turn), `runner-environment-lifecycle.test.ts` (no reusable leases →
environment unchanged, no warm window),
`environment-runtime-reusable-lease-probe.test.ts` (`fake`, an
undeclared plugin, a declared plugin whose worker does not verify the
reuse methods, and a plugin without a running worker read false),
`runtime-selection.test.ts` ("defaults an agent without a lifecycle on
...": reusable → warm/keep_running; reuse configured on an unsupported
provider → per_turn/destroy_after_turn; ephemeral → per_turn),
`environment-run-orchestrator.test.ts` (probe result decides
`reuseLease` and the forwarded window).
- Rule 2 (explicit configuration wins): explicit per-turn agent, pinned
environment, admitted session, configured `idleTimeoutMs`, managed
Claude per-turn, managed Codex warm; the orchestrator does not probe
when a lifecycle is explicit.
- Rule 3 (omitted provider): `{}` resolves to Codex in the environment
view; `runtime-selection.test.ts` "agrees with the environment view's
lease for ..." feeds the environment view's lease into selection for
Codex, a provider-less Codex agent, OpenCode, ACPX Claude, ACPX without
an agent, and Copilot (per-turn), and checks both decisions agree. A
plugin-task target stays out of the default.
- The two suites that failed on the previous head
(`runtime-selection.test.ts`,
`workspace-recovery-preparation-dispatch.test.ts`) pass locally.
Locally, 6 connector-skill cases in `runtime-selection.test.ts` fail
with `ENOENT .claude/skills/slack/SKILL.md` because this checkout is a
nested worktree; they do not touch this change and pass in CI.
- End-to-end check of this default on Daytona (before the gate was
added; Daytona keeps reusable leases, so the gate does not change this
path): a user-default environment (lifecycle inherited, lease reuse
unset, `--daytona-inherit`), a fresh paperclip clone, built from the
image's source revision with this change (branch measure/remote-warm).

  | | Before (per-turn sandbox) | After (this change) |
  |---|---|---|
  | Follow-up, Codex | 30.4 s | 7.6 s |
  | Follow-up, Claude | about 30 s | 11.0 s |
  | First follow-up after an idle K1, Codex | 45.9 s | 20.3 s |
  | First follow-up after an idle K1, Claude | 38.3 s | 18.3 s |

The run-log reuse records show the follow-ups reusing the workspace,
environment, runner and provider session, with no
`workspace_sync.transfer`. First turns still upload the workspace once
(14.8 s and 26.1 s). Every sandbox was cleaned up.
- Daytona restart chaos wave (K1/K2/K4 with a mid-run comment,
inheriting environment, agents with no lifecycle, runner image built
from this branch): wave `w-20261110T171438-s20048`, seed 20048, on
b7f6243d60 with image `paperclip-daytona-runner@sha256:76a33275…6fe29`
(built from this branch by the `image_only` runner workflow; runner
content id `ed503454…a479` matches every later commit here, which change
only server, adapter-utils types and tests). `--daytona-inherit
--lifecycle default --adapters codex,claude --workloads A1,C1 --triggers
time,comment --faults K1,K2,K4 --tasks 6 --concurrency 3`.
- **6/6 tasks pass** (R1–R6 and T), all 6 crossed a restart; cells:
Codex A1 K2, Codex A1 K4, Claude A1 K4, Claude C1 K1, Claude C1 K4,
Codex C1 K2 at the mid-run comment. **No task needed Retry** (R3 n/a on
every task; one Claude task recovered automatically from
`process_lost`). No open signatures, 0 speed breaches.
- The default applied: the R6 sweep found 6 retained reusable leases
(one per task, Daytona keeps them) and no orphan or leaked sandbox; the
environment was deleted cleanly.
- Timings (Daytona, first turns, n=3 each): TTFT p50 Codex 21.9 s,
Claude 31.0 s. Restart recovery p50/p95: Codex 10.5 s / 29.6 s (n=3),
Claude 8.8 s / 23.9 s (n=2). Warm follow-up TTFT has no clean sample in
this wave (both follow-ups were crossed by a fault), so the follow-up
comparison above (30.4 s → 7.6 s Codex, ~30 s → 11.0 s Claude) remains
the speed evidence; no separate per-turn chaos wave was run.
- An earlier attempt, `w-20261110T170357-s20048`, never started tasks:
the first sandbox from the new image stayed `creating` past the
harness's 300 s Codex qualification bound (Daytona building the new
snapshot). It left one sandbox that the harness reported after deleting
the environment; the rerun on the cached snapshot is the wave above.
- The idle-window fix (9a74116082) landed after the wave; it changes
only runs whose inheriting environment sets `runnerIdleTimeoutMs`, which
this wave's environment does not.

## Previous-release story

No persisted state changes shape. A session admitted per-turn before
this change keeps its saved policy (`persistedPolicy` wins) until a new
session starts. A run in flight at the cutover keeps its admitted
lifecycle for recovery (`admittedLifecycleMode` wins in the environment
view).

## Cost and opt-out

- **Cost:** after a turn, the sandbox and its warm runner stay up for
the warm idle window (the environment's `runnerIdleTimeoutMs`, else the
agent's `idleTimeoutMs`, else 5 minutes). Then the controller suspends
the runner, and the provider auto-stop follows the same window. One idle
window per active task.
- **Opt-out:** set the agent's lifecycle to "Turn by turn"
(`lifecycleMode: per_turn`), or set the environment's
`runnerLifecycleMode` to `per_turn`. Either wins over this default.

## Reuse scope, restart and saved work

- **One lineage per sandbox.** The Daytona plugin keys a reusable lease
by the agent plus the execution workspace (or issue). Another task never
gets a sandbox holding a different task's files, and subtasks still get
fresh workspaces from the project base, so they get their own sandbox.
The workspace is adopted only after its stamp matches the host's.
- **After a restart,** a warm sandbox is reattached or replaced through
the existing warm recovery and owner fencing. A sandbox with a live
owner is never claimed twice. The Daytona chaos wave above checks this.
- **No lost work.** Each turn's workspace copyback
(`workspace_finalize`) runs at the end of the turn, before any idle
suspend, as for per-turn runs.

## Risks

- **Cost:** an idle sandbox stays up for the warm idle window (5
minutes) after a turn, then suspends. Only on providers with reusable
leases.
- **Probe cost:** one plugin-registry lookup before acquisition, only
for runs the default can apply to. No wait for a worker: a provider
whose worker is not running yet reads as unsupported, so that run is
per-turn.
- **Known gap (pre-existing, not widened):** with an explicit agent warm
choice on an inheriting environment that sets `runnerIdleTimeoutMs`, the
provider gets the environment's window while the host uses the agent's,
as on master.
- **Restart behavior on warm remote sandboxes** uses the existing warm
recovery paths. The Daytona chaos wave above checks K1, K2 and K4.
- Sessions admitted per-turn before this change stay per-turn until a
new session starts.
## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:49:14 -06:00
DottaandClaude c37eacbee3 fix(native): Retry re-finalizes a saved result that ran out of finalization retries (#20578)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs save an accepted result. Then a finalizer applies that
result to the task.
> - Finalization has its own budget of 3 failed attempts (#20555). When
the budget runs out, the coordinator goes to `terminal_failure` with
`native_finalization_retry_exhausted`.
> - For a provider-succeeded run, the task then rests `in_review` with a
board recovery card. The task shows "resolved" and offers no Retry. The
user is stuck.
> - The ordinary Retry is also wrong here: it starts a new provider run,
which repeats work the agent already finished.
> - This pull request adds two things. First, contention failures on
such a result get two longer automatic delays before the board gets the
task. Second, when the state is still exhausted, the task offers Retry,
and that Retry applies the same saved result again.
> - The benefit: fewer tasks need a Retry, and when one does, Retry
works and does no duplicate provider work.

## Linked Issues or Issue Description

Refs #20555 (it names this gap under Risks).

**What happened?**
In restart-chaos wave `w-20261110T044338-s20385`, task t03 ended with a
succeeded run, an accepted result, and a coordinator in
`terminal_failure` / `native_finalization_retry_exhausted`. The issue
stayed `in_review` with a board-owned `active_run_watchdog` card and
`canRetry=false`. #20555 stopped the early exhaustion. A real exhaustion
still leaves the same stuck state. Coordinators that older builds
already left in this state are stuck the same way.

**Expected behavior**
Transient finalization failures recover by themselves when that is safe.
When they do not, the task offers Retry. That Retry re-finalizes the
saved result. It does not start a provider run and does not charge a
provider attempt.

**Steps to reproduce**
1. Let a native run save an accepted result and end `succeeded`.
2. Make each finalization of that result fail until the budget of 3 runs
out.
3. Before this fix, the task presentation is `resolved` with
`canRetry=false`, and the board card has no action that applies the
result. The new test
`server/src/__tests__/native-exhausted-finalization-retry.test.ts`
reproduces this.

**Paperclip version or commit**
master at c58356003f (after #20555).

## What Changed

- `native-run-finalizer.ts` (`recordRetryableFailure`): when the budget
runs out on an accepted result of a provider-succeeded run, and the
cause is contention (`status_cas_exhausted` or
`side_effect_planning_failed`), the coordinator stays
`retryable_failure`. It tries again after 2 minutes, then after 10
minutes, each with a fresh budget. Each delay is recorded once in
`recoveryHistory` (`automatic_finalization_backoff`). After both delays,
the old terminal path runs. Deterministic causes (for example
`native_finalization_invalid`) and workspace-scope failures are
unchanged. The existing deadline worker (`native-result-commit-work`)
runs the retry, so no new timer or poll is added.
- New `native-runtime/exhausted-finalization-retry.ts`:
- `exhaustedNativeFinalizationCondition()`: one SQL predicate for
"succeeded native run, accepted result, coordinator `terminal_failure`
with `native_finalization_retry_exhausted`, no lease, no successor".
- `rearmExhaustedNativeFinalization()`: under the task lock, reopens the
same coordinator (`arbitrating` or `workspace_finalizing`), records
`exhausted_finalization_retry` in `recoveryHistory`, resolves the board
card (`handed_back`), and resets `finalizationFailureCount` to 0. It
sets a 30 s fallback `nextAttemptAt`, so the deadline worker finishes
the job if the process stops first.
- `refinalizeSavedNativeResult()`: runs `finalizeNativeRun` for the same
run with `preserveProviderAttempt: true`. A failure goes through the
finalizer's normal bounded budget.
- `task-interruption-presentation.ts`: the run read adds
`nativeFinalizationRetry` from that predicate. A succeeded run with this
flag shows `state: "failed"`, `canRetry: true`. It also adds
`nativeFinalizationRetryPending` (`savedResultRetryPendingCondition()`:
coordinator not committed or terminal, with an
`exhausted_finalization_retry` entry). While it is true the task shows
`state: "working"` with `recoveryStep: "finishing_recovery"`, so the
user sees the Retry took effect. Done or cancelled tasks, user gates,
and ordinary runs are unchanged.
- `task-retry.ts` (`admitTaskRetry`): for a succeeded native run in the
exhausted state, Retry re-arms the coordinator instead of creating a
provider retry. It writes a `coalesced` `task_retry` receipt bound to
the same run with strategy `finalize_result` (an existing strategy
value), joins any unadmitted Retry intent to it, and logs
`issue.retry_requested` with `strategy: "finalize_result"`. The
idempotency key still returns the same receipt on replay. A later click
with a new key, after an earlier saved-result Retry of the same run
(still finalizing, or applied), joins that Retry with another coalesced
`finalize_result` receipt and starts no provider work.
- `routes/issues.ts` (`POST /issues/:id/retry`): after the receipt
commits, it starts `refinalizeSavedNativeResult` for that run instead of
the provider Retry dispatch.
- `doc/execution-semantics.md`: documents the automatic delays and the
re-finalizing Retry.

## Verification

- `cd server && npx vitest run
src/__tests__/native-exhausted-finalization-retry.test.ts` (4 tests):
- Exhausted state reports `state: "failed"`, `canRetry: true`. Retry
re-arms the coordinator, writes a `finalize_result` receipt and an
`issue.retry_requested` activity row, and replays idempotently. A second
click with a new key while pending, and a third after commit, join the
same Retry; no provider Retry wake exists. While pending, the task shows
`working` / `finishing_recovery`. Re-finalization commits, and the task
shows resolved. The run count for the task stays 1, the coordinator
`attempt` is unchanged, the run stays `succeeded`, and no board card
stays active.
- Old-shape row (no `finalizationFailureCount`, `attempt: 3`, already
terminal): the same Retry works with no migration.
- Re-finalization that fails again: the coordinator goes to
`retryable_failure` with a fresh count of 1, then to `terminal_failure`
after the budget, and the task again shows `canRetry: true`. Nothing is
lost silently.
- Contention failures (failpoint `status_projection`): two automatic
delays (2 min, 10 min) with the task status unchanged, then the board
Retry, which commits with no new run.
- Red/green: with only the new module and test applied (finalizer,
presentation, task Retry, and route reverted), all 4 tests fail
(`canRetry` false; no automatic delay).
- `npx vitest run src/services/task-interruption-presentation.test.ts`
adds unit cases: the flag offers Retry; ordinary succeeded runs stay
resolved; ordinary failed runs keep `canRetry: true`; done tasks stay
resolved.
- Also passing: `native-finalization-retry-budget`,
`native-finalization-recovery`, `native-run-finalizer-telemetry`,
`task-interruption-query`, `interrupted-preparation-restore-retry`,
`native-result-commit-work`, `workspace-recovery-admission`,
`workspace-recovery-preparation-gates`,
`deferred-execution-receipt-work`.
- `npx tsc --noEmit -p server/tsconfig.json` passes.
- Not run: the full local suite (`pnpm verify` selects about 2,450 files
for these shared modules; CI covers it), and no live restart-chaos wave.

## Risks

- Previous-release story: coordinators saved by older builds in
`terminal_failure` / `native_finalization_retry_exhausted` qualify by
phase and code. The count is optional. Nothing is migrated. Older rows
do not get the automatic delays; they get the working Retry.
- Automatic delays add at most two extra finalization rounds of the same
saved result, only for contention causes, only for provider-succeeded
accepted results. During the delays the task keeps its prior status, as
it does during the normal 30 s retries. Supersession by a newer run is
still checked first on every attempt.
- The re-finalizing Retry applies the agent's saved result after a
person may have looked at the task in review. It uses the normal status
arbiter and CAS. Done and cancelled tasks, other assignees, and newer
runs refuse it.
- No change before `turn.start`: all new work is on the finalization,
presentation, and Retry paths.

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

## Model Used

- Anthropic Claude, model ID `claude-opus-5-5`, used through Claude Code
with tool use (shell, code 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 <noreply@anthropic.com>
2026-11-10 11:48:47 -06:00
Nicky LeachandPaperclip 98ce93dd4e Choose project storage from compatible environments (#20587)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Projects supply repository files to agent runs through core staging
or a storage plugin.
> - A storage plugin's default claim silently attached new projects even
when their agents used a different environment.
> - An environment name or driver type does not establish shared storage
access. The existing binding and task-driver contracts do.
> - This pull request groups creation choices by that contract and
selects storage from the creator's effective environment.
> - The benefit is a visible, validated choice and a clear error before
an incompatible managed workspace operation.

## Linked Issues or Issue Description

Fixes #20459.

Related: #20156 covers a separate provider-failure diagnostic. The PR
search found no other fix for #20459. ROADMAP.md has no duplicate
project-creation change.

## What Changed

- Show **Available in** when configured environments require different
project storage. Each option names the environments that can use the
same storage owner. Hide the selector when all choices are compatible.
- Derive compatibility from repository staging and managed binding
ownership. Local, SSH, and sandbox environments use core staging.
Managed storage requires a registered task driver from the same
installed storage plugin. Separate storage plugins remain incompatible.
Unknown plugin contracts and the fake sandbox are excluded.
- Follow the creating agent's environment, or the person's primary
agent, then the instance default and local fallback. Apply managed-only
and forced Kubernetes policy. The product has no separate user or
company environment-default field today.
- Add a company-authorized discovery endpoint and an optional creation
`environmentId`. Validate the choice again on save. Store the storage
owner without overriding agent run environments. Preserve explicit API
storage choices and existing project ownership.
- Keep the hidden repository-free private-task project independent of
environment availability. Submit the observed storage owner as
`expectedStorageProviderPluginId` so a changed contract fails at save
without setting a workspace policy. Instance task-isolation defaults
stay inherited.
- Reject an incompatible managed workspace before a provider operation.
Name the storage and selected environment in the error. Add regression
tests, API documentation, creation-story fixtures, and minimal
corrections to obsolete map text.

## Verification

- **102 tests passed across 7 files:** project environment integration,
installed storage policy, repository persistence, project environment
routes, project creation telemetry routes, OpenAPI, and
NewProjectDialog. Run with `node node_modules/vitest/vitest.mjs run
server/src/__tests__/project-environments.test.ts
server/src/__tests__/project-workspace-provider.integration.test.ts
server/src/__tests__/project-repositories-persistence.test.ts
server/src/__tests__/project-routes-env.test.ts
server/src/__tests__/project-goal-telemetry-routes.test.ts
server/src/__tests__/openapi-routes.test.ts
ui/src/components/NewProjectDialog.test.tsx --silent`.
- The review fixes cover both archived-default private-task cases and
changed-storage rejection. With both instance isolation flags enabled, a
new core repository project keeps a null policy and resolves to an
isolated Git worktree. The complete affected set passes all 102 tests.
- The CI query-error policy failure was fixed by using the shared query
view. The form and shared query-view suites pass **24 tests**, including
cached choices during a transient refetch failure. `node
scripts/check-query-error-rendering.mjs --enforce --summary` reports
zero findings.
- CI exposed stale SDK source hashes and heartbeat fixtures that paired
plugin storage with Local. Refreshed exactly four shared-source hashes
with the maintained generator (runtime pins unchanged). All **53 SDK
privacy/source-proof tests**, including real built SDK output, pass. All
**7 corrected managed-storage heartbeat scenarios** pass; the fixtures
declare the owning plugin's task environment and substitute transport at
the existing execution seam.
- Shared and UI TypeScript checks pass. The UI production build passes.
`pnpm check:token-gates`, `node scripts/check-maps.mjs`, `node
scripts/check-module-boundaries.mjs`, and diff checks pass.
- Tested UI states: compatible choices, incompatible groups, default
within a group, manual override, create failure, loading, discovery
failure and retry, and an unavailable default. Tests use the production
form and existing design tokens.
- The integration tests use real isolated PostgreSQL on macOS arm64 with
Node 26.12.0. They cover user/agent defaults, storage persistence,
policy restrictions, company and authentication denials, and rejection
before a provider call. Discovery uses saved configuration and makes no
worker or provider requests.
- Local verification limits: `pnpm -r typecheck` and `pnpm build` stop
in unchanged adapter-utils code because the installed ACPX types lack
`inheritProcessEnv`. A direct server check also finds an unchanged AI
SDK type-version mismatch. The existing heartbeat test `inherits the
instance default environment across companies while preserving explicit
agent overrides` times out at line 518 on both this change and
unmodified master with the same dependencies. The full `pnpm test:run`
attempt passed all six chat shards (1,948 tests), then the Git workspace
expiration suite reported 14 failures. I stopped that broad run. Two
sampled failures reproduce on unchanged master with the same
`unknown_worktree_git_files` refusal; the other expiration failures were
not independently compared. A later broad workspace-preparation run also
hit workspace validation failures and was stopped; the seven corrected
fixture scenarios pass independently. These limits do not establish a
full local suite pass. Hosted CI remains required.
- Initial missing-package and loopback-denial failures were resolved by
repairing isolated dependency links, building the SDK, and running
database tests with loopback access. No deployed or live-provider
execution is claimed.

## Risks

- Creation defaults now follow the effective environment. A plugin's
`defaultForProjects` claim alone no longer selects storage. This is the
intended behavior change.
- Compatibility covers repository project storage. It does not guarantee
arbitrary host paths, retained-workspace moves, adapter/image support,
credentials, or worker readiness. Matching provider configs cannot prove
a cross-plugin storage contract.
- A person's primary agent supplies the personal default because there
is no separate personal environment setting. An unavailable configured
default requires a valid explicit choice or a configuration repair.
- No migration runs. Existing projects keep their storage owner. This
change does not detach existing managed data or provide a storage
migration.
- Full local verification has the limits listed above. CI and standard
Greptile review are pending.

## Model Used

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

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 17:38:31 +00:00
DottaandClaude Opus 5.5 580ec41110 fix(workspaces): run private tasks parked in a personal project (#20589)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A private task without a project is parked in its owner's "My
private tasks" project
> - The spec says an issue-level grant can expose a task without
exposing its containing project
> - Workspace setup for a run also required project read on the task's
project
> - So an agent that could read its private task still failed at setup,
before any work, when the task was in "My private tasks"
> - This pull request accepts task access for that personal project when
the project supplies no workspace files
> - The benefit is that a person can make a chat or task private and the
agent can still work on it, without gaining access to the person's other
private tasks

## Linked Issues or Issue Description

**What happened**
A person made their chat with the CEO agent private. The chat moved into
the person's "My private tasks" project. The next CEO run failed at
execution setup in about one second with "Project is private and the
actor is not an access member."

**Expected behavior**
The CEO can work in a private chat (or on a private task) that it can
read. The CEO does not get access to the person's other private tasks.

**Steps to reproduce**
1. Open a chat with an agent and make the chat private (the chat has no
project).
2. Send a message to the agent.
3. See the run fail at setup with `deny_project_private` on the person's
"My private tasks" project.

## What Changed

- `assertTaskWorkspaceExecutionAccess` marks the task's own project as a
container when it is not also the selected workspace or source project.
- `assertTaskWorkspaceSourceProjectAccess` accepts a
`deny_project_private` decision for such a container only when it is a
personal "My private tasks" project with no project workspaces. Task
read is already authorized earlier in the same check.
- Shared private projects, personal projects with workspaces, workspace
and source projects, and low-trust boundary denials are not changed.

## Verification

- Red then green: `cd server && npx vitest run
src/__tests__/agent-project-access.test.ts` (new test `runs a private
task in its owner's personal project without granting that project`
fails on `master` with the eval's error; 12 passed with the change). The
test also checks that the personal project stays unreadable, that a
shared private project still needs membership, and that a personal
project with a workspace still needs membership.
- `npx vitest run src/__tests__/workspace-preparation-history.test.ts
src/__tests__/project-workspace-root-provenance.test.ts
src/__tests__/task-workspace-child-trust.test.ts
src/__tests__/task-workspace-source-copy-privacy.test.ts
src/__tests__/privacy-production-review.test.ts` (81 passed).
- `tsc --noEmit -p server`: no errors in the changed files.

## Risks

- The change relaxes one authorization check. It applies only to the
task's own personal project, only after task read is allowed, only for
the private-project denial reason, and only when that project has no
project workspaces. A project read through any other path is not
changed.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:37:00 -06:00
DottaandClaude Opus 5.5 accf22076f fix(search): apply task privacy to artifact results (#20595)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Private tasks are visible only to the people and agents that have
access
> - Company search already filters task results by the caller's task
read condition
> - Artifact results (agent documents, work products and attachments) in
search, and the company artifacts list, did not use that condition
> - So a company member could find a private task's document title and
snippet through search
> - This pull request applies the same task read condition to artifact
results
> - The benefit is that private task outputs stay private in search and
in the artifacts list

## Linked Issues or Issue Description

**What happened**
An agent saved a document on a private task. A different company member
without access to that task searched for a word in the document. The
search returned the document as an artifact result with a snippet.

**Expected behavior**
Search and the artifacts list show artifacts only from tasks the caller
can read.

**Steps to reproduce**
1. Create a private task and let an agent save a document on it.
2. Sign in as a company member who cannot read the task.
3. Search for a word in the document, or open the company artifacts
list.
4. See the private document in the results.

## What Changed

- `company-search.ts`: artifact counts and artifact rows apply
`options.issueReadCondition`.
- `routes/companies.ts`: `GET /companies/:companyId/artifacts` passes
the caller's `issueReadSqlCondition` to the artifacts service.

## Verification

- Red then green: `cd server && npx vitest run
src/__tests__/company-search-service.test.ts -t "private task
artifacts"` (fails on `master` with the private document in the
outsider's results).
- Red then green: `npx vitest run
src/__tests__/company-artifacts-service.test.ts -t "caller can read"`.
- `npx vitest run src/__tests__/company-search-service.test.ts
src/__tests__/company-search-extract-service.test.ts
src/__tests__/company-search-extract-routes.test.ts
src/__tests__/company-artifacts-service.test.ts` (all pass).
- `tsc --noEmit -p server`: no errors in the changed files.

## Risks

- Low risk. The read condition is the same one task results use. Callers
with full access (local board, instance admin, privacy mode off) get
`true` and see no change.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:34:23 -06:00
DottaandClaude Opus 5.5 491dfa1c6d fix(tool-gateway): make gateway client token creation person-only (#20581)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Tool gateways let external MCP clients use a company's tools with a
gateway client token
> - A gateway client token is shown only once, to the caller that
creates it
> - Since delegated connection management (#17737), an agent can create
a gateway client token for its responsible person
> - In that case the agent receives the one-time token value, and it can
repeat the value in chat
> - This pull request makes gateway client token creation person-only
> - The benefit is that the one-time token value goes only to the person
who creates it in the gateway UI

## Linked Issues or Issue Description

Refs #17737

**What happened**
A person asked the CEO agent in chat to replace the token of a gateway
client. The agent called `POST
/api/tool-gateway/gateways/{gatewayId}/tokens` under delegated
authority. The response gave the agent the new token value, and the
agent put the value in its chat reply.

**Expected behavior**
An agent does not receive a gateway client token value. A person creates
the token on the gateway's Tokens page and gives it to the client. The
agent tells the person where to do this.

**Steps to reproduce**
1. Give an agent delegated connection management for a responsible
person with `tools:admin`.
2. As the agent, call `POST
/api/tool-gateway/gateways/{gatewayId}/tokens` (for example through
`call_api`).
3. See a 201 response that contains the new token value.

## What Changed

- `POST /api/tool-gateway/gateways/:gatewayId/tokens` returns 403
`gateway_token_requires_person` to agent callers before it creates a
token. The message is final. It gives the Tokens page path
(`/<prefix>/apps/gateways/<gatewayId>/tokens`) and tells the agent not
to ask for or repeat token values. The response also has `tokensPath`.
- Board (person) callers are not changed.
- The API catalog entry for the route now says that it is person-only.
- Existing agent-issued tokens are not changed. Runtime issuer and
responsible-user checks still apply to them. The integration tests now
seed such tokens directly to keep that coverage.

## Verification

- Red: the new integration test `never returns gateway client token
plaintext to agent callers` fails on `master` (the agent `call_api`
result is 201 with the token value).
- Green: `cd server && npx vitest run
src/__tests__/connection-management.integration.test.ts -t gateway` (24
passed). The test also checks that no token row is created for the
agent, and that a person session still gets a 201 with the token.
- `npx vitest run src/__tests__/tool-gateway.test.ts
src/__tests__/openapi-routes.test.ts` (152 passed).
- `npx vitest run src/services/native-runtime/runner-api.test.ts` (2090
passed).
- `tsc --noEmit -p server`: no errors in the changed files.

## Risks

- Behavior change: agents can no longer create gateway client tokens. A
workflow that relied on that now gets a 403 that sends the person to the
Tokens page. Agents can still revoke tokens and manage gateways under
delegated authority.
- Low migration risk: no schema change, and existing tokens keep
working.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:31:43 -06:00
DottaandClaude Opus 5.5 d5c1f48180 perf(native): index the backup lease lookup by scope digest (#20571)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native harness backup expiration removes old session backups. Before
it removes a backup, it finds the environment leases that reference the
backup root
> - This lookup reads leases by run ID, by the
`metadata.nativeHarnessBackup.sessionScopeSha256` digest, or by the
identity's own lease ID, in one `OR` query
> - The metadata arm has no index. So PostgreSQL reads every lease of
the company, and the metadata of each lease, for each lookup
> - On a hosted test database, this statement ran 21,736 times at a mean
of 158 ms in one window. After other fixes, it still ran 2,364 times at
a mean of 121 ms
> - This pull request adds a small partial expression index on the
digest
> - The benefit is that each lookup reads a few index entries instead of
all company leases. The query text and the result do not change

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The lease lookup in `backupDependencies` in
`server/src/services/native-runtime/native-harness-backup-expiration.ts`.
`reconcileBackup` calls it for each backup root when the expiration
worker loads or checks a root.

**Subsystem affected**
Native harness backup expiration and the `environment_leases` table
schema.

**Current behavior**
The query is `company_id = $1 and (heartbeat_run_id in (...) or
metadata->'nativeHarnessBackup'->>'sessionScopeSha256' = $n or id =
$m)`. The run arm and the ID arm have indexes. The digest arm has no
index. A `BitmapOr` plan needs an index for every arm, so the planner
reads all company leases and evaluates the JSON expression on each row.
Lease metadata holds sandbox details, so these reads are large.

**Proposed behavior**
The partial index `environment_leases_native_backup_scope_idx` keys
leases by the digest. It holds only leases that have a backup stamp.
Every arm of the `OR` now has an index, and the planner combines three
index probes. The SQL and the returned lease IDs do not change.

**Reason and benefit**
This statement was in the top ten by total database time on a loaded
hosted database. A local reproduction shows a query that is more than
6,000x faster and reads 9 buffers instead of about 436,000.

**Breaking changes**
None. The API, the lease-matching rules and the results do not change.

## What Changed

- Add the partial expression index
`environment_leases_native_backup_scope_idx` on
`(metadata->'nativeHarnessBackup'->>'sessionScopeSha256')`, where that
value is not null.
- Add migration `0459`. It uses `CREATE INDEX CONCURRENTLY IF NOT
EXISTS`. The migration runner runs it as a top-level statement, the same
as migrations 0454–0458.
- Add a regression test. It checks the lease-matching rules: run leases,
a lease with only the backup stamp, and the identity's own lease match.
Another digest, a non-string stamp, a top-level key with the same name,
and the same digest in another company do not match. The test also
checks that the custom plan and a forced generic plan of the generated
SQL use `BitmapOr` with the new index and no sequential scan.
- Add `scripts/benchmark-environment-lease-backup-lookup.ts`. It
captures the SQL that the real service generates and measures it with
and without the index.
- Document the index in `doc/DATABASE.md`.

## Verification

Cause and thesis. The cost comes from the one `OR` arm without an index,
which forces a scan of all company leases and their metadata. Thesis: an
index on the digest lets the planner use a `BitmapOr` of three index
probes, so the cost no longer depends on the company's lease count. The
results stay identical. The benchmark confirms the thesis.

I did not change the SQL. With the index, the existing `OR` query
already gets the index plan. A `UNION` rewrite would not help without
the index, because the digest arm would still scan the company.

Local SQL benchmark:

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-environment-lease-backup-lookup.ts
```

Fixture (synthetic): one company with 100,000 leases with 4 KiB of
incompressible metadata each. 30% of the leases have a backup stamp for
another session. A second company has 20,000 leases. The table is 678
MB. The backup root has 3 run leases, 1 lease with only the stamp, and 1
identity lease. The same digest is also in the other company. Embedded
PostgreSQL 18 on an Apple laptop, which other work loaded heavily. The
hosted database is Neon with different compute, so absolute times are
local SQL measurements only.

The script measures in the order: no index, index, no index, index. Each
measurement has 2 warmups and 11 samples of `EXPLAIN ANALYZE` execution
time and of the full `reconcileBackup` call.

| Measurement | Without index | With index |
| --- | --- | --- |
| Lease query execution, run 1 | median 196 ms (138–562 ms) | median
0.010 ms (0.007–0.017 ms) |
| Lease query execution, run 2 | median 185 ms (144–295 ms) | median
0.030 ms (0.020–0.045 ms) |
| `reconcileBackup` wall time (includes file reads), runs 1 and 2 |
median 188 ms and 216 ms | median 1.4 ms and 4.2 ms |
| Plan | `Gather > Seq Scan` | `Bitmap Heap Scan > BitmapOr` of
`heartbeat_run_idx`, the new index and `pkey` |
| Buffers | 365,880 hit + 70,050 read | 9 hit |

These numbers are from head `65bd9d7ac6`. The final head only renumbers
the migration to 0459 after rebases; the SQL, the index and the service
code are the same. Two earlier runs on the same fixture, SQL and index
gave 236–425 ms without the index and 0.023–0.033 ms with the index. The
machine had heavy load from other work, so the spread without the index
is wide. Output check: in all four measurements, the service returned
the same 5 lease IDs, and the complete query rows were equal.

Index build (`CREATE INDEX CONCURRENTLY` from the migration file):
0.8–2.4 s for the 678 MB table over all runs. Index size: 3.4 MB with
30% of leases stamped.

Tests:

- `cd server && npx vitest run
src/__tests__/native-harness-backup-expiration.test.ts`: 109 passed. The
new test failed when I dropped the index before the plan check
(confirmed red), and it passes with the index.
- `pnpm --filter @paperclipai/db check:migrations`: passed.
- `pnpm --filter @paperclipai/db typecheck` and `pnpm --filter
@paperclipai/server typecheck`: passed.
- `pnpm verify`: it asks for broader coverage (exit 2) because of the
new script. See the CI results for this head.

## Risks

- Migration time. A concurrent index build reads the full
`environment_leases` table and does not block writes. Locally, the build
took 0.8–2.4 s for 678 MB. The time grows linearly with the table size.
I did not time it on a production-size clone. I expect a few seconds to
tens of seconds on a large instance, which is in the 1–2 minute budget.
This estimate is not proven.
- If the build stops, PostgreSQL can leave an invalid index. The
migration runner repairs invalid concurrent indexes when it runs again,
and `IF NOT EXISTS` makes a rerun safe.
- The index expression must stay textually equal to the query
expression. If someone changes the JSON path, the planner cannot use the
index. The new test catches this for custom and generic plans.
- The index is partial, so ANALYZE does not collect statistics for the
expression. The planner uses the default equality estimate. On the large
fixture and in the small test, the planner still selects the index plan.
A company with very few leases can still get a sequential scan, which is
cheap.
- Each lease write with a backup stamp writes one more small index
entry. The write cost is small.
- This change does not reduce how often the worker calls the lookup. It
makes each call cheap. The other digest lookups in backup retention
(`native-harness-backup-retention.ts`) and session retention checks can
also use the index.
- Previous-release story: no persisted state changes. The index is
derived from existing lease metadata.
- The hosted improvement is not verified until deployment. After
deployment, compare a new query-statistics window for this statement.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:30:05 -06:00
DottaandClaude 7368a0b15c fix(recovery): re-queue native runs that fail on a transient database fault before provider work (#20561)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native (Paperclip Runner) runs prepare a task, pass a final dispatch
gate, and only then start a provider.
> - A native run can fail before any provider work starts. Examples are
a Postgres serialization or deadlock error (`40001`, `40P01`) at budget
admission, or a transient database error during setup.
> - Today such a run ends `adapter_failed` or `setup_failed`. `failRun`
writes the "no provider work" record only for legacy runs, and the
bounded retry paths do not retry these codes for native runs. The
release then opens the native hold
(`native_continuation_requires_reconciliation`), and a person must click
Retry.
> - The goal is that 0% of tasks need a Retry click after a restart or a
storage/DB fault. Duplicate provider work is worse than a Retry click,
so the fix must only act when the server can prove no provider work
started.
> - This pull request records that proof for native runs and sends a
transient database failure to the existing uncharged, bounded
`interrupted_preparation` retry lane.
> - The benefit is that these runs re-queue by themselves, and runs with
any sign of provider work keep today's hold.

## Linked Issues or Issue Description

**What happened?**

In restart-chaos waves with database pressure, a native run failed at
budget admission with exhausted serialization retries (`40001`/`40P01`).
The run event history had no `native.process_start_requested` event.
About 15 s later the server recorded "Automatic recovery stopped" with
cause `native_continuation_requires_reconciliation`. The task stayed
blocked until a person clicked Retry.

**Expected behavior**

A native run that failed before any provider work, on a fault that
clears by itself, re-queues automatically. The attempt is bounded and
does not spend the failure budget. A persistent failure still ends
failed, with a working Retry.

**Steps to reproduce**

1. Start a native (Paperclip Runner) task.
2. Make budget admission or a setup database write fail with SQLSTATE
`40001` or `40P01` until its bounded retries are exhausted.
3. See the run end `adapter_failed`/`setup_failed`, the task go to
`blocked`, and a `native_continuation_requires_reconciliation` recovery
action that waits for Retry.

**Paperclip version or commit**

`master` at 902f376796.

## What Changed

- `run-failure-cause.ts`: new durable record
`resultJson.nativePreDispatchFailure = { version: 1,
providerWorkStarted: false }`, and
`isNativePreDispatchTransientFailure`. The predicate needs a native
failed run, a generic code (`adapter_failed`/`setup_failed`), the
record, and a transient database `failureCause` (`40001`, `40P01`,
`55P03`, `57P01`-`57P03`, `53300`, `08xxx`, postgres.js connection
codes).
- `run-completion.ts`: `failRun` and `failRunSetup` write the record for
a native run only when all of these are true:
- the caller reports `nativeDispatchMayHaveStarted: false` (an omitted
value means "may have started");
- the run was not stopped during the failure, and has no terminal native
coordinator failure;
- the run's `native_run_finalizations` coordinator, if any, is still the
unclaimed row that runtime selection creates (`observed`, attempt 0, no
lease owner, no controller, no result). Any executor claim, in any
generation, disqualifies;
- the run has no recorded process (`processPid`, `processGroupId`,
`processStartedAt`), no `sessionIdAfter`, and no `native_run_results`
row. Process fields copied from a warm predecessor (marked by
`runnerProfileJson.inheritedProcessIdentityRunId`) are not this run's
own process and do not disqualify; the run's own start events are still
checked;
- the run's whole event history has no execution-start evidence. This
uses the canonical `hasNativeExecutionStartEvidence` helper from restart
recovery (`native.process_start_requested`,
`native.process_identity_recorded`, `adapter.invoke`,
`tool.execution.started`, or any runner event with a source event ID).
The helper is unchanged; the earlier inline copy matched it exactly and
was removed.
- `run-completion.ts`: a recorded transient failure closes the unclaimed
coordinator as `terminal_failure` (`native_pre_dispatch_failure`),
because `hasPendingRunCleanup` otherwise holds the successor on it. It
then calls the existing `scheduleBoundedRetryForRun` with `retryReason:
"interrupted_preparation"`. An answered-interaction continuation keeps
its own retry lane. The storage-outage lane is unchanged.
- `heartbeat.ts`: passes `nativeDispatchMayHaveStarted`. It is true once
native dispatch started, once the dispatch gate entered the executor
(also when the gate then fails), while native ownership is held, or for
a restart-recovery or native-lease resume.
- No change to the native hold in
`modules/wake-queue/adapters/postgres.ts`. The release already returns
early when a queued/scheduled successor exists, so a scheduled retry
never opens the hold. See Risks for why the hold stays for the exhausted
and no-successor cases.

## Verification

- `heartbeat-native-predispatch-retry.test.ts` (assembled heartbeat,
mock native backend): budget admission fails once with `40001`. The run
records the proof, one uncharged `interrupted_preparation` successor is
scheduled, no recovery action opens, and the promoted retry succeeds
once. A failure after the dispatch gate entered the executor gets no
record and no such successor.
- `run-completion.test.ts` (embedded Postgres):
- native pre-dispatch `40P01` in `failRun` writes the record and
schedules `interrupted_preparation`; the release suppresses immediate
recovery;
  - native `setup_failed` on `40001` does the same;
- a run with `native.process_start_requested` in its history gets no
record and no retry;
- `nativeDispatchMayHaveStarted: true` and an omitted value (older
caller) get no record and no retry;
- a claimed native coordinator gets no record and no retry; an unclaimed
one is closed and the run re-queues;
- a run with a recorded process or provider session gets no record and
no retry;
  - a non-transient cause gets the record but no retry.
- `heartbeat-retry-scheduling.test.ts`: the real scheduler chains two
`interrupted_preparation` retries for a recorded native failure. The
first is due at once. `failureRetries` stays 0. A spent lane returns
`retry_exhausted`, queues nothing, and the run stays `failed`.
- `wake-queue/adapters/postgres.test.ts`: with a scheduled successor,
the release opens no recovery action and the task stays `in_progress`.
Without one, today's hold opens (`blocked`,
`native_continuation_requires_reconciliation`, Retry available).
- `run-failure-cause.test.ts`: the predicate is false for a row without
the record (older server), for `providerWorkStarted: true`, for a
non-transient cause, a non-generic code, a legacy run, and a cancelled
run.
- Command: `cd server && npx vitest run
src/__tests__/heartbeat-native-predispatch-retry.test.ts
src/services/heartbeat/run-failure-cause.test.ts
src/services/heartbeat/run-completion.test.ts
src/modules/wake-queue/adapters/postgres.test.ts
src/__tests__/heartbeat-retry-scheduling.test.ts` (all new cases pass).
`tsc --noEmit` for `server` passes. `pnpm verify` selected 2451 files
because `heartbeat.ts` is a hub; I stopped it and rely on CI for the
broad suite.
- Live path unproven: I did not run a restart-chaos wave for this
change.

## Risks

- A wrong "no provider work" record could cause duplicate work. The
record needs several independent facts (no gate entry, no resume claim,
no claimed coordinator, no process, session or result, no
execution-start evidence in the whole history). Any read error keeps the
hold.
- Rows saved by an older server have no record. They keep today's hold
and manual Retry. Nothing is migrated. A run resumed after a deploy is a
restart-recovery or lease resume, so it gets no record.
- The normal path gains no I/O. The evidence reads run only on the
failure path, and only when dispatch was never entered.
- The native hold is not skipped when no successor exists. Skipping it
would send the run to the generic immediate-recovery tail, whose new run
does not carry this lane's counter. That could loop without a bound. So
a spent lane, and a crash between the failure write and the retry
scheduling, still end in today's hold with a working Retry.
- The lane is bounded by `INTERRUPTED_PREPARATION_RETRY_MAX_ATTEMPTS`
(12) with backoff after the first attempt, and it never revives a chain
whose failure budget is already spent.

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

## Model Used

- Anthropic Claude, model ID `claude-opus-5-5`, used through Claude Code
with tool use (file edits, shell, 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

Refs #20484, #20385.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 11:24:37 -06:00
DottaandClaude Opus 5.5 6ed6b0754b perf(recovery): probe recovery source runs and stale-continuation runs by key (#20584)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The workspace recovery worker saves the files of failed runs. After
each pass, it reads the time of its next deadline
(`nextWorkspaceRecoveryAt`)
> - That read finds the source run, its finalizer and its leases with
`id::text = evidence->>'runId'`. A text comparison cannot use a key.
Each exhausted hold read every run, finalizer and lease of its company
> - On a hosted staging database this statement used about 4 s per call
(21 calls in the sample window). The cost grows with exhausted holds
multiplied by the company's run count
> - The ownership reconciler's stale-continuation check
(`checkStaleDroppedComments`) has the run lookup shape that #20412 fixed
in the lost-wake check: one OR across the legacy context and the native
column
> - This pull request compares the run id as a canonical uuid, and it
gives the stale-continuation check the per-binding run probes from
#20412
> - The benefit is the same recovery decisions with index-driven work:
11 s to 93 ms for the deadline read on a representative local fixture

## Linked Issues or Issue Description

Refs #20412

**What happened?**

The workspace recovery deadline read was one of the most expensive
statements that remained on a hosted staging database after the fixes in
#20393, #20405, #20386, #20412, #20391 and #20400. Query statistics
showed about 4 s per call. The statement text starts with `select case
when (issue_recovery_actions.…`.

The statement evaluates the retained-source fallback and stop-proof
conditions for each recovery action. These conditions read the source
run (`heartbeat_runs`), its finalizer (`native_run_finalizations`) and
its leases (`environment_leases`) with `x.id::text =
evidence->>'runId'`. The cast prevents the use of the primary key.
PostgreSQL reads all rows of the company for each exhausted hold. The
worker's discovery (`retainedWorkspaceCandidates`) uses the same
conditions.

The stale-continuation check matched a task's runs with
`(context_snapshot->>'issueId' = issue OR native_issue_id = issue)`.
With the OR, PostgreSQL can join runs only by company. It also hashed
every wake request of the company for the stale-wake test.

**Expected behavior**

Each condition uses a key lookup. The worker and the reconciler make the
same decisions for the same saved state.

**Steps to reproduce**

1. Seed a company with a deep run history and a few thousand workspace
recovery actions. Let a few hundred be exhausted holds that stay active.
2. Call `nextWorkspaceRecoveryAt`.
3. The call takes seconds, and its time grows with holds multiplied by
runs. The benchmark below shows this.

**Paperclip version or commit**

Baseline: `8d2bf78323`.

**Deployment mode**

Server with PostgreSQL. The local proof uses disposable PostgreSQL 18.1.
Staging uses hosted PostgreSQL.

## What Changed

- `workspace-recovery-fallback-discovery.ts`: the source run, finalizer
and lease probes compare `id =
canonicalExecutionUuid(evidence->>'runId')`. The helper returns null
unless the text is the canonical lowercase uuid form. Thus the probe
matches exactly the rows that `id::text = evidence->>'runId'` matched.
The helper already exists in `execution-identity.ts`.
- `nextWorkspaceRecoveryAt` and worker discovery use these conditions.
Their SQL changes only in these probes.
- `checkStaleDroppedComments` reads in two stages. The first stage keeps
comments that a stale-continuation cancellation dropped and that no open
wake carries. The second stage applies the run probes. The run probes
use the `taskRuns` helper from #20412, which is now shared by both
checks. The stale-wake lookup ends with `offset 0`, so it stays a
per-comment index probe.
- The final `order by c.created_at limit N`, the projection and the
delivery check after the query do not change.
- Add `scripts/benchmark-recovery-status-checks.ts`. It loads the
changed files from an explicit baseline commit and from the working
tree. Before it records time, it compares every changed condition for
every recovery action, the complete service results and the positional
SQL rows.
- Add tests: each run binding in the stale-continuation check (native,
legacy, live run on either binding, wrong agent, wrong company, failed
run, run outside the comment time, closed by a person, open wake). Also
a test that a hold with a non-canonical saved run id is not discovered,
as before.
- Document both checks and the benchmark in `doc/DATABASE.md`.
- No schema change, index, migration, or new startup work.

## Verification

- `pnpm exec vitest run
src/__tests__/ownership-reconciliation-report.test.ts
src/services/workspace-recovery-fallback.test.ts
src/services/workspace-recovery-stop-proof.test.ts
src/__tests__/heartbeat-held-comment-wake.test.ts --maxWorkers=1` (from
`server/`).
- `pnpm --filter @paperclipai/server typecheck`.
- `pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-recovery-status-checks.ts --baseline-ref=8d2bf78323
--samples=7`

Benchmark setup: PostgreSQL 18.1 (embedded, local), `shared_buffers` 128
MB, `work_mem` 4 MB. All data is synthetic, with md5-hex filler. Each
fixture has two companies (full size and a quarter). Two warmup rounds,
then 7 timed samples for each arm, in alternating order. Each sample
runs the real service function and asserts the same result. These are
local SQL measurements on a shared machine, so the spread is wide.

Workspace recovery deadline (`nextWorkspaceRecoveryAt`). About 8% of
actions are exhausted holds that stay active. Some actions save a
non-canonical (uppercase) run id. Before timing, the benchmark compares
six conditions (fallback pending, due time, due; re-arm pending, due
time, due) for every action of the fixture.

| Fixture | Runs / actions / pending holds | Baseline median (min–max) |
Treatment median (min–max) | Shared buffers |
| --- | --- | --- | --- | --- |
| small | 6,500 / 255 / 36 | 50.9 ms (47–76) | 6.8 ms (6–8) | 19,133 →
1,075 |
| representative | 78,750 / 3,813 / 588 | 11,098 ms (8,277–12,790) | 93
ms (35–145) | 3,493,417 → 24,902 |
| deep history | 191,250 / 3,813 / 588 | 40,146 ms (31,190–52,521) | 40
ms (36–230) | 13,491,792 → 26,911 |

Stale-continuation check (`checkStaleDroppedComments`). The fixture is
pessimistic: more than half of recent comments on done tasks have a
stale cancelled wake. The table shows statement time from `EXPLAIN
ANALYZE` with default settings, and one statement run with
`enable_nestloop = off` (the company-only hash plan). The result is
checked as equal in both modes.

| Fixture | Runs / recent comments / reported | Statement, baseline →
treatment | Hash plan, baseline → treatment |
| --- | --- | --- | --- |
| small | 3,792 / 76 / 24 | 3.2 → 1.5 ms | 5.5 → 3.8 ms |
| representative | 75,406 / 1,125 / 200 (limit) | 34 → 26 ms | 66 → 25
ms |
| busy hour | 152,031 / 5,625 / 200 (limit) | 328 → 153 ms | 2,302 → 165
ms |

- Neutral path: the complete stale-continuation service call stays about
the same (busy hour 750 → 655 ms median). The per-comment delivery check
after the query (`deliveredQueuedCommentIds`) is most of that time. This
change does not touch it.

## Risks

- Low risk. Every predicate keeps the same logic. Only the probe shape
and the evaluation order change. The benchmark compares each changed
condition for every recovery action, including non-canonical saved run
ids, and the complete results of both checks.
- Previous-release story: no saved state, schema or identity changes.
Holds saved by older releases are read with the same rules, so they
carry forward unchanged.
- Rows with the same comment `created_at` have no defined order, as
before.

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:12:41 -06:00
DottaandClaude Opus 5.5 be0d100a8b fix(native): settle cleanup-gate refusals after a completed turn without a dead-end Retry (#20572)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs execute a provider turn, then close the provider session
and prove it stopped
> - When close cannot prove that the old runner, provider and tools
stopped, the runner holds a cleanup gate
(`native_session_cleanup_quarantined`) that refuses the next session on
that task
> - A run finished its provider turn and saved its work. A server
restart had left one tool callback without a saved result, so close
could not prove the drain, and the gate refused the run's own same-run
recovery 30 seconds later
> - The task then showed "Couldn't finish this attempt" with a Retry
button, but the retry route refuses this error code with 409. The saved
error text told an operator to act
> - This pull request makes the task projection, the execution blocker,
the executor and the retry route agree, and replaces the operator text
with plain wording
> - The benefit is that finished work no longer looks like a failed
attempt, and the UI never offers a Retry that the server refuses

## Linked Issues or Issue Description

**What happened?**
A remote native run completed its provider turn and saved its comments
and documents. A server restart during the turn left one semantic tool
callback without a saved result. Session close then reported
`native_session_settlement_incomplete` (runner suspended, provider drain
unproven), and the runner kept an operator-required cleanup gate. The
same-run disposition recovery was refused at admission, and the run
ended `failed` with `native_session_cleanup_quarantined`.

**Expected behavior**
The task should show its real state (here, waiting on a child task). It
should not show a failure banner or a Retry that the server refuses. No
text should ask an administrator to act.

**Steps to reproduce**
1. Start a remote native run and restart the server while a tool
callback runs.
2. Let the resumed turn complete without a semantic result, so a
same-run recovery is scheduled.
3. Observe the refusal at admission, the "Couldn't finish this attempt"
banner, and a Retry that returns 409.

**Paperclip version or commit**
master at d0057ee384 (remote native runner, Daytona sandbox).

## What Changed

- Add `server/src/services/native-runtime/completed-turn-cleanup.ts`. It
holds one predicate for "a cleanup-gate refusal after this run's own
provider turn completed" and the plain user wording. It reads only the
saved `errorCode` and `runnerProfileJson.sessionCheckpoint`.
- `task-interruption-presentation.ts`: the projection never sets
`canRetry: true` for a cleanup-quarantined run, which matches
`routes/agents.ts` and `canRetryStoppedRun`. A quarantine after a
completed turn projects as `resolved` only while the task waits on
unresolved blocker tasks, so the task shows that wait. Without such a
wait, the missing task disposition stays visible. Other quarantined runs
show a plain settling message without Retry. The list query computes the
completed-turn fact in SQL.
- `native-session-executor.ts`: the refusal keeps its permanent, named
code, its hold and its ownership, and does not replay provider work. The
next action uses plain wording, and names the saved work after a
completed turn.
- `execution-blocker.ts`: show plain wording for this code instead of
the stored error text, also for rows that earlier releases saved.
- Runner error message and retry-route conflict message use plain
wording.
- `doc/execution-semantics.md`: document how the cleanup gate settles
and how the task shows it.

## Verification

- Red before the fix, green after:
- `server/src/services/task-interruption-presentation.test.ts` "native
cleanup quarantine presentation" (2 failed before: `canRetry: true`,
`state: failed`).
- `server/src/__tests__/task-interruption-query.test.ts` "reads a
quarantine saved after a completed turn as settled work" (real Postgres,
row in the previous release's saved shape; before: `state: failed,
canRetry: true`).
- `server/src/services/native-runtime/native-session-executor.test.ts`
"settles a cleanup refusal after a completed turn" (before: operator
next action and board ownership).
- `server/src/services/explicit-native-continuation.test.ts` "shows
plain wording for a cleanup refusal saved with the earlier operator
text" (before: stored operator text).
- The four files pass in full: 1851 tests.
- `tsc --noEmit` passes for `server` and `packages/paperclip-runner`.
- `pnpm verify` returns exit 2 (a global doc input changed), so required
CI supplies the broad coverage.
- No UI files changed, so token gates and UI tests do not apply.

## Risks

- Previous-release story: no persisted shape changes. Runs that earlier
releases quarantined carry forward. The new rules read existing fields,
so these runs show the new projection without a migration.
- The code stays permanent and named. No automatic provider replay is
added.
- A quarantine whose turn did not complete keeps its board-owned
recovery. Only its text and its Retry visibility change.
- The runner still keeps the in-process cleanup gate until environment
release proves the stop. Existing lease termination receipts
(`acknowledgeRemoteStop`) clear it. This PR does not change the runner
drain rule for a callback that a restart orphaned. That is a separate
follow-up.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:09:59 -06:00
DottaandClaude Opus 5.5 d0c30e69a9 fix(storage): abort a snapshot upload a restart cut off once its save attempt is superseded (#20542)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Remote workspaces save their files as encrypted snapshots in object
storage. Snapshots over 16 MiB use S3 multipart uploads.
> - A server restart can cut off a snapshot upload in the middle of an
`UploadPart` request.
> - The server aborts a multipart upload only in-process, on an error or
an abort signal. A SIGKILL can never do that. On SIGTERM, the drain step
timed out while the store held the part, and the process exited without
`AbortMultipartUpload`.
> - The incomplete upload then stays in the bucket. Nothing records its
upload id and nothing finds it again, so incomplete uploads pile up and
the operator pays for their parts.
> - This pull request aborts the incomplete uploads of each save attempt
when that attempt can no longer win, with no polling and no schema
change.
> - The benefit is that no incomplete upload outlives its save attempt.

## Linked Issues or Issue Description

**What happened?**
A restart-chaos wave (Daytona, R1 workload, storage fault ST4,
`--storage-trigger snapshot-part`, faults K1 and K2, master
`fea4530a30`) passed all 3 tasks. All 3 committed snapshots read back
intact. But the storage audit found 3 incomplete multipart uploads. Each
one was the exact upload that a restart cut off in the middle of
`UploadPart`. The retried save then published a different attempt of the
same snapshot.

**Expected behavior**
When a save attempt can no longer become the snapshot's object, its
incomplete multipart uploads are aborted. The storage audit shows 0
incomplete uploads after the wave.

**Steps to reproduce**
1. Run a snapshot save larger than 16 MiB against S3.
2. Kill the server (SIGKILL), or send SIGTERM while the store holds an
`UploadPart` request.
3. Let the save retry and publish.
4. List the bucket's multipart uploads. The cut-off attempt's upload is
still there.

**Paperclip version or commit**
master `fea4530a30` and later.

**Relevant logs or output**
`shutdown step timed out; continuing shutdown without it
{"step":"delivery_work_stop","durationMs":20001}`, then `Paperclip did
not stop within 30000ms; sending SIGKILL`.

## What Changed

- The S3 storage provider and the storage service get
`abortIncompleteUploads(objectKey)`. It lists the multipart uploads
under that exact key (`ListMultipartUploads` with the key as the
prefix), skips other keys that only share the prefix, and aborts each
one. `NoSuchUpload` counts as already done. The local-disk provider has
no multipart uploads and does nothing.
- Save publication: the save journals every attempt's object key in
`metadata.uploads` before any remote I/O. When one attempt moves the
snapshot from `pending` to `uploaded`, no other attempt can win any
more. The winning process now aborts the incomplete uploads of every
other journaled attempt key. Each abort is best effort and bounded to 5
s. A snapshot with one attempt sends no extra request.
- A losing uploader that is still live: if its upload fails after
another attempt has published the snapshot, it now returns the published
snapshot (after it verifies it) instead of the error. So an abort from
the winner never fails a save that is still running in another process.
- Snapshot retention: before it deletes a retired snapshot's objects
(this includes saves that #20502 retires as abandoned), retention aborts
the incomplete uploads of each attempt key. A store that refuses the
listing (for example a missing `s3:ListBucketMultipartUploads`
permission) logs a warning and does not block the deletion.
- Abandoned saves: when the ownership reconciler retires an abandoned
save (`remote_snapshot_save` and `remote_snapshot_ledger` settlement,
from #20502), it now aborts the incomplete uploads of every journaled
attempt key after its transaction commits. The owner run is terminal and
the save work is settled, so no live uploader can own those uploads.
This is the durable path for SIGKILL: the reconciler pass after boot
settles the dead save and cleans its uploads.
- SIGTERM drain: the multipart upload code keeps an in-process set of
running uploads. Shutdown aborts them right away when
`delivery_work_stop` times out. It also aborts them after the heartbeat
run drain, before the finalizer and workspace-recovery waits, after a 5
s grace for them to finish. It waits at most 10 s for the
`AbortMultipartUpload` calls. Shutdown does nothing extra when no upload
runs.
- No polling and no periodic sweep. All triggers are events that already
exist: publication, abandonment settlement, retention of a retired
snapshot, and shutdown.

Previous-release story (`rules/upgrade-safe-run-state.mdc`): this adds
no persisted state and changes no saved shape. A snapshot that the
previous release saved with cut-off attempts in `metadata.uploads`
carries forward. Its incomplete uploads are aborted when the new release
publishes a retry of that snapshot, or when retention retires the
snapshot.

## Verification

- Red before, green after (all fail on master, pass with this change):
- `server/src/services/remote-workspace-snapshot-storage.test.ts`:
"aborts the incomplete upload of an attempt a restart cut off once a
retried save publishes" and "adopts the published save when another
uploader aborts this attempt's upload".
- `server/src/services/remote-workspace-snapshot-retention.test.ts`:
"aborts each attempt's incomplete multipart upload before deleting a
retired snapshot's objects". A second test proves that a refused listing
does not block retention.
- `server/src/__tests__/storage-s3-multipart-shutdown.test.ts`: file and
streamed uploads whose part the store holds are aborted at shutdown, and
the provider aborts only the exact key across listing pages.
- `server/src/__tests__/remote-snapshot-ledger-settlement.test.ts`:
"aborts the incomplete multipart uploads of every attempt when the
reconciler retires an abandoned save" (both settlement kinds; a key
outside the snapshot's prefix is skipped).
- `tests/restart-chaos/storage-orphan-uploads.test.ts` (MinIO): the real
provider aborts both cut-off uploads of one attempt key, and keeps a
sibling attempt and a longer key that shares the prefix.
- `cd server && npx vitest run src/__tests__/storage-s3 src/storage
src/__tests__/remote-snapshot-ledger-settlement.test.ts
src/services/remote-workspace-snapshot
src/services/remote-workspace-transport.test.ts
src/__tests__/remote-workspace-file-resources.test.ts`: 19 files, 517
tests pass; with `src/services/recovery`, `src/__tests__/ownership*` and
`heartbeat-workspace-busy.test.ts`, 32 files and 580 tests pass.
- `cd server && npx tsc --noEmit` and `node
scripts/typecheck-test-projects.mjs tests/restart-chaos` pass.
- Live proof, Daytona R1, ST4, `--storage-trigger snapshot-part`, K1/K2,
seed 1033, the evidence wave's flags, image
`paperclip-daytona-runner@sha256:05a4a092…` (controllerMatch: match):
- Before (master `fea4530a30`): `w-20261110T130541-s1033`: 3/3 pass, **3
incomplete multipart uploads**.
- `w-20261110T134623-s1033` on an earlier revision of this PR (publish +
retention hooks only): 2 incomplete uploads remained. Those saves were
abandoned, not retried, so neither hook ran. This led to the settlement
hook.
- After (head `8bc8fc66d0`): `w-20261110T141144-s1033`: 3/3 tasks pass,
exit 0, every fired restart proved it cut the held `UploadPart`, the
reconciler settled 3 abandoned saves, shutdown logged one in-flight
abort, and the storage audit shows **0 incomplete multipart uploads**
and 3 committed snapshots that read back intact.

## Risks

- A live uploader in another process can lose its incomplete upload when
a different attempt publishes first. That uploader now adopts the
published snapshot, so its save still succeeds.
- Narrow gap: an uploader that recorded its attempt but had not yet sent
`CreateMultipartUpload` when the winner published, and that then crashes
during its upload, leaves an incomplete upload until retention retires
the snapshot. An S3 lifecycle rule for incomplete uploads stays a good
backstop.
- Stores without `ListMultipartUploads` permission log a warning and
keep the previous behavior.
- Shutdown can take up to 10 s longer, and only when an upload is still
running after the drain.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:56:52 -06:00
DottaandClaude Opus 5.5 765ccd8691 perf(chat): index chat-admission checks by company and payload issue id (#20562)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat connections save each external message as a `chat_actions` row
before the server admits it as an agent wakeup.
> - The server checks if an issue holds chat input that it did not
admit. Recovery skips such an issue. Manual task start refuses it.
> - On a hosted instance, the recovery read with this check used the
most total database time: 488 calls at about 1 s each. The manual-start
check ran 155 times at about 0.9 s.
> - The check matches `chat_actions.payload->>'issueId'` against the
issue. No index covers this value, so each call reads the full
`chat_actions` table and its payloads.
> - This pull request adds one concurrent partial index for the check.
The check SQL does not change.
> - The benefit is less background database load, so request reads wait
less. The results stay the same.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The chat-admission check `unadmittedChatWakeupCondition`. Recovery uses
it in the per-issue candidate read. Manual task start uses it in the
held-input check.

**Subsystem affected**
Server chat admission, issue recovery, manual task start, and the
database schema.

**Current behavior**
The check finds `inbound_wakeup` actions by `company_id` and
`payload->>'issueId'`. Two nested checks find `failed_run_retry` actions
and later `inbound_wakeup` actions on the same company and payload issue
ID. `chat_actions` has no index on `company_id` or on the payload issue
ID. Each call reads the full table and reads each payload. On a hosted
instance, the two statements used about 630 s of database time in one
statistics window.

**Proposed behavior**
The check and its nested checks read a small set of rows from one index.
The admission result does not change.

**Reason and benefit**
The hosted database had many concurrent active queries. Request reads
waited behind background work such as this check.

**Breaking changes**
None. The migration adds an index only.

## What Changed

- Migration `0458_chat_admission_issue_index` builds
`chat_actions_company_issue_kind_idx` with `CREATE INDEX CONCURRENTLY IF
NOT EXISTS`. It runs through the existing top-level statement path,
which repairs an invalid index on retry.
  - Keys: `(company_id, payload->>'issueId', kind, status)`.
  - Partial: `kind IN ('inbound_wakeup', 'failed_run_retry')`.
- One index serves the outer input lookup, the failed-run retry lookup,
and the later-input lookup. All three match on the same company and
payload issue ID.
- The Drizzle schema declares the index. The snapshot chain moves to
`0458` and keeps five snapshots.
- New regression test
`server/src/__tests__/chat-admission-condition.test.ts` pins the
admission semantics on real PostgreSQL:
- Pending and failed input without a receipt holds the issue. Received,
processed and cancelled input does not. Other action kinds do not.
- A skipped receipt, a receipt for another agent, or a receipt in
another company does not admit input. A failed or cancelled run still
counts as admission.
- The issue ID is compared as company-scoped text. Uppercase, array,
null, other-issue and other-company values do not match.
- Only an admitted retry in the same conversation and issue that lists
the action releases skipped input.
- Only later admitted input in the same conversation, issue and session
generation releases a failed input. Pending input stays held.
  - The check reads `chat_actions` only through the new index.
- `scripts/benchmark-chat-admission-index.ts` measures both caller
statements with and without the index and fails if the rows differ.
- `scripts/benchmark-chat-admission-index-migration.ts` times the
migration through the normal migration runner.
- `doc/DATABASE.md` documents the index and the benchmark commands.

## Verification

Local SQL measurements on synthetic data. PostgreSQL 18.1 (embedded),
default settings. The fixture has 100,995 `chat_actions` rows (662 MB):
60,000 GitHub actions with 6 KiB incompressible payloads, 2,000 issues
with 20 admitted inbound messages each (3 KiB payloads), and 1,000
failed-run retries. Each case has 2 warmups and 9 samples of `EXPLAIN
ANALYZE` execution time. Runs alternate: no index, index, no index,
index.

| Caller statement | Case | No index, median (range) | Index, median
(range) |
| --- | --- | --- | --- |
| Manual-start held check | quiet issue | 3,093 ms (2,150–3,519) / 3,586
ms (2,375–11,235) | 0.036 ms (0.034–0.052) / 0.040 ms (0.033–0.047) |
| Manual-start held check | admitted input | 3,555 ms (3,310–8,080) /
5,205 ms (3,353–9,525) | 0.049 ms / 0.052 ms |
| Manual-start held check | unadmitted input | 3,002 ms (2,864–3,221) /
4,179 ms (3,200–6,585) | 0.051 ms / 0.044 ms |
| Manual-start held check | failed, later admitted | 3,586 ms / 3,722 ms
| 0.066 ms / 0.054 ms |
| Manual-start held check | admitted retry | 2,733 ms / 4,953 ms | 0.049
ms / 0.053 ms |
| Recovery candidate read | quiet issue | 43 ms (41–48) / 96 ms (44–219)
| 0.047 ms / 0.055 ms |
| Recovery candidate read | admitted input | 203 ms (48–575) / 174 ms
(44–314) | 0.055 ms / 0.058 ms |
| Recovery candidate read | unadmitted input | 66 ms / 144 ms | 0.053 ms
/ 0.054 ms |

- Buffers per call fall from about 40,000 shared hits to 5–23.
- Locally, the recovery read without the index uses a hashed full scan
of `chat_actions`. The held check uses a full scan per call. On the
hosted instance both took about 1 s per call. The local fixture is
smaller than the hosted table.
- Correctness: complete positional rows match across all four runs for
all six cases (quiet, admitted, unadmitted, failed then admitted,
retried, other-company decoy). The script asserts the expected held
result for each case.
- Migration timing: 600,001 `chat_actions` rows (1,759 MB), 300,000 of
them in the index scope. First run through `applyPendingMigrations`: 4.2
s. Rerun after the history row is removed: 0.5 s. Concurrent build
alone: 3.4 s. Index size: 30 MB.

Commands:

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-chat-admission-index.ts
OTHER_PAYLOAD_KIB=2 pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-chat-admission-index-migration.ts
cd server && npx vitest run src/__tests__/chat-admission-condition.test.ts   # 6 passed
pnpm --filter @paperclipai/db check:migrations                               # passed
pnpm --filter @paperclipai/server typecheck                                  # passed
```

## Risks

- The migration builds one index concurrently. It does not block writes.
On the 1.76 GB local fixture it took about 4 s. A larger hosted table
takes longer, but the build does not lock out reads or writes.
- Writes to `inbound_wakeup` and `failed_run_retry` actions maintain one
more index. Status updates on these rows were already not HOT updates,
because other partial indexes use `status`.
- The planner has no expression statistics for a partial index. The new
test pins that the check uses the index on an analyzed table with
unrelated rows.
- The check SQL does not change, so admission behavior does not change.

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

## Model Used

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

## Checklist

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:44:51 -06:00
DottaandClaude Opus 5.5 99edcd28a7 perf(attention): read each retention candidate kind in its own bounded branch (#20566)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The attention retention worker archives old "Needs you" items, such
as open decisions, pending questions, failed runs and items in review
> - To find its work, the worker reads a page of retention candidates.
It reads one page per changed scope, and one single-source read for each
check of a due item
> - On a hosted stack this candidate read ran 2,835 times at about 1 s
per call, 4,201 s of database time in one afternoon window
> - The read built every candidate of every kind before it applied the
keyset limit. Its company and source filters used the `param is null or
column = param` form. Under a generic prepared plan these filters cannot
use an index. The failed-run branch also matched retry receipts with a
bound LIKE parameter, so it could not use the partial index
`heartbeat_run_events_retry_exhausted_idx`
> - This pull request reads each kind in its own branch, puts every
filter into that branch, and limits each branch to one page
> - The benefit is a bounded read for each page, and a primary-key read
for each single-source check, with the same rows, order and cursors

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The attention retention candidate scan in
`server/src/services/attention-retention.ts`.

**Subsystem affected**
Server: attention retention background work (database reads only).

**Current behavior**
Each page reads all candidates of all selected kinds, then sorts them
and keeps 100. Optional filters use `($n is null or column = $n)`. A
generic prepared plan cannot use an index for these filters. The
single-source check compares `id::text`, so it cannot use the primary
key. The retry receipt LIKE pattern is a parameter, so the plan cannot
use the partial receipt index.

**Proposed behavior**
Each kind is one branch with its company, source, issue and keyset
filters inside it and `limit 100`. Kinds before the cursor are not read.
A source id filter uses the uuid primary key. The receipt pattern is a
SQL literal that matches the partial index predicate. The page result
does not change.

Refs #20386 (adds the partial receipt index that this branch now uses).

## What Changed

- `server/src/services/attention-retention.ts`: replace the single union
scan with `candidateQuery`. Each source kind is a branch that has its
own filters, retention join and one-page limit. The outer query still
orders by `(source_kind, source_id)` and keeps 100 rows.
- Only add a filter when the scope sets it. There is no `param is null
or ...` form now.
- A single-source read compares `id = $uuid`. It returns no rows for
text that is not the canonical lowercase uuid form. The former `id::text
= $text` comparison also never matched such text.
- Skip the kinds that come before the keyset cursor kind.
`ATTENTION_RETENTION_KIND_ORDER` holds the kind order, and a test checks
it against the database collation.
- The failed-run branch matches `'Bounded retry exhausted%'` as a
literal, so it reads `heartbeat_run_events_retry_exhausted_idx`.
- Export `listAttentionRetentionCandidates` for the test and the
benchmark. The service uses it without a change in behavior.
- New test
`server/src/__tests__/attention-retention-candidates.test.ts`. It runs
on real PostgreSQL and compares every page, row and cursor with the
former scan (kept in the test as an oracle). It covers: one company, all
companies, one kind, the blocker kind (which ignores retention), an
unknown kind, issue scopes (including uppercase and malformed ids),
exact source ids (uppercase, braces, no dashes, empty and malformed text
return nothing), a `null` source id from a decoded scope, other-company
isolation, inactive companies, kept and archived retention, and failed
runs without a lifecycle receipt.
- New benchmark `scripts/benchmark-attention-retention-candidates.ts`.
It loads the baseline service from git, captures the SQL that each
version sends for the same scopes, and times both under custom and
generic plans. Each round alternates the order. It fails if the rows
differ.
- No migration. The existing indexes cover each branch: company and
status indexes on each source table, the issues company/status index,
and the partial receipt index from #20386.

## Verification

- `cd server && npx vitest run
src/__tests__/attention-retention-candidates.test.ts`: 10 passed.
- `cd server && npx vitest run
src/__tests__/attention-retention-work.test.ts`: see the note under
Risks.
- `cd server && npx tsc --noEmit`: pass.
- Benchmark (local SQL measurement only):
`pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-attention-retention-candidates.ts
--baseline-ref=8d2bf78323`
Embedded PostgreSQL 18.1. Fixture: 12 companies (1 large and 11 at 1/5
size), 38,400 issues, 64,000 runs, 1.29 M run events, 6,400 decisions,
9,600 interactions, 4,800 recovery actions, 7,534 retention rows. Seven
samples for each scope, after two warmups, with `plan_cache_mode` forced
to custom and then to generic. Rows were equal in every sample.

| Plan | Scope | Rows | Before median (min–max) | After median (min–max)
| Speedup | EXPLAIN shared blocks before → after |
| --- | --- | --- | --- | --- | --- | --- |
| generic | single source (check path) | 1 | 4.82 ms (3.81–5.88) | 0.19
ms (0.12–0.66) | 25× | 3,491 → 10 |
| generic | company failed runs | 81 | 45.0 ms (40.5–51.7) | 2.9 ms
(2.3–4.6) | 15× | 3,752 → 3,752 |
| generic | company page 1 | 100 | 39.4 ms (30.1–63.7) | 7.6 ms
(5.8–14.3) | 5.2× | 4,396 → 4,719 |
| generic | company page 2 | 100 | 33.3 ms (31.2–38.1) | 6.9 ms
(5.2–10.3) | 4.8× | 4,237 → 4,712 |
| generic | company issue scope | 4 | 11.8 ms (9.8–24.0) | 4.1 ms
(3.4–6.4) | 2.9× | 4,243 → 4,225 |
| generic | all companies page 1 | 100 | 107.2 ms (100.2–116.0) | 29.3
ms (18.8–64.4) | 3.7× | 13,657 → 14,672 |
| custom | single source (check path) | 1 | 2.68 ms (2.01–5.33) | 0.81
ms (0.65–1.50) | 3.3× | 3,491 → 10 |
| custom | company failed runs | 81 | 5.7 ms (3.9–15.8) | 4.6 ms
(3.2–7.1) | 1.2× | 3,752 → 3,752 |
| custom | company page 1 | 100 | 11.7 ms (9.2–13.3) | 10.7 ms
(9.4–19.5) | 1.1× (neutral) | 4,396 → 4,719 |
| custom | company page 2 | 100 | 11.2 ms (7.6–14.2) | 10.1 ms
(8.5–12.0) | 1.1× (neutral) | 4,237 → 4,712 |
| custom | company issue scope | 4 | 10.9 ms (9.1–13.3) | 8.4 ms
(6.4–10.4) | 1.3× | 4,243 → 4,225 |
| custom | all companies page 1 | 100 | 21.3 ms (17.4–25.9) | 24.4 ms
(20.3–72.0) | 0.9× (neutral) | 13,688 → 14,672 |

- The timing connection uses named prepared statements, as the server
does by default. The benchmark checks `pg_prepared_statements` before it
collects samples, so the samples reuse the parsed statement.
- The driver uses named prepared statements by default, so after five
runs PostgreSQL can switch a statement to a generic plan. The generic
rows are the relevant case for a busy server. Custom plans were already
good for most scopes, and the change is neutral there.
- Plan after the change (company page, generic): the failed-run branch
reads the partial receipt index (13 blocks), then probes
`heartbeat_runs` by primary key. The single-source check reads each
primary key and the retention unique index (10 blocks in all).

## Risks

- Low behavior risk. The test compares the new pages with the former
query on the same data, including cursors in the middle of a kind and at
kind boundaries.
- Kind skipping depends on the order of kind names. Every kind name is
different from the others before its first underscore, so C and
linguistic collations give the same order. A test checks the order
against the database.
- The benchmark fixture is smaller than the hosted database. The hosted
cost (about 1 s per call) is larger than the local baseline. I did not
measure the hosted improvement. It needs a fresh query-statistics window
after deploy.
- On this loaded local machine, one full run of
`attention-retention-work.test.ts` had 3 tests time out at 30 s (no-run
issue finalization, no-run workspace finalization, and interaction
create/withdraw). The same 3 tests pass when run alone. CI is the
authority for this suite.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic. Agentic coding with
tool use (shell, file edits, local PostgreSQL).

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:15:48 -06:00
DottaandClaude Opus 5.5 faaae003a9 perf(chat): find the conversation head from open delivery work (#20564)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - External chat messages go into a durable delivery queue. Each
conversation drains in provider order.
> - Each drain step reads the head of one conversation. The head query
filters one endpoint's deliveries by state and thread.
> - On a hosted instance, this query ran about 10,000 times at about 300
ms each. The database was busy, and request reads waited behind
background work.
> - PostgreSQL read every processed delivery of the endpoint and its
`normalized_event` value to find a few open rows.
> - This pull request finds the endpoint's open delivery IDs first and
fetches only those rows.
> - The benefit is less background database load. The selected head and
the delivery order do not change.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The inbound conversation head query
(`earliestOpenConversationDelivery`).

**Subsystem affected**
Server chat inbound delivery queue.

**Current behavior**
The query has `endpoint_id = $1 and (state in ('received', 'retry',
'processing') or (state = 'processed' and <pending inbound wake>))`, a
thread filter on `normalized_event`, and a sort on `normalized_event`
keys. No index matches the `or`. PostgreSQL scans all deliveries of the
endpoint. For each row it reads `normalized_event` to test the thread.
It also reads all of `chat_actions` to build the pending-wake set. A
busy endpoint keeps all of its processed history, so the cost grows with
that history.

**Proposed behavior**
The query selects `id in (<open delivery IDs of the endpoint>)`. That
set is a `union all` of two small reads:
- received, retry and processing deliveries of the endpoint, through the
existing `chat_deliveries_work_idx`;
- processed deliveries that an issued or processing `inbound_wakeup`
action of the same endpoint (and company) still owns.

PostgreSQL fetches only those rows by primary key, then applies the same
reaction, thread and order rules.

**Reason and benefit**
The cost no longer grows with processed history. On a local fixture with
60,000 processed deliveries, the head query reads 1,309 shared buffers
instead of 262,630.

**Breaking changes**
None. There is no schema change, migration, API change, timer or poll
change.

Related work: #17971 changed the pending-wake predicate of the same
queue. #20400 added the index for the other chat delivery statement in
the same report (`select state ...
normalized_event->'githubAutomatic'->'context'->>'deliveryId'`). That
statement already uses the #20400 index on master, so this pull request
does not change it.

## What Changed

- `delivery-ledger.ts`: add `openConversationDeliveryIds(endpointId)`.
It returns the endpoint's open delivery IDs. It selects the same set as
the old state predicate for that endpoint.
- `conversation-queue.ts`: the conversation head query uses `id in
(openConversationDeliveryIds(endpointId))` in place of the state `or`.
The thread filter, reaction exclusion, order, limit and lifecycle
dependency read do not change.
- `inbound-queue-discovery.test.ts`: add a real PostgreSQL test. It
compares the new set with the old predicate on three endpoints. It
covers each state, issued and processing wakes, duplicate wakes, wakes
of non-processed deliveries, processed, other-kind and null-delivery
actions, an action on a different endpoint of the same company, and
reactions. It also checks that the real captured head query returns the
oracle's first row.
- `benchmark-inbound-queue-discovery.ts`: add
`--endpoint-history=<rows>` (a busy endpoint with 4 KiB payloads) and
`--only=<fixture>`. Add a work budget: the treatment head query must
visit at most 100 delivery rows on the history fixtures.
- `doc/DATABASE.md`: describe the head query and the benchmark command.

## Verification

Local SQL measurements. PostgreSQL 17.10 (Postgres.app, macOS arm64),
disposable database, synthetic data, default settings with 256 MB shared
buffers. The host was under heavy load from other work, so timings are
noisy. Shared buffers and visited rows are stable. Each arm has two
warmups and five alternating samples (median [min..max]). Baseline:
`origin/master` at `8d2bf78323`.

| Fixture | Query | Baseline | Treatment | Shared buffers | Delivery
rows visited | Output |
| --- | --- | ---: | ---: | ---: | ---: | --- |
| 60,000 processed, 4 KiB | head | 290.09 ms [220.21..554.19] | 7.34 ms
[5.85..7.68] | 262,630 → 1,309 | 60,002 → 4 | Equal |
| 20,000 processed, 4 KiB | head | 276.02 ms [211.77..322.12] | 34.61 ms
[4.45..194.15] | 67,618 → 486 | 20,002 → 4 | Equal |
| 1,500 processed, 32 KiB | head | 15.88 ms [13.66..21.79] | 2.29 ms
[1.64..7.75] | 10,602 → 80 | 1,502 → 4 | Equal |
| 20 processed | head | 1.49 ms [0.92..3.66] | 1.85 ms [1.01..1.97] | 2
→ 45 | 22 → 968 | Equal |

- The deadline and ready selectors do not change. Their buffers are
identical in both arms; their timings move only with host noise.
- The small control is neutral. On a near-empty table, PostgreSQL picks
a sequential scan for the open-state branch. This costs about 45
buffers.
- The 60,000-row run passes `--require-work-budget`. The baseline
exceeds the budget (`baselineExceedsWorkBudget: true`). This is the
red/green proof. There is no timing threshold in CI.
- On the treatment, the remaining head cost is the scan of
`chat_actions` for pending wakes of the endpoint. The baseline also
reads all of `chat_actions` for its hashed subplan.
- `pnpm exec vitest run src/__tests__/inbound-queue-discovery.test.ts`
(server): 6 tests pass, including the new equivalence test.
- `chat-channels.integration.test.ts` filtered with `-t
"reorder|orders|FIFO|conversation drain|lease renewal|receipt
order|edit|lifecycle"`: 148 assembled chat tests pass. They cover Slack,
Teams, Telegram, Discord and GitHub provider-order drains, head-of-line
barriers, lease loss, lifecycle edits and wake retries.
- `pnpm exec tsc --noEmit -p tsconfig.json` (server) passes.

Reproduce:

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-inbound-queue-discovery.ts --baseline-ref=8d2bf78323 --endpoint-history=60000 --only=endpoint-history --require-work-budget
```

Set `PAPERCLIP_TEST_POSTGRES_ADMIN_URL` to use an existing disposable
PostgreSQL server. The benchmark uses synthetic data only.

## Risks

- Query plans depend on data and settings. These local numbers do not
prove hosted request latency. A follow-up statistics window after deploy
must confirm the gain.
- The pending-wake branch still scans the endpoint's `chat_actions`. A
partial index on pending `inbound_wakeup` actions would remove that
scan. I did not add it, to avoid a migration.
- Low behavior risk. The new set equals the old predicate for each
endpoint, and the order expression is unchanged. Tests compare both on
the same rows.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:15:29 -06:00
DottaandClaude Opus 5.5 56aa160a4e perf(native): index heartbeat runs by company and native session (#20560)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs share a native session. Retention, recovery, resume and
archive code reads all runs of one session from `heartbeat_runs`.
> - On a hosted instance, the native retention lookup ran about 67,000
times in one day at about 22 ms, and about 10,000 times at about 11 ms
in a later window.
> - No index has `native_session_id`. Each read walks the agent's or the
company's run history and filters the rows.
> - This pull request adds one concurrent partial index on `(company_id,
native_session_id)`. The SQL does not change.
> - The benefit is less background database load. Request reads wait
less, and the results stay the same.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The native session-run lookups. The largest caller is
`sessionRetentionBlocker` in native harness backup and checkpoint
retention. It selects `id, status, finished_at`, an incomplete-recovery
flag and the issue ID for one company, agent and native session.

**Subsystem affected**
Server native runtime (retention, recovery, resume and archive reads)
and the database schema.

**Current behavior**
`heartbeat_runs` has no index on `native_session_id`. PostgreSQL reads
the whole run history of the company (or of the agent through
`heartbeat_runs_company_agent_started_idx`) and filters by session. The
cost grows with the run history, not with the size of the session.

**Proposed behavior**
The index finds the few runs of one session directly. Every caller
already filters by `company_id` and `native_session_id`, so one index
serves the agent-scoped and the issue-scoped reads.

**Reason and benefit**
This statement family used about 1,500 s of database time in one day on
a busy hosted database. The local proof below shows 12.3 ms to 0.04 ms
per call.

## What Changed

- `packages/db`: add `heartbeat_runs_company_native_session_idx` on
`(company_id, native_session_id) WHERE native_session_id IS NOT NULL`.
Migration `0457_native_session_run_index` builds it with `CREATE INDEX
CONCURRENTLY IF NOT EXISTS`. The migration is registered as a
top-level-statement migration, the same way as 0454–0456.
- Add a regression test in `native-harness-backup-expiration.test.ts`.
It captures the real retention lookup and proves that its plan can use
the index. It also pins the company and session scope: a live run of
another company with the same session ID does not protect a checkpoint.
A live run of the same agent in another session does not protect it. A
live run of the same session does protect it.
- Add `scripts/benchmark-native-session-run-index.ts`. It runs the real
checkpoint retention pass (dry run), captures the exact SQL, and replays
it with and without the index. It also times the migration through the
normal migration runner.
- `doc/DATABASE.md`: describe the index and the benchmark command.

## Verification

Local SQL measurements, synthetic data. PostgreSQL 18.1 (embedded test
server). Fixture: 100,000 runs (50,000 of the measured agent, 50,000 of
a second agent), 4 runs per session, 4 KiB incompressible
`runner_profile_json` per run, 638 MB table. Order: no index, index, no
index, index. 15 `EXPLAIN (ANALYZE, BUFFERS)` samples after 3 warmups.

| Pass | Plan | Median execution | Min / max | Shared buffers |
| --- | --- | --- | --- | --- |
| No index (1) | Seq Scan | 12.30 ms | 10.33 / 35.27 ms | 3,607 |
| Index (2) | Index Scan using
`heartbeat_runs_company_native_session_idx` | 0.036 ms | 0.021 / 0.115
ms | 38 |
| No index (3) | Seq Scan | 12.38 ms | 10.24 / 26.14 ms | 3,607 |
| Index (4) | Index Scan using
`heartbeat_runs_company_native_session_idx` | 0.043 ms | 0.022 / 0.098
ms | 38 |

- Correctness: the service SQL is the same in every pass. The returned
rows (all columns) and the retention result are equal in every pass. The
script fails on any difference.
- Migration: the first run through the migration runner took 525 ms on
the 638 MB fixture. The rerun took 273 ms and skipped the existing
index. A direct concurrent build took 145–221 ms. Index size: 1.9 MB.
- `pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-native-session-run-index.ts`
- `npx vitest run
src/__tests__/native-harness-backup-expiration.test.ts` (server): 108
passed.
- `pnpm --filter @paperclipai/db check:migrations`: passed. Five
snapshots remain.
- `pnpm verify` exits 2 (a new script under `scripts/` is a global CI
input). Required CI supplies the broad coverage.

## Risks

- Low risk. The migration adds one index concurrently and does not lock
writes. The SQL of every caller is unchanged.
- The index adds a small write cost to `heartbeat_runs` inserts and to
updates of `native_session_id`. Rows with a null session are not
indexed.
- Persisted run state: no saved run, session or checkpoint shape
changes. State saved by the previous release carries forward unchanged.
- Hosted latency is not measured yet. The numbers above are local SQL
measurements.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:04:22 -06:00
DottaandPaperclip ce1ee9e89f fix: stop prompts from encouraging subtasks (#20362)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents read runtime prompts, tool help, skills, and role
instructions.
> - Several of these texts tell agents to create child tasks as a normal
next step.
> - One accepted-plan prompt even forbids work on the current task.
> - Task parentage is useful, but it does not need a prompt that
encourages task creation.
> - This pull request removes those prompts and keeps the task APIs
available.

## Linked Issues or Issue Description

Refs #10642. This change adjusts prompt wording; it does not add
delegation-cycle guards.

**Current behavior**
Prompts tell agents to create children after plan approval, use children
for long work, and create a task just to pass context. Onboarding
requires a child even for a single request.

**Proposed behavior**
Continue work on the current task. Keep explicit assignments, optional
task creation, parentage, dependencies, and approval gates. Use the
separate-task recipes when the user requests that structure or when
continuing existing work.

**Reason and benefit**
Reduce needless task trees without removing subtask support. Keep the
patch limited to wording, existing test expectations, and generated
catalog data.

## What Changed

- Align accepted-plan prompts and remove direct child-creation advice
from runtime and error text.
- Make task tool descriptions neutral. Keep their fields, defaults, and
handlers.
- Update the main skill, onboarding, CEO instructions, company
templates, specialist workflows, and matching guides.
- Keep historical release snapshots intact. Refresh the explicitly
seeded schema fixture's catalog digest; the server still verifies
historical release snapshot hashes.
- Refresh the affected generated catalogs, capability inventory,
semantic contracts, and fixture manifest.

## Verification

- Final tested head: `c6e304d0c105cdbfbe0b2ab0a233307fcd91a04b`.
- All 66 check runs are complete: 60 pass, one is neutral, and five are
skipped by CI conditions. Snyk also passes. No checks fail or remain
pending.
- [PR
CI](https://github.com/paperclipai/paperclip/actions/runs/42846226554)
passes typecheck, build, server/general tests, all eight E2E shards,
Runner support, Runner Rust, and the aggregate verify gate.
[Windows](https://github.com/paperclipai/paperclip/actions/runs/42846226546)
and [Docker
Runner](https://github.com/paperclipai/paperclip/actions/runs/42846226108)
also pass.
-
[Greptile](https://github.com/paperclipai/paperclip/pull/20362#issuecomment-6751419835)
and [Paperclip
review](https://github.com/paperclipai/paperclip/pull/20362#issuecomment-6751454010)
give this exact head 5/5. All six inline threads are resolved. Neither
current summary has findings.
- Merged master `bf0220d34a156c81853755c3d1365ddc527037de`. Resolved
three generated-file conflicts with the canonical generators.
- Local verification passes: 473 focused prompt, continuation, UI,
catalog, runtime and native-execution tests; Runner TypeScript build;
generated metadata; UI token gates; and diff checks.
- The prior head `c22018cb0f` failed three SDK evidence tests because
two source hashes were stale. Reproduced all three failures locally. The
existing generator refreshed only the hashes for the denial-copy file
and its test. All 53 affected evidence/source-pin tests then passed,
including private-value rejection. No scanner logic, assertions, or
runtime dependency pins changed. Current CI confirms the support suite
passes.
- A prior commit failed the Rust process reaping test. Its cause remains
unproven. No Rust code or assertions changed. Later Rust runs pass; this
does not establish the cause of the earlier failure.
- Local preflight built shared/plugin SDK packages, then stopped because
`cargo` is absent. Current-head CI supplies Rust and full build
coverage. `pnpm verify --explain` selects broad required CI; full local
suites were not repeated.

## Risks

- Wording changes guide the model; they do not enforce a ban on
subtasks.
- Existing installed skill versions and saved custom instructions need
their normal refresh process. This PR does not migrate company
configuration.
- Existing approved task assignments and approval gates remain in
effect.

## Model Used

- OpenAI GPT-6, through Codex, with reasoning, tool use, and code
execution. The exact routed model ID and context-window size were not
exposed in this session.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 09:58:54 -06:00
DottaandClaude Opus 5.5 e5abcbf7a9 fix(native): release a restart claim that lapsed during a failed preparation (#20529)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - After a restart, native restart recovery claims an unfinished run
and prepares it again on the same heartbeat run.
> - The restart claim holds a lease on the run's native coordinator row.
For a remote claim with durable authority, the lease is 60 s.
> - When that preparation fails, the failure path first preserves the
remote workspace. Then it settles the run.
> - When preparation outlived the claim lease, preservation took the
run's own lapsed claim for another owner. It threw
`NativeWorkspaceFinalizationBusyError`, and the execution returned
without settling. The run stayed `running` with no event until a later
scan reclaimed it (4.5 min in one wave, never in another).
> - This pull request releases exactly that lapsed claim and asks
restart recovery to decide on the run right away. It also logs the cause
when the failure path defers.
> - The benefit is that a failed restart-claim preparation is reclaimed
or blocked visibly within seconds, instead of hanging.

## Linked Issues or Issue Description

Refs #20514 (the same restart-chaos investigation).

**What happened?**
In restart-chaos wave `w-20261110T101517-s11936` (Daytona R1, K2, ST1),
run `9d39550a` got a `bootstrap_incomplete` restart claim (generation
3). Its `heartbeat.prepare.run_output` step waited 141 s through an ST1
storage outage. The 60 s claim lease lapsed meanwhile, and the begin
failed with `run_log_writer_authority_lost` when storage came back. The
inner failure path then called `preserveNativeFailedWorkspace`. Its
`ownsClaim` check required an unexpired lease, so the run's own claim
counted as another owner and the call threw
`NativeWorkspaceFinalizationBusyError`.
`preserveNativeWorkspaceOnFailure` set `nativeOwnershipHeld` and the
execution returned before `runCompletion.failRun`. No failure was
recorded, and nothing was logged. The run stayed `running` until a later
scan reclaimed it 4.5 minutes later. In a second wave no such scan came,
and the run stayed `running` for 30 minutes.

**Expected behavior**
A failed preparation must settle or retry, or be claimed promptly by its
next owner. It must not stay `running` with no record.

**Steps to reproduce**
1. Claim a remote native run for restart recovery.
2. Keep its preparation busy past the 60 s claim lease, for example with
a storage outage during `runOutput.begin`.
3. Let preparation fail. The run stays `running` with no failure event.

**Paperclip version or commit**
`master` at 23fa37f5bb.

## What Changed

- `native-execution-lease.ts`: new `releaseLapsedNativeRestartClaim`. It
is one conditional UPDATE that releases the claim only when the run is
`running`, the coordinator is `observed` with no result, and it still
names this exact `leaseOwner`, `controllerGeneration` and `attempt` with
an expired lease. A successor claim, a result, or a live lease leaves
the row unchanged.
- `heartbeat.ts`: when failed-workspace preservation defers on a
restart-claim execution, the failure path tries that release. If it
succeeds, the execution schedules a restart-recovery pass for the run
(`scheduleNativeSessionResumeDispatch(..., "restart")`) after it leaves
`activeRunExecutions`. Recovery then reclaims the run, or blocks it with
a named reason, right away. The path also logs the redacted cause, the
claim kind and whether the claim lapsed. Before, this return was silent.
- `preserveNativeFailedWorkspace` is unchanged. It still refuses an
expired claim (`refuses a stale expired recovery claim`).
- New `heartbeat-restart-claim-lapse.test.ts`. A restart-claim execution
whose preparation fails after its claim lease lapsed, on a run with a
prepared remote workspace, gets an immediate new restart-recovery
decision. It failed before the change: the run stayed `running` with the
lapsed claim and recovery answered `controller_still_alive`.

Not in this PR: renewing the claim lease during preparation for
`bootstrap_incomplete` claims (recovery.ts). Another restart-chaos fix
owns that change. This PR makes sure a lapsed claim's failure is handed
back at once either way.

## Verification

- `cd server && npx vitest run
src/__tests__/heartbeat-restart-claim-lapse.test.ts`: passed. Without
the heartbeat change it times out.
- `cd server && npx vitest run
src/services/native-runtime/native-failed-workspace-recovery.test.ts
src/__tests__/heartbeat-native-restart-suspension.test.ts
src/services/native-runtime/native-execution-lease.test.ts`: passed (195
tests across the four files).
- `cd server && pnpm typecheck`: no errors.
- Live Daytona proof uses `--native-trigger` from #20538. Results follow
in a comment.

## Risks

- Low risk. The release matches only the exact lapsed claim of a
running, result-less, `observed` run in one statement. A takeover
between the check and the release changes the owner or generation, so
the release does nothing.
- The run is not settled by this execution. Restart recovery makes the
decision under its own rules, which is the same path a later sweep would
take.
- Previous-release story: no persisted shape or identity changes. Rows
saved by the previous release are read and released the same way.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:55:21 -06:00
DottaandClaude c58356003f fix(native): give run finalization its own retry budget (#20555)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runs save an accepted result. Then a finalizer applies that
result to the issue.
> - After a control-plane restart, recovery can resume the same run.
Each resume uses one provider attempt (`coordinator.attempt`).
> - The finalizer used the same `coordinator.attempt` value as its own
retry budget. After three resumes, `attempt` was 3. So the first
transient finalization failure made the coordinator `terminal_failure`.
> - The result was then lost: the issue stayed `in_review` and no Retry
was available. This breaks the rule that a restart must not leave a task
stuck.
> - This pull request gives finalization its own retry count. Provider
attempts no longer use up finalization retries.
> - The benefit: a resumed run that finishes its turn also finishes its
task, and finalization stays bounded.

## Linked Issues or Issue Description

**What happened?**
In restart-chaos wave `w-20261110T044338-s20385`, task t03 (local,
Codex, three K1 SIGKILL restarts, `--db-pressure 4`), recovery resumed
run `d5d0e839` three times (`providerAttempt: 3`). The resumed turn
completed and the server logged `run.result.accepted`, and the run ended
`succeeded`. The first finalization hit `NativeStatusRaceError` three
times under row contention. `recordRetryableFailure` then compared
`coordinator.attempt` (3) with its limit of 3 and wrote
`terminal_failure` / `native_finalization_retry_exhausted`. The run's
`execution.nextAction` is "Reassess against the latest authoritative
issue status version.", which only the `status_cas_exhausted` branch
writes. The second finalize call 2 s later got
`native_finalization_terminal_failure` from `claimCoordinator`. The
issue stayed `in_review` with `canRetry=false`.

**Expected behavior**
A transient finalization failure must stay retryable. The finalizer must
apply the saved result without another provider turn. Finalization must
still have a limit.

**Steps to reproduce**
1. Resume a native run until its coordinator `attempt` is 3, for example
with three restarts.
2. Let the resumed run save an accepted result.
3. Make the first finalization fail with a retryable error.
4. Before this fix, the coordinator goes directly to `terminal_failure`.
The new test
`server/src/__tests__/native-finalization-retry-budget.test.ts`
reproduces this order.

**Paperclip version or commit**
The wave ran at e2b0290213. The bug is still on master at 902f376796. I
found no later merged PR that changes this check.

## What Changed

- `native-run-finalizer.ts`: `recordRetryableFailure` counts failed
finalizations of the saved result in
`heartbeat_runs.result_json.finalizationFailureCount`. It ends retries
after 3 failures. It no longer reads `coordinator.attempt`, which counts
provider attempts. The count goes on the run because each claim clears
the coordinator's `failure_detail`. Workspace-scope failures keep their
existing `workspaceFinalizeAttempt` budget.
- New regression test `native-finalization-retry-budget.test.ts`: a
coordinator saved at `attempt: 3` with no count stays
`retryable_failure` after the first failure. It goes to
`terminal_failure` only after the third failure.

## Verification

- `cd server && npx vitest run
src/__tests__/native-finalization-retry-budget.test.ts
src/__tests__/native-finalization-recovery.test.ts`: 37 tests pass.
- The new test fails on the unchanged finalizer (`terminal_failure`
received, `retryable_failure` expected). It passes with the fix.
- `pnpm verify` (selected tests).
- I did not run a live chaos replay. The evidence is the wave's run
events, its server log, and the code path.

## Risks

- Low risk. The only behavior change: runs that used provider attempts
now get their full 3 finalization retries.
- Previous-release story: rows that an older server saved have no
`finalizationFailureCount`. Their count starts at 0, so they get at most
3 more finalization tries, with no provider work. A coordinator that an
older server already set to `terminal_failure` stays terminal. This PR
migrates nothing.
- If another writer replaces `result_json` without spreading it, the
count can reset. That allows only a few more cheap retries, 30 s apart.
It cannot cause an unbounded loop unless the overwrite repeats.
- Not addressed here: when finalization really runs out of retries for a
succeeded run, the issue still rests `in_review` with a board recovery
card and no Retry. A safe Retry for that case must re-finalize the saved
result, not start a new provider run. That needs a separate change.

## Model Used

- Anthropic Claude, model ID `claude-opus-5-5`, used through Claude Code
with tool use (shell, code 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 <noreply@anthropic.com>
2026-11-10 09:45:39 -06:00
DottaandClaude Opus 5.5 bd30ef8510 perf(chat): authorize the waits of an issue page with one read (#20551)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents can work on tasks that start from an external chat, such as
Slack. When the agent waits for the next chat message, the task shows
"in review" with an external response wait.
> - Issue lists and inbox attention show these waits. To show a wait,
the server checks that the chat conversation is still authorized: the
messages, the destination, the endpoint, and each person who wrote.
> - The server did this check one time for each waiting task, and it
started all the checks at the same time. A page with 19 waits sent 19
copies of the message-link statement at the same moment, each on its own
pool connection, and then 19 copies of each policy read.
> - On a hosted database, 19 identical copies of this statement were
active at the same moment, about 670 ms each. They were 19 of the 20
active queries.
> - This pull request reads the messages of all waits of a page with one
statement and shares the policy reads between the waits. Each wait gets
the same authorization result as before.
> - The benefit is fewer statements and fewer connections for each issue
page, so one page cannot fill the pool with copies of one read.

## Linked Issues or Issue Description

Refs #20400 (shares wait reads between the callers of one request; this
change removes the per-wait fan-out inside that shared read).
Refs #18966 (reuses the prepared message-link statement; this change
keeps that statement for single runs).

**What happened**

`listExternalReviewWaits` ran `authorizeChatConversationForBoundRun` one
time for each waiting task, inside `Promise.all`. Each call sends the
message-link statement, then a resource read, and three reads for each
person. A page with N waits sent N copies of each statement at the same
time, on up to N pool connections.

**Expected behavior**

One issue page sends one message-link statement for all of its waits. A
resource or a person that is common to many waits is read one time. The
authorization result of each wait does not change.

**Steps to reproduce**

1. Put many tasks of one company in review with an external chat
response wait.
2. Open the issue list or the inbox.
3. Look at the active statements of the database. One copy of the
message-link statement runs for each wait.

## What Changed

- `server/src/services/native-runtime/chat-attachment-reuse.ts`:
- Split `authorizeChatConversationForBoundRun` into steps: read the
bound provider and wake comments, read the message rows, and apply the
policy to the rows (`authorizeBoundRunLinks`). The single-run path uses
the same prepared statement, the same lock modes and the same policy
code as before.
- Move the message-link join and its policy filters to one query
builder, `boundRunLinksQuery`. The single-run statement and the batch
statement both use it.
- Add `authorizeChatConversationsForBoundRuns(db, companyId, runs)`. It
is read-only and takes no locks. It reads the messages of all runs with
one statement, then gives each run only the rows that the single-run
statement returns for that run (same issue, agent, provider and
comments). The runs share each distinct resource and principal read.
Each run gets its conversation, or `null` for a policy denial. Other
errors are thrown, as before.
- Add `isBoundChatAuthorizationDenial` for the three policy denial
errors.
- `server/src/services/native-runtime/external-review-wait.ts`:
`listExternalReviewWaits` uses the batch authorization. The inherited
GitHub review-wait reads do not change.
- `server/src/__tests__/batched-chat-authorization.integration.test.ts`:
new tests on real PostgreSQL. For each case, the batch result must equal
the result of separate single-run reads. The cases include each denial:
a missing or foreign comment, no comments, no execution marker, a wrong
provider, agent or issue, a person without a link or without a
membership, a viewer member, a revoked link, a disabled or unavailable
destination, a paused endpoint, a closed conversation, an unprocessed
delivery, a delivery without a person, and a verifying endpoint. A test
also makes sure that one person's decision never applies to another
person in the same batch, and that a batch sends one message-link
statement.
- `scripts/benchmark-chat-bound-authorization.ts`: a local benchmark
that loads the pre-fix modules from a baseline commit, checks that both
versions return the same waits, and measures them.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/batched-chat-authorization.integration.test.ts
src/__tests__/prepared-chat-authorization.integration.test.ts` passes
(10 tests).
- Mutation check: when I removed the issue filter from the per-run row
selection, or shared one principal decision for all people of an
endpoint, the new tests failed (1 and 3 failures).
- Related suites pass: `heartbeat-reviewed-chat-binding`,
`external-chat-wait`, `preserved-native-response-wait`,
`chat-attachment-reuse` and `external-review-wait` (392 tests).
- `pnpm verify` asks for broader verification because the change adds a
file under `scripts/`. Required CI supplies that coverage.
- Benchmark: `pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-chat-bound-authorization.ts
--baseline-ref=f4337bb5fb` (the measured pre-fix commit)

Benchmark fixture (synthetic, local PostgreSQL 18.1, pool of 10): one
company, one Slack endpoint, 20 tasks waiting for a chat response from 5
people, 4 of those waits denied (the person is a suspended member), and
2,000 earlier conversations with 50,000 processed deliveries (6 KiB
events, 419 MB). Both versions return the same 16 waits. 15 alternating
pairs after warm-up:

| One issue page (20 waits) | Before | After |
| --- | --- | --- |
| Statements sent | 101 | 18 |
| Message-link statements | 20 | 1 |
| Server time of the message-link statements | 0.50 ms (sum of 20) |
0.13 ms |
| Page time, median (min to max) | 8.86 ms (6.94 to 12.05) | 4.05 ms
(3.62 to 5.54) |
| 8 pages at the same time, median (min to max) | 54.18 ms (48.36 to
89.24) | 14.99 ms (12.60 to 19.59) |

## Risks

- The batch must give each wait the same result as a separate read. The
batch statement uses the same join and policy filters. It then selects
each run's rows with the equality filters of the single-run statement.
The policy code is the same function for both paths. The tests compare
both paths for each denial case.
- Shared reads: the batch reads each resource and each person one time.
All reads are plain reads with no locks, as before. A policy change that
occurs during one page read can now apply to all waits of the page at
the same time, instead of to some of them.
- An invalid wake comment id in one wait makes the whole page read fail.
This was already true: the failure of one wait rejected the whole
`Promise.all`.
- The single-run path (attachment reuse, status decisions, run
preparation and others) keeps its prepared statement and lock modes. The
order of the conditions in its WHERE clause changed; the conditions are
the same.
- Not proven: why each copy took about 670 ms on the hosted database.
Locally one copy takes about 0.03 ms with the expected index plan. The
likely causes are the burst of copies on a small compute and cold pages.
Verify after deployment that the statement runs at most one time for
each issue page request.
- No migration and no schema change.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:25:23 -06:00
f4337bb5fb test(evals): land the chat catalog eval harness on master (#19614)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat-feature evals measure whether agents complete real chat
requests through the product
> - The chat catalog eval harness and its repairs live on the
`chat-eval/master-candidate` branch, not on master
> - Today's work made 139 of 242 chat evals pass in verified single-case
live runs. Each product fix landed on master as its own pull request
> - The eval-side changes (setups, graders, fault seams, evidence
capture) still exist only on the candidate
> - This pull request lands the harness on master. Master stays the
source of truth for product behavior
> - The benefit is one tree: evals run against master commits, and
product fixes reach the evals as soon as they merge

## Linked Issues or Issue Description

Refs #19441, Refs #19599, Refs #20270, Refs #20334, Refs #20342, Refs
#20344, Refs #20354, Refs #20360, Refs #20373, Refs #20382, Refs #20409

**What existing behavior does this improve?**
The chat catalog eval harness and the product it evaluates live on a
long-running branch outside master. Eval fixes do not reach master, so
master-based eval runs use old setups and graders.

**Subsystem affected**
`tests/runner-e2e` chat catalog harness, plus a small set of product
changes that exist only on the candidate.

**Proposed change**
Land the harness on master. Every product fix from this eval work
already merged to master as its own pull request. Conflicts keep
master's product behavior.

## What Changed

- Most of the diff (808 of 851 files) is under `tests/runner-e2e/`:
chat-catalog domains, scenarios, descriptors, the TestOps manifest,
private evidence tooling, and their tests.
- Graders now check saved state, setup literals and rendered or clicked
links. Many keyword and regex checks on free-form replies are removed.
Each changed grader has a bumped `oracleVersion` and calibration tests
(a valid reply, a reworded valid reply, and broken variants that must
fail).
- The shared link helper `hasLink` no longer treats a list link (for
example `/COM/issues?view=mine`) as a link to every task without a URL
key.
- Setup fixes build real state through supported product routes: routine
history, agent grants through the board grant route, budget holds with a
real cap, and a storage-layer archive fault for
`inbox-search.archive-partial-failure`.
- Browser boundary journeys (sign-in, chat-disabled, onboarding) now
write the standard pass evidence files.
- Retargeted cases, approved by the maintainer:
`agent-chat.chat-disabled` (Conference Room gate),
`agents.restore-instructions` (no saved instruction history),
`access.manage-membership` (member permission edits stay board-only).
- The private evidence trusted files (`manual-private-handoff.ts`,
`manual-private-retention.ts`, `manual-private-report-assets.ts`) are
byte-identical to master.
- Product files outside `tests/` (43 files, +1,328 −49) carry
candidate-only changes that master does not yet have. Most are tests.
The main code changes are in `request-human-input.ts`,
`heartbeat-run-summary.ts`, `native-completion-feedback.ts`,
`native-run-finalizer.ts`, `openapi.ts`, `secrets.ts` and
`IssueChatLongThreadPerf.tsx`.

## Verification

- `pnpm vitest run chat-catalog-domains chat-catalog-contract.test.ts
chat-catalog-consistency.test.ts unit-launcher.test.mjs
--testTimeout=180000` in `tests/runner-e2e`: 269 files pass, 3,550 tests
pass.
- `node --test chat-full-catalog-testops-manifest.test.mjs`: 10 pass.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`:
no change.
- `tsc` for `server` and `tests/runner-e2e`: clean.
- Live single-case verification: 139 of 242 cases passed on paid runs,
and a person read the saved evidence for each. These are single-case
runs, not one full-cohort run.

## Risks

- Large diff. Most of it is test-only, but the 43 non-test files change
product code paths. Review those files first.
- Some graders are now less strict on reply wording. A person must read
replies for meaning on those cases.
- 42 cases need disposable outside accounts (Notion, Slack, GitHub,
Daytona and others) and stay blocked. `access.invite-teammate` stays
failing until local agent isolation lands, because the runner's durable
state keeps a live invite token.
- The paid workflow compares the trusted private-evidence files with
master. They are unchanged, so this risk is low.

## Model Used

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

## 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>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 08:02:11 -06:00
DottaandClaude Opus 5.5 c9226de30b fix(heartbeat): leave a preparing run to the next controller after a graceful drain (#20514)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The heartbeat service runs each agent task. A graceful shutdown
(SIGTERM) drains running native runs and hands them to the next server.
> - The drain detaches the native runner session and releases the run's
lease with the recovery state `awaiting_runner_reattach`.
> - An in-process execution can still be in run preparation at that
moment, for example while the run-log journal claim waits on a slow or
unavailable object store.
> - When that execution then lost writer authority
(`run_log_writer_authority_lost`), the generic failure path settled the
run as `adapter_failed`. The next server then saw
`native_continuation_requires_reconciliation`, and replay stayed
blocked.
> - This pull request makes a preparing execution that a restart
detached stop quietly, the same way a detaching native session already
does.
> - The benefit is that a slow preparation step plus a graceful restart
no longer turns into a failed run that needs a manual retry.

## Linked Issues or Issue Description

**What happened?**
In restart-chaos wave `w-20261110T092411-s701` (local, A1/C1, K1/K2, ST7
read-after-write lag), three of four tasks failed with R3
`retry_needed`. For run `218a0425`, the K2 SIGTERM drain wrote
`native.recovery.transition` with `awaiting_runner_reattach` and
released the lease. About 3 s later, the old server's in-process
execution, which was still in `runOutput.begin`, got
`run_log_writer_authority_lost`. `runCompletion.failRun` then settled
the run `adapter_failed` with a durable settlement. The next server
found `native_continuation_requires_reconciliation` and did not replay
the run.

**Expected behavior**
The drain hands the run to the next server. The old execution must not
settle it. A real authority loss outside a drain must still fail the
run.

**Steps to reproduce**
1. Start a native run recovery, and hold it in preparation (slow store,
ST1 outage wait, or ST7 lag).
2. Send SIGTERM so `drainRunningRunsForShutdown` suspends the run.
3. Let preparation continue and check writer authority. The run settles
`adapter_failed`.

**Paperclip version or commit**
`master` at 914e3ae14f.

## What Changed

- `native-session-executor.ts`: add `watchNativeRestartDetach(runId)`.
It records the run's restart-detach marker when the execution starts. It
reports `true` only when a later restart detach for that run happens. A
marker left by an earlier detach does not count.
- `heartbeat.ts`: the execution creates the watcher at start. In the
inner failure path (before `runCompletion.failRun`) and the setup
failure path (before `runCompletion.failRunSetup`), if a restart
detached the run and no provider dispatch started, the execution logs
the handoff and stops without settling. It uses the same teardown flag
(`nativeSessionResumeScheduled`) as
`NativeControllerDetachedForRestartError`.
- `heartbeat-native-restart-suspension.test.ts`: add a
red-before/green-after regression. A recovery execution is drained
during preparation and then checks the real run-log writer authority.
With the drain, the run stays `running` with `awaiting_runner_reattach`,
and the next controller resumes it. Without the drain (the lease only
expired), the run still fails `adapter_failed`.

## Verification

- `cd server && npx vitest run
src/__tests__/heartbeat-native-restart-suspension.test.ts`: 98 passed.
Before the fix, the new drained case failed with `status: failed,
errorCode: adapter_failed`.
- `cd server && pnpm typecheck`: passed.
- Live restart-chaos wave: see the comment below.

## Risks

- Low risk. The new branch applies only when this process's own restart
detach for this run happened after the execution started, and before any
provider dispatch. Typed native outcomes (startup cleanup, ownership
unverified, cancellation pending, resume scheduled) keep their earlier
handling because they are checked first.
- Previous-release story (upgrade-safe run state): no persisted shape,
launch identity or pack identity changes. The marker is process-local.
State saved by the previous release carries forward unchanged. The
deploy-lands-mid-run case is the regression test itself: a drain lands
during recovery preparation and the next controller resumes the same
run.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:00:09 -06:00
DottaandClaude Opus 5.5 15a734d068 fix(heartbeat): show a storage outage wait as live status, then record it once (#20460)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - In cloud mode a run reads and writes file storage on its critical
path: it starts its run log (`s3_segments`), restores its saved
workspace, and saves it again
> - Since #20299 a run waits out a storage outage there for up to 5
minutes instead of failing
> - That wait was silent. The board showed "running" with no sign of
life, so a storage wait looked like a dead run (restart-chaos R5
`silent_dead_run`)
> - The first version of this PR wrote a run event on every wait. That
writes the run row (`next_event_seq`, `updated_at`) while the run-log
claim is still in progress, and it covered only the run-log begin
> - This PR shows the wait as live run status instead, writes nothing
during the wait, and records one lifecycle event when the wait ends. It
covers every critical-path wait in the run's storage scope
> - The benefit is that a storage outage shows on the board as waiting,
the run's history keeps one honest line about it, and the claim path is
left alone

## Linked Issues or Issue Description

Refs #20299 (wait out a store outage on a run's critical path).

**What happened?**
- `w-20261110T043533-s11936` (Daytona R1, ST1 194 s, K2): run `d49f58d8`
waited 186 s in `heartbeat.prepare.run_output` with no event. The grader
failed it as `R5:silent_dead_run`.
- `w-20261110T105903-s11936`: the restore download
(`storage.snapshot.download`) waited 182 s with no sign of life.

**Expected:** the board shows the run waiting for file storage, and
nothing about the notice can disturb run-log ownership.

**On the failed `run_output` seen on builds of the first version**
(`w-20261110T101517-s11936` t01 run `9d39550a`,
`w-20261110T081647-s11936` t02 run `9da10424`): the evidence points to
an expired restart lease, not to the notice's row writes. Neither the
writer-authority check (`run-log-authority.ts`) nor the journal claim
compares `next_event_seq` or `updated_at`. A remote
`bootstrap_incomplete` restart claim gets a 60 s lease
(`REMOTE_CONTROLLER_LEASE_MS`), and preparation renews it only for
`reattach_remote_runner` claims. In t01 the claim was at 10:19:55, the
outage ended at 10:22:18.15, and `run_output` failed at 10:22:18.17, on
the first authority check after the store came back. That fix ships
separately as its own PR.

## What Changed

- `storage/storage-unavailable.ts`:
- The run's storage scope can carry a wait observer
(`reportStorageOutageWaitsTo`).
- `retryThroughStorageOutage` tells it about each backoff (`waiting`).
The operation that owns the wait reports one `ended` (`recovered` true
or false), so nested waits do not double-report.
  - `storageOutageWaitFor(purpose, work)` names the step.
- An observer that throws never changes the wait.
`endStorageOutageWaitOn` keeps the observer.
- `heartbeat/storage-wait-notice.ts` (new):
- Each wait sets live runtime status (phase `waiting_for_storage`,
"Waiting for file storage before starting the run log (45s so far)") and
publishes `heartbeat.run.progress`. Nothing goes to the database.
- When the wait ends, it writes one `lifecycle` warn event: "Waited Ns
for file storage before …", with payload `reason:
"storage_outage_wait"`, `purpose`, `waitedMs` and `recovered`.
- `heartbeat.ts`: installs the notice next to `endStorageOutageWaitOn`
(one line).
- Purposes:
  - run-log begin: "starting the run log";
  - `loadWorkspaceSnapshotArchive`: "restoring the workspace";
  - `uploadWorkspaceSnapshot`: "saving the workspace".
- `packages/shared`: adds `waiting_for_storage` to
`HeartbeatRunStatusPhase`.
- `tests/restart-chaos/live-status-evidence.ts` (new): R5 also counts
the board's live run status (`/api/issues/:id/live-runs`
`currentStatusUpdatedAt` / `lastEventAt`) as evidence that a running run
is alive. Wiring it into `observer.ts` takes one import and one changed
line.
- `doc/run-log-events.md`: documents the live status and the one event.
- Removed: the first version's per-wait run event.

## Verification

- `cd server && npx vitest run src/services/heartbeat/run-output.test.ts
src/__tests__/storage-outage-wait.test.ts
src/services/remote-workspace-snapshot-storage.test.ts
src/services/object-byte-journal.test.ts
src/__tests__/storage-s3-credential-rotation.test.ts`: 103 pass.
- Red/green: with the observer disabled (master behaviour), these new
tests fail. With this change they pass:
- run-output "shows the wait as live status and writes nothing to the
run while its log claim waits": no run-row write and no event during the
claim's waits; the live status is `waiting_for_storage`; the claim's
writer-authority check still passes; one lifecycle event afterwards;
- the helper's observer tests (one end per wait; gave-up reporting; a
throwing observer; the Stop signal keeps the observer);
- snapshot storage "tells the run it is waiting for storage while a save
or a restore waits out an outage".
- `PAPERCLIP_IN_WORKTREE=false npx vitest run --config
tests/restart-chaos/vitest.config.ts
tests/restart-chaos/live-status-evidence.test.ts
tests/restart-chaos/observer.test.ts
tests/restart-chaos/triggers.test.ts`: 65 pass. `node
scripts/typecheck-test-projects.mjs tests/restart-chaos`: pass. `cd
server && npx tsc --noEmit`: pass.
- Live restart-chaos waves: results in a comment below.

## Risks

- Low.
  - The retry policy, its budget and Stop handling are unchanged.
- During a wait, the only new effect is an in-memory status and a live
event.
  - Afterwards, one event per wait, written without blocking the run.
- Runtime status is per server process. A board on another instance sees
the waiting line only after the closing event.
- Shared journal work (`runSharedStorageWork`) keeps its own scope
without the observer. Its waits surface through the enclosing run-scoped
wait, as before.
- Previous-release state: this persists no new run or session state. A
run saved by the previous release resumes unchanged.

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 07:42:43 -06:00