Commit Graph
601 Commits
Author SHA1 Message Date
Devin FoleyandPaperclip 8148fc0b1e fix(release): fetch draft-notes PR summaries in batches (#20602)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The release workflow drafts the next stable's release notes when a
beta publishes (`draft_stable_notes`)
> - The draft nests each referenced PR's summary under its commit
subject, and it fetches each summary with its own `gh pr view` call
> - A beta range now references more than 1,500 PRs, so these serial
calls do not finish inside the job's 10-minute timeout
> - This pull request fetches the summaries in batches of 50 per GraphQL
query
> - The benefit is that the job finishes in under a minute and pushes
the notes branch again

## Linked Issues or Issue Description

**What happened?**

The `draft_stable_notes` job of [run
42763789753](https://github.com/paperclipai/paperclip/actions/runs/42763789753)
(beta `2026.1110.0-beta.0`) reached its 10-minute timeout. GitHub
cancelled it during "Draft stable notes from the published beta", while
a `gh` process was still running. The beta itself published and its
smoke tests passed. The `release-notes/v2026.1110.0-beta.0` branch was
not pushed.

**Expected behavior**

The job writes the notes draft and pushes the branch for every beta,
whatever the size of the commit range.

**Steps to reproduce**

1. Publish a beta whose range from the last stable has many
PR-referencing commits. This range has 1,586 commits, and 1,583
different PRs.
2. `draft-stable-notes.sh` calls `gh pr view <n>` once for each commit,
one after another.
3. The job reaches `timeout-minutes: 10` before the loop ends.

**Paperclip version or commit**

`09e24dd12c36251dd12af2d73dbc715ce990f922` (beta `2026.1110.0-beta.0`).
This was the first beta to run the job after #12124 added the PR
summaries; earlier betas skipped it.

## What Changed

- `scripts/draft-stable-notes.sh`:
- Collects the unique PR numbers first. Fetches their bodies 50 per `gh
api graphql` query (aliased `pullRequest(number:)` fields), into a
temporary cache that `enrich_pr` reads.
- Parses the raw response with `jq`, not with `gh --jq`. When any number
in a batch is not a PR (for example, an issue reference), gh exits
non-zero and does not apply its `--jq` filter. The other PRs in the
batch still have usable data.
- The output format does not change. A missing `gh` or `jq`, a failed
batch, a cache directory that `mktemp` cannot create, or
`DRAFT_NOTES_SKIP_PR_ENRICHMENT=1` still gives bare subjects.
- No code comments are added (per `CONTRIBUTING.md`); this description
holds the explanation.
- `scripts/draft-stable-notes.test.mjs`:
- The enrichment tests now use a fake `gh` that answers GraphQL like
GitHub.
- New tests: batching (120 PRs make 3 queries, and a repeated PR is
fetched once); a batch with a non-PR number; a failed batch; a failed
`mktemp`; prose-only and empty bodies; the opt-out.
- Range tests now run with enrichment disabled, so an authenticated `gh`
on the test machine cannot reach GitHub.
- `package.json`: adds the test file to `test:release-registry`. These
tests did not run in CI before.

## Verification

- `node --test scripts/draft-stable-notes.test.mjs`: 17 pass. They also
pass with macOS bash 3.2 first on `PATH`.
- `node scripts/ci-profile/node-test.mjs
scripts/draft-stable-notes.test.mjs scripts/release-lib.test.mjs`: 31
pass.
- Red check: the new tests against the old script fail 5 cases (all
batching and enrichment cases). The `mktemp` test fails against an
unguarded `mktemp` call.
- Live run on the real range: `./scripts/draft-stable-notes.sh
2026.1110.0-beta.0` finished in 47 seconds. It wrote 4,596 excerpt lines
for 1,583 PRs.
- Parity: for 40 randomly sampled PRs from that range, the new excerpts
are byte-identical to the old per-PR `gh pr view` output.
- `shellcheck`: no new findings. The two remaining notices were there
before this change.

## Risks

- Low. The change touches only the post-publish draft job, and every
failure still gives the bare-subject draft.
- `jq` is now needed for the PR summaries. GitHub's Ubuntu runners
include it. Without `jq`, the draft has no summaries, the same as
without `gh`.
- I did not change `release.yml`. The job already checks out `master`,
so after this merges, re-running the failed job of run 42763789753
should push the missing notes branch with the fixed script.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) via Claude Code, with tool use
(shell, file editing, test runs, read-only GitHub API calls).

## 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 11:48:55 -08: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
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
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 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 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
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
DottaandClaude 94cfb84150 fix(scripts): build the plugin SDK in the agent worktree preflight (#20473)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors and agents prepare each new git worktree with `node
scripts/agent-preflight.mjs`
> - The server imports `@paperclipai/plugin-sdk` through its built
`dist`. The preflight did not build that `dist`.
> - On a fresh worktree, the server and the restart-chaos harness stop
at startup with `ERR_MODULE_NOT_FOUND` for
`server/node_modules/@paperclipai/plugin-sdk/dist/index.js`
> - The SDK exports `dist` on purpose: plugin workers load it under
plain Node. CI, `paperclipai run` and the typecheck scripts already
build it with `scripts/ensure-plugin-build-deps.mjs`.
> - This pull request makes the preflight run the same builder as a step
> - The benefit is that one preflight command gives a worktree that can
start the server and the harness

## Linked Issues or Issue Description

**What happened?**
After `node scripts/agent-preflight.mjs` on a fresh worktree, `node
cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts ...` stops
at startup. The error is `ERR_MODULE_NOT_FOUND` for
`server/node_modules/@paperclipai/plugin-sdk/dist/index.js`, imported
from `server/src/services/reusable-sandbox-resume-error.ts`. The
workaround is `pnpm --filter @paperclipai/plugin-sdk build`.

**Expected behavior**
The preflight prepares everything that the server needs to start. The
server and the harness import their modules without a manual build.

**Steps to reproduce**
1. Make a new worktree from `origin/master`.
2. Run `node scripts/agent-preflight.mjs`.
3. See that `packages/plugins/sdk/dist` does not exist.
4. From `server/`, import
`./src/services/reusable-sandbox-resume-error.ts` with tsx. The import
fails with `ERR_MODULE_NOT_FOUND`.

**Paperclip version or commit**
`origin/master` at a5e01634f6.

## What Changed

- `scripts/agent-preflight.mjs`: add a `plugin-sdk` step after install.
It runs `scripts/ensure-plugin-build-deps.mjs`. That script builds
`@paperclipai/shared` and `@paperclipai/plugin-sdk`, and it skips them
when their content fingerprints match the last build. The step reports
`built` or `skipped`. It fails when the builder fails or when a
completion marker is missing after the build.
- The SDK build does not use the shared cross-worktree cache. Both
packages compile in about 1.6 s with the native compiler, so a cache
clone does not save time.
- `scripts/__tests__/agent-preflight.test.mjs`: new tests. They use the
real builder script with a stand-in compiler. They cover a fresh build,
a skip when sources do not change, a rebuild after a source change, a
rebuild when only the SDK `dist` is missing, a failed build, and missing
outputs. The file is added to `test:release-registry` next to
`agent-build-cache.test.mjs`.
- `doc/DEVELOPING.md`: the "Agent worktree preflight" section describes
the new step.

I checked the other option: change the SDK exports to `src`, as most
sibling packages do. I did not do that. Plugin workers load the SDK
under plain Node, and other tools already depend on the built `dist`. I
also checked the other workspace packages that the server and the
harness import. Only `@paperclipai/paperclip-runner` and
`@paperclipai/paperclip-eval-kernel` also export `dist`, and the
preflight already builds them. The harness builds its sandbox plugin
fixtures itself (`tests/restart-chaos/plugin-build.ts`).

## Verification

- Reproduced on a fresh worktree from `origin/master`: after the
preflight, `packages/plugins/sdk/dist` did not exist and the tsx import
of `server/src/services/reusable-sandbox-resume-error.ts` failed with
`ERR_MODULE_NOT_FOUND`.
- On a second fresh worktree with this change: the preflight printed
`plugin-sdk: built in 1.6s`. A second run printed `plugin-sdk: skipped`.
A tsx import of `reusable-sandbox-resume-error.ts` and
`server/src/app.ts` passed. A tsx import of every module that
`tests/restart-chaos/main.ts` imports passed. I did not run a harness
wave.
- `node --test scripts/__tests__/agent-build-cache.test.mjs
scripts/__tests__/agent-preflight.test.mjs
scripts/__tests__/ensure-plugin-build-deps.test.mjs`: 23 pass.

## Risks

- Low risk. The change affects only the local agent preflight. A fresh
preflight takes about 2 s more. A current worktree adds one fingerprint
check (about 0.1 s).
- The step uses the builder's lock. If another build in the same
worktree holds the lock, the step waits for it, as the other callers do.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 08:03:56 -06:00
DottaandClaude bf45700ca3 fix(native): make Retry work after a turn start cut off once Codex recorded the turn (#20516)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native Codex run sends `turn/start` through its runner. A
control-plane restart can kill the runner and Codex while that request
is in flight
> - #20507 lets the runner resume by itself when Codex's thread history
proves the turn was never created. When Codex did record the turn, the
turn may have done work, so the runner still fails closed and nothing
replays it
> - That branch must still end in a Retry that works. The next run on
the same conversation is refused at the runner-state fence before it
asks for a runner. #20507 let Retry continue after that run by matching
the refusal's error text
> - Driving that branch end to end with the real runner exposed a second
dead end. When the owner's runner dies, its provider-session close
cannot prove suspension, so this server process can keep an
operator-required cleanup quarantine for the task. Retry was then
refused outright ("Send a new message")
> - This pull request replaces the text match with a server-authored
run-log event, lets Retry take the same verified-stop path as a new
message, and adds a real-runner test of the whole branch: the cut-off
owner fails closed, the follow-up is refused with structured proof,
Retry is admitted, and the fresh Retry run completes
> - The benefit is that this restart window cannot strand a task, and
the Retry gate no longer depends on error wording

## Linked Issues or Issue Description

**What happened?**
Follow-up to #20507 (restart-chaos wave `w-20261110T033426-s108`, t03).
#20507 left one item open: prove that a person's Retry produces a
working run when the restart cut off `turn.start` after Codex recorded
the turn. It also matched the fence refusal by its error text.

**Expected behavior**
After that cut-off, the owner stays failed closed and the turn is never
started again by itself. A person's Retry starts a new run that
completes. It never fails with `quarantine_owner_unverified` and never
returns 202 with no run.

**Steps to reproduce**
1. Run `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/native-cut-turn-start-retry.integration.test.ts`.
2. Without the new event, the test fails: the refused follow-up has no
proof that it never launched, so Retry after it would wait on
`process_identity_missing`.

## What Changed

- `native-session-executor.ts`: when the runner-state fence refuses a
run before launch (`prior_owner_*` or `legacy_prior_owner_*`), the
executor records the server-authored event
`native.prior_owner_fence_refused` with the closed refusal `reason`,
then fails the run as before. A failed write leaves the run held (the
safe side).
- `native-local-process-stop.ts`: `nativeRunNeverLaunched` accepts a
failed run that has this event (and still no launch, identity,
`adapter.invoke` or runner-sourced event). It no longer reads the error
text.
- `explicit-native-continuation.ts`: when the task's in-process cleanup
quarantine is operator-required, a Retry no longer returns
`cleanup_quarantined` at once. Like a new message, it must first pass
`verifyStoppedNativeSessionForContinuation`, which releases the
quarantine and records `native.stopped_conversation_verified`. A native
Retry always starts a fresh conversation (`forceFreshSession`), so it
never resumes the interrupted turn. If the proof fails, Retry still
returns `cleanup_quarantined`.
- `verifyStoppedNativeSessionForContinuation`
(`native-session-executor.ts`), three narrow additions:
- a run with `native.prior_owner_fence_refused` may stand in for the
saved state's owner, like a run refused by the cleanup quarantine (both
were refused before launch);
- when the session root is gone, it reads the fence's single
`identity_indeterminate` quarantined copy of the owner's state (several
copies stay ambiguous);
- a provider startup attempt in phase `initialization_failed` whose
direct child exited and whose process and process group are gone no
longer blocks the proof. A same-run restore that fails closed leaves
exactly this record. Those PIDs are also re-checked before the
quarantine is released.
- New real-runnerd integration test
`native-cut-turn-start-retry.integration.test.ts` (real runnerd and fake
Codex with `--hang-turn-start after`):
1. The owner run's `turn/start` hangs after Codex recorded the turn; the
runner and Codex are killed. The owner's provider state keeps the
ambiguous start, and the thread history keeps the turn.
2. The same-run resume restores the runner and fails closed with "cannot
safely recover an ambiguous Codex turn start". Codex saw exactly one
`turn/start`.
3. A follow-up on the same conversation is refused with `prior_owner_*`,
never `quarantine_owner_unverified`. It records
`native.prior_owner_fence_refused`, launches nothing, and
`nativeRunNeverLaunched` is true for it.
4. The owner's operator-required in-process cleanup quarantine is pinned
(whether the real close lands there is timing-dependent; about half of
the local runs did).
5. Retry of the refused follow-up passes
`admitExplicitNativeContinuation` (not a dry run) with no block reason,
releases the quarantine and records
`native.stopped_conversation_verified`.
6. The Retry run, prepared with a fresh conversation as task Retry does
(`forceFreshSession`), uses a different runner-state root and completes
with exit code 0. Codex saw one new `thread/start` and one new
`turn/start`; the cut-off turn was never started again.
- `explicit-native-continuation.test.ts`: the fence rows now insert the
event. A new row shows that the refusal text alone keeps the run held.
The "worker loss (retry)" case now expects a verified Retry to be
admitted like a new message.
- `native-recovered-owner-followup.test.ts`: a fixture event moves to a
free sequence number, because the refused follow-up now writes its own
event.
- Docs: `doc/run-log-events.md` (new event) and
`packages/paperclip-runner/docs/durable-recovery.md`.

## Verification

- Red/green:
- Without the event write, the integration test fails (no fence event,
so the follow-up is not proven unlaunched).
- Without the gate and proof changes, it fails with `expected [
'cleanup_quarantined' ] to deeply equal []`: Retry is refused while the
owner's quarantine is held.
  - With all changes, it passes.
- `vitest run` of `native-recovered-owner-followup`,
`explicit-native-continuation` (427 tests), `quarantined-task-retry`,
`issue-confirm-run-stopped-routes`, `native-finalization-recovery`,
`task-interruption-query`, `execution-projection`,
`chat-run-publications`, `closed-native-receipt-accounting`,
`agent-live-run-routes` and the new integration test pass.
- Server `tsc --noEmit` passes.
- Runner code and the pack `contractDigest` do not change.

## Risks

- Low-medium risk. The new event is written only on the fence refusal
path, which already fails the run. It is local run-log data (no
Telemetry or OpenTelemetry).
- Retry after an operator-required cleanup quarantine is a behaviour
change: it was always refused, and now it is admitted only after the
same exact stop proof a new message needs. That proof checks the owner's
runner stop record, every recorded provider and startup PID and process
group, and the unchanged state fingerprint before it releases the
quarantine.
- A run refused by a build between #20507 and this change has only the
error text. It stays held on `process_identity_missing` until a person
sends a new message or the board confirms the stop. #20507 has not
shipped in a release yet.
- The test models the restart in-process: it kills the runner and Codex
and calls the executor again. It does not drive the HTTP Retry route;
the route's own behaviour (fresh conversation, admission gate) is
covered by existing tests and by `nativeRunNeverLaunched` here.
- Open decision, unchanged: a turn Codex recorded before the restart is
never replayed or adopted. Continuing is a person's decision.

> 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`), via Claude Code with tool use
(shell, file edits, test execution).

## Checklist

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

Refs #20507

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 06:16:42 -06:00
DottaandClaude Opus 5.5 347d85fef3 perf(chat): index GitHub review lookups and share review-wait reads (#20400)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - GitHub chat endpoints let agents review pull requests. The server
reads review state when it lists issues and when it updates GitHub
checks.
> - On a hosted instance, two GitHub review queries used the most total
database time. One query ran about 660,000 times at about 33 ms. The
other query ran about 10,000 times at about 1 s.
> - The first query finds a source comment's chat link. No index covers
`chat_message_links.comment_id`, so each call reads the full table.
Issue lists also read the same review waits two times per request.
> - The second query joins deliveries, links and runs on a JSON key. The
planner walks the agent's run history in creation order and reads each
run's context.
> - This pull request adds four concurrent indexes, splits the run
lookup into two small reads, and shares review-wait reads in one
issue-list request.
> - 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?**
GitHub review state reads. These are the inherited review-wait lookup by
source comment, and the review-check lookup of the run that a delivery's
comment woke.

**Subsystem affected**
Server chat channels (GitHub review waits and checks), issue list
attention maps, and the database schema.

**Current behavior**
- `readInheritedGitHubReviewWait` filters `chat_message_links` by
`company_id`, `comment_id` and `direction`. No index has `comment_id`,
so PostgreSQL reads the full table on each call. The run-origin check
does the same lookup first.
- An issue list calls the external review-wait read from the review
attention map and again from the blocked-inbox attention map. The two
maps read the same in-review issues.
- `githubReviewCheckState` joins deliveries, links and runs with
`context_snapshot->>'wakeCommentId' = comment_id::text`, sorts by
`created_at desc` and takes one row. The planner reads the agent's runs
in creation order and reads each run context. If no run matches yet, it
reads all of them. The delivery filter on `normalized_event` and the
link join on `delivery_id` also have no index.

**Proposed behavior**
The same rows come back from small index reads. Each issue's review wait
is read one time per issue-list request.

**Reason and benefit**
These two statement families used about 33,000 s of database time in one
cumulative statistics window. The database had about 50 concurrent
active queries, and request reads waited behind this background work.

**Breaking changes**
None. The migration adds indexes only.

## What Changed

- Migration `0456_chat_review_lookup_indexes` builds four indexes with
`CREATE INDEX CONCURRENTLY IF NOT EXISTS`. It runs through the existing
top-level statement path, which repairs an invalid index on retry:
- `chat_message_links_company_comment_idx` on `(company_id, comment_id)`
where `comment_id is not null`.
- `chat_message_links_delivery_idx` on `(delivery_id)` where
`delivery_id is not null`.
- `chat_deliveries_github_automatic_delivery_idx` on `(endpoint_id,
normalized_event->'githubAutomatic'->'context'->>'deliveryId')` where
that value is not null.
- `heartbeat_runs_company_wake_comment_idx` on `(company_id,
context_snapshot->>'wakeCommentId')`. This index is not partial. The
planner does not use expression statistics from a partial index. Without
these statistics, it estimates many matches and keeps the creation-order
walk.
- `githubReviewCheckState` reads the delivery's comment IDs first. If
there is a comment, it reads the newest same-agent run with one of these
`wakeCommentId` values. If there is no comment, it skips the run read.
The comparison stays text equality on `comment_id::text`.
- `createExternalReviewWaitReader` shares one read per issue between the
review and blocked-inbox attention maps of one issue-list request. The
reader exists only for that request.
- `doc/DATABASE.md` describes the indexes and the new read order.
- `scripts/benchmark-review-lookup-indexes.ts` compares the pre-fix and
new code with and without the indexes.
`scripts/benchmark-review-lookup-index-migration.ts` times the migration
on a large synthetic database.

## Verification

Local SQL measurements on a synthetic fixture. They are not production
timings. Environment: embedded PostgreSQL 18.1, baseline revision
`02bfe71a6b`. Fixture: 50,000 links, 5,000 deliveries with about 2 KiB
events, and 4,000 runs with about 12 KiB incompressible contexts. The
wake run is in the middle of the run history. Each value is the median
server execution time from `EXPLAIN ANALYZE` (2 warmups, 9 samples). The
order was: no indexes, indexes, then no indexes again.

| Caller and statement | Before (no indexes, two passes) | After |
Shared buffers before → after |
| --- | --- | --- | --- |
| Inherited review wait, link by source comment (same SQL) | 2.72 / 2.36
ms | 0.076 ms | 929 → 12 |
| Review check, wake run found | 31.9 / 21.9 ms | 0.29 ms (two
statements) | 25,776 → 62 |
| Review check, no wake run yet | 71.0 / 47.5 ms | 0.06 ms (two
statements) | 34,861 → 12 |

- Indexes alone do not fix the review check. The old SQL with the new
indexes took 8.6 ms (run found) and 29.7 ms (no run), because it still
walked the run history. The new SQL without the new indexes was neutral
(23.5 and 35.1 ms).
- Service wall times on this shared machine were noisy, so the table
reports server execution time.
- Correctness: the benchmark fails unless the inherited statement
returns the same positional rows in each index state. The check service
result and the final wake-run rows of the new code must also match the
pre-fix module for both cases.
- Migration timing, normal migration runner, synthetic data: 200,000
runs (1.8 GB with 8 KiB incompressible contexts), 500,000 links and
100,000 deliveries. First run 4.2 s; rerun 0.38 s. Separate builds:
links by comment 1.5 s, run wake comment 1.1 s, deliveries 0.7 s, links
by delivery 0.3 s. All indexes were valid. The run index was 8.3 MB.
- New tests:
- `server/src/__tests__/github-review-check-state.test.ts` checks that
the newest same-agent run is selected. It also checks that other agents,
other companies, uppercase IDs, non-string values, list-only
`wakeCommentIds`, comment-less links and unknown deliveries do not
match. It checks that the generated statements can use the new indexes.
- `server/src/services/native-runtime/external-review-wait.test.ts`
checks that the reader reads each issue one time, keeps concurrent
callers correct, and rejects all waiting callers when a read fails.
- Red/green: with the pre-fix `review-check-state.ts`, the behavior test
passes and the index test fails. With this change, both pass.
- Focused runs pass: `issue-blocker-attention`,
`heartbeat-issue-liveness-escalation`, review-wait, review-check-state,
external-review-wait, review-repair (106 tests), and
`external-chat-wait.integration` (285 tests). `pnpm --filter
@paperclipai/server typecheck` and `pnpm --filter @paperclipai/db
check:migrations` pass.

Commands:

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-review-lookup-indexes.ts --baseline-ref=02bfe71a6b
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-review-lookup-index-migration.ts
```

## Risks

- The migration builds four indexes concurrently. It does not block
writes. On a large database, the run index reads every run context one
time. The local run took 1.1 s for 1.8 GB from a warm cache. Cold
storage on smaller compute can be slower.
- The run index adds one entry for each run insert and for each run
update that is not a heap-only update. Many runs have no
`wakeCommentId`, so these entries hold a null key.
- The review check now sends two statements instead of one when a
delivery has a comment. Each statement reads only a few index entries.
- When two matching runs have the same `created_at`, either can be
selected. The previous query had the same tie behavior.
- This change does not change the number of in-review issues that one
issue list reads. The blocked-inbox map still reads all in-review issues
of the company. A smaller scope needs a separate change to the liveness
classifier.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) by Anthropic, in 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-09 18:47:34 -06:00
DottaandClaude Opus 5.5 326df52673 perf(api): rank Mine inbox candidates from the participation set (#20413)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Mine inbox reads `GET /api/companies/:companyId/inbox/mine`. Its
main query is `issueService.list` with "touched by me", "not archived by
me", six statuses including `done`, and `LIMIT 100` by canonical last
activity.
> - That query evaluates `touchedByUserCondition` (a five-way OR of
EXISTS) on every status-matching issue in the company. Because `done` is
included, the scan grows with company history, not with the user's work.
> - The unread badge already solved the same problem with a UNION of
indexed participation sources (#17973). The list path did not.
> - This pull request makes the Mine list rank page ids from that same
participation UNION, then load full rows (with the expensive select-list
subqueries) only for the 100 selected ids.
> - The benefit is that the main Mine query scales with the user's
participation instead of the company's issue count. Results stay
identical, and a new test proves it against the original scan.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The latency of the Mine inbox snapshot, `GET
/api/companies/:companyId/inbox/mine`.

**Subsystem affected**
Server: issue list service (`server/src/services/issues.ts`) and the
Mine route.

**Current behavior**
Measured on 2026-11-09: 280–570 ms server time on a dev instance and
1.9–3.2 s on staging. The list query scans every status-matching issue
(28k of 30k in the fixture below) to test participation, then sorts the
touched ones.

**Proposed behavior**
The list query reads only the user's participation set and computes the
row projection only for the page.

**Reason and benefit**
Inbox opens are frequent. The old cost grows with every completed task
in the company.

Refs #17973 (same UNION for the unread badge).

## What Changed

- Extracted the participation UNION from `unreadInboxIssueIds` into
`mineParticipationIssueIds()`. The unread badge uses it unchanged.
- New `IssueFilters.touchedCandidates: "participation"`. When it is set
with `touchedByUserId` (and there is no search and no
`oneChildPerParent`), `list`:
1. replaces `touchedByUserCondition` with `issues.id IN (<participation
UNION>)`. Each UNION member is one OR branch of the old condition, so
the set is the same;
2. runs a narrow `candidates` query (`SELECT id … WHERE <all other
filters> ORDER BY <same order> LIMIT/OFFSET`);
3. loads the full projection for only those ids (re-checking company,
visibility and the read condition), and restores the phase 1 order.
- Only the Mine route sets the flag. Every other `svc.list` caller keeps
the old SQL.
- A new Server-Timing phase, `inbox_issues_candidates`.
- `server/src/__tests__/inbox-mine-candidates.test.ts`: a
result-equivalence test.
- `scripts/benchmark-inbox-mine.ts`: a local benchmark that seeds a
large fixture, compares full payloads, and records `EXPLAIN (ANALYZE,
BUFFERS)` per query.

### Not changed, with evidence

- **Recomputed comment/activity maxima** (`lastActivityStatsForIssues`):
0.9 + 0.2 ms per call in the plans. Not worth a second code path.
- **Touched condition inside `userCommentStatsForIssues`**: 1.4–2.1 ms.
Not changed.
- **`liveDescendantCountMapForIssues`**: 12 ms at 30k issues and 40 ms
at 90k. It now costs about as much as the list query. It scans all live
runs and hashes all company issues. It is company-wide work, outside the
candidate selection, so I left it for a separate change.

## Verification

### Correctness

`inbox-mine-candidates.test.ts` runs the old scan and the new path on
the same data. It asserts equal ordered ids and equal full JSON
payloads. Fixture: 170+ touched issues (more than one page, with tied
timestamps) plus these edge cases:

- included: created by me, assigned to me only, user activity, my direct
comment, `done`, pending request addressed to me, archived then
re-activated by a newer outside comment, read state with a later agent
comment, a live child (live descendant count);
- excluded: archived with no newer activity, mention-only, read audit
only (`issue.read_marked`), uppercase entity id, channel-sourced
comment, agent-created with my user id, `cancelled`, hidden, harness,
plugin operation, private to another user (`issueReadSqlCondition`),
answered request, untouched issues, another company.

It also compares other shapes: no archive filter, `status=done` with the
full projection, `offset`/`limit`, ascending order, unrestricted read
condition, a second user, and a user with no participation. Mutation
check: removing the comment source from the UNION makes the test fail.

The benchmark also asserts identical full payloads for every sample of
both arms.

Focused suites (local): `inbox-mine-candidates`, `inbox-mine-routes`,
`authorization-privacy-query`, `issues-unread-inbox`,
`inbox-archive-routes`, `issues-assignee-unread`,
`issues-creation-visibility` — all pass.

### Performance (local SQL measurements)

Command: `pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-inbox-mine.ts --baseline-ref=18c3f4ef7b
--issues=<N> [--touch-density=<d>]`. Baseline = 18c3f4ef7b, the #20408
head with the same timing hooks. Embedded PostgreSQL 18.1,
`shared_buffers` 128MB, `work_mem` 4MB, JIT on (not triggered), pool of
8. Alternating order, 2 warmups. Fixture per run: 12 activity rows and 6
comments per issue, 55% `done`, archives, read states, pending requests,
150 live runs.

| Fixture | Arm | `svc.list` median (min–max) | List query phase | Main
query plan |
| --- | --- | --- | --- | --- |
| 30k issues, user touched ~1.1k | before | 47.8 ms (41–66) | 30.0 ms |
28.1 ms exec, 33.7k buffers |
| | after | 39.4 ms (36–42) | 19.4 + 4.2 ms | 16.0 ms exec, 22.1k
buffers (+1.8 ms rows) |
| 90k issues, user touched ~3.4k | before | 124.4 ms (113–180) | 71.1 ms
| 70.8 ms exec, 103.9k buffers |
| | after | 102.9 ms (88–118) | 43.1 + 3.8 ms | 41.7 ms exec, 72.7k
buffers |
| 30k issues, user touched ~11k (dense) | before | 135.0 ms | 119.2 ms |
112.6 ms exec, 128k buffers |
| | after | 136.6 ms | 108.7 + 4.9 ms | 101.9 ms exec, 144k buffers |

- The old plan scanned 27,993 status-matching issues to test
participation. The new plan reads the participation UNION (1,155 ids at
30k; 3,448 at 90k) and probes `issues_pkey`.
- **Neutral case:** when a user has touched most issues (dense row), the
UNION does not shrink the set. The per-candidate privacy check, archive
visibility and activity maxima dominate. I report this as neutral, not
as a gain.
- On a loaded machine (a parallel test run), the same 30k fixture took
129 ms before and 55 ms after. Planning time of the old query rose to 27
ms there. I report the quiet-machine numbers above as the result.
- I did not get query evidence from staging's database. This session had
no Neon or CloudOps access (no `neonctl`, no stack credentials), so all
numbers are local. #20408 adds the per-phase header, which can confirm
the effect on staging after deploy.

### Pre-PR checks

- `tsc --noEmit -p server`: no errors in the changed files.
- `pnpm verify --explain` selects 2,417 of 3,204 test files for this
change, because `services/issues.ts` is imported almost everywhere. I
did not repeat that full suite locally (AGENTS.md §7); required CI
supplies it.

## Risks

- **Equivalence depends on the UNION matching
`touchedByUserCondition`.** Each branch has a UNION member:
human-created by me, assigned to me, pending request addressed to me, my
participation activity (canonical lowercase UUID ids only, the same as
the old `= issues.id::text` join), and my direct human comments. The
equivalence test pins these. A future change to `touchedByUserCondition`
must also change the UNION. Both live next to each other, and the unread
badge already depends on the same equality.
- **Two reads instead of one.** A row can change between the candidate
query and the row query. The row query re-checks company, visibility and
read access, so a row that became unreadable is dropped. The page can
then have fewer than 100 rows for that one response.
- Scope: only the Mine route sets the flag. No migration.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, embedded PostgreSQL benchmarks). Standard
context window; extended thinking not used.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:17:03 -06:00
DottaandClaude Opus 5.5 0116e45958 perf(db): index pending budget Stops instead of scanning all run results (#20391)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Budget enforcement stops agent runs when a budget hard limit is
reached. The stop intent is saved in each run's `result_json` until run
control delivers it.
> - The budget worker, run control, and the idle-sleep check find these
pending stops with a predicate on `result_json`. No index supports that
predicate.
> - Each check therefore reads every heartbeat run and its large result
JSON from TOAST. On a hosted database, this statement family was among
the most expensive: about 8,000 calls at about 1 s each, about 9,800 s
in total.
> - Pending stops are rare. The predicate is constant, so a partial
index can contain only those rows.
> - This pull request adds that partial index. The SQL that the server
sends does not change.
> - The benefit is that each check reads a few index entries instead of
the full table. The database has more capacity for request work.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The check for committed budget Stops that are not delivered yet. The
budget worker runs it each time it looks for work
(`hasPendingBudgetEnforcement`), and again for each delivery pass
(`listPendingBudgetCancellationScopes`). Run control and the idle-sleep
check use the same predicate.

**Subsystem affected**
Budget enforcement in the server
(`server/src/services/heartbeat/budget-cancellation.ts`,
`server/src/services/budgets.ts`,
`server/src/services/heartbeat/run-control.ts`,
`server/src/services/idle-sleep-safety.ts`) and the `heartbeat_runs`
schema in `packages/db`.

**Current behavior**
The query groups all rows of `heartbeat_runs` and reads
`result_json->'startupCancellation'->'budgetEnforcement'` for each row.
A limit of 1 does not help, because the group and sort occur before the
limit. Cost grows with the run history, not with the number of pending
stops.

**Proposed behavior**
A partial index, `heartbeat_runs_pending_budget_cancellation_idx`,
contains only runs with a pending budget Stop. The planner uses it for
all four callers. The results are the same.

**Reason and benefit**
Local SQL measurements show a 300x to 1,000x decrease in time and buffer
reads (see Verification).

**Breaking changes**
None.

## What Changed

- Added the partial index
`heartbeat_runs_pending_budget_cancellation_idx` on `heartbeat_runs
(company_id)`. Its predicate is identical to
`pendingBudgetRunCancellationPredicate`.
- Added migration `0455_pending_budget_cancellation_index.sql`. It uses
`CREATE INDEX CONCURRENTLY IF NOT EXISTS`, and it is in the list of
migrations that run outside a transaction, as `0436` is.
- Moved the scoped run-control read and the idle-sleep check into
exported builders in `budget-cancellation.ts`, next to the scope list
query. The SQL does not change. The callers and the test now share the
same statements.
- Added a real PostgreSQL regression test. It checks that delivered,
malformed, and missing intents are not pending. It also EXPLAINs the
statements that the callers send (scope list with limit 1 and 100,
scoped read, `EXISTS` check) and requires the new index in each plan. If
a person changes a query shape, the server predicate, or the index
predicate, the test fails.
- Added `scripts/benchmark-budget-cancellation-scan.ts`. It captures the
SQL that the real service sends, then compares the plans, timings, and
complete rows with and without the index.
- Updated `doc/DATABASE.md`.

## Verification

Red/green regression test:

- Before the migration, the new test failed: the plan was `Seq Scan`
with the predicate as a filter.
- After the migration: `npx vitest run
src/services/heartbeat/budget-cancellation.test.ts` passes (17 tests).
- Also passed: `src/__tests__/budgets-service.test.ts`,
`src/__tests__/idle-sleep-diagnostics-postgres.test.ts`,
`src/services/budget-resume-continuation.test.ts`.
- `pnpm --filter @paperclipai/db check:migrations`, and typecheck of
`@paperclipai/db` and `@paperclipai/server` pass.

Performance and result equality (local SQL measurements only):

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-budget-cancellation-scan.ts --runs=20000 --payload-kib=16 [--reverse]
```

- Fixture: embedded PostgreSQL 18.1. 20,241 runs in 2 companies, with
incompressible result JSON of about 16 KiB (table 379 MB). 40 pending
stops in 6 scopes, 200 delivered or malformed decoys, and 1
cross-company intent.
- The SQL is the same in both treatments. The script toggles only the
new index. 2 warmups and 7 samples per query. Rows are compared outside
the timed part. Ordered queries must match in order. The scoped
run-control read has no `ORDER BY`, so it is compared as a multiset.

| Query (caller) | Baseline median | With index median | Shared buffers
|
| --- | --- | --- | --- |
| `listPendingBudgetCancellationScopes(db, 1)` (worker `hasPending`) |
688 ms | 2.1 ms | 243,500 → 1,158 |
| `listPendingBudgetCancellationScopes(db)` (delivery) | 799 ms | 1.4 ms
| 243,500 → 1,158 |
| Scoped pending runs (run control) | 357 ms | 1.1 ms | 121,835 → 322 |
| `EXISTS` pending (idle-sleep check) | 440 ms | 0.37 ms | 240,560 → 2 |

- Reverse order (index first) gave the same result: 524 ms → 1.6 ms, 464
ms → 1.4 ms, 306 ms → 1.0 ms, 306 ms → 0.42 ms.
- Plans change from `Seq Scan` (or a broad bitmap scan) to `Index Scan`
or `Index Only Scan` on the new index.
- All result rows are equal in every run.

Migration duration:

- 20,241 runs (379 MB): index build 2.0 s to 3.9 s.
- 200,241 runs with 8 KiB result JSON (2.1 GB): index build 5.6 s. With
this table size, the four queries change from 1,148 / 879 / 514 / 4,392
ms to 1.1 / 1.2 / 0.85 / 8.3 ms.
- The build is concurrent, so it does not block run writes.

## Risks

- Migration: the build reads every run's result JSON one time. It is
concurrent and resumable (`IF NOT EXISTS`). The executor repairs an
invalid concurrent index on retry.
- Write cost: run writes evaluate the index predicate. The index
contains very few rows, so the extra cost is small.
- Predicate drift: if a later change edits only one of the two
predicates, the planner cannot use the index. The new regression test
detects this.
- The hosted timings are not measured yet. The local numbers come from a
synthetic fixture.

## 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-09 18:14:52 -06:00
DottaandClaude Opus 5.5 ab80fea62f perf(recovery): probe run bindings per comment in the lost-wake check (#20412)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The ownership reconciler finds user comments whose wake a restart
lost, and it saves a wake for them (plan invariant 4)
> - Its lost-wake check matched a task's runs with one OR across the
legacy context ID and the native issue column
> - With that OR, PostgreSQL can join runs only by company. It then
tests each recent comment against every run of its company and reads
each run's context
> - On a hosted staging database, this statement used about 23 s per
call on average (384 calls, about 8,900 s in total). One call ran for 35
s. Other requests waited behind it
> - This pull request reads the check in three stages and probes the two
run bindings separately with existing indexes
> - The benefit is the same result with bounded, index-driven work for
each recent comment

## Linked Issues or Issue Description

**What happened?**

The reconciler's user-comment lost-wake check was one of the most
expensive statements on a hosted staging database. Query statistics
showed 384 calls with a mean of about 23 s. One active call was seen at
35 s. The database compute was busy, and short authentication reads
waited behind long statements.

The statement matched runs with `(context_snapshot->>'issueId' = issue
OR native_issue_id = issue)` in three subqueries. The OR gives
PostgreSQL no join key except `company_id`. A local plan on synthetic
data showed the cost: a hash join of 273 comments against all 38,002
runs, which took 1.0 s of a 1.3 s statement. The work grows with recent
comments multiplied by the company's run count, and each test reads a
run context.

**Expected behavior**

Each recent comment uses a small number of indexed probes. The check
returns the same comments in the same order, with the same limit and
grace logic.

**Steps to reproduce**

1. Seed a company with a deep run history and a few hundred recent user
comments.
2. Let some recent comments have no wake (for example, after a restart
lost the wake of a steered comment).
3. Run the check. PostgreSQL can pick a hash join on `company_id` with
the OR as a join filter. With nested loops off, the old statement takes
seconds. The local benchmark below shows this.

**Paperclip version or commit**

Baseline: `02bfe71a6b`.

**Deployment mode**

Server with PostgreSQL. Local proof uses disposable PostgreSQL 18.1.
Production and staging use hosted PostgreSQL.

Related: the earlier change that split native and legacy bindings for
other ownership reads (see the "Execution recovery query performance"
section in `doc/DATABASE.md`). A search found no open pull request for
this check.

## What Changed

- `checkUserCommentsWithoutWake` reads in three materialized stages:
1. Recent user comments on assigned, non-conversation tasks that no wake
request carries. This stage keeps the same issue, status and
agent-closed rules.
2. Of those, comments with no live or later run and with at least one
earlier run.
3. Of those, comments that no live run acknowledged in an item event
(the steer check). Only this stage reads event payloads.
- Each run lookup is two probes: one on the legacy context ID and one on
the native issue column. Both use existing indexes. `exists (A or B)`
becomes `exists (A) or exists (B)`, which is logically the same.
- Each probe ends with `offset 0`. This keeps it a per-row index lookup.
Without it, PostgreSQL can build a hashed subplan over all runs, or a
hash join over all item events of the company. The repository already
uses this fence in `execution-blocker.ts`.
- The final `order by c.created_at limit N`, the projection, the grace
window and the reported reason do not change.
- Add `scripts/benchmark-comment-wake-check.ts`. It loads the check from
an explicit baseline commit and from the working tree, and it compares
the complete service result and the complete positional SQL rows before
it records time.
- Add a regression test for each run binding on its own: native only,
legacy only, live run, later run, steered run, an acknowledgement before
the comment, a log-only mention, a run that ended before the comment,
and a run in another company.
- Document the check and the benchmark command in `doc/DATABASE.md`.
- No schema change, index, migration, or new startup work.

## Verification

- `pnpm exec vitest run
server/src/__tests__/ownership-reconciliation-report.test.ts
--maxWorkers=1`: 34 passed. This includes the existing lost-wake,
restart, steered and agent-closed tests and the new binding test.
- `pnpm --filter @paperclipai/server typecheck`: passed.
- `pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-comment-wake-check.ts --baseline-ref=02bfe71a6b
--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 so
contexts and payloads do not compress well. There are two companies per
fixture. Two warmup rounds, then 7 timed samples for each arm, in
alternating order. Each sample runs the real check function. Before any
timing, each fixture asserts that the complete check result and every
positional SQL row are equal for baseline and treatment, and that the
fixture reports at least one owed comment. Every timed sample also
asserts the same result.

| Fixture | Runs / recent comments / owed | Baseline median | Treatment
median | Speedup | Hash-join plan, baseline → treatment |
| --- | --- | --- | --- | --- | --- |
| small | 2,541 / 50 / 6 | 8.5 ms | 2.6 ms | 3.2x | 19 ms → 3 ms |
| representative | 75,502 / 750 / 75 | 26.4 ms | 23.3 ms | 1.1x | 2,262
ms → 17 ms |
| lost steer wakes | 38,002 / 750 / 75 | 454.7 ms | 66.9 ms | 6.8x |
2,717 ms → 68 ms |
| deep history | 150,750 / 1,125 / 114 | 26.9 ms | 18.8 ms | 1.4x |
4,006 ms → 34 ms |
| busy hour | 128,750 / 5,625 / 200 (limit) | 79.2 ms | 47.4 ms | 1.7x |
20,047 ms → 75 ms |
| long steered runs (stress) | 19,100 / 600 / 60 | 626.4 ms | 457.4 ms |
1.4x | 5,871 ms → 502 ms |

- The table above used baseline `02bfe71a6b`, the branch base at that
time. After the rebase, a repeat run with baseline `8004ad069e` (the new
merge base, with the helper guard fixed to run from the repository root)
gave the same picture on a busier machine: small 15.4 → 9.1 ms, lost
steer wakes 1,733 → 139 ms, busy hour 103 → 65 ms, all with equal
results.
- The "lost steer wakes" fixture shows the slow plan with default
planner settings. PostgreSQL hashed all runs by company and tested 273
comments against 38,002 runs.
- The last column runs each statement once with `enable_nestloop = off`.
This makes the company-only hash join that the old statement allows
visible, and it shows that the new statement has no such plan. The
result is checked as equal in this mode too. Staging's 23 s mean matches
this growth: recent comments multiplied by the company's run count.
- Shared buffers fell in every fixture except "small" with default
settings (for example, busy hour 176,416 → 89,803).
- Neutral path: in the "long steered runs" stress fixture, every steered
comment lost its wake. These comments correctly reach the steer check.
That check must read the payload of each item event after the comment in
the run that was live, so it is the remaining cost. In normal operation
a comment sent to a live run has a deferred wake that carries it, so
stage one removes it.

## Risks

- Low risk. The predicate is the same conjunction of conditions. Only
the evaluation order and the probe shape change, and the benchmark
asserts complete-row equality on six fixtures and in both planner modes.
- Rows with the same `created_at` have no defined order, as before. The
benchmark uses distinct times so positional comparison is meaningful.
- `offset 0` stops PostgreSQL from turning these probes into joins. If a
task someday has a very large number of runs, each probe still uses the
company and issue indexes.
- Upgrade safety: this change reads saved run, wake and event state
only. It changes no persisted shape, run identity, or saved session, so
state saved by the previous release carries forward unchanged. A cutover
test is not applicable.
- Not verified on the hosted database. The local proof uses synthetic
data, and I did not capture a hosted plan. Confirm with a fresh query
statistics window after deployment.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use (shell,
file editing, 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
- [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-09 18:12:02 -06:00
DottaandClaude Opus 5.5 c985541a57 perf(db): index retry-exhaustion receipts for the attention scan (#20386)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The attention feed shows runs that failed after automatic retries
stopped
> - To find these runs, the feed reads every `heartbeat_run_events` row
of the company and filters the rows by a message prefix
> - Run output makes this table very large, so each feed request does a
large scan
> - On a hosted test database, this one statement used the most total
database time: 923 calls at a mean of 5 s (4,709 s in total)
> - Request authentication waits behind this background work, so all
users feel the load
> - This pull request adds a small partial index that holds only the
retry-exhaustion receipts
> - The benefit is that the same query reads a few index entries instead
of all company events

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The attention feed query that finds retry-exhausted runs
(`listAttentionExhaustedRuns`).

**Subsystem affected**
Server attention feed and the `heartbeat_run_events` table schema.

**Current behavior**
The query reads all company rows in `heartbeat_run_events`, filters them
by `event_type = 'lifecycle'` and the `Bounded retry exhausted` message
prefix, and then joins the few matches to failed or timed-out runs. The
cost grows with all run output. It does not grow with the number of
exhausted runs.

**Proposed behavior**
A partial index holds only the matching receipts. The query plan reads
the index and joins a few rows. The query text and the result do not
change.

**Reason and benefit**
The scan was the most expensive statement by total time on a loaded
hosted database. A local reproduction shows a 54x faster query and about
116x fewer buffer reads.

**Breaking changes**
None. The API, the feed rows, and their order do not change.

## What Changed

- Add the partial index `heartbeat_run_events_retry_exhausted_idx` on
`(company_id, run_id, id DESC)`. The index covers only rows where
`event_type = 'lifecycle'` and `message like 'Bounded retry
exhausted%'`.
- Add migration `0454`. It uses `CREATE INDEX CONCURRENTLY IF NOT
EXISTS`. The migration runner runs it as a top-level statement, the same
as migrations 0432, 0436 and 0450.
- Add a regression test. It pins the row order (newest run first, then
the newest receipt on a tie) and the latest receipt per run. It also
checks that the query plan can use the new index. If the query predicate
changes so that the index no longer applies, the test fails.
- Add `scripts/benchmark-exhausted-run-attention.ts`. It measures the
same query with and without the index, and the time to build the index.
- Document the index in `doc/DATABASE.md`.

## Verification

Cause and thesis. The scan cost comes from reading all company events,
not from the few exhausted runs. Thesis: an index that holds only the
receipts makes the query cost depend on the number of receipts, and
keeps the result identical. The benchmark below confirms the thesis.

Rejected alternative without a migration. I first tried a LATERAL lookup
that starts from the failed and timed-out runs. On the same fixture it
was about 2x slower (median 379 ms against 194 ms). It reads the full
event history of each failed run with random I/O, and that costs more
than one sequential scan.

Local SQL benchmark (the SQL that the service generates; same SQL with
and without the index):

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-exhausted-run-attention.ts --runs=30000 --events-per-run=100 --samples=9
```

Fixture (synthetic): one company with 30,000 runs (3,819 failed or timed
out) and 3.0 M events, plus a decoy company with 0.5 M events. The table
is 1.4 GB. About 5% of events are `lifecycle` events. Events of 20
concurrent runs are interleaved. 1 in 23 runs has a receipt, some runs
have two receipts, and output decoys contain the prefix. Two agents are
terminated. Embedded PostgreSQL 18.1 on an Apple laptop. The hosted
database is Neon with different compute, so the absolute times are local
SQL measurements only.

The script runs two cycles (without index, then with index; then with
index, then without index) to expose cache bias. Each measurement has 2
warmups and 9 samples.

| Query | Without index | With index | Samples and spread | Output check
|
| --- | --- | --- | --- | --- |
| `listAttentionExhaustedRuns` | median 157 ms (96–1,271 ms) | median
3.3 ms (2.2–5.7 ms) | 18 each | 133 rows, positional rows equal in all 4
measurements |
| Buffers (EXPLAIN ANALYZE) | 127,743 blocks | 5,580 blocks | | plan
uses the new index (asserted) |

Speedup: 48x (median), on head `ae6037b2a0` (the same SQL and index as
the final head). The machine was under heavy load from other work, so
the spread without the index is wide. An earlier run on the first head
gave 248 ms against 4.6 ms (54x). Index build (`CREATE INDEX
CONCURRENTLY` from the migration file): 1.7 s and 2.5 s for the 1.4 GB
table. Index size: 152 KiB.

Review fix: the query now writes `event_type = 'lifecycle'` as a SQL
literal, not a bound parameter. With prepared statements, PostgreSQL can
reuse a generic plan. A generic plan cannot prove that a bound parameter
matches the index predicate, so it would not use the index.

Tests:

- `cd server && npx vitest run src/__tests__/attention-service.test.ts`:
44 passed. The new test checks a fresh plan and a forced generic plan
(`plan_cache_mode = force_generic_plan` with `PREPARE`). It failed with
the bound `event_type` parameter and with a changed message prefix
(confirmed red), and it passes with the fix. Locally, the file's
`afterAll` database cleanup hook timed out on a heavily loaded machine
(load average about 28). The timeout also occurs when the new test is
excluded.
- `pnpm --filter @paperclipai/db check:migrations`: passed.
- `pnpm --filter @paperclipai/db typecheck` and `pnpm --filter
@paperclipai/server typecheck`: passed.
- `pnpm verify`: see the CI results for this head.

## Risks

- Migration time. A concurrent index build reads the full
`heartbeat_run_events` table and does not block writes. Locally, the
build took 1.2–2.5 s for 1.4 GB. The time grows linearly with the table
size. I did not time the build on a production-size clone. On a database
with a 5 s full company scan, I estimate the build at tens of seconds,
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 predicate must stay textually equal to the query predicate.
If someone changes the message prefix or binds a predicate value as a
parameter, the planner cannot use the index. The new test catches both
cases, for fresh and generic plans.
- Each new receipt writes one more small index entry. Receipts are rare,
so the write cost is very small.
- 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). Standard
context window and reasoning mode.

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:37:20 -06:00
DottaandClaude Opus 5.5 c57cd1bdb9 perf(server): read plugin lifecycle inbox heads in one pass (#20405)
## 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.
> - The inbox page query checked each unacknowledged event against all
older events of the same resource. Each check walked the acknowledged
history of that resource.
> - Agent runtime streams get a new event for each configuration change,
so one resource can have thousands of acknowledged events. The cost grew
as pending events times acknowledged history.
> - On a hosted instance, three copies of this read ran for 601 seconds
each, and one earlier copy ran for 19 minutes. The delivery worker runs
it again after each acknowledgment.
> - This pull request selects the head of each resource in one pass and
keeps the same result.
> - The benefit is that the page read is linear in the company journal
size. In local tests it is 250x to 660x faster, and the result rows are
equal.

## Linked Issues or Issue Description

**What happened?**
The plugin lifecycle inbox page query (`select "id", "company_id",
"resource_type", "resource_id", "action", "created_at" from
"resource_lifecycle_events" where ...`) ran for 601 seconds on a hosted
database. Three copies were active at the same time.

**Expected behavior**
A lifecycle page read must stay fast when a resource has a long
acknowledged history and a pending backlog.

**Steps to reproduce**
1. Give a company 40 agents with 1,500 `agent_runtime` events each.
2. Acknowledge the first 1,200 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 10 seconds for 60,000 events of that company. At
200,000 events it takes about 6 minutes.

**Paperclip version or commit**
`02bfe71a6b` (master).

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

## What Changed

- `server/src/services/plugin-lifecycle-inbox.ts`: `list` now selects
one head per resource with `DISTINCT ON (resource_type, resource_id)`.
The head is the unacknowledged creation, else the oldest unacknowledged
event. Then the query applies `afterId`, id order and the limit. This is
the same set that the old `firstForResource` predicate admits.
`acknowledge` keeps the old predicate for its single-event check.
- `plugin-lifecycle-inbox.ts`: new `hasPending(companyId, types)`. It
returns whether any unacknowledged event exists. Each resource with an
unacknowledged event has a head, so this gives the same answer as
`list(companyId, 1).length > 0`.
- `server/src/services/plugin-lifecycle-delivery.ts`: the idle-sleep
journal check uses `hasPending`. This keeps its early exit. A one-row
`list` page would read the full journal.
- `server/src/__tests__/resource-lifecycle-events.test.ts`: new database
test for heads across acknowledged history, a creation recorded after
transitions, acknowledged gaps, shared ids across resource types,
per-plugin progress, another company, `afterId` pages, limits,
`hasPending`, and the next head after an acknowledgment. The test also
passes on the old query, so it locks the contract.
- `scripts/benchmark-plugin-lifecycle-inbox.ts`: new benchmark. It loads
the baseline service from git, runs both on the same fixture, alternates
order, asserts equal rows, and prints plans.
- `doc/DATABASE.md`: describes the read cost and the benchmark command.
- No schema change, index or migration.

## Verification

Thesis: the old read cost is (unacknowledged candidates) x (acknowledged
older events per resource), because each candidate walks the
acknowledged prefix of its resource. One pass that keeps the
per-resource head removes that product and returns equal rows.

Baseline plan at 220,080 events: 40,020 candidates each walked about
3,999 older rows, which gave 160,039,980 acknowledgment probes and
562,140,833 buffer hits. The treatment plan reads the journal once with
a hash anti join and sorts the 40,020 unacknowledged rows (3,925 buffer
hits).

Local SQL timings (embedded PostgreSQL 18, Apple Silicon; event counts
include a 20,000-event second company, synthetic data, warm cache, one
warmup, order alternates):

| Fixture (events / acknowledged) | Case | Before median | After median
| Samples (after) | Speedup |
| --- | --- | --- | --- | --- | --- |
| 40,060 / 16,040 | page of 100, all types | 3,263 ms | 13.1 ms | 3
(12.0-14.1) | 250x |
| 80,080 / 48,060 | page of 100, all types | 10,137 ms | 23.9 ms | 5
(22.7-32.6) | 424x |
| 220,080 / 160,060 | page of 100, all types | 355,718 ms | 538 ms | 3
(72-785) | 662x |
| 1,020,080 / 960,060 | page of 100, all types | not run (est. > 30 min)
| 1,682 ms | 7 (1,203-2,384) | - |
| 80,080 / 48,060 | page of 100, agent and project only | 1.5 ms | 1.6
ms | 5 | neutral |
| 80,080 / 48,060 | idle-sleep check | 14.5 ms | 13.8 ms | 5 | neutral |
| 1,020,080 / 960,060 | idle-sleep check | not run | 406 ms | 7 | - |

- Result rows are equal (`assert.deepEqual` on all mapped rows, in
order) for each case where both versions ran.
- The agent runtime revision read in `plugin-agent-runtime-state.ts` and
`agent-file-store.ts` (`select id ... order by id desc limit 1`) uses a
backward index-only scan and reads 5 buffers at 1M events. It is not
changed.

Commands:

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=02bfe71a6b --agents=40 --per-agent=1500 --acked=1200 --samples=5
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-plugin-lifecycle-inbox.ts --baseline-ref=02bfe71a6b --agents=40 --per-agent=25000 --acked=24000 --samples=7 --treatment-only
cd server && npx vitest run src/__tests__/resource-lifecycle-events.test.ts src/__tests__/plugin-lifecycle-delivery.test.ts src/__tests__/agent-runtime-hooks.test.ts
```

- `resource-lifecycle-events.test.ts`: 28 passed.
`plugin-lifecycle-delivery.test.ts`: 40 passed (includes idle-sleep
"none" and "present"). `agent-runtime-hooks.test.ts`: 22 passed.
- `tsc --noEmit -p server`: pass.

## Risks

- The page read is still linear in the company journal size, because
acknowledgments are per plugin and the journal has no retention yet. At
1M events a page takes about 1.7 s locally. Each acknowledgment wakes
another full pass. A per-plugin cursor or retention can remove this
later.
- The planner can choose a different plan on the hosted database. The
local plans use a hash anti join and one sort. The hosted improvement is
not yet measured.
- `acknowledge` still checks one event against older events of its
resource. That check is linear in that resource's history per call, as
before.
- Low behavior risk: the new test passes on both the old and new
queries.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:37:14 -06:00
DottaandClaude Opus 5.5 17014cd7db perf(server): make run liveness backfill lookup issue-scoped (#20393)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Each finished agent run gets a liveness classification. The task
page shows it, and recovery reads it.
> - Run finalization writes the classification. A backfill repairs runs
that do not have it, for example older runs or runs whose finalization
failed.
> - The task page runs read (`GET /issues/:id/runs`) starts this
backfill on each read, for each task.
> - The candidate lookup reads every unclassified finished run in the
company. For each row, it reads the run context and probes
`activity_log`. Rows of other tasks never match, so each read repeats
the same work.
> - On a hosted test instance, this lookup used approximately 1 s per
call, 2,636 calls, and 3,500 s in total.
> - This pull request makes the lookup read only the rows of the task,
and skips a task after a pass finds nothing left to repair.
> - The benefit is less database work per task page read. The
classification still gets written.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The run liveness backfill that the task runs read starts
(`scheduleRunLivenessBackfill` in `server/src/services/activity.ts`).

**Subsystem affected**

Server: activity service, task runs read, run liveness backfill.

**Current behavior**

Each task runs read runs this lookup:

```sql
select ... from heartbeat_runs
where company_id = $1 and liveness_state is null and status not in ('queued', 'running')
  and (context_snapshot ->> 'issueId' = $2
       or exists (select 1 from activity_log where company_id = $3 and entity_type = 'issue'
                  and entity_id = $4 and run_id = heartbeat_runs.id))
limit 20
```

PostgreSQL reads all unclassified finished runs of the company through
`heartbeat_runs_company_liveness_idx`. It then reads the context of each
row, which can be large, and probes `activity_log` for each row.
Unclassified runs of other tasks, and runs without a task, stay
unclassified, because no read of their own task repairs them. Thus the
cost grows with the company, not with the task. The lookup also runs
again on each read when the task has nothing to repair.

**Proposed behavior**

- The lookup reads only the runs of this task. One branch uses
`heartbeat_runs_company_ctx_issue_created_idx` (context `issueId`). The
other branch uses `activity_log_company_entity_created_idx` (issue
activity rows with a run id). The result keeps the same filter: same
company, liveness missing, status not `queued` or `running`, at most 20
rows.
- When a pass finds fewer than 20 rows, it has repaired all rows of the
task. The service then skips the task for 10 minutes. A full batch of 20
does not start the skip window, so the next read repairs the next batch.

**Reason and benefit**

The lookup is now proportional to the history of one task. Most reads
find nothing, and those reads now do no database work for 10 minutes.

**Breaking changes**

None. No migration, no API change, no new background work.

**Additional context**

The chat bootstrap read already skips the backfill
(`skipLivenessBackfill`). That option still works, and a test covers it.

## What Changed

- `server/src/services/run-liveness-backfill-query.ts`: new
`missingRunLivenessCandidatesQuery`. It replaces the `OR` and the
correlated `exists` with `id in (context branch union all activity
branch)`. Both branches are filtered by company and task.
- `server/src/services/activity.ts`: the backfill uses the new query and
returns the number of rows it found. `scheduleRunLivenessBackfill` keeps
a bounded in-memory map (at most 10,000 tasks, oldest entry is removed
first) of tasks with nothing left to repair. It skips these tasks for 10
minutes.
- `server/src/__tests__/activity-service.test.ts`: four new
real-database tests.
- The backfill repairs runs found by context and runs found only by
issue activity. It does not change a running run, an unclassified run of
another task, or a run of another company that names the same task id.
- After a pass leaves nothing to repair, more reads do not repair a new
unclassified run. After the skip window ends, the next read repairs it.
- A task with 25 unclassified runs (more than one batch) gets all of
them repaired over later reads.
- A read with `skipLivenessBackfill` does not repair. The next normal
read does.
- `scripts/benchmark-run-liveness-backfill.ts`: a local SQL benchmark
that compares the pre-fix query with the new query.

## Verification

Local SQL benchmark (embedded PostgreSQL 18.1, macOS, synthetic data):

```sh
cd server
pnpm exec tsx ../scripts/benchmark-run-liveness-backfill.ts --baseline-ref=02bfe71a6bbd7c5676cb7e72778b361c5bc72765
```

- Baseline: `02bfe71a6b` (pre-fix master). Treatment: `80fcfcf7f1`,
clean tree.
- Fixture assumptions (staging row counts were not available): one
company with 30,000 classified runs, 6,000 unclassified finished runs of
other tasks or of no task, each with an approximately 20 KiB hex
context, 400 other tasks, and 300,000 `activity_log` rows (half with a
run id). The target task has 40 classified runs, 200 activity rows, and
the missing rows of each case. Decoys: one `running` run of the task,
and one run in another company that names the task id. A small control
uses 500 classified and 50 unclassified runs.
- Each arm: 2 warm-up runs and 11 measured runs, in alternating order.

| Case | Candidates | Baseline median (min to max) | New median (min to
max) | Speedup | Shared buffers (baseline to new) |
| --- | --- | --- | --- | --- | --- |
| Task with nothing to repair | 0 | 37.2 ms (31.1 to 44.1) | 1.21 ms
(0.42 to 1.79) | 30.8x | 34,247 to 138 |
| Task with 3 missing | 3 | 37.8 ms (27.2 to 39.6) | 1.16 ms (0.77 to
2.05) | 32.6x | 34,265 to 148 |
| Task with 25 missing | 25 | 37.5 ms (34.6 to 69.8) | 1.63 ms (1.06 to
3.47) | 23.0x | 34,361 to 154 |
| Small control | 3 | 0.79 ms (0.70 to 1.06) | 0.70 ms (0.48 to 1.70) |
1.1x (neutral) | 314 to 18 |

The `EXPLAIN ANALYZE` execution time for the large cases goes from
approximately 35 ms to approximately 0.15 ms. These are local SQL
measurements. The hosted latency improvement is not yet measured.

The benchmark also checks result equality. For each case, it runs both
queries without a limit and compares every column of every row (as
sorted positional rows). It also checks that the limited batch of 20
comes from the baseline candidate set. All cases match.

Beyond the query, the skip window removes the lookup for most reads of a
task with nothing to repair.

Tests:

```sh
cd server
pnpm exec vitest run src/__tests__/activity-service.test.ts   # 24 passed
pnpm exec vitest run src/__tests__/activity-routes.test.ts src/__tests__/task-cold-open.test.ts src/__tests__/liveness.test.ts
pnpm exec tsc --noEmit -p .
```

- The skip-window test fails on the pre-fix `activity.ts` (red), and
passes with this change (green).
- `pnpm verify` asks for broader coverage, because the change adds a
script. Required CI supplies that coverage.

## Risks

- A run that misses its classification after a pass finds nothing for
its task gets repaired up to 10 minutes later. Normal runs get their
classification at finalization, so only the repair path is late.
- The skip map is in memory for each server process. A restart clears
it. That only causes one more lookup for each task.
- The new query keeps the same rows, filter, and limit. The query has no
order, as before. Thus the 20 rows that a pass picks can differ when a
task has more than 20 candidates. Later reads repair the rest in both
versions.
- Low risk. No migration and no API 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-09 17:37:08 -06:00
DottaandPaperclip 282503b5b3 fix(ci): prepare native binaries for Runner chaos checks
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Release verification checks that Runner preserves work through
faults and shutdown.
> - A governed-wait recovery case now runs a real native Runner and a
controlled Codex process.
> - That case needs the workspace debug binaries before the chaos suite
starts.
> - The chaos workflow builds JavaScript contracts but does not build
those native binaries.
> - A clean release worker fails before it can verify recovery.
> - This pull request builds the native prerequisites with the existing
package command before the tests.

## Linked Issues or Issue Description

Refs #20336, which added the real-Runner recovery case. Refs #20322,
whose normal delivery exposed the missing prerequisite. Existing native
test preparation and related chaos PRs were checked; no duplicate
preparation repair was found.

**What happened?**

The [Cloud readiness chaos
job](https://github.com/paperclipai/paperclip/actions/runs/42686564449/job/129397518634)
failed on Core `c67909cb0f1fa973e0991ba068d9c8b2f8f64924`. The
governed-wait recovery test tried to open
`packages/paperclip-runner/runner/target/debug/paperclip-runnerd`, which
did not exist. The job had installed dependencies and built eval
contracts. It had not built native workspace binaries.

**Expected behavior**

The chaos job builds its native prerequisites before it starts the
Runner fault tests. A build failure must fail the job. The existing
recovery assertions must still execute.

**Steps to reproduce**

1. Use a source checkout with JavaScript dependencies and contracts
prepared but no Rust debug workspace binaries.
2. Run `src/native-session-runtime.test.ts` with the test name `settles
a governed wait through real Runner shutdown after a missing Codex
terminal`.
3. Observe a missing native artifact. On the hosted job it was the
daemon. In the local reproduction, canonical JavaScript preflight had
already built a release daemon, so the missing artifact was debug
`fake-codex-app-server`.
4. Build the native workspace binaries and run the unchanged case again.

**Paperclip version or commit**

Hosted failure: `c67909cb0f1fa973e0991ba068d9c8b2f8f64924`. This branch
is based on fresh master `8a6b08227484591a30a7adea3840f7e96755ed81`.

**Deployment mode**

GitHub Actions source readiness and reusable Runner chaos verification.

## What Changed

- Run the existing `pnpm --filter @paperclipai/paperclip-runner
build:rust` command before the Runner fault suites. This builds the
locked native workspace binaries, including the daemon and controlled
Codex server.
- Extend the existing release-workflow contract tests to require
unconditional native preparation after dependency installation and
before the real-Runner suite.

## Verification

- The new workflow contract failed against the previous job setup. It
passed after adding the prerequisite.
- The unchanged governed-wait case failed with a missing debug
fake-Codex binary before native preparation: 1 failed, 251 intentionally
skipped, 2.92 seconds.
- Complete canonical worktree preflight prepared the debug binaries in
10.8 seconds. The unchanged real-Runner case then passed: 1 passed, 251
intentionally skipped, 2.03 seconds. It verifies saved work, accepted
handoff, confirmed provider exit, durable state and a single admitted
turn.
- After rebasing onto refreshed master, the exact case passed again in
**1.84 seconds**. The three affected workflow contracts also passed.
- **412 existing workflow, routing and native-cache contract tests
passed** in 26.58 seconds. Their input files did not change in the
rebase.
- `actionlint -oneline -shellcheck= -pyflakes=` and `git diff --check`
passed.
- The test uses the real local Rust Runner and a controlled local
provider. It does not call a live model or modify a hosted instance.
- The full local chaos suite was not run. Hosted execution of the
changed reusable chaos job remains a postmerge check.
- Required CI for final head `dd751732bff287dbeb05ddadd25c3370c817dd7a`:
[workflow attempt
2](https://github.com/paperclipai/paperclip/actions/runs/42689605454/attempts/2)
**passed** at 20:58:20 UTC on November 9: all 53 jobs accounted for, 52
successful and 1 skipped. The fresh [Build
job](https://github.com/paperclipai/paperclip/actions/runs/42689605454/job/129419966076),
[server shard
4](https://github.com/paperclipai/paperclip/actions/runs/42689605454/job/129419970716),
and [final verify
aggregate](https://github.com/paperclipai/paperclip/actions/runs/42689605454/job/129431023346)
all passed. The exact-head check rollup and classic Snyk status are
successful, with no failures or checks still pending.
- Attempt 1 remains a nonpass: the clean public npm install requested an
AWS SDK package version before that version was published, and server
shard 4 exceeded an existing five-second completion poll before its test
retry collided with a retained fixed-name environment fixture. The
unchanged exact server case passed locally. One bounded failed-job retry
reran both failed leaves; neither the fixture nor assertions were
changed. The initial cause of the five-second completion miss has not
been established.
- Fresh exact-head Greptile review is **5/5** with no open inline
threads. Ponytail is **4/5, Ship**, with two explicitly nonblocking
suggestions: a separate native-build step deadline and tighter
workflow-job scoping in the contract test. The native build remains
bounded by the existing job deadline; the current contract proves
prerequisite ordering in the affected workflow. Both suggestions are
retained for follow-up rather than changing this final tested revision.

## Risks

- A cold native build adds compilation time and disk use to the chaos
worker. The existing job timeout remains 40 minutes on the protected AWS
route and 45 minutes on the hosted route.
- Native build failures stop the job. The test selection, assertions,
permissions and source-proof gates remain in force.
- This change addresses the observed missing artifacts. Earlier
runner-loss waves and the separate managed-credential assertion remain
nonpasses; this change does not establish their causes or repair them.

## Model Used

OpenAI Codex, GPT-6.1 Sol (`gpt-6.1-sol`), with reasoning, repository
tools and code execution. Context-window size is not exposed by the
runtime.

## Checklist

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

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-09 15:00:19 -06:00
DottaandPaperclip a3a25ce6c9 fix(workspaces): stop running Paperclip's worktree setup before every agent turn (#20269)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Before an agent turn starts, the harness prepares the task's git
worktree
> - The harness runs a repository's `scripts/provision-worktree.sh` when
the project sets no `provisionCommand` and the file exists
> - The harness runs it on every turn, both when it creates the worktree
and when it reuses it
> - In the Paperclip repository, that script writes the worktree's
isolated instance config and runs `pnpm install`
> - Its "install needed" check compares against every `node_modules`
directory in the base checkout, including other worktrees, so it
reinstalls on every turn
> - This pull request renames the Paperclip script so that the harness
default does not find it in this repository
> - The benefit is that Paperclip agent turns no longer wait on a 6–9
second install. Other repositories keep the harness feature.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
Time to first token for agent turns in managed worktrees of the
Paperclip repository.

**Subsystem affected**
Execution workspace provisioning
(`server/src/services/workspace-runtime.ts`) and the repository script
`scripts/provision-worktree.sh`.

**Current behavior**
Every agent turn in a Paperclip worktree first runs `bash
./scripts/provision-worktree.sh`. In a local test, a `pnpm install
--frozen-lockfile` with nothing to change took 6.1 s and 6.3 s, and a
full install from a warm store took 9.0 s. The script's fingerprint skip
does not prevent this, because the script also requires every
base-checkout `node_modules` path (up to depth 4) to exist in the
worktree. On a developer machine that list had 1,735 entries, including
other worktrees under `.claude/worktrees/` and `.worktrees/` and pnpm's
internal `node_modules/.pnpm/*/node_modules`. A managed worktree never
contains the other worktrees' paths, so the script always decides to
reinstall.

**Proposed behavior**
The Paperclip repository does not ship a file at
`scripts/provision-worktree.sh`. The setup script becomes
`scripts/setup-paperclip-worktree.sh`. People can run it by hand or set
it as the project `provisionCommand` to opt in. The harness default
stays for other repositories.

**Reason and benefit**
Agents install dependencies when they need them (`node
scripts/agent-preflight.mjs`, as `AGENTS.md` says). Many turns never run
code. Instance setup stays available on demand:
`scripts/provision-worktree-runtime.sh` runs the setup script when a dev
server starts in a worktree that has no config. Database seeding was
already deferred.

**Breaking changes**
A project that explicitly sets `provisionCommand` to `bash
./scripts/provision-worktree.sh` for this repository will fail with
"file not found". Change it to `bash
./scripts/setup-paperclip-worktree.sh` or clear it.

## What Changed

- Rename `scripts/provision-worktree.sh` to
`scripts/setup-paperclip-worktree.sh`. The content is unchanged.
- `scripts/provision-worktree-runtime.sh` calls the new name when the
worktree has no config.
- Tests read the real script from its new path
(`provision-worktree-self-heal`, `workspace-runtime`,
`worktree-seed-server-spawn`). Server fixtures still copy it into their
test repositories as `scripts/provision-worktree.sh`, so the harness
default stays covered.
- `doc/DEVELOPING.md` says that the Paperclip repository deliberately
has no `provision-worktree.sh`, and tells people how to opt in.

## Verification

- `node --test scripts/__tests__/provision-worktree-self-heal.test.mjs`:
33 passed, 1 skipped (the skip already existed).
- `cd server && npx vitest run src/__tests__/workspace-runtime.test.ts
src/__tests__/heartbeat-workspace-source-trust.test.ts
src/__tests__/execution-workspace-reopen.test.ts`: 242 passed on the
rebased head.
- `cd server && npx vitest run
src/__tests__/worktree-seed-server-spawn.test.ts`: 2 passed.
- Manual: in a Paperclip worktree, start an agent turn. The run log does
not show a `workspace_provision` operation for the script.

## Risks

- Explicit `provisionCommand` settings that name the old path will fail.
See "Breaking changes".
- Open pull requests that edit `scripts/provision-worktree.sh` (for
example #16213, #11335, #11733, #19764) will conflict on the rename. Git
rename detection should carry most edits over.
- An agent that runs tests in a new worktree without first running
preflight will see a missing-module error. `AGENTS.md` already tells
agents to run `node scripts/agent-preflight.mjs`.
- The script's install check still has the bug described above. It now
runs only on demand. A separate fix can address it.

## Model Used

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

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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: Paperclip <noreply@paperclip.ing>
2026-11-09 14:03:24 +00:00
DottaandClaude Opus 5.5 051834bdc8 feat(chaos): add the on-demand crash-point suite and its manual CI workflow (#19946)
## Thinking Path

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

## Linked Issues or Issue Description

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

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

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

## What Changed

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

## Verification

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

## Risks

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

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:41:15 -06:00
DottaandPaperclip 57ae2fc309 perf(workspaces): reduce snapshot stream scheduling overhead (#19900)
Use bounded 1 MiB filesystem buffers during workspace archive hashing, encryption, and restore. Preserve integrity, ownership, cancellation, and snapshot format guarantees.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-11-07 14:24:42 -06:00
DottaandClaude Opus 5.5 b5b972ceb1 fix(runner): stop provider tool processes that leave the owned process group (#19727)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The local runner (`paperclip-runnerd`) supervises each provider
process, such as the ACPX sidecar for Claude, in its own process group
> - Run teardown stops that process group, and nothing else
> - Claude Code starts each Bash tool command in a new session
(`setsid`). A command such as `nohup ./bench.sh & echo $!` leaves a job
in that session, and the wrapper exits at once. The job is then a child
of init and outside the owned group
> - In a restart chaos storm, a run failed after a restart resume. Its
tool job kept running beside the task's next run, in the same workspace,
for more than four minutes
> - This pull request makes the supervisor record every process that its
provider starts, including processes that leave the group, and stop them
all before teardown reports success
> - The benefit is that a run's tools cannot outlive the run and cause
duplicate side effects in the next run

## Linked Issues or Issue Description

**What happened?**
A restart chaos storm ran a local ACPX Claude task. After a restart
resume, the agent ran `nohup ./bench.sh <nonce> > log 2>&1 & echo
"started pid $!"` and then ended its turn without a result. The run
failed with `native_finalization_missing`. The runner tore down the
sidecar group, and the guardian killed the provider group. `bench.sh`
was in the session that Claude Code made for the tool (pgid and sid =
the exited wrapper, ppid = 1). It was never signalled. It kept forking
`sleep 1` while the task's next run started and finished in the same
workspace.

**Steps to reproduce**
1. Run a local ACPX Claude task whose agent runs a Bash tool command
such as `nohup ./bench.sh x > log 2>&1 & echo $!`.
2. End the run (any outcome, including failure after a restart resume).
3. Run `ps -axo pid,ppid,pgid,command | grep bench.sh`. The job is still
running with ppid 1.

**Agent adapter(s) involved**
Claude Code (local ACPX). The gap is in the shared supervisor, so any
provider whose tools leave the group is affected.

**Root cause**
`SupervisedProcess`
(`packages/paperclip-runner/runner/crates/runner-core/src/process_supervisor.rs`)
spawns with `process_group(0)` and stops only that group in
`terminate_group`, `terminate_group_immediately`, `wait` and `Drop`. Its
own comment said that a caller that must exclude escaped or re-parented
descendants needs a separate lifetime fence. No caller had one. The ACPX
sidecar transport (`acpx_sidecar_transport.rs`, `shutdown` and `poison`)
and the ACPX provider guardian (`installation-integrity.ts`, which kills
only its own group) therefore left every `setsid` tool job behind. The
failure path after the resume was not special: the same gap applies to
every teardown.

**Expected behavior**
When a run ends for any reason, every process that its provider or tools
started stops, and teardown proves that they are gone. Identity comes
from PID, group, session and start time, never from a command name.

## What Changed

- Add the `paperclip-process-table` crate. It reads PID, parent, process
group and start time for every process (libproc on macOS, `/proc` on
Linux). `runner-core` forbids unsafe code, so the two checked libproc
calls live in this small crate.
- Add `process_lineage.rs` to `runner-core`. A sampler thread (every 50
ms) records each descendant of the supervised process by PID and start
time. It also records each group and session that a descendant leads,
with the leader's start time. A process belongs to the lineage when its
parent belongs, or when it is in a recorded group or session and started
no earlier than its leader. Groups and sessions that the supervised
process only inherited (runnerd's session) are never recorded. A
recorded ID whose PID now names a different process is dropped.
- `SupervisedProcess` samples the lineage just before it signals, and
after the leader is reaped it stops every live member: TERM, then KILL
after the shutdown grace. It fails with an error until it proves that no
member is alive (grace plus 5 s). This applies to `terminate_group`,
`terminate_group_immediately`, `wait` and `Drop`.
- If the lineage sampler cannot start, the supervisor stops the process
it just started and reports the failure, so a provider never runs
without tool cleanup.
- Table reads and state updates happen under one lock, so an older read
cannot erase members that a newer read recorded.
- The process table reports processes that exist but cannot be read. A
recorded member that cannot be read stays unresolved, and teardown fails
until its exit is proven.
- The Runner workspace now has two members.
`scripts/runner-release-crate-cache.mjs` and
`scripts/runner-debug-crate-cache.mjs` cache and mark current the
process-table lib unit with the Runner crate (otherwise Cargo rebuilds
the Runner crate after every restore). The debug contract accepts only
this library-only member and still refuses any other workspace change.
The workflow cache path lists and the debug clean step are updated to
match.
- `terminate_group_immediately` now accepts `EPERM` from the group
signal when the leader has already exited. macOS returns `EPERM` for a
group whose only member is the unreaped leader. The lineage pass then
proves that no other member remains.

## Verification

- `cargo test -p paperclip-runner-core --lib -- process_lineage tool_job
immediate_group_fence` (macOS, local).
- New real-process tests in `process_supervisor.rs`: a supervised shell
starts a wrapper with `setsid` (Perl `POSIX::setsid`), the wrapper runs
`nohup <loop> &`, writes the job PID and exits. The test asserts that
the job is outside the owned group and re-parented, then calls
`terminate_group`, `terminate_group_immediately` or drops the process,
and asserts that the job is gone when the call returns.
- Red: with the tracker disabled, all three fail with `tool job <pid>
outlived its provider`.
  - Green: all three pass.
- Unit tests in `process_lineage.rs` cover a re-parented job, a job that
left its group but stayed in the recorded session, a session made after
the first sighting, PID reuse, and no claim through the supervisor's
inherited session.
- `node --test scripts/__tests__/runner-release-crate-cache.test.mjs
scripts/__tests__/runner-debug-crate-cache.test.mjs` (new case: the
process-table library is cached with the crate, and any other member
still needs review).
- `cargo test -p paperclip-process-table` (includes an exited process
reported as gone), and `cargo test -p paperclip-runner-core --lib` (570
passed).
`acpx_provider_backend::tests::unconfirmed_suspension_state_becomes_recoverable_only_after_cleanup_and_suspend`
fails sometimes under full-suite load. It fails at the same rate with
the tracker disabled, so it is a flake that already existed.
- Sampler cost: one table read takes about 0.4 ms for 1,200 processes on
macOS (about 0.8% of one core for each supervised process).
- The restart-chaos replay (`--replay w-20261107T023921-s7106/t67`) did
not run. The local session was not permitted to load provider
credentials. It is not proven against a live Claude run.

## Risks

- Teardown now stops all the processes that a provider started, also
intentional background jobs. This is the intended contract: nothing
outlives the run.
- A wrapper that lives less than one sample (50 ms) and orphans a job
before the sampler sees it is not recorded. Claude Code's wrapper
sources a shell snapshot and lives for seconds. On Linux, a
child-subreaper would close this gap. That is not in this change.
- When `runnerd` itself is killed with SIGKILL, this code does not run.
The provider guardian still kills its own group, but escaped tool
sessions are not stopped in that case.
- Teardown can return a new error when it cannot prove the lineage
stopped. Callers already handle `terminate_group` errors as provider
shutdown failures.
- The server does not yet gate the next run on the tool lineage of the
prior run directly. It relies on the runner finishing its teardown
before the runner exits.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 08:24:41 -06:00
Nicky LeachandPaperclip beb3e55072 Continue a released Runner after confirmed graceful eviction (#19760)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Core can release a completed Runner while its host retains the
session.
> - A host can later retire that Runner under its retention policy.
> - Graceful retirement saves a suspended session before physical
shutdown.
> - A cached Core owner still expects the old physical task and rejects
a new allocation.
> - This pull request retires that dead local controller after verified
stop.
> - The benefit is ordinary session continuation for Codex and qualified
Claude.

## Linked Issues or Issue Description

Refs #19611, #19683, #19196, #19733 and #19157.

After a host gracefully evicts a released task, the next run can receive
a new allocation. A cached controller rejects it with
`native_session_released_owner_scope_mismatch`. The detached-controller
path can already recover the suspended session.

## What Changed

- Require the exact source, old task and current account/workspace scope
before releasing a cached controller.
- Use the existing physical-stop receipt checks. Detach only the local
controller, then use the same suspended-state admission as the detached
path.
- Preserve the current run's instruction reservation. Retain a failed
detach for cleanup.
- Add actual Core, PostgreSQL and native Runner tests for Codex and
qualified Claude through ACPX. Route them once through the native CI
lane.

## Verification

- The original real-entry Codex case failed at the cached-task scope
check after native retirement, confirmed process exit and a recorded
stop receipt.
- The current head's native CI lane passed all 16 eviction cases for
Codex and qualified Claude. These check cached and detached owners,
current instruction preparation, reused-allocation inspection, missing
stop/state, foreign identity and non-suspended state. They verify the
same provider session, two ordinary turns, one accepted source result
and no replacement submit on refusal.
- Local checks passed the composition matrix, 12 targeted executor
controls, 43 CI lane-selection checks and server TypeScript. All eight
refusal cases now assert the expected error and whether the checkpoint
request or host read was reached. This addresses the first standard
review's test finding.
- Commands: `pnpm exec vitest run
src/services/native-runtime/plugin-runner-eviction.integration.test.ts`,
focused `native-session-executor.test.ts` controls, `node
../node_modules/typescript/bin/tsc --noEmit -p tsconfig.json`, and `node
--test scripts/__tests__/run-vitest-stable-shard.test.mjs`.
- [Full
CI](https://github.com/paperclipai/paperclip/actions/runs/42349579107)
passed on head `9a25709ef43365b1f1849413b935785838eca19b`. [Standard
review](https://github.com/paperclipai/paperclip/pull/19760#issuecomment-6703629255)
is 5/5 on this head, with no unresolved findings. Whitespace and
added-line secret/PII checks passed.

## Risks

- Controlled provider processes and an authenticated local host fixture
model the Launcher edge. This does not qualify live vendor transcript
storage, mounts or a deployment.
- This covers graceful eviction before allocation or before stopped-task
inspection during `describe`. Loss after that inspection or an ambiguous
claim remains held for reconciliation. No unavailable reply proves
physical stop.
- Missing or invalid suspended state still refuses continuation. The
deterministic refusal rule from #19733 remains unchanged. Abrupt death
without a suspended checkpoint stays with the separate #19157 work.
- No new capability, mode, schema, dependency, deployment or activation
is added. The existing draft #19157 and dependent branches remain
unchanged.

## Model Used

OpenAI GPT-6.1 Sol (`gpt-6.1-sol`), high reasoning, through Codex with
repository tools and code execution. The runtime does not expose a
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-06 23:21:45 -08:00
Nicky Leach b21884bdcf Release and resume retained plugin Runner sessions in Core (#19611) 2026-11-06 20:45:02 -08:00
Nicky LeachandClaude Opus 5.5 fb3cf1a072 feat(ci): retry a failed Vitest case once in PR CI and report each retried pass (#19637)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors wait for PR CI before merge, so CI wall-clock time sets
the pace of work
> - PR CI runs Vitest shards with 4 workers on 4-vCPU runners, so
timing-sensitive tests sometimes time out under load
> - One such timeout fails the whole run, and a re-run costs another 15
minutes or more
> - In one 7-hour window on 2026-11-06, about 30 failed jobs were test
flakes on commits that passed on a later attempt
> - This pull request retries a failed Vitest case once in PR CI only,
and reports every pass that needed the retry
> - The benefit is fewer whole-run re-runs, while each flaky test stays
visible so that we can fix it

## Linked Issues or Issue Description

**What existing behavior does this improve?**

PR CI fails the whole run when one Vitest case fails once because of
runner load.

**Subsystem affected**

CI: the PR Vitest lanes (general, serialized, workspace and Runner
package lanes).

**Current behavior**

Vitest uses `retry: 0` everywhere. One timeout under load fails the run.
A person must notice it and re-run the failed jobs. About 20% of
recently merged PRs needed at least one re-run.

**Proposed behavior**

PR CI retries a failed Vitest case one time. A test that passes only on
the retry gets a GitHub warning annotation and a row in the job summary.
Local runs, release, cloud readiness and master keep `retry: 0`.

**Reason and benefit**

A re-run of one test case costs seconds. A re-run of the CI run costs 15
minutes or more. The report keeps flaky tests visible. Separate pull
requests fix the known flaky tests.

**Breaking changes**

None.

## What Changed

- Add `scripts/ci/vitest-retry.mjs`. It sets 1 retry when
`GITHUB_ACTIONS=true` and `GITHUB_WORKFLOW=PR`, and 0 in other contexts.
`PAPERCLIP_VITEST_RETRY` overrides the value.
- Add `scripts/ci/retry-reporter.mjs`. It writes a `::warning`
annotation and a step-summary table row for each test that passed only
on a retry.
- `scripts/run-vitest-stable.mjs` passes `--retry` to `vitest run`. The
`vitest list` collection step does not retry.
- The root `vitest.config.ts` and
`packages/paperclip-runner/vitest.config.ts` add the retry reporter
beside the existing reporters. The Runner config sets `retry` from the
same gate.
- `.github/workflows/pr-trusted.yml` reads the `CI_VITEST_RETRY`
repository variable. Set it to `0` to turn retries off without a code
change.
- Add `scripts/ci/retry-reporter.test.mjs` to `test:release-registry`,
and document the behavior in `doc/DEVELOPING.md`.

## Verification

- `node --test scripts/ci/retry-reporter.test.mjs`: 11/11 pass. The
tests cover the gate matrix, the reporter output, a real Vitest run with
a test that fails once, the wrapper arguments, and the Runner config.
- `node --test scripts/__tests__/run-vitest-stable-shard.test.mjs`:
39/39 pass.
- `node --test .github/scripts/tests/*.test.mjs`: 744/744 pass.
- Manual check: a temporary test that fails once ran through
`run-vitest-stable.mjs` and through the Runner package config. With the
PR environment it passed and wrote the annotation and the summary row.
With `GITHUB_WORKFLOW=Release` it failed. The temporary test is removed.

## Risks

- A retry can hide a flaky test. The annotation and the summary row
reduce this risk.
- A test that fails for a real reason now runs twice in PR CI, so a
failed run takes slightly longer.
- `pr.yml` loads `pr-trusted.yml` from master, so the `CI_VITEST_RETRY`
switch works only after merge. The retry itself is in the scripts and
starts with this pull request.
- Low risk otherwise: release and master verification are not changed.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and
code execution. A Claude sub-agent wrote the change, and the parent
session reviewed it.

## 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-06 16:43:02 -08:00
Nicky LeachandClaude Opus 5.5 8ed712f00a test(server): split workspace-recovery tests into seven feature files (#19664)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors wait for PR CI before merge, so CI wall-clock time sets
the pace of work
> - PR CI runs the general server tests in 12 shards, and each shard
runs files in parallel on 4 workers
> - `server/src/services/workspace-recovery.test.ts` takes about 699
seconds in CI by itself. A file runs whole on one worker, so shard 2/12
takes 11.5 minutes (median) when the other shards take about 7 minutes
> - A different shard count cannot fix this, because the time of one
file sets the minimum time of its shard
> - This pull request splits the file into seven feature files with a
shared fixture
> - The benefit is that the shards can balance again. This removes about
2 minutes from the median PR CI time and about 5 minutes from the p90

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The time of the general server test shards in PR CI.

**Subsystem affected**

Server tests: workspace recovery
(`server/src/services/workspace-recovery*.test.ts`) and the
general-server shard weights.

**Current behavior**

One file holds 460 tests and takes about 699 seconds in CI. Its shard is
the slowest in most PR runs.

**Proposed behavior**

Seven files of about 60 to 140 seconds each. The shard partitioner
places them on different shards.

**Reason and benefit**

The general server shards finish close together. The PR CI critical path
becomes shorter.

**Breaking changes**

None. No product code changes.

## What Changed

- Split `server/src/services/workspace-recovery.test.ts` into seven
files by feature area:
  - `retry-copyback` (76 tests);
  - `preparation` (80);
  - `preparation-proof` (53);
  - `preparation-dispatch` (25);
  - `preparation-gates` (43);
  - `warm-handoff` (36);
  - `admission` (147).
- Add `server/src/services/workspace-recovery.test-fixture.ts` with the
shared hooks, `seed`, helpers and an embedded Postgres per file.
  - Test bodies, hooks and helpers are moved without change.
- Four lines that assigned the shared `db`, `root` or `connectionString`
now call `replaceFixtureState(...)`.
- `scripts/general-server-shard-durations.json`: replace the old key
with the seven new files. The weights divide the current CI cost of the
old file (636 seconds, master runs 42296943853 and 42294279748) in
proportion to measured local time. A `splitEstimates` entry records the
method, as for earlier splits.
- Update the file references in `doc/task-recovery.md` and
`maps/feature-map/recovery.md`.

## Verification

- `vitest list`, before and after: 136 declarations each, with identical
sorted names. At run time, 460 tests before and after.
- Each new file passes when run alone: 76, 80, 53, 25, 43, 36 and 147
tests.
- All seven files run together with `--maxWorkers=1`: 460/460 pass.
- A timing check of the original file:
- `afterEach` took 4.7 seconds in total and `beforeAll` 4.1 seconds, out
of about 1,000 seconds, so the teardown is not the cost;
  - most of the time is in three `it.each` blocks.
- Focused `tsc --noEmit` on the eight new files: clean.
- `node --test scripts/__tests__/run-vitest-stable-shard.test.mjs`:
39/39 pass.
- `node scripts/check-maps.mjs`: aligned.
- With the new weights, the 12-shard partition puts the seven files on
seven different shards.

## Risks

- The CI time of each new file is an estimate from local runs. If
`workspace-recovery-preparation.test.ts` takes more than about 150
seconds in CI, the next split point is the "keeps ancestor Retry held"
block.
- Each file starts its own embedded Postgres, which adds a few seconds
of setup per file.
- The optional external-database environment variables keep their old
behavior but were not tested. CI does not set them.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and
code execution. A Claude sub-agent made the split, and the parent
session reviewed it and corrected the shard weights.

## 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-07 00:38:41 +00:00
DottaandPaperclip b8d94b3ad1 test(server): verify status and object deadlines across cold sleep (#19347)
## Thinking Path

> - Paperclip manages agents and their useful work.
> - Status cards and external objects can have future refresh deadlines.
> - A stopped tenant needs a durable Cloud alarm to return for those
deadlines.
> - A local quiet report alone does not prove that handoff or later
useful work.
> - Acceptance must exercise the normal producer, authorization, claims
and alarm owner.
> - This PR adds opt-in signed cold-owner acceptance and bounded
launcher controls.

## Linked Issues or Issue Description

Refs #19001 and #19191, which have merged. This PR targets master and
adds acceptance for the existing status and object owners.

**What happened?**

The existing local coverage did not join useful status generation or
built-in GitHub refresh with signed Cloud alarm persistence, physical
child retirement and cold reactivation at the original deadline.

**Expected behavior**

Keep the feature enabled. Complete useful work through its normal owner.
Persist the exact authorized alarm before stopping the child. Wake and
process the same original deadline without losing identity, claims,
retries or useful results.

**Steps to reproduce**

Use the checked-in Node24 launcher with separate owned loopback
App/Cloud test databases, a compiled Cloud checkout and the pinned
Node22 executable. Select one status or object bootstrap, cycle or
identity case. The command validates fresh evidence and database
ownership before starting its guarded worker.

**Paperclip version or commit**

Current candidate `ea911278b22c855d6722e43afc026bfc5c031405` normally
merges fetched master `07a548073ab48f13cb93b2fdc31553a5da88a070`. All
eight owned acceptance files are byte-identical to
`c08e64fa857d39bdb79352e94d156db7a4dbebf3`; all unrelated paths exactly
match master. The guard-argument repair and source-specific historical
cycle proof are preserved.

## What Changed

- Add one named opt-in case per owner/stage/diagnostic combination. Keep
the shared business body and its original deadlines, assertions and
cleanup.
- Drive actual managed App startup, authenticated producers, persisted
claims and the signed Cloud SQL alarm before physical child stop.
- Retain the generated five-secret fixture profile across cold boots.
Store it in private directories and retain only names, hashes and modes
in evidence.
- Add optional query timestamps to the existing proxy and harness.
Existing callers keep their default behavior.
- Add launcher refusal controls and assert its complete child argument
list. Both explicit guard preloads and the exact child `NODE_OPTIONS`
must match.

## Verification

- The current preflight repair has causal proof: the old controls pass
all nine cases with an invalid worker preload flag. The new assertions
reject that same command in both success controls (two failures/seven
passes). Restoring the correct command passes all nine cases. The
original refusal, freshness, symlink and byte-preservation assertions
remain unchanged. All 33 recorded Node PIDs and three groups close;
temporary copies and private directories are removed. These controls
stop at the declared spawn stub. They verify the full command contract
and do not claim to execute Vitest.
- The prior real named status bootstrap passed in 72.942 seconds: one
selected case/six explicit skips. It exercised the actual launcher and
Vitest fork, useful normal generation, current authorization, settled
claims, original 900000ms interval and signed SQL alarm
generation/operation-lock acknowledgement before physical child stop.
Recorded processes and final SQL sessions closed; both successful
databases were normally dropped.
- Earlier normal status and GitHub-object cycles qualified their real
900-second and 300-second original deadlines, cold reactivation and
useful second results. Their complete original failure and recovery
records remain source-specific; no shortened business clock or
manufactured completion is used.
- On current integrated head `ea911278`, own frozen offline setup and
normal shared/SDK, Runner and App declaration prerequisites passed. The
status test and its imported closure pass the affected normal test-tree
compiler program with original options/references. The actual named
normal status bootstrap then passed: one selected case/six skips, 82.202
seconds in Vitest and 85.616 seconds in the launcher. It used the real
checked-in launcher, normal App/SDK/process agent and exact compiled
Cloud `389878364b42dd129835d263f91882c5774d5123`. The original 900000ms
interval and 120000ms alarm lead remain unchanged; useful v1 and signed
SQL alarm generation/operation-lock acknowledgement precede physical App
stop. All six recorded process IDs and the runtime group were absent;
independent final test-database sessions were zero, with no observer
during execution. Both fresh success databases were normally dropped.
This refreshes the changed consumer seams through handoff and stop; it
is not another cold-wake/useful-v2/full-cycle result. Historical cycles
remain tied to their measured source.
- Fresh exact-head CI and complete edited bot reviews are required
before merge. A whole final-tree local build/test or deployed suspension
is not claimed. Local provider, process and S3 boundaries are controlled
fixtures; no live GitHub provider or Railway/Neon suspension claim
follows.

## Risks

- These tests use real SQL and wall clocks and are opt-in. Their
fixtures must stay isolated and retain failed evidence.
- Stable fixture secrets must remain private and must not appear in
logs. Cleanup keeps them when owner closure is uncertain.
- The acceptance body stays shared to preserve measured evidence. The
named registration and command controls make selection failures visible.
- This PR does not change production, schema or feature enablement.

## Model Used

OpenAI `gpt-6.1-sol` produced the delegated acceptance work. OpenAI
Codex, based on GPT-6, performed managing review, source integration and
the preflight argument repair. Both used repository tools and code
execution. The coordinator's exact backend snapshot and context-window
size were not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used
- [x] I have checked ROADMAP.md and confirmed this does not duplicate
planned core work
- [x] I have searched related PRs and linked the existing foundations
- [x] I have described the issue with the bug-template fields
- [x] I have not referenced internal or instance-local issues or links
- [x] My branch name describes the change
- [x] I have run the stated focused local tests and preserved their
limits
- [x] I have added or updated tests
- [x] I have documented the opt-in command and evidence requirements
- [x] I have documented risks
- [ ] All current-head CI gates are green
- [ ] Current-head Greptile is 5/5 with no open findings
- [x] I will address reviewer findings before merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-06 18:15:56 -06:00
Nicky LeachandClaude Opus 5.5 97953e9b59 fix(ci): repair the weekly test-weight refresh and extend it to e2e and Runner lanes (#19650)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors wait for PR CI before merge, so CI wall-clock time sets
the pace of work
> - CI test shards are balanced with per-file durations that a weekly
workflow refreshes
> - The weekly refresh has failed since 2026-11-02, and the e2e weights
have not been measured since 2026-09-15. Today e2e shards 3 and 6 take
7.0–7.7 minutes of tests, and shard 1 takes 4.3 minutes
> - A single file can also grow until it sets the time of its shard, and
nothing tells us about it
> - This pull request repairs the refresh, extends it to the e2e and
Runner package lanes, and warns about test files over 180 seconds
> - The benefit is balanced shards that stay balanced, and early notice
of oversized test files

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The weekly refresh of the test-duration weights that balance CI shards.

**Subsystem affected**

CI: `scripts/ci/refresh-test-durations.mjs`,
`.github/workflows/ci-test-durations.yml`, and the Vitest and Playwright
CI reporters.

**Current behavior**

- The 2026-11-02 refresh (run 41408033686) failed. `gh api …/logs` now
refuses output that contains terminal escape sequences.
- Most successful `cloud-readiness.yml` master runs are admission-only
and have no server jobs. The script took the newest 2 runs, so it would
fail with "No server timing jobs found" on most weeks.
- The e2e weights (`scripts/e2e-shard-durations.json`) have no refresh
and are from 2026-09-15.
- No signal tells us when one test file becomes too slow.

**Proposed behavior**

The refresh runs again, selects master runs that ran the server jobs,
refreshes the e2e and Runner package-lane weights from merged PRs, and
lists oversized files. CI writes a warning for each test file over 180
seconds.

**Reason and benefit**

Unbalanced shards add minutes to every PR. The current e2e weights put
472 seconds on the slowest shard. The new weights put about 300 seconds
on each shard.

**Breaking changes**

None. Weights change only shard placement, never which tests run.

## What Changed

- Add `scripts/ci/gh-api.mjs`. It always passes
`--allow-escape-sequences`, retries without the flag when an older `gh`
does not know it, and strips ANSI codes from logs.
- `scripts/ci/refresh-test-durations.mjs`:
- scans up to 50 master runs and uses the 2 newest that ran the server
jobs;
- adds the `--dry-run`, `--pr-body=<path>` and
`--lanes=server,e2e,runner` options;
- validates every lane before it writes a file. If a merged-PR lane has
fewer than 2 valid runs, it keeps the current weights and writes a
warning.
- Add `scripts/ci/pr-run-durations.mjs`. It uses the final passing
`pr.yml` run of each merged PR, and it validates:
  - the PR is merged into `master`;
  - the merge commit is in the checkout;
  - the run's head SHA is the PR's final head SHA;
  - every shard of the run succeeded.

  It parses Playwright per-spec times and Runner per-file times.
- Add `scripts/ci/oversized-test-files.mjs` (Vitest) and
`scripts/ci/oversized-test-files-playwright.mjs` (Playwright). In GitHub
Actions they write a `::warning` for each test file over
`PAPERCLIP_CI_OVERSIZED_TEST_FILE_SECONDS` (default 180). They never
fail a job. The root and Runner Vitest configs and the e2e Playwright
config load them.
- `scripts/ci/test-timing-reporter.mjs`: move the per-file duration
calculation into an exported `moduleDurationMs()` helper. The behavior
does not change.
- `.github/workflows/ci-test-durations.yml` and
`.github/ci-duration-pr.md`:
  - the bot PR can also update the e2e and Runner manifests;
  - the PR body lists oversized files;
  - the verify step also runs the new tests and the e2e shard tests.
- Refresh `scripts/e2e-shard-durations.json`: 56 of 56 specs measured
(before: 30), from merged PRs #19613, #19191 and #19176. The file now
uses the shared schema with `sources` and `coverage`.

## Verification

- `node --test scripts/ci/test-selection-pr-timings.test.mjs
scripts/ci/test-selection-timings.test.mjs
scripts/__tests__/e2e-shard.test.mjs`: 32/32 pass. These include the e2e
shard balance check with the new weights.
- `node scripts/ci/refresh-test-durations.mjs --dry-run` against real
GitHub data, all lanes:

| Manifest | Measured | Sources |
| --- | --- | --- |
| `general-server-shard-durations.json` | 1488/1534 files | master runs
42296943853, 42294279748 |
| `serialized-shard-durations.json` | 167/167 files | the same two runs
|
| `e2e-shard-durations.json` | 56/56 specs | PR runs 42296374365,
42297700476, 42296994179 |
| Runner `vitest-lane-durations.json` | 310/311 files | the same three
PR runs |

- e2e placement over 8 shards, with the new medians as the real costs:
  - with the old weights, the slowest shard has 472 seconds;
  - with the new weights, every shard has about 300 seconds.
- The dry run lists 12 files over 180 seconds. The largest is
`server/src/services/workspace-recovery.test.ts`, at 636 seconds.
- In this PR, only the e2e weight file changes. The weekly run refreshes
the server weights. The Runner manifest is added by #19646.

## Risks

- The `--allow-escape-sequences` path ran only in unit tests. The local
`gh` did not support the flag, so the real dry run used the fallback.
The next weekly run is the first real test of the flag.
- The changes to the bot-PR shell steps did not run in Actions yet.
- The e2e weights come from 3 PR runs. Real shard times vary more than
the estimate.
- This PR and #19637 both edit the `reporters` lists in
`vitest.config.ts` and `packages/paperclip-runner/vitest.config.ts`. The
second PR to merge needs a small merge resolution.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and
code execution. A Claude sub-agent wrote the change, and the parent
session reviewed it.

## 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-06 15:50:02 -08:00
Nicky LeachandClaude Opus 5.5 a2aa69bc62 perf(ci): split the Runner package Vitest lane into three duration-balanced lanes (#19646)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors wait for PR CI before merge, so CI wall-clock time sets
the pace of work
> - The Runner package Vitest lane is the PR CI critical path. It
finishes a median 15.3 minutes after the push
> - That lane runs all 311 package test files in one job with 2 workers:
1,381 seconds of serial work and 727 seconds of wall time
> - Ten files account for 61% of the time. Vitest's `--shard` splits by
file count, not by duration, so it cannot balance this work
> - This pull request splits the package files into three lanes balanced
by duration, and runs the native server group in its own lane
> - The benefit is about 232 seconds of tests per package lane instead
of 727 seconds

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The time that PR CI needs to run the Runner package Vitest tests.

**Subsystem affected**

CI: the `Verify Paperclip Runner` Vitest lanes in `pr-trusted.yml`, and
`packages/paperclip-runner/scripts/run-pr-vitest-lane.mjs`.

**Current behavior**

The `vitest package` lane takes a median of 13.6 minutes. It is the last
job to finish in most PR runs. The lane keeps a 2-worker limit so that
Runner and sidecar processes can start within their deadlines. Average
CPU use is 29%.

**Proposed behavior**

Three package lanes each run about one third of the files, balanced by
measured duration. Each lane keeps the 2-worker limit. The native server
group runs in its own lane, as before.

**Reason and benefit**

The Runner lane stops being the critical path. More workers per lane
would add CPU contention and timeouts, so this change adds lanes
instead. The cost is about 3 runner-minutes per run for the extra setup.

**Breaking changes**

None. The `master` ruleset requires only `ci / verify` and `ci / e2e`,
so the new job names do not change required checks.

## What Changed

- Add `packages/paperclip-runner/scripts/vitest-package-lanes.mjs`.
- It gets the file inventory from `vitest list --filesOnly --json`, so
it follows `vitest.config.ts`.
- It partitions the files with the `partitionGeneralServerSuites`
function from `scripts/general-server-shard.mjs`.
- Add `packages/paperclip-runner/scripts/vitest-lane-durations.json`.
- It uses the same schema as
`scripts/general-server-shard-durations.json`.
- It is seeded from CI run 42290920882. It measures 310 of the 311
files.
- `run-pr-vitest-lane.mjs` accepts two new arguments:
  - `--package-lane=K/N` runs one partition.
  - `--native-server` runs only the native server group.
- The `--shard=1/2` and `--shard=2/2` arguments keep their exact
behavior for the matrix that master still sends.
- `.github/ci-task-matrix.json` and the fallback matrix in
`pr-trusted.yml` have three `vitest package (K/3)` lanes and the `vitest
native server` lane. The labels keep the `vitest` prefix because
`scripts/ci-profile/trace.mjs` matches `Verify Paperclip Runner
(vitest`.
- Tests:
- the lanes cover the inventory exactly once, checked with real `vitest
list`;
  - the old `--shard` commands plan the same commands as before;
  - the manifest schema is correct;
  - bad lane arguments are refused.
- Update the Runner Vitest lane paragraph in `doc/DEVELOPING.md`.

## Verification

- `node --test scripts/__tests__/run-vitest-stable-shard.test.mjs`:
44/44 pass.
- `node --test scripts/__tests__/runner-release-crate-cache.test.mjs
.github/scripts/tests/ci-plan.test.mjs`, together with the file above:
71/71 pass.
- Related workflow, cache, routing and profile tests: 446/446, 153/153
and 14/14 pass.
- Local end-to-end run of `GITHUB_WORKFLOW=PR node
scripts/run-pr-vitest-lane.mjs --package-lane=3/3` on macOS:
- the prep steps ran, and the lane ran 104 of 311 files in 123 seconds;
- 2 tests failed: a macOS `kill EPERM` and a 5-second timeout under full
local load without the CI worker limit;
  - both files pass when run alone (35/35 tests).
- This PR's own CI runs the matrix from master, so it uses the old
`--shard` commands. The new lanes run in CI only after merge. Reviewers
can compare the job durations of the first PR runs after the merge.

| Lane | Files | Serial estimate | Wall estimate with 2 workers |
| --- | --- | --- | --- |
| 1/3 | 103 | 464.9 s | ~232 s |
| 2/3 | 104 | 465.0 s | ~232 s |
| 3/3 | 104 | 465.0 s | ~232 s |

## Risks

- The weights come from one CI log and do not include collection or
setup time. The balance can be uneven until a refresh measures real lane
runs.
- Each lane passes its file paths to Vitest as filters, and Vitest
matches a filter as a substring of the path. A path that is a substring
of another path would run a file in two lanes. No current path does
this. If one does, the script prints a warning, and no file is dropped.
- Each package lane runs `ensure:eval-build-deps` and `build:rust`
again, which adds about 3 runner-minutes per run.
- A comment in `scripts/run-vitest-stable.mjs` (about line 110) still
says that the last Runner shard runs the native group. A follow-up will
correct it.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and
code execution. A Claude sub-agent wrote the change, and the parent
session reviewed it and ran the local lane check.

## 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-06 15:12:05 -08:00
DottaandPaperclip 44c60c7221 Verify default skill sync through two real cold sleep cycles (#19191)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Automatic skill sources check upstream every five minutes.
> - The domain deadline contract can hand future work to a durable wake
alarm.
> - A saved deadline or a worker unit test does not prove that a stopped
tenant resumes the real producer.
> - This pull request checks the default cadence through the actual app
and a matching service checkout.
> - It checks two cold starts and three distinct persisted skill
revisions with real clocks.
> - The benefit is repeatable local evidence without changing production
timing or authority.

## Linked Issues or Issue Description

Refs: #19001, #17008, #17923.

This is a test and documentation follow-up to #19001. #19001 is now
merged; this PR targets master. Feature code remains in that merged
foundation. #17008 covers the earlier source worker change. #17923
covers separate skill-cache retention; this PR does not change that
behavior.

A scoped search of public PRs and issues found no duplicate
default-cadence cold-cycle acceptance test. No matching public issue was
found. The enhancement fields follow.

**Pre-submission checklist**

This improves checks for an existing feature. It adds no product
endpoint or execution authority. The public related-work search is
complete.

**What existing behavior does this improve?**

Verification of automatic skill-source updates across a durable idle
handoff and cold restart.

**Subsystem affected**

`server/` test fixtures and development documentation.

**Current behavior**

Domain tests cover claims, access and future deadlines. They do not
prove the full default-300-second path from ordinary source attachment
through signed tenant reports, durable alarms, stopped compute and a
changed persisted skill.

**Proposed behavior**

An opt-in launcher checks bootstrap first, then two full real-clock
cycles. Ordinary CI can run the transport controls without the host
fixture. The launcher-specific worker assertion runs only when prewire
is requested, and fails closed in that lane.

**Reason and benefit**

The test can detect an early refresh, a lost deadline, a stale proof, an
ACK after physical stop, a failed cold restart or a producer that does
not publish changed contents.

**Breaking changes**

None to product APIs, schema, cadence, authorization, claims or retry
policy. Existing maintenance-helper defaults stay unchanged.

**Additional context**

The opt-in host flow needs an authorized matching service checkout, its
normal compiled acceptance bridge, Node 22 and two owned PostgreSQL test
databases. The standalone controls do not need that service or
PostgreSQL. Roadmap review places this under existing Skills Manager and
Cloud deployment verification, not a new core feature.

## What Changed

- Add an opt-in default-cadence acceptance test and checked-in launcher.
- Reuse real server startup, managed tenant authority and the ordinary
project attachment route. Control only the remote Git snapshot boundary;
keep actual authorization, scanning, claims and publication.
- Use actual signed tenant routes and the matching service's gate, sleep
controller and PostgreSQL wake-alarm store. Stop and restart real app
children through a controlled local provider.
- Preload the Node transport guard before coordinator, worker and child
module work. Check worker-local and child PID, thread and source-hash
receipts. Keep caught unexpected transport attempts fatal.
- Require both persisted refresh times and actual upstream handler
timestamps to be at or after each original deadline.
- Join physical callbacks during bridge close, stop a late wake-created
child, and preserve the original cleanup budget. Add a real
IPC/HTTP-child counterfactual.
- Accept valid owned HTTP options paths without allowing Unix sockets or
unknown origins. Remove the unused App helper export.
- Add finite transport and launcher controls. Defer only the
launcher-specific parent assertion in vanilla CI. Keep all subprocess
controls default-runnable.
- Add optional child entry, preload and bounded pool settings to the
existing test helper. Preserve its defaults and the real observer test
body.
- Remove added explanatory comments under the contribution policy.
Preserve executable syntax, directives and literals.
- Document pinned setup, fresh owned databases, bootstrap and full
two-cycle commands in `doc/DEVELOPING.md`.

The diff against the intended base contains nine test/helper/launcher
files and this one document. It adds no production feature code or Cloud
source to the App repository.

## Verification

The latest two-file CI repair preserves both original failed jobs. A
newer master workflow introduced two required step inputs absent from
its extracted-step test environment; the faithful false/false inputs now
pass all five existing target-authorization cases, after an actual
missing-input counterfactual failed. A controlled socket case also
reproduces legal reuse of the released runner port; the fixture now
holds that reservation until the preview binds, releases it in finally,
and asserts distinct ports. All original retry/result/accounting
assertions and timeouts stay unchanged. The original Linux listener
owner remains unknown; the control proves this fixture collision
mechanism, not that original attribution. Fresh current-head full CI and
complete reviews are pending.


Current merge-integration candidate:
`f6f686103b7bfd0a4ab83085634c622af3896ecb`, a normal merge after #19001
landed, including actual master
`1c9767d323cf872a266962b9e6b4bd5cbcb57f1c`. All nine acceptance code
files are byte-identical to148a82e5; both parents' documentation
additions survive. Every other path equals actual master. The only
conflict was the old foundation's startup block; current master was
retained in full. Fresh exact-head CI and complete reviews are pending.
The local measurements below belong to their explicitly named revisions;
no fresh full614-second run is claimed.

Current candidate: `148a82e54c502759785407357001c6b50c1439dd`, normally
carrying the current base `8c912e1ba4fc66397a7341ade59373af8075068f`.
All nine acceptance code blobs remain identical to qualified repair
`e092746f4da181c8414c63be71474619a84b97e7`; documentation retains both
parents. The matching built service for the fresh bootstrap is
`389878364b42dd129835d263f91882c5774d5123`, using Node 22.23.4. The App
uses Node 24.22.2 and pnpm 9.15.4.

Use the exact controlled setup and commands in
[doc/DEVELOPING.md](doc/DEVELOPING.md#controlled-default-cadence-sleep-acceptance).
Fresh owned databases and the matching compiled service are
prerequisites; the launcher does not create a cluster or build
dependencies.

- **Current affected semantic check passed**, using actual native
TypeScript 7 and the current generated CI options/references (0.640
seconds). Normal declaration references passed in 11.726 seconds, with
source-matched SDK/shared/kernel/Runner prerequisites and the frozen
current lockfile.
- **Current guarded controls: 44 passed**, plus the same single explicit
real-PG observer deferral, in 4.790 seconds. The executing worker
checked its own global PID/thread/preload source hash and matching
receipt. Guard hash:
`7daf9ae7d2f26e8fcd389b1d560fac1d4e218a8304a2c58e49f646af108165bc`. No
unexpected attempts or PG sockets occurred. Vanilla controls on the
identical repair files passed 22 cases with one explicit launcher-parent
assertion deferral (2.044 seconds).
- **Real cleanup counterfactual:** unchanged old helper failed the
actual endpoint assertion in 1.568 seconds: its late HTTP child remained
reachable after close returned. The same fixed-helper case passed in
1.485 seconds: close waited, the child exited, the port was unreachable
and no IPC error occurred. The isolated driver records only the known
legacy closed-IPC error long enough to inspect cleanup; current helper
requires zero such errors. All recorded processes and ports were
independently checked empty.
- **Fresh current bootstrap passed once**, 16.159 seconds wall / 14.12
seconds Vitest / 12.237 seconds body. The ordinary enabled producer
persisted useful revision 1, cleared its lease and retained exactly
300,000 ms between success and original next deadline. A signed
current-generation report produced the matching
generation/operation-lock SQL ACK before physical child stop (ACK
06:54:02.020 UTC; stop signal 06:54:02.025 UTC). The alarm retained the
original D and D-120 eligibility. Both fixture databases ended with zero
sessions; the coordinator independently audited and normally removed
them. This bootstrap does not execute a due wake or the new v2/v3 timing
assertions.
- **Historical full two-cycle result remains 1/1 passed in 614.213
seconds wall**, at measured App
`74f7c012ca17ad0d7302d76403f5033affb1430b` and service
`2cb54f904031dcf69a32902cd853b43f74d6f301`. Two new processes published
distinct immutable v2/v3 at original D+115 ms and D+98 ms. Exact signed
SQL ACKs preceded both physical stops; real SQL D-120 claims caused both
cold starts. Features, claims and original business deadlines stayed
unchanged.
- Both historical 10.002-second quiet windows kept app SQL, upstream and
outgoing HTTP counts unchanged. Two awake PG sockets remained. Quiet
counts do not mean all connections closed.
- The stronger timing checks are additional assertions, not
retroactively executed results. Offline inspection of the original
receipts confirms persisted successes at D+115/+98 ms and earliest
upstream handler entries at D+44/+30 ms; all five requests in each
refresh group started after D. The exact new pre-sleep request counter
and dispatch-before-handler time were not independently recorded
historically.
- Source review found 19 critical automatic Git producer/runtime files
identical to the measured App. The current active cache helper change
does not run on automatic Git publication and adds no pool. The matching
service carries unchanged measured helper/sleep/controller/alarm seams
through its normal master merge. The current bootstrap and focused
controls qualify the changed cleanup/guard and parent integration. No
fresh current-head 614-second pass or whole-tree identity is claimed.
- Runtime is serialized: worker 1, per-pool limits at most 4, configured
App potential 10 + shared service pool 2 + one observer against server
max 40. Current bootstrap point samples observed at most 3 owned clients
plus the observer; those samples are not a continuous aggregate peak.
App/service guard peaks were 2 each, with zero remaining sockets at
cleanup.

Failures remain accounted for. The first historical bootstrap cleanup
masked an unknown primary error. Later attempts exposed missing
fixture-owned domain metadata and lost origin snapshots; test-only
ownership/cleanup corrections preserved the original assertions and
timeouts. The new causal driver first lacked a final observation, then
encountered sandbox bind EPERM, then exposed the old closed-IPC error;
only the final real endpoint failure above is labeled the cleanup
counterfactual. An offline dependency miss and a generator-cleared
ignored compiler config were retained and corrected without weakening
compiler options. The former inherited policy failure (397 findings) is
resolved by the genuine current-master base/scanner, with zero findings
and seven scanner tests; no unrelated UI site was edited and the gate
was not relaxed.

Historical real TCP observer coverage passed 1/1 in 3.688 seconds.
Comment-only and opt-in-selection carries remain documented in prior
evidence; self-hash changes are metadata exceptions with fresh
executing-worker checks.

The full workspace typecheck, test, build and browser commands were not
run locally for this test-only slice. The current base passes its full
exact-head CI and Greptile 5/5; this leaf now has all 56 exact-head
check runs terminal (54 success, one neutral, one skip), the additional
legacy security status passing, complete current Greptile 5/5 and
Ponytail 5/5 reviews, and all four review threads resolved across all
pages. No live provider or deployed host/database suspension was
exercised.
## Risks

- The host test requires a matching service checkout and compiled
bridge. An unrelated service revision is not a valid substitute.
- Controlled localhost Git, S3 and physical process operations do not
qualify deployed Railway or Neon suspension, external permissions or
provider uncertainty.
- The Node guard does not prove native subprocess transport isolation.
Only finite local Git metadata and Node version commands were needed.
- Two short SQL/HTTP quiet windows do not establish strict 90-second
host acceptance. Separate plugin-host LISTEN, MCP wake and other domain
gaps remain open.
- Explicit test URLs are destructive fixture inputs. Use fresh, owned
`_test` databases. The launcher does not start a cluster or choose a
database implicitly.
- Passing CI and review do not qualify deployed suspension; the current
proof boundaries above still apply.

> For core feature work, check `ROADMAP.md` first and discuss it in
`#dev` before opening the PR. This PR adds verification to the existing
feature in its base.

## Model Used

OpenAI `gpt-6.1-sol` assisted with implementation, repository review and
local code execution. OpenAI Codex, based on GPT-6, performed managing
review and integration. The context-window sizes and exact served
backend snapshots were not exposed.

## 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 and contains no internal
Paperclip ticket id
- [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 — fresh merge-head gates pending
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
fresh merge-head review pending
- [x] I will address all Greptile and reviewer comments before
requesting merge


### Current master reconciliation (2026-11-06)

Merged actual master `e28158b353fd274f0322be76d62cd223e0df599f`
normally. All nine qualified acceptance code files and the held-port
repair remain byte-identical. Upstream independently landed the workflow
input fix in #19602; the fixture now uses that exact upstream version
once, avoiding duplicate object keys in a squash merge. All other master
changes are preserved. The earlier two-cycle local evidence remains
source-specific; no fresh 614-second run or deployed suspension is
claimed. This new head requires fresh CI and bot reviews.

### Actual combined-tree typecheck failure and repair

CI job `128156887548` failed with TS1117 at workflow-target.test.ts:22:
two independently landed master fixes declared the same `IMAGE_ONLY` and
`TESTOPS_REQUESTED` keys. The complete original log is retained. A
further normal master merge now removes the duplicate declarations,
retaining exactly one false/false pair and every original assertion. All
nine qualified acceptance code paths and the held-port repair remain
byte-identical; unrelated master changes are preserved. This is a
concrete compiler failure and source repair, with fresh exact-head
CI/reviews required.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-06 15:55:05 -06:00
DottaandClaude Opus 5.5 24508a8ba5 perf(dev): add agent preflight with a shared content-keyed build cache (#19555)
perf(dev): add agent:preflight with a shared content-keyed build cache

Agent worktrees each built the Runner from nothing: a cold cargo debug and
release build, the Runner TypeScript build and its checks, about 8 minutes
per fresh worktree on a loaded machine.

`pnpm agent:preflight` replaces the manual install + Runner build sequence.
It skips steps whose inputs are unchanged and reuses other worktrees'
results from ~/.cache/paperclip/agent-build:

- Runner and eval-kernel dist, keyed by the Runner build-input digest and
  the toolchain
- Runner debug binaries, keyed by every file the Rust build reads
- a cargo seed with third-party crates only; the Runner crate is scrubbed
  because cargo records its out-of-package includes by absolute path

Entries are written once with an atomic rename and restored as APFS clones.

Also exclude provider-assets from the Runner build inputs. The build writes
that directory itself, so the first build in every fresh checkout saw its
inputs change and never recorded a build stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:01:54 -06:00
Nicky LeachandClaude Opus 5.5 79c724b292 ci: run the Runner support suite as a required PR check (#19537)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Runner support suite (`pnpm test:e2e:runner:unit`) checks the
code behind paid Runner runs: workflow security policy, evidence
oracles, cost receipts and controller recovery.
> - Only a manual Docker Runner check ran this suite. PR CI did not run
it.
> - So changes on master broke 16 of its tests, and nobody saw it.
#19527 and #19529 fix those tests.
> - This pull request runs the suite in PR CI and makes the `verify`
check require it.
> - The benefit is that a change that breaks the suite fails on its own
pull request, before merge.

## Linked Issues or Issue Description

Refs #19527 and #19529 (both merged; they repair the suite). Also
accounts for #19533, which added the sandbox provider job.

**Merge order:** #19527 and #19529 have merged, so the suite now passes
on master. This PR can merge.

**What existing behavior does this improve?**

PR CI for `pr-trusted.yml`.

**Current behavior**

`pnpm test:e2e:runner:unit` runs only in `docker-runner-check.yml`, by
manual dispatch. Master changes broke 12–13 tests by Nov 3 and 16 by Nov
6, with no signal on any pull request.

**Proposed behavior**

A required "Runner support suite" job runs the suite on pull requests.

**Reason and benefit**

Drift fails on the pull request that causes it. Authors update
deliberate pins (profile versions, suite hashes, workflow policy) in the
same pull request.

## What Changed

- `pr-trusted.yml`: new job `verify_runner_e2e_support` ("Runner support
suite"). It uses the same steps as other trusted jobs: checkout without
credentials, setup-node directly before pnpm, the npm overrides, the
shared `setup-ci-dependencies` restore, and CI profiling. Then it runs
`pnpm test:e2e:runner:unit`. It has no secrets. Timeout: 20 minutes.
- `pr-trusted.yml`, job `verify`: the job now needs the new job and
checks its result. A failed, cancelled or wrongly skipped run fails
`verify`.
- `turbo.json`: new routing task `ci:runner-e2e-support`. Its inputs are
`server/**`, `cli/**`, `packages/**` and `ui/**`. Changes to `tests/**`,
`scripts/**` and `.github/**` already force full CI, so they also run
the job.
- `.github/ci-task-matrix.json`: one lane for the new job.
- Tests: `ci-plan.test.mjs`, `ci-profile.test.mjs`, `e2e-shard.test.mjs`
and `workflow-security.test.ts` now include the new job. A UI component
change now selects this one Runner task. The suite imports UI modules
and checks that catalog-listed UI files stay tracked in git, so UI
changes can break it.
- CI profiling: when `PAPERCLIP_CI_PROFILE` is on, the suite's Vitest
config adds `scripts/ci-profile/vitest-reporter.mjs`, and `run-unit.mjs`
runs the Node tests through `scripts/ci-profile/node-test.mjs`. So the
job uploads per-test timings, like the root and Runner package suites.
The launcher loads the profiling helper only after its "Vitest is not
installed" check.
- `tests/runner-e2e/README.md`: says that PR CI runs the suite, and that
deliberate pin changes go in the same pull request.

## Verification

- `node --test .github/scripts/tests/*.test.mjs scripts/ci/*.test.mjs
scripts/__tests__/e2e-shard.test.mjs
scripts/__tests__/release-verify-workflow.test.mjs
scripts/__tests__/runner-release-crate-cache.test.mjs`: 830 passed, 3
failed. The same 3 fail on the base branch without this change. They
need local tools that are not on this Mac.
- A local run with `PAPERCLIP_CI_PROFILE=true` wrote per-test and
per-file timing records for Vitest and for Node. Without profiling,
output and exit codes do not change. Both `unit-launcher.test.mjs` tests
pass.
- The aggregate test now covers the new job: `verify` rejects it when it
fails, is cancelled, or is skipped while selected.
- The pnpm bootstrap policy tests in `workflow-security.test.ts` pass
with 10 setups in `pr-trusted.yml`.
- On this PR's merged tree (`80b526d697`, master plus this PR): `pnpm
test:e2e:runner:unit` passed, Vitest 320 files (6,830 tests) and
node:test 373, in 58s. It also passes on master `4c1f629c31`.
- Suite runtime on a 10-core Mac: 43–63s. With `--maxWorkers=4`: Vitest
118s, about 490s of CPU in total. On a 4-vCPU GitHub runner I expect
about 3–4 minutes for the tests and 1–2 minutes for setup. The job runs
in parallel with the longer test shards, so it should not lengthen CI.
- `pr.yml` calls `pr-trusted.yml@master`. This PR's own CI does not run
the new job. The first real CI timing comes from the first pull request
after merge.

## Risks

- More runner minutes: one extra job on most pull requests (all except
documentation-only ones).
- The suite has not run on Linux CI recently: old `docker-runner-check`
logs have expired. A Linux-only failure would show up on the first pull
requests after merge. The job can be removed from the `verify` list
quickly if needed.
- #19229 removes `version: 9.15.4` from the pnpm steps in this file.
Whichever PR merges second needs a small rebase.

## Model Used

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

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:50:42 -08:00
github-actions[bot]andlockfile-bot b5c06d660a chore(lockfile): refresh pnpm lockfiles (#19560)
## Thinking Path

> - Paperclip uses a dependency lock for reproducible builds.
> - Dependency changes on master need matching lock entries.
> - Paid eval reports also pin a reviewed offline correction.
> - Reports must install before paid model work starts.
> - This automated PR keeps the lock and report proof together.

## Linked Issues or Issue Description

**What happened?**

Dependency manifests on master changed after the last committed lock
refresh.

**Expected behavior**

Frozen installs must match those manifests. Eval reports must retain
matching offline lock proof.

**Steps to reproduce**

Run a frozen install before and after this lock refresh.

## What Changed

- Refresh the dependency lock from the manifests on master.
- Refresh the sandbox provider lockfiles from their manifests on master.
- Include the matching offline report correction and hashes.

## Verification

- Pinned pnpm resolution completed with lifecycle scripts disabled.
- The report proof refresh accepted the reviewed committed baseline.
- The workflow rejected changes outside the lock and report proof files.

## Risks

The lockfile bot merges this PR without CI or review when every added
package version is at least 3 days old on npm. Otherwise the PR waits
for a maintainer, and the daily refresh tries again. The bot merges only
the exact commit this workflow generated from manifests already reviewed
on master. Unknown report lock baselines remain blocked. This update
changes no permissions or paid enable gates.

## Model Used

None — generated by pnpm and the deterministic report-lock updater.

## Checklist

- [x] Thinking path and problem description included
- [x] Generated lock and matching report proof included
- [x] Release-age gate decides between bot merge and maintainer review

Co-authored-by: lockfile-bot <lockfile-bot@users.noreply.github.com>
2026-11-06 19:09:36 +00:00
Nicky LeachandClaude Opus 5.5 7dfdc97b9f fix(deps): let Dependabot update the sandbox providers (#19489)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandbox providers (`packages/plugins/sandbox-providers/*`) are
standalone published packages. `pnpm-workspace.yaml` keeps them out of
the root workspace on purpose.
> - `.github/dependabot.yml` watches only `/`, so Dependabot never saw
the providers.
> - Because of this, the Kubernetes provider stayed on zod 3
(GHSA-mrhx-c7xc-m3w5) after the rest of the repo moved to zod `^4.6.6`.
#18839 fixed it by hand.
> - We do not want to keep these versions current by hand in many
places.
> - This pull request lets Dependabot update the providers, and keeps
each provider standalone.
> - The benefit is that a provider dependency that falls behind gets a
pull request that can pass CI.

## Linked Issues or Issue Description

Refs #18839 (adds `scripts/typecheck-sandbox-providers` and fixes
Kubernetes zod by hand).
Refs #18998 (a Dependabot pull request for the Pi distribution that
fails CI by design).

**What existing behavior does this improve?**
Dependabot version updates. They skip every package that the root pnpm
workspace excludes.

**Subsystem affected**
CI and dependency automation: `.github/dependabot.yml`,
`scripts/typecheck-sandbox-providers`.

**Current behavior**
Dependabot does not update the sandbox providers. A provider can stay on
an old or unsafe version, and nobody gets a pull request.

**Proposed behavior**
Dependabot updates the providers each week. Each pull request changes
the provider manifests and the lockfiles that CI installs frozen, so the
pull request can pass CI.

**Reason and benefit**
We stop having to find and fix old provider dependencies by hand.

## What Changed

- `.github/dependabot.yml`: add an npm entry for
`/scripts/typecheck-sandbox-providers`. Dependabot treats this workspace
as one unit. One pull request per dependency changes every provider that
uses the dependency and the shared `pnpm-lock.yaml` together. So the
entry needs no `groups`. One entry per provider would open a separate
pull request for each provider, and each one would fail the frozen
install in CI.
- `.github/dependabot.yml`: add an npm entry for
`/packages/plugins/sandbox-providers/daytona`. Daytona is the only
provider with its own lockfile, and the release build and the Daytona
end-to-end job install it frozen. Each lockfile has exactly one
Dependabot entry, so every pull request changes a manifest and its
lockfile together.
- Take Daytona out of the shared typecheck workspace. This pull request
does not commit `scripts/typecheck-sandbox-providers/pnpm-lock.yaml`:
since #19522, only the lockfile refresh workflow writes it. After merge,
the bot removes Daytona's packages from it (no other version changes;
checked with pnpm 9.15.4). Until then CI resolves it in place.
`scripts/typecheck-test-programs.mjs` now installs Daytona from its own
lockfile (`LOCKED_PROVIDERS`, with `--ignore-workspace`). A stale
lockfile is resolved in place for the run, for both installs.
- `.github/dependabot.yml`: add a comment that tells why the Pi and Kimi
runtime distributions have no entry (see Risks).
- `scripts/typecheck-sandbox-providers/pnpm-workspace.yaml`: list each
provider by its full path. Dependabot does not expand a workspace glob
that starts with `../`, so with the glob it saw no providers.
- `scripts/typecheck-test-programs.test.mjs`: add tests. They check that
the workspace list has no glob and matches `SANDBOX_PROVIDERS` without
`LOCKED_PROVIDERS`, that each locked provider has its lockfile, that a
locked provider installs frozen with `--ignore-workspace`, and that a
stale locked-provider lockfile is resolved in place.
- Add `.npmrc` (`auto-install-peers=true`) in
`scripts/typecheck-sandbox-providers` and in the Daytona provider.
Without it, Dependabot reads the root `.npmrc` (`false`). That rewrites
the lockfile settings, and the frozen install then fails. npm and pnpm
never publish `.npmrc`.
- `scripts/typecheck-sandbox-providers/package.json`: add
`"packageManager": "pnpm@9.15.4"`, the version CI uses. Without it,
Dependabot guesses pnpm 10.
- `doc/DEVELOPING.md`: say that contributors do not refresh or commit
Daytona's lockfile either, and that a new provider goes in both lists.

No provider moves into the root workspace. There is no pnpm catalog. The
root `pnpm-lock.yaml` does not change.

## Verification

I ran the real Dependabot updater on this branch with the Dependabot CLI
(`dependabot update ... --local`, updater image
`dependabot-updater-npm@sha256:c5a9451c…`):

- Before the change, the workspace entry loaded only
`cloudflare/bridge-template` (the one path without a glob). It skipped
all the other providers.
- After the change, `--dep typescript` gave one pull request. It changed
the 9 non-Daytona provider `package.json` files and the shared
`pnpm-lock.yaml`, and did not touch Daytona.
- `--dep wrangler` changed only the wrangler closure in the shared
lockfile. Before the `.npmrc` fix, the same run changed
`autoInstallPeers` to `false` and 757 lockfile lines.
- The Daytona entry (`--dep @daytonaio/sdk`) changed Daytona's
`package.json` and its own lockfile in one pull request, and kept
`autoInstallPeers: true`. It keeps the exact pin exact: `0.203.0`
becomes `0.227.3`.
- Both frozen installs (shared workspace, and Daytona with
`--ignore-workspace`) pass with pnpm 9.15.4.
- Dependabot installed `pnpm@9.15.4` from the new `packageManager`
field.

Other checks:

- `.github/dependabot.yml` passes the SchemaStore `dependabot-2.0`
schema (Ajv). A wrong key fails the same check.
- With pnpm 9.15.4, `install --resolution-only --ignore-scripts
--no-frozen-lockfile` in `scripts/typecheck-sandbox-providers` (the
lockfile bot's command) gives exactly the lockfile that passed the
frozen installs earlier in this pull request.
- The lockfile rule from #19522 allows this pull request's diff: it
changes no lock content.
- `node --test scripts/typecheck-test-programs.test.mjs`: the new test
and the provider tests pass. Two other tests need a root `node_modules`
(`tsc`), which my worktree did not have. CI runs them.

These checks cannot run before merge. GitHub reads `dependabot.yml` only
from the default branch. After merge, open Insights → Dependency graph →
Dependabot and make sure that the two new entries show no errors and run
on Monday.

## Risks

- A dependency in both a non-Daytona provider and Daytona (for example
`typescript`) gets two pull requests: one from each entry. Each one
passes alone, and they can merge in any order.
- The typecheck job now typechecks Daytona's tests against Daytona's own
lockfile instead of the shared one. Its versions are a little older (for
example vitest 4.1.11), so a type difference can show up there.
- Majors of shared dev tools (for example TypeScript 7, Vitest 5) come
as one pull request across all providers. It can need code changes
before it passes. That is the same as the root entry.
- Pi and Kimi get no version updates. Every direct dependency there is a
pinned provider release, and the pins feed the provider's ACPX
`commandDigest`. The Runner checks compare the Pi lock with
`PI_DISTRIBUTION_PINS`, and the Kimi build compares the lock SHA-256
with a pin. So any Dependabot change there fails CI (see #18998), and a
real upgrade starts saved sessions fresh. GitHub security alerts still
cover both lockfiles.
- The Pi zod `3.25.76` pin is still affected by GHSA-mrhx-c7xc-m3w5
(open alert). It is deliberate: `pi-acp@0.0.33` has `zod ^3.25.0` as a
direct dependency, not a peer. A fix needs a `pi-acp` release that
allows zod 4. This pull request does not change it.
- `packages/plugins/examples/plugin-orchestration-smoke-example` is also
outside the root workspace. It uses `workspace:*` dependencies, so it
cannot install alone, and this pull request does not cover it.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, local Docker runs of the
Dependabot CLI).

## Checklist

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:08:39 -08:00
Nicky LeachandClaude Opus 5.5 3b3037aab5 fix(ci): run the sandbox provider tests in required CI (#19533)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandbox provider plugins (Daytona, E2B, Cloudflare, Kubernetes,
Modal, and others) run agents in remote sandboxes
> - The providers are outside the root pnpm workspace, so `pnpm
test:run` and the CI test lanes never run their Vitest suites
> - paperclipai/paperclip#18839 typechecks those tests, but a test that
fails at runtime still fails nothing
> - The providers have about 1,040 tests, and they finish in about 15
seconds on Linux
> - This pull request adds a required `Sandbox provider tests` job that
runs them when a change can affect them
> - The benefit is that a broken provider, plugin SDK, or shared
contract fails CI on the pull request instead of in a release or a paid
end-to-end run

## Linked Issues or Issue Description

Refs #18839

**What happened?**

No CI job runs the sandbox provider tests. `runner-full-stack-e2e.yml`
builds the Daytona plugin for a paid nightly run, but it does not run
any provider's `pnpm test`.

**Expected behavior**

A pull request that can break a provider runs that provider's tests, and
a failure blocks `ci / verify`.

**Steps to reproduce**

Add `expect(1).toBe(2)` to a test in
`packages/plugins/sandbox-providers/e2b/src`. Open a pull request. CI
stays green.

## What Changed

- Added `scripts/test-sandbox-providers.mjs` and its tests. The script
installs the providers through `typecheckTestPrograms` with no programs,
because that function already owns the provider install and the plugin
SDK link. Then it runs each provider's `pnpm test`, four at a time, and
prints each provider's output as one block. It fails if a suite fails or
if no provider has a test script.
- The script skips `cloudflare/bridge-template`, because Cloudflare's
Vitest config already runs those tests.
- Added the `sandbox_provider_tests` job to
`.github/workflows/pr-trusted.yml`, and made `ci / verify` require its
result.
- Added the `//#ci:sandbox-providers` task to `turbo.json` and its lane
to `.github/ci-task-matrix.json`. The CI planner runs the job only when
a change touches `packages/plugins/sandbox-providers/**`,
`packages/plugins/sdk/**`, or `packages/shared/**`. `packages/shared` is
the plugin SDK's only workspace dependency. Global CI inputs, such as
`scripts/**`, `.github/**`, `pnpm-lock.yaml`, or any `package.json`,
still select every job.
- The job turns off core dumps. A Daytona capacity test kills a process
at a file-size limit inside the exported directory. If core dumps are
on, the core file joins the export, and the test sees 12 files instead
of 11.
- Updated the job lists in `scripts/__tests__/e2e-shard.test.mjs` and
the job count in `.github/scripts/tests/ci-profile.test.mjs`. Added
planner tests to `.github/scripts/tests/ci-plan.test.mjs`. One of these
tests fails if the plugin SDK gets a new workspace dependency that is
not in the job's inputs.
- Added the new test file to `test:release-registry`, and documented the
script in `doc/DEVELOPING.md`.

## Verification

- `node scripts/test-sandbox-providers.mjs` on macOS: 8 of 9 suites pass
in 31.6 s, including the install. Kubernetes fails 12 `file-sync` tests,
because macOS has no `/proc/self/fd`. Some Daytona tests skip, because
they need GNU `tar` and coreutils.
- The same script in a Linux container (`node:24`, a non-root user, core
dumps off): all 9 suites pass, with 1,012 tests passed and 26 skipped.
The script took 15 s after the install.
  - Kubernetes: 205 passed.
  - Daytona: 590 passed and 16 skipped.
- With core dumps on, Daytona's `preserve literal capacity repair` test
fails. The export includes a `core` file. With `--ulimit core=0`, it
passes.
- To look for timing failures under load, I ran the whole script 12 more
times in the container, limited to 2 CPUs (8 runs) and 1 CPU (4 runs).
All 12 runs passed. They took 45 to 76 s on 2 CPUs and 73 to 102 s on 1
CPU.
- `ubuntu-latest` has `zstd`, and my container did not, so some Daytona
tests skipped in those runs. With `zstd` installed, Daytona passed with
598 tests and 8 skipped, and only the live tests skipped. One earlier
`zstd` run failed while the stress runs used the same CPUs, and I did
not capture its output. The next run passed.
- `node --test` on every test that reads `pr-trusted.yml`, the CI
planner, or the task matrix: 816 passed. These include
`.github/scripts/tests/*.test.mjs`,
`scripts/__tests__/e2e-shard.test.mjs`,
`scripts/ci/test-selection-workflow.test.mjs`, and the new tests.
- `pnpm run test:release-registry` on macOS: 251 of 255 pass, including
the new tests. The 4 failures are `wait_for_npm_package_versions` tests
in `scripts/release-lib.test.mjs`. They fail the same way on master on
the same machine, and CI runs this suite on Linux.
- `actionlint` 1.7.7 reports no errors on the changed workflow.
- I called `planTasks` from `.github/scripts/ci-plan.mjs` with one
changed file at a time. `server/src/index.ts` and `ui/src/App.tsx` leave
`sandbox_provider_tests` off.
`packages/plugins/sandbox-providers/e2b/src/plugin.ts` and
`packages/shared/src/index.ts` turn it on.

This pull request cannot run its own new job. `pr.yml` calls
`pr-trusted.yml@master`, so the job first runs on pull requests after
this one merges. After merge, check the first pull request that changes
a provider or `packages/shared`.

## Risks

- **First real run is after merge.** I measured the time on Linux in
Docker, not on a CI runner. CI runners are about 2 times slower than my
machine, so I expect about 2 to 3 minutes for the job, including the
dependency restore. The job runs in parallel with the other jobs, and
the slowest required job takes about 13 minutes. So the job should not
make CI slower.
- **Open provider pull requests.** After this merges, open pull requests
that change a provider run its tests. If those tests fail, `ci / verify`
blocks them until they are fixed.
- **`packages/shared` changes often.** Any change there runs this job. A
Linux-only provider failure would block those pull requests. All suites
pass on Linux today.
- **One unexplained Daytona failure.** One Daytona run with `zstd`
failed under CPU contention, and I did not capture its output. Some
Daytona tests have short deadlines. If the job is flaky after merge,
quarantine the failing test or revert the job, and do not leave `ci /
verify` red.
- **Path rules can be too narrow.** If a provider starts to import
another workspace package, a change to that package does not run this
job. The plugin SDK dependency test covers the SDK. It does not cover a
provider that imports a workspace package directly. Today no provider
does.
- **Overlap with paperclipai/paperclip#19489 and
paperclipai/paperclip#19522.** Both pull requests change the provider
install in `scripts/typecheck-test-programs.mjs`. This pull request does
not edit that file. It calls the same install function, so it uses their
install changes when they merge. All three pull requests edit the same
section of `doc/DEVELOPING.md`, so a small text conflict is possible.

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

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), in Claude Code with
tool use (shell, file edits, GitHub CLI, Docker). Extended reasoning was
on.

## 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-06 10:34:03 -08:00
github-actions[bot]andlockfile-bot 17b4f972bf chore(lockfile): refresh pnpm lockfiles (#19534)
## Thinking Path

> - Paperclip uses a dependency lock for reproducible builds.
> - Dependency changes on master need matching lock entries.
> - Paid eval reports also pin a reviewed offline correction.
> - Reports must install before paid model work starts.
> - This automated PR keeps the lock and report proof together.

## Linked Issues or Issue Description

**What happened?**

Dependency manifests on master changed after the last committed lock
refresh.

**Expected behavior**

Frozen installs must match those manifests. Eval reports must retain
matching offline lock proof.

**Steps to reproduce**

Run a frozen install before and after this lock refresh.

## What Changed

- Refresh the dependency lock from the manifests on master.
- Refresh the sandbox provider lockfiles from their manifests on master.
- Include the matching offline report correction and hashes.

## Verification

- Pinned pnpm resolution completed with lifecycle scripts disabled.
- The report proof refresh accepted the reviewed committed baseline.
- The workflow rejected changes outside the lock and report proof files.

## Risks

The lockfile bot merges this PR without CI or review when every added
package version is at least 3 days old on npm. Otherwise the PR waits
for a maintainer, and the daily refresh tries again. The bot merges only
the exact commit this workflow generated from manifests already reviewed
on master. Unknown report lock baselines remain blocked. This update
changes no permissions or paid enable gates.

## Model Used

None — generated by pnpm and the deterministic report-lock updater.

## Checklist

- [x] Thinking path and problem description included
- [x] Generated lock and matching report proof included
- [x] Release-age gate decides between bot merge and maintainer review

Co-authored-by: lockfile-bot <lockfile-bot@users.noreply.github.com>
2026-11-06 18:09:43 +00:00
Nicky LeachandClaude Opus 5.5 e3041f5481 fix(ci): let the lockfile bot own the sandbox provider lockfiles (#19522)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The repository has a supply-chain rule: contributors do not commit
lockfiles. A bot and verified Dependabot commits write them.
> - The rule checks only the root `pnpm-lock.yaml`.
> - The sandbox providers are outside the root workspace, so they have
two more pnpm lockfiles. Pull requests edited them by hand, for example
#12791, #18839 and #19489.
> - This pull request makes the same rule and the same bot cover those
two lockfiles.
> - The benefit is one lockfile rule for every managed pnpm lockfile,
and no lockfile edits in contributor pull requests.

## Linked Issues or Issue Description

Refs #19489

**What existing behavior does this improve?**

The lockfile policy and the lockfile refresh bot.

**Subsystem affected**

CI: `.github/scripts/lockfile-trust.mjs`,
`.github/workflows/refresh-lockfile.yml`, and the sandbox provider
installs.

**Current behavior**

`lockfile-trust.mjs` blocks contributor edits to `pnpm-lock.yaml` only.
A contributor can edit
`scripts/typecheck-sandbox-providers/pnpm-lock.yaml` or
`packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml`, and CI
passes. The refresh bot does not update these files.
`typecheck:test-programs` fails on a stale provider lockfile and tells
the contributor to commit a new one.

**Proposed behavior**

The policy blocks contributor edits to all three lockfiles. The refresh
bot regenerates all three after merge. CI resolves a stale provider
lockfile in place for the run, as it does for the root lockfile.

**Reason and benefit**

Lockfile content comes only from the bot or from verified Dependabot
commits. Contributors do not need to run pnpm in special folders.

**Breaking changes**

None. A pull request that edits a provider lockfile now fails the policy
check. Remove the lockfile change from the pull request. The bot commits
it after merge.

## What Changed

- `lockfile-trust.mjs` now exports `LOCKFILES`: the root lockfile, the
shared sandbox provider lockfile, and the Daytona lockfile. The policy
blocks contributor edits to each of them. A Dependabot pull request must
match a verified Dependabot commit for each lockfile that it changes.
The `merged` command and the reporting-lock proof still use only the
root lockfile.
- `check-pr-lockfile.mjs` (advisory check) uses the same list.
- `refresh-lockfile.yml`:
- A job-level `LOCKFILES` env lists the same three files. A test checks
that it equals the policy list.
- A new step regenerates the two provider lockfiles with
`--resolution-only --ignore-scripts --no-frozen-lockfile`.
- The unexpected-changes check, the change check, and `git add` cover
all three lockfiles.
  - The release-age gate checks the added versions in every lockfile.
- A held refresh saves all three lockfiles at their repository paths,
with a `source-locks.sha256` file. The next run reuses them only if
every master lockfile is unchanged. An artifact in the old format is not
reused.
  - The PR title is now `chore(lockfile): refresh pnpm lockfiles`.
- `check-lockfile-release-age.mjs` accepts more than one
`--base`/`--head` pair.
- `scripts/typecheck-test-programs.mjs`: if the frozen install of the
provider workspace fails, the script resolves the lockfile in place and
installs frozen again.
- `scripts/build-standalone-public-packages.mjs`: the same fallback
applies to a package with a committed lockfile (Daytona). `release.sh`
already restores tracked files when it exits.
- `doc/DEVELOPING.md` and `doc/RELEASE-AUTOMATION-SETUP.md` describe the
managed lockfiles. They also say that the Pi and Kimi distribution
lockfiles stay hand-managed, because they hold attested provider pins.

## Verification

- `node --test .github/scripts/tests/*.test.mjs
scripts/__tests__/build-standalone-concurrency.test.mjs`: 731 tests
pass. New tests cover:
- the policy for provider lockfiles, including a real git history with a
Dependabot commit and a later hand edit
  - the workflow and policy lists match
  - the unexpected-changes check, run with bash against a fixture
- a held refresh round trip, and its invalidation when a provider
lockfile changes on master
  - the release-age gate across lockfile pairs
  - the inline fallback in both install scripts
- `node --test scripts/typecheck-test-programs.test.mjs`: the install
tests pass. Two tests fail locally only because this worktree has no
root `node_modules` (no `tsc`). They also fail without this change.
- I ran the new bot commands on master with pnpm 9.15.4. No package
version changed. pnpm 9.15.4 removes `libc:` fields and adds
`deprecated:` notes, so the first refresh is a metadata-only change. The
release-age gate passes it because it adds no versions.
- I ran the release-age and held-lock steps locally with bash against
this commit. The gate reported `eligible=true`, and `sha256sum --check`
accepted the saved hashes.
- The workflow YAML parses, and `bash -n` accepts every `run` block.
- Not testable before merge: the policy job loads `lockfile-trust.mjs`
from the base branch, so the new rule applies only to pull requests
opened after this merges. The refresh workflow runs only on master.

## Risks

- The release build now resolves a stale Daytona lockfile in place, from
the registry. The root release install already resolves without a frozen
lockfile, so this is the same level of trust.
- The paid runner e2e job still installs Daytona with
`--frozen-lockfile`. After a Daytona manifest change merges, its Daytona
cases fail until the bot's refresh merges. This matches the rule that
paid jobs refuse a new root lockfile until its refresh merges.
- Dependabot pull requests for the provider entries in #19489 change a
provider lockfile. They pass only if each lockfile matches a verified
Dependabot commit, the same as for the root lockfile.
- Low risk otherwise: the bot keeps its existing token, environment, and
merge rules.

## Model Used

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

## 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-06 10:07:57 -08:00
DottaandClaude Opus 5.5 871a9b3180 test(restart-chaos): add Phase 1 control-plane restart chaos harness (#19477)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The control plane owns task execution, recovery and Retry for every
agent run
> - Server restarts were the largest remaining source of stalled tasks
in the staging soak campaign
> - The soak could only observe restarts that staging happened to
perform, so a failure could not be repeated
> - This pull request adds a script harness that causes the restarts on
purpose and grades each task on fixed rules
> - The benefit is a repeatable restart failure with a replay command
and an evidence bundle for each fixer

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The restart test coverage. Today the runner-e2e restart cells test one
fixed restart point per cell. They cannot run many tasks at once, cannot
choose a random fault time, and cannot replay a failure.

**Subsystem affected**
Test tooling (`tests/restart-chaos/`). It reuses the runner-e2e process
code in `tests/runner-e2e/`. Product code does not change.

**Current behavior**
Restart robustness is checked through a small set of fixed cells. A
random restart during a real task has no defined pass rule.

**Proposed behavior**
A harness starts an isolated control plane and seeds a "Chaos Lab"
company. It launches seeded workloads, injects K1, K2 or K4 faults at
seeded times, and grades each task on rules R1 to R6 plus a
deterministic task oracle. `--replay <wave>/<task>` repeats one task.

**Reason and benefit**
Fixers get a replay command and an evidence bundle for each failure
signature. The first smoke wave already found three product signatures
(dead Retry after a restart, a false over-budget stop after restarts,
and an OpenCode turn killed mid-turn that ends as a provider failure).

**Breaking changes**
None. The change adds new test files, one entry in the test-project
typecheck list and a gitignore entry.

## What Changed

- Add `tests/restart-chaos/`:
- `supervisor.ts` owns one isolated server. It reuses
`replaceController`, the process-tree stopper, the environment isolation
helpers, the config seed, the port reservation, the wrapper-owned
Postgres and `server-entry.ts` from `tests/runner-e2e/`. It does not
copy them. Faults: K1 (SIGKILL, immediate restart), K2 (SIGTERM,
restart) and K4 (SIGKILL, 2 to 5 minute hold). Postgres and the
controller runtime cache stay across a fault.
- `seed.ts` creates the company, encrypted provider secrets, native
Codex, ACPX Claude and OpenCode agents from the runner-e2e catalog, and
the `chaos-sandbox` project. The project is a git repository with a
Python CLI, one failing test and a four-minute `bench.sh`.
- `scheduler.ts` makes a seeded plan of workload, adapter, fault, fire
time, hold and nonce for each task. It saves the plan so that `--replay`
can run one task again.
- `observer.ts` polls each issue and its runs. When a task stops and
offers Retry, it sends `POST /api/issues/:id/retry` one time, with the
same body as `useTaskRetry.ts`.
- `grader.ts` grades R1 to R6 as pure functions. `workloads.ts` defines
workloads A1, B1, B5 and C1 and their oracles.
- `evidence.ts` and `ledger.ts` write an evidence bundle for each
failure, a JSONL ledger and `SUMMARY.md`. All results go to the
gitignored `tests/restart-chaos/results/`. Known provider key values are
redacted.
- Unit tests: `scheduler.test.ts` (determinism, replay and CLI parsing)
and `grader.test.ts` (R1 to R6 and the oracles on fixture timelines).
These tests need no credentials.
- Add `tests/restart-chaos` to the test-project typecheck list
(`scripts/typecheck-test-projects.mjs`), with a `tsconfig.layers.json`
like `tests/runner-e2e`. The harness runs through direct script
invocations, not `package.json` scripts.
- After the first Greptile review:
- A failed fault or an unexpected server exit now gets one bounded
recovery. If the recovery fails, the wave fails and does not hang.
- Incomplete cleanup is now a harness error, and the harness keeps the
instance.
- After the C1 follow-up comment, the observer needs a fresh rest window
before it grades the task.
- `--replay` now fires the recorded faults that crossed the task, at the
same offsets, including the faults that other tasks requested.
- A failed workspace-operation read is now a sweep error. It cannot give
a clean rest.
- R2 now checks that workspace files from before the first fault still
exist at rest.
- B5 now needs the completion record that `bench.sh` writes for each
task.
- After the second round of reviews:
- An idle `todo` or `in_progress` task with no live run and no named
wait is no longer treated as resting. The observer keeps watching it
until the task resumes or passes the R5 stall limit.
  - A failed injected fault now fails the wave with exit 2.
- T-oracle commands run agent-editable code. They now run under a macOS
Seatbelt sandbox with a scratch `HOME` and no network, and they cannot
read the operator's home directory. On other platforms these commands
are refused unless the operator explicitly allows them.
- R4 now counts every active run. A wake comment may be served again
only after a run that did not succeed.
- Evidence redaction now covers every environment variable name that
ends in `KEY`, `TOKEN`, `SECRET` or `PASSWORD`.
- If the pre-fault snapshot fails, only R2 for that task is left
unchecked. The wave continues.
- B5 also needs bench progress that the harness observed while it
polled.
- Add `doc/plans/2026-11-06-control-plane-restart-chaos.md` and its
campaign ledger.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`:
38 tests pass. The tests include observer tests with a fake API, a real
`bench.sh` run, and a sandbox test that cannot read a canary secret.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`:
passes.
- `server/scripts/typecheck-layers.test.mjs`: 45 tests pass, including
"covers every project under tests/".
- A supervisor check with no credentials passed. K1 was ready again in
about 6 seconds. K2 with a 3-second hold was ready again in about 9
seconds. Company data stayed readable.
- One paid smoke wave ran: `--env local --adapters codex,claude,opencode
--workloads A1,B1,C1 --faults K1 --tasks 3 --concurrency 2 --seed 7`.
The harness worked from start to end, and all three tasks crossed a
restart. The 0/3 result comes from product failures, which the campaign
ledger records. A scan found no provider key in the results.
- To run a wave:

  ```sh
  pnpm --filter @paperclipai/paperclip-runner build:runner-binaries
  set -a; source ~/.secrets; set +a
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --env
local --adapters codex,claude,opencode --concurrency 4 --tasks 8
--faults K1,K2,K4 --seed 42
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts
--replay <wave>/<task>
  ```

## Risks

- Low product risk. The change adds test tooling only and does not
change product code.
- A wave spends provider money. The company budget hard cap
(`--budget-cents`, default 3000) is the only spend limit. The plan's
per-wave and per-night refusal is not implemented yet.
- Known gaps:
- `--env daytona` is reserved. It fails with a TODO in `seed.ts`. The
Daytona sandbox sweep for R6 is not implemented yet.
- The project requests isolated git-worktree workspaces. A B1 task still
ran in the shared fixture repository. Concurrent B1 and B5 tasks can
share files. The oracles use marker files for each task.
- Only K1, K2 and K4 at seeded random times are available. Boundary
triggers (B1 to B16), other faults and the UI probe come in Phase 2.
  - The paid smoke did not exercise B5 or K4.
- On macOS, B1 oracle commands need `python3` outside the operator's
home directory, for example `/usr/bin` or Homebrew. On Linux they are
refused until a sandbox is added.
- The B5 proof is best-effort against a deliberately adversarial agent,
because that agent can write to the workspace.
- Startup phase timings come from polling `/api/ready`. The server
records its own timings only as OpenTelemetry spans.
- `pnpm verify` and the CI Vitest jobs do not select these unit tests.
The root Vitest projects cover workspace packages only. CI typechecks
the files, but the tests run only through the vitest command above.
- After the smoke, rest detection and the review fixes changed. A
recovery action that the board owns now counts as a rest, and R6 reports
it. A paid wave did not run again after these changes.

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

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used in
Claude Code as an agent with tool use (shell, file edits) and extended
thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [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-06 10:45:52 -06:00
Nicky LeachandClaude Opus 5.5 83c6832ccb fix(build): typecheck package, Playwright, and e2e test files in CI (#18839)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Runner, plugin, and Playwright tests verify how those agents run,
connect, and appear in the board
> - Most package tsconfigs exclude tests, and Vitest strips types, so a
type error in those tests fails nothing
> - The tests drifted to 803 real type errors, and some of those errors
hid assertions that checked nothing
> - paperclipai/paperclip#18739 and paperclipai/paperclip#18738 check
the server test tree and four test projects, but not these files
> - This pull request adds a required check for these test files, fixes
every error, and fails CI if a test file is in no program
> - The benefit is that a type error in any test file outside
`server/src` now fails CI

## Linked Issues or Issue Description

Refs #18739
Refs #18738

**What happened?**

517 test files were in no program that required CI checks. A type error
in those files did not fail CI.

**Expected behavior**

CI fails on a type error in any test file.

**Steps to reproduce**

Add `export const value: number = "text";` to a test file under
`packages/paperclip-runner/src` on master. CI stays green, because
`tsconfig.json` excludes test files and Vitest strips types.

**Measured gap**

Each row is a program with its tests included, on one thread. File and
error counts are for head `7d4b5e907a`. Memory peaks were measured
before the two latest merges of master.

| Group | Test files | Real errors | Peak |
| --- | ---: | ---: | ---: |
| `paperclip-runner` Node source | 290 | 644 | 1.09 GiB |
| `paperclip-runner` surfaces | 9 | 12 | 0.42 GiB |
| `paperclip-runner` devtools and examples | 14 | 0 | 0.68 GiB |
| `sandbox-providers/kubernetes` | 21 | 65 | 0.44 GiB |
| 8 other sandbox providers and `bridge-template` | 32 | 4 | 0.34 GiB |
| `plugins/sdk` | 22 | 15 | 0.41 GiB |
| `plugin-workspace-diff` | 5 | 0 | 0.37 GiB |
| `plugin-orchestration-smoke-example` | 1 | 1 | 0.15 GiB |
| `kv-demo-mcp-server` | 3 | 0 | 0.13 GiB |
| `packages/db` scripts | 1 | 0 | 1.01 GiB |
| 8 Playwright suites under `tests/` | 52 | 25 | 1.72 GiB |
| `tests/runner-acceptance` | 3 | 0 | 0.58 GiB |
| `tests/e2e`, through layer declarations | 63 | 37 | at most 1.59 GiB |
| `ui` `AttentionInteractionResolver.test.tsx` | 1 | 0 | n/a |
| **Total** | **517** | **803** | |

27 of the 803 errors came from master while this pull request was open.
Each merge of master brought new test code that no check had read.

Errors that were config errors, not real errors:

- One TS2688 for each provider, because the root install does not
install provider dependencies.
- 9 in the Runner Node program (TS7016 and TS6059).
- 2 in `tsconfig.browser.json`. TypeScript 7 removed `baseUrl`, so that
config was invalid.
- 213 in the Playwright suites (`@/*` aliases and Vite types).
- About 60 in `tests/e2e` (import extensions and aliases).

`packages/plugins/create-paperclip-plugin` has no gap. Its tsconfig
excludes its test, but `cli/tsconfig.json` reaches that test through an
import. `server/test-baselines` also has no gap, because
`tests/lifecycle-baseline` reads it.

## What Changed

- Added `scripts/typecheck-test-programs.mjs` and its tests. The
`Typecheck + Release Registry` job runs it. It checks each program in
`PROGRAMS` on one thread.
- The script fails if a tracked `*.test.ts(x)` or `*.spec.ts(x)` file
outside `server/src` is in no program. The compiler file list decides
this, because `include` drops `foo.test.tsx` when `foo.test.ts` exists.
- Added a `tsconfig.test.json` to each uncovered package, and
`tests/tsconfig.playwright.json`.
- Added `tests/e2e` to `PROJECTS` in
`scripts/typecheck-test-projects.mjs`, with a `tsconfig.json` and a
`tsconfig.layers.json`.
- Added `scripts/typecheck-sandbox-providers`. It is a small workspace
that installs provider SDKs from one lockfile. I did not add a lockfile
inside each provider, because
`scripts/build-standalone-public-packages.mjs` uses a frozen install
when one exists, and that changes what a release resolves.
- Corrected `packages/paperclip-runner/tsconfig.browser.json` for
TypeScript 7.
- Set `rootDir` to `..` in
`packages/paperclip-runner/tsconfig.test.json`, because one Runner test
imports from `packages/adapter-utils/src`. The program does not emit
files, so this changes no build output.
- Moved the Kubernetes provider from `vitest ^3.2.4` to `^4.1.8`, the
version the other providers use. Vitest 3 requires `tinypool ^1.1.1`,
which has two critical advisories (GHSA-85c8-ppgw-ccpr and
GHSA-5gmw-xhrv-c9v3). The first patched version is 2.1.2. Its tests have
the same result on both versions: 189 passed, and the same 12
`file-sync` tests failed.
- Moved the Kubernetes provider from `zod ^3.24.2` to `^4.6.6`, and gave
its two `z.record` calls a key schema. This is the only production
change, and it stays in this pull request. See "Production change for
review".
- Renamed `AttentionInteractionResolver.test.ts` to
`AttentionInteractionResolver.replace.test.ts`, so TypeScript reads the
`.test.tsx` beside it.
- Fixed all 803 errors, with changes to test files and test support
only. The fix for `contracts/native-execution.test.ts` is the same as
the fix in paperclipai/paperclip#18739, so those two changes merge
cleanly.
- Updated `doc/DEVELOPING.md`.

This pull request does not edit `server/scripts/typecheck-layers.mjs`.
paperclipai/paperclip#18739 and paperclipai/paperclip#18738 have both
merged, and this branch includes them.

### Production change for review

The new lockfile made dependency review flag `zod@3.25.76` as a
high-severity advisory (GHSA-mrhx-c7xc-m3w5). The first patched version
is 4.6.6, so 3.x has no fix. The published Kubernetes plugin uses zod to
validate operator config. The zod 3 source does not compile against zod
4, so a typecheck-only override cannot fix this.

The existing tests do not prove this change, because they also passed
with the invalid one-argument `z.record`. So I parsed 45 inputs with the
same schema on zod 3.25.76 and zod 4.6.7:

| Result | Inputs | Detail |
| --- | ---: | --- |
| Identical | 23 | Includes defaults, valid config, and a `__proto__`
key |
| Same outcome, new error wording | 21 | For example, `Invalid url` is
now `Invalid URL`. `validateConfig` shows this text to operators. |
| Different outcome | 1 | `jobTtlSecondsAfterFinished: 2 ** 60` was
valid and is now rejected. The new limit is `Number.MAX_SAFE_INTEGER`. |

I did not test this against a real cluster.

The decision is to keep this change in this pull request. Since then,
paperclipai/paperclip#17364 has moved the rest of the repository to zod
4.6.x, so the Kubernetes plugin now matches it.

### Behavior change for review

`reducedMotion` under `use` is not a Playwright option. It had no effect
in 11 configs and 2 `test.use` calls, so those suites ran with
`no-preference`. The options are now under `contextOptions`, so reduced
motion now takes effect. `app-shell-design.spec.ts` asserts a real
transition, so it now asks for `no-preference` itself.

The baseline manifest has `snapshotCount: 0`, so this invalidates no
published baseline. A frozen trial reference PNG can differ.

### Findings

These are product questions. I did not change them, and I did not hide
them with casts:

1. **`PrpEvent` and its schema disagree.** The TypeScript type in
`src/protocol/replay-contract.ts` requires `normalizedSessionId`. The
`required` list in `protocol/schemas/event.schema.json` does not include
it. `validatePrpEvent` accepts events that the type forbids.
2. **`requestCheckboxConfirmation` is stricter than its runtime.** It
uses the output type, so plugin authors must pass `continuationPolicy`
and `allowDeclineReason`, although the schema gives them defaults.
`z.input` would fix this.
3. **`RetainedRunnerdMaintenanceInput.backend.kind` is a free string.**
The quarantine domain comes from `descriptor.kind`, but the release uses
`proof.backend.kind`. If they differ,
`completeRetainedNativeSessionCleanup` returns 0 and the quarantine
stays. The production caller passes `"runner"` today, so this is not a
live bug.
4. **`CodexHarnessSession.close(input?)` is optional, but the contract
requires `{ reason }`.**

These are tests that verified nothing. I corrected them, and they pass:

- `backends/native-instruction-measurement.test.ts` filtered on
`placement === "core"`, which is not a valid placement. It measured an
empty tool list. It now measures 14 tools.
- `drivers/acpx/codex-acpx-driver.test.ts` checked for `usage.updated`,
which is not an event type.
- The `hostFixture()` helper in `drivers/acpx/runtime-host.test.ts`
ignored two overrides, so
`expect(prepareSandbox).not.toHaveBeenCalled()` could not fail.
- `live/live-session.test.ts` set `agent: "pi"` before a spread that
overwrote it.
- `native-distribution-integrity.test.ts` passed an AbortSignal where
`openCommand` expects `{ signal }`.

These are gaps that this pull request does not close:

- **The root lockfile still has some old zod versions.**
paperclipai/paperclip#17364 moved the direct zod dependencies to 4.6.x.
`pnpm-lock.yaml` still resolves `zod@3.25.76`, `4.1.11`, and `4.4.3`,
which are below the patched 4.6.6.
`packages/paperclip-runner/scripts/pi-distribution/package.json` pins
`zod` to `3.25.76`, and some agent SDKs resolve `4.4.3`. Dependency
review does not flag them, because this pull request does not add them.
- **Dead GitHub coverage.** `chat-adapters-ui-providers.spec.ts`
returned early for GitHub, so four later `provider === "github"`
branches never ran. I removed them. They included the private key file
and paste match, and the reconnect flow. Nothing asserts those behaviors
in that spec now.
- **Provider tests do not run in CI.** The root `vitest.config.ts` lists
only Daytona. The other 8 providers, `bridge-template` included, are
typechecked now, but CI does not run their tests.
- **One `packages/db` test never runs.**
`scripts/clean-poisoned-claude-sessions.test.ts` is outside the
`include` of `packages/db/vitest.config.ts`.
- **`plugin-orchestration-smoke-example` does not collect.**
`@paperclipai/shared` is not linked in its `node_modules`. It passes 3
of 3 with an alias.

## Verification

Run these commands:

```sh
node scripts/typecheck-test-programs.mjs --low-memory
pnpm --filter @paperclipai/server typecheck
node scripts/typecheck-test-projects.mjs --low-memory --reuse-declarations
node --test scripts/typecheck-test-programs.test.mjs
```

Results on macOS (arm64), Node 26.12. CI uses Node 24, and its
`Typecheck + Release Registry` job also passes. At head `7d4b5e907a`,
the test programs, `tests/e2e`, and the script tests pass again with 0
errors. The times and peaks below come from the run before the two
latest merges of master:

| Check | Result |
| --- | --- |
| Test programs, `--low-memory` | Exit 0, 0 errors, 42 seconds, peak
1.78 GiB |
| Server layers, low memory | Exit 0, 0 errors, 38 seconds, peak 3.41
GiB |
| Test projects, including `tests/e2e` | Exit 0, 0 errors, 13 seconds,
peak 1.59 GiB |
| Script tests | 12 passed |
| Injected type error in each of 23 places | Each one failed its program
with TS2322 |
| Coverage check | 0 unchecked files, 10 seconds |

I did not run this in a 4 GiB container. The peaks above are `maximum
resident set size` for the largest single process.

**Runtime check of the fixes.** Types do not prove that a test still
works, so I ran the suites before the two latest merges of master:

| Suite | Result |
| --- | --- |
| Runner, full suite | 4,885 passed, 3 failed, 13 skipped |
| Sandbox providers, 10 packages | 939 passed, 12 failed, 36 skipped |
| Plugin SDK | 200 passed |
| `kv-demo-mcp-server` | 12 passed |
| Renamed UI test and its sibling | 4 passed |

All 15 failures exist without these fixes or come from the local
machine:

- **3 Cursor Cloud timeouts.** `artifact-delivery.test.ts` has 1 and
`cursor-cloud-driver.test.ts` has 2. They time out in a full local run,
and all 133 tests in those files with `codex-security-config.test.ts`
pass when run alone. They fail in the same way with those two files at
their unedited versions.
- **12 Kubernetes `file-sync.test.ts` failures.** They fail in the same
way before and after the fixes. The first failure is that
`/proc/self/fd` does not exist on macOS.

After each of the two latest merges of master, I ran only the Runner
test files that I changed:

| Merge | Result |
| --- | --- |
| `885c249cb9`, 8 files | 534 passed |
| `7d4b5e907a`, 7 files | 672 passed, 16 failed |

The 16 failures are in the two `source-reconciliation` test files. They
fail in the same way with master's unedited versions of those files. 15
of them fail with `ACPX sidecar exited at initialize`, so I think my
local build of the Rust and sidecar binaries is out of date. I did not
rebuild them to check. CI builds them from scratch, and all Runner jobs
pass in CI at `7d4b5e907a`.

**Not run:**

- `tests/e2e` and the 8 Playwright suites need a server, so most of them
have only compile checks. `playwright test --list` loads 308 tests from
the main e2e config.
- `packages/plugins/sandbox-providers/kubernetes/test/integration` needs
a cluster, so it has only compile checks.
- I did not run the full `pnpm test:run` or root `pnpm build`. Required
CI runs them.

**Reduced motion.** I ran 6 motion-sensitive Storybook specs in system
Chrome with a temporary config, once with `reduce` and once with
`no-preference`. Both runs had 45 passed and the same 5 failed. So
reduced motion did not change those results. The 5 failures do not
depend on motion. At least one comes from the browser, because system
Chrome prints `oklch(55.6% 0 0)` where the test expects `oklch(0.556 0
0)`. I did not run the other Storybook specs. The `storybook-visual`
label runs that workflow.

## Risks

- **A new lockfile to maintain.** A change to a provider `package.json`
now fails the typecheck job until someone refreshes
`scripts/typecheck-sandbox-providers/pnpm-lock.yaml`. The failure
message gives the command.
- **The job has a new install.** The step downloads provider SDKs, so it
depends on the npm registry. It uses `--frozen-lockfile` and
`--ignore-scripts`.
- **The local install replaces provider `node_modules`.** A contributor
with a standalone provider install must reinstall that provider after
running the script.
- **Reduced motion can change Storybook results.** See "Behavior change
for review". That workflow is not part of required CI.
- **Kubernetes plugin validation changed.** A release of this plugin
ships zod 4. Error wording changes, and very large integers are now
rejected. See "Production change for review".
- **Master moves fast.** Each merge of master brought new test code with
type errors, 27 so far. After this merges, the check reports those
errors in the pull request that adds them.

## Model Used

- Provider: Anthropic Claude, through Claude Code
- Model: Claude Opus 5.5 (`claude-opus-5-5`)
- Capabilities: tool use, code execution, and subagents for the type
fixes in separate files

## 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)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived
details. The app generated the branch name.
- [ ] I have run tests locally and they pass. See the failures explained
in Verification.
- [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-06 07:33:59 -08:00
DottaandPaperclip 2f35eb1080 feat(dev): add incremental local verification and pass reuse (#19345)
## Thinking Path

> - Paperclip coordinates agents that make and verify repository
changes.
> - Local verification currently defaults to the complete Vitest suite.
> - Contributor instructions also require broad local checks at each PR
handoff.
> - CI already owns conservative affected-test selection and broad
final-revision checks.
> - This pull request adds local selection and pass reuse from those
existing contracts.
> - Agents can get focused feedback and use CI to close the remaining
coverage gaps.

## Linked Issues or Issue Description

**Subsystem affected**

Developer verification scripts and contributor guidance. Related
selection work: #18398 and #18464.

**Problem or motivation**

An agent that repeats an unchanged local verification step starts the
complete suite again. The handoff guidance also duplicates broad CI
coverage locally.

**Proposed solution**

Add `pnpm verify` for the actual working tree. Select affected Vitest
specifications with the CI task policy and dependency graph. Reuse
successful checks whose inputs are modeled. Run opaque checks fresh.
Report exit 2 when broader verification is required. Update the
contributor contract to accept applicable final-revision CI coverage.

**Alternatives considered**

Plain `vitest related` omits filesystem and opaque dependencies.
Commit-only cache keys miss dirty files. Running full local suites at
every handoff repeats CI work.

**Roadmap alignment**

This is repository development tooling. It does not change the product
roadmap.

## What Changed

- Add `pnpm verify`, `--explain`, `--base`, `--full`, and `--no-cache`.
- Reuse CI selection and exact runner ownership for dirty, staged,
committed, deleted, renamed, and untracked inputs.
- Keep native server suites and serialized suites in their local owners.
Keep six sequential chat shards.
- Cache selected passes within one worktree. Hash source, fixtures,
installed package contents, dependency bindings and installation
metadata, dotenv files, child environment, toolchains, platform, and
selection.
- Run opaque consumers fresh. Preserve failed, interrupted,
mutated-input, and partial-coverage outcomes.
- Update AGENTS.md, CONTRIBUTING.md, and DEVELOPING.md guidance. Record
the shared `verify` command as the sole exception to the package-script
rule. Register the new tests in the existing CI contract-test command.
- Add optional root/environment inputs to shared graph helpers. Existing
CI defaults stay the same.

## Verification

- Fourteen new integration tests pass locally across focused runs with
real Vitest selection and execution; CI colors are covered.
- 51 existing targeted selector/input, project-isolation, and
stable-runner checks passed. Four relevant runner contracts also pass
after the workspace change.
- Repeated unchanged execution skips reusable tests. Source,
environment, installed code, dependency bindings, and new untracked
tests invalidate results.
- Installed-code mutation with a restored modification time still
invalidates a cached pass. Internal skill-directory links preserve
reuse.
- Workspace B/C tests prove discovery, reusable/fresh partition
execution, pass reuse, and external-input failure. Chat selection
produces exactly six single-worker partitions.
- A deliberate unrelated failing test is omitted by narrow selection and
caught by `--full`.
- Red/green regressions prove fingerprints preserve file boundaries and
reject edits made between diff discovery and snapshotting.
- Opaque external-file changes still fail on a subsequent run. Source
mutation and process interruption cannot publish reusable passes.
- `pnpm verify --explain` works in the checkout and requests broad CI
coverage for this tooling change. No duplicate full local suite was
started.
- [Final-revision
CI](https://github.com/paperclipai/paperclip/actions/runs/42117291789)
passed on `ed85d96ddff82c05c325948680b593b0f48c473d` (attempt 2). The
scoped Canary retry preserved all passing test/build results.
- [Greptile
review](https://github.com/paperclipai/paperclip/pull/19345#issuecomment-6670050175)
is 5/5; all four review threads are resolved.
- The additional Paperclip review concluded neutral because its
execution stopped; it produced no assessment.

## Risks

- Pass reuse depends on conservative dependency classification. Opaque
consumers, external database/live settings, and non-isolated projects
retain fresh or broad coverage.
- Local passes cover selected Vitest checks. Required CI,
build/typecheck, release, browser, and platform acceptance remain
separate obligations.
- Cross-worktree caching and cross-agent scheduling are outside this
change.
- The initial installation scan reads dependency contents;
stat-validated, worktree-local digest memoization avoids rereading
unchanged contents. Subsequent scans still inspect file identity and
nanosecond change/modification times.
- No dependency lockfile or workflow changes.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, max reasoning, with local shell and file
tools. Context window size is not exposed by this host.

## 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-06 07:29:10 -06:00
Nicky LeachandPaperclip 2506bcdaf4 Admit exact stopped Runner sources under the current run lease (#19134)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native execution keeps provider state and a durable controller
journal.
> - A completed process can stop before the next run attaches.
> - The current run must own recovery of that exact source.
> - An old controller must lose permission to change the consumed source
journal.
> - This pull request adds explicit source admission and protected task
dispatch.
> - The benefit is a narrow recovery boundary that preserves the
original successful result.

## Linked Issues or Issue Description

Native prerequisites #19132 and #19153 are merged into master. This PR
targets master and updates the TypeScript Runner driver contract
alongside Core and SDK admission. This change adds the SDK and Core
admission boundary. Executor selection and fresh source preparation are
a separate change. Related #17916 supplies the workspace export holds
preserved here.

## What Changed

- Plugins advertise `codexSourceRecoveryVersion: 1` for Codex App Server
and `acpxSourceRecoveryVersion: 1` for ACPX. Hosts can qualify either,
both, or neither. Core selects the matching field, because a
stopped-source check for one provider cannot establish the other
provider’s recovery guarantees. ACPX orchestration is tracked separately
in #19196. Use the existing exact source identity and `resumeFrom` under
the authenticated current run. No public recovery mode is added. Require
the claimed source, current account and prepared launch at protected
dispatch.
- Keep the first source binding pinned on the consumer lease, including
after uncertain dispatch. A newly acquired coordinator can refresh only
the same source.
- Claim one stopped successful source under the current run's acquired
native lease. Store the token-free consumer reference in existing lease
metadata. Preserve the source status and result.
- Check the exact six source identity fields, encrypted journal header,
company, task, agent, environment, driver, workspace, account selection,
and current coordinator generation. Keep workspace export and legacy
settlement holds.
- Serialize claims and checkpoint writers in the same coordinator lock
order. Reject stale source writes after consumption. Check the current
consumer before recovered source writes.
- Check source admission before and after protected worker dispatch.
Keep the request and lease owned by the current run while the native
identity remains the source.
- Allow the same owner to prepare B after a fresh source submission,
using the exact consumed-source proof instead of a generic warm-reuse
flag. Recheck that proof after dispatch.
- Preserve ordinary cached adoption and repeated preparation after
launch. The current authenticated owner can differ from the adopted PRP
identity without requesting a new source launch. Keep ordinary suspended
recovery and live task attachment checks. Extract the shared source
resolver to avoid a module cycle, and make ordinary selection honor the
same durable source consumer.

## Verification

- Current head `cac9cf188a` includes merged #19300 and the symmetric
capabilities introduced in `b7e25a64b4`. The task-owner and warm-rebind
fixtures now identify their physical task. All 29 SDK schema cases, 95
Core ownership and source-admission cases, 25 native Codex and ACPX
recovery cases, and the binary metadata check passed on this head. The
SDK and Runner builds and server typecheck passed. Both native providers
reject the other provider’s capability. GitHub CI is green and [standard
Greptile scored this commit
5/5](https://github.com/paperclipai/paperclip/pull/19134#issuecomment-6645574114)
with no open threads.

- Previous head `bda49271a4ae362a15baf3fe6d6d297e25fd2000` is directly
based on master after #19132 and #19153 landed. [GitHub workflow
42072192023](https://github.com/paperclipai/paperclip/actions/runs/42072192023)
passed on its first attempt. All check runs completed successfully or as
intentional workflow skips; Snyk passed. Standard Greptile scored this
exact head 5/5. That review had no unresolved findings.
- Runner and SDK TypeScript builds and server typecheck passed. The
focused SDK, task-owner, task-runtime, PostgreSQL source and authority
suites passed all 161 cases. The production transport-factory getter
regression and 14 native-lane selection checks also passed.
- The Linux native lane executed the new ordinary warm A→B process case.
It kept one Runner/provider process and completed two turns on the same
thread. The eager-getter mutation fails with
`native_runner_source_attachment_pending`; preserving the live getter
passes. The production factory test changes the current owner after
transport creation and checks that same options object.
- PostgreSQL cases cover both concurrent source-writer/claim orders,
exact source submission under the current caller, source-switch denial
after uncertain dispatch, same-source coordinator refresh, stale
same-boot generation, corrupted journal headers, competing consumers,
account changes and export holds.
- The protected dispatch sequence submits A, prepares B and retries B
preparation. It rejects undispatched sources, changed accounts and
foreign target bindings. Source status and accepted result remain
unchanged.
- Ordinary adopted A preparation under current B, repeated ordinary
preparation after launch and ordinary suspended continuation retain
their existing path. Absent or false claims preserve ordinary identity
denial; malformed claims fail closed.
- Earlier development attempts exposed ordinary preparation regressions
and the live-getter omission; the final regressions cover those fixes.
The first embedded PostgreSQL attempt needed missing library aliases
repaired in ignored dependencies. Those attempts are not passing
evidence.
- Historical, superseded CI heads had unchanged UI fake-timer and
Playwright health-response-lifetime failures. Their isolated cases and
scoped retries passed. The response-lifetime cause remains unproven. No
UI/test timeout was changed. The final rebased-head workflow above
passed without a retry.
- Whitespace and added-line secret/PII scans passed. Full workspace
checks ran on GitHub. No live vendor probe or deployment was performed.

## Risks

- Any driver that used the earlier Codex TypeScript option must use
`codexSourceRecoveryVersion`. The new plugin description rejects the
generic spelling. The Launcher’s existing Codex-specific HTTP header can
map to this property when the plugin adopts the new SDK. The change
requires no journal migration or ROM change.

- This is an inactive admission foundation. Executor orchestration is
separate in #19157. Native restore integrity and provider
stopped-source/account validation must be present before executor
activation. The native prerequisite uses ordinary recovery followed by
attachment. The composed executor proofs are in #19157.
- The source claim is durable. A later controller must recover the same
consumer or keep the hold. It cannot select another source after an
ambiguous launch.
- Admission adds bounded database checks around protected startup calls.
Busy issue locks fail closed; other lock waits have a one-second bound.
- No migration, snapshot format, proactive receipt, recurring worker, or
deployment is included. Full workspace checks and platform coverage run
in CI. These tests do not prove vendor history durability or a hosted
rollout.

## Model Used

OpenAI GPT-6.1 Sol (`gpt-6.1-sol`), high reasoning, through Codex with
repository tools and code execution. The runtime does not expose a
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 on the current head
- [x] Standard Greptile is 5/5 with no unresolved findings on the
current head
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-05 12:55:06 -08:00
DottaandPaperclip 3a090b815b fix: retain native checkpoint ownership through warm close (#19225)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A native warm session must save its checkpoint under its exact
coordinator owner.
> - A failed turn can return an accepted result before that checkpoint
is saved.
> - The executor stopped renewal and released the owner before the
managed session closed.
> - The backup owner guard then refused the uncommitted, released owner.
A failed close could remain in the warm session map.
> - This pull request keeps the claim and its existing renewal through
checkpoint, close and instruction collection.
> - The benefit is a valid checkpoint owner and one fenced release after
the work joins.

## Linked Issues or Issue Description

Refs #19208, #19206 and #14695. Those fixes cover a destroyed backup
source, Stop before a provider session opens, and retained agent-file
ownership. They do not change this checkpoint ownership order.

**What happened?**

A failed warm turn accepted its result. Its coordinator reached
`workspace_finalizing`, with a result ID and no lease owner. Managed
close then called the durable checkpoint writer. The writer correctly
refused this state with `native_harness_storage_owner_missing`. The
failed close could remain for the next fresh session to retire.

**Expected behavior**

The current claim and renewal remain valid until the required
checkpoint, close and instruction collection join. Cleanup stops and
joins renewal, then releases only that exact claim. A changed owner is
not cleared. An earlier error keeps its identity.

**Steps to reproduce**

1. Run the new real-Postgres regression against the base commit.
2. Complete a structured failed warm turn through the normal
control-plane result producer.
3. Let the actual managed-session close call its production checkpoint
callback.
4. Observe the missing-owner refusal after coordinator release.

**Paperclip version or commit**

Base: `56517dda6c`. The native ownership order also exists on the
earlier failing revision.

**Deployment mode**

Native warm execution with durable harness storage and a managed remote
workspace.

## What Changed

- Join warm checkpoint, managed close and instruction collection before
coordinator release.
- Keep the existing lease renewal active through that work. Stop and
join it before release.
- Run the same fenced release after a checkpoint or collection error.
Preserve the initiating error unless restart detachment has won. Keep
the typed restart outcome and changed-owner fences. Attach only closed
cleanup-failure flags.
- Add real-Postgres ownership, delayed-renewal, error, retry and denial
cases. Route the managed-session regression through the existing
artifact-equipped native test lane. Update the continuity test for the
retained claim. Document the active renewal lifetime.

## Verification

- Red: the producing coordinator had an accepted result,
`phase=workspace_finalizing` and `leaseOwner=null` at managed close. The
production checkpoint callback refused it with
`native_harness_storage_owner_missing`.
- Green: all 9 real-Postgres regression cases pass. They cover a real
20-second renewal tick, a failed checkpoint, one force-fresh successor,
and wrong-boot and changed-owner denial. Actual restart detachment wins
even when checkpoint and joined renewal-stop both reject. The
replacement claim remains unchanged. No new provider turn or
cancellation receipt is created. A private canary does not enter the
closed diagnostic flags or an error cause.
- The executor, restart-recovery and heartbeat compatibility suites
pass: 1,259 tests, with 20 gated skips. The container-enabled continuity
case was not run locally. Its 21-second timer check remains. It now
requires the exact claim during `hasChanges()` and checks release after
the joined work.
- All 39 shard-routing and combined-coverage tests pass. The new
regression runs in the existing native lane with the required Runner
artifact.
- Final local head `0edcc37f35` passes `pnpm -r typecheck`, `pnpm
build`, `node scripts/check-maps.mjs` and the 9-case regression. The
earlier broader compatibility run used identical source bytes. A
separate earlier repo-wide local suite on `6cdd68e915` ended with exit
1. Its general-server stage had one GitHub enrollment acknowledgement
failure, 31,469 passing tests and 215 skipped tests. That
acknowledgement failure reproduced in one narrow run on the same old
tree. It is not an established flake or proven caused by this fix. Later
repository stages did not run. No duplicate broad suite or full local
pass is claimed.
- The first published head failed review for detach-error precedence and
the old continuity assertion. Its server shard also failed because the
new test lacked a Runner daemon in the general lane. Those failures
remain recorded. This candidate fixes their causes. Both exact-head
reviews are now 5/5. The hosted native-server lane ran all 9 new
ownership tests successfully. One unchanged tool-gateway rate-rule test
failed in the first current-head CI run. It passed previously and passed
locally, both narrowly and in its full 183-test file. The single
same-head failed-job rerun passed. All current-head hosted gates are
clean. The initial failure remains recorded; no test assertion was
weakened.
- The provider transport is an in-memory fixture. The managed wrapper,
production checkpoint callback, control-plane result and Postgres
ownership are real. No paid provider or live remote process is
exercised. This does not prove a deployed recovery or a remote stop
receipt.

## Risks

- A slow required checkpoint extends the lifetime of the existing active
renewal. Its cadence stays at 20 seconds for remote claims and 5 minutes
for local claims. It runs only while the admitted checkpoint, close and
collection remain active, then stops and joins on completion or
unwinding. The tests observe a real remote renewal tick. No idle timer,
new retry budget or new worker is added.
- The release still checks company, task, run, owner, attempt, boot,
PID, process start and expiry. Restart detachment retains its recovery
owner.
- The storage owner guard is unchanged. No migration, permission,
provider setting or deployment change is included.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, with reasoning, source inspection, local
code execution and read-only staging metadata. Context window and
reasoning effort are host-managed.

## 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-05 05:23:19 -06:00
a3313f6944 fix(ui): complete read recovery and enforce query error rendering (#18822)
## Thinking Path

> - Paperclip lets people manage agent work through the board.
> - The board reads task, agent, settings, plugin, and run data from the
server.
> - A deploy can interrupt those reads after content is already visible.
> - Local error branches can replace that content with an error or show
raw codes.
> - This pull request moves the remaining reads to the shared recovery
behavior.
> - The query-error guard prevents new local error patterns from
returning.

## Linked Issues or Issue Description

**What happened?**
Loaded lists, settings, task panels, and manual reads could clear their
content or show errors during a server restart. Some reads also disabled
the shared retry policy.

**Expected behavior**
Keep loaded content during a temporary failure. Show the shared
connection banner. Retry reads after recovery. Replace content after a
real access denial or missing resource, with readable copy.

**Steps to reproduce**
Open a task, settings page, or run log. Return 503 with
`tenant_app_unavailable` from the API. Restore the API and check that
content refreshes. Repeat with 403 and 404 to check the failure state.

Related merged work: Refs #15754, Refs #15772, Refs #17960. This change
covers remaining reads and enforcement. Browser persistence and the
outage browser suite follow separately.

## What Changed

- Repair an existing PostgreSQL deadline fixture at UTC boundaries. A
daily cap defers a heartbeat until both its original interval and the
next UTC day allow it. Keep the counted run in its seeded day and cover
noon, just before midnight and the first second after midnight with
explicit clocks and real PostgreSQL. Production scheduling is unchanged.

- Integrate the saved list, settings, shell, task, and manual-read
recovery changes against current master. Preserve the current task,
Inbox, and run-log structure.
- Add `useResilientFetch` for component-local reads. Preserve loaded
data, cancel stale requests, and retry through the shared connectivity
policy. Keep browser and pairing capabilities in memory.
- Retain run log chunks across stream changes and catch up after
recovery without duplicate chunks or stalled requests.
- Keep genuine authorization and missing-resource failures visible.
Translate known single-word codes such as `forbidden`. Show known skills
and disconnected-app failures before sibling loading states. Keep Retry
bound to its failed request.
- Make the query-error guard enforce by default and run it in the CI
policy job. Document the narrow exceptions and the read recovery
contract.
- Invalidate pending OAuth discovery when the operator leaves setup or
its context changes. Keep reserved-popup cleanup bound to the current
attempt.

## Verification

- Current read head `09d1db29dea` is merge-ready: all 59 check runs are
completed (54 successful, three skipped, two neutral), the legacy Snyk
status is successful, and both required `ci / verify` and `ci / e2e`
gates pass. Greptile Apex reviewed this exact head at 5/5 with no
unresolved threads; the branch has no merge conflicts. The current CI
run is `41748339602`.
- Current read head `09d1db29dea` includes master `683fa4725e9`. The
latest change touches only the existing server deadline test fixture.
Application source remains the previously verified `86376fa751e`
revision: all 60 affected skills/disconnected-app tests and both
application UI typechecks passed, along with token gates and the
enforcing query-error guard. Those UI suites were not repeated for this
test-only repair.
- The fixture repair passes all seven deadline integration cases and the
complete server typecheck, including the server test layer. The old
assertion deterministically fails at 23:59:06.888 UTC; its
one-second-old run also fails the first-second-of-day probe. The
corrected fixture passes noon and both midnight probes. The permanent
three-case coverage uses seeded 2099 clocks.
- Twelve new regressions fail against the previous implementation. Both
skills variants and disconnected-app pages show either request's 403 or
429 while the sibling pauses or remains fetching. Permission failures
stay visible. Retry stays usable for its failed request and recovers
independently. An initial test-fixture query-key typo was corrected
before the passing run. The finding is replied to and resolved. Greptile
reviewed prior application revision `86376fa751e` at 5/5 with no new
actionable findings or unresolved threads. The current test-only head
has now received its own Apex 5/5 review with no unresolved threads, and
its CI is successful as recorded above.
- The preceding OAuth fix passed all 237 setup tests on both application
branches. Its ten regressions prove that Back, Cancel, unmount, or
context changes retire initial discovery without creating a connection,
caching old identities, navigating an abandoned popup, or closing a
newer attempt.
- Earlier master integration `e992f770339` passed all 309 tests across
eight affected Slack, recovery, retry, submission and task files, plus
UI typecheck, token gates, enforcing query-error scan and seven guard
tests. The integration preserves Slack workspace reconnect and shared
recovery, readable execution errors, source-inspection company scoping,
and current submission behavior. Newly inherited retained-file recovery
controls keep cached data through transient failures and hide it after
definitive denial.
- The preceding cache head `97c0bc3966f` passed 1,038 focused
cache/write/editor/account/task/API tests. All 22 built-UI
outage/write/loading cases passed in 4.7 minutes on exact browser head
`b7deaaee5da`, with strict checking of all three specs. The latest
skills/app rendering fix is covered by its affected component suites;
those broader cases were not repeated for this separate read path.
- Historical broader UI, CLI and build results and unresolved aggregate
server/DB/adapter failures remain in the PR discussion. Interrupted CI
runs were audited and recorded separately; they are not counted as
passes. Controlled local outages do not prove hosted authentication or a
real Cloud restart/deploy. Real hosted acceptance remains required after
merge and deployment.

Commands for the latest fix:

```sh
pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx
pnpm --filter @paperclipai/ui typecheck
pnpm check:token-gates
node scripts/check-query-error-rendering.mjs --enforce --summary
```

## Risks

This migration touches many read surfaces. The main risks are initial
loading, request cancellation, retained authorization state, and run-log
paging. The tests cover those states. There are no server, schema,
dependency, or API contract changes. The follow-up browser suite and
hosted deploy acceptance are required before release acceptance.

Recovered commits retain their original authors. If squash-merging,
preserve `Co-Authored-By: Bender (Fable) <bender@agents.paperclip.ing>`
and the Paperclip trailer.

## Model Used

OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool
use, and code execution. The context window size is not exposed. Saved
source commits keep their original authorship.

## 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 described the issue in-PR following the bug report template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] The affected local tests pass; broader limitations are recorded
above
- [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 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: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Bender (Fable) <bender@agents.paperclip.ing>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 14:07:18 -08:00
Dotta 8002dfb06e fix(evals): repair bounded TestOps protocol admission (#19011)
## Thinking Path

> - Paperclip lets people manage AI agent work through a governed
control plane.
> - Runner protocol qualification uses real providers and records
selected cases in TestOps.
> - A manual registration run starts an Allure watch session before it
validates the case selection.
> - Watch creates the job run, but it does not fill the empty job-run
input in the child environment.
> - The selection helper therefore stops before it can validate the new
job run.
> - A configured AWS concurrency ceiling can also reject a valid smaller
hosted-runner request.
> - Hosted tool-cache permissions can also violate Runner
qualified-executable admission before a provider starts.
> - This pull request captures official session identities, keeps both
concurrency ceilings, and stages the same Node bytes in an owned
directory.

## Linked Issues or Issue Description

Refs #18985.

**What happened?**

The [first registration
attempt](https://github.com/paperclipai/paperclip/actions/runs/41877603952)
created an Allure job run, then stopped with
`runner_testops_jobrun_missing`. The child inherited an empty
`ALLURE_JOB_RUN_ID`. The helper made no job-run API read and executed no
provider case.

The current configured concurrency ceiling is 100. The controlled hosted
cap is 57. The original catalog guard rejects 100 before it applies a
valid requested value of 2. The [first service-selected
attempt](https://github.com/paperclipai/paperclip/actions/runs/41884365232)
reproduced this second pre-provider failure. Its selected case was
correctly reported as broken. No provider cell ran.

A [second service-selected
attempt](https://github.com/paperclipai/paperclip/actions/runs/41887671585)
passed selection, lock and cost preflight. Runner rejected a qualified
ACPX launch artifact as group- or world-writable. It recorded no model
request. The verified sidecar is mode 0755. Hosted Node is the only
other launch artifact, which points to Node as the cause. The original
run did not retain its exact mode. The TestOps reporter retained the
infrastructure failure.

**Expected behavior**

The manual path must capture the job-run and launch IDs from the active
official watch session. It must validate those IDs against the same
GitHub run, job, launch and standing plan before provider work. A
requested concurrency value must fit the configured ceiling and the
selected runner cap. The default must use the lower ceiling. Before
credentials, the worker must stage the same Node bytes in an owned
directory and preserve Runner permission and digest admission.

**Steps to reproduce**

1. Dispatch the trusted Runner protocol workflow with reporting enabled
and an empty job-run service input.
2. Let `allurectl watch` create the registration session.
3. Observe that the original helper rejects the empty child input before
selection validation.

## What Changed

- Call the supported `allurectl job-run env` command for manual
registration. Use a fixed argument list without a shell and bound its
time and output.
- Parse only numeric job-run and launch IDs. Reject missing, duplicate,
malformed or conflicting values. Discard all other command output.
- Bind the captured launch ID to the authoritative job-run response.
Keep the service-supplied ID path and its existing checks.
- Apply requested parallelism within both configured and physical
ceilings. Use their minimum as the default. Keep the selected-case
capacity and provider budget checks.
- Snapshot the same current Linux Node bytes into a fresh owner-only
directory before credentials. Verify the copy, execute it to bind its
path and version, and put only that directory first on the job-local
path. Record original and copied Node modes and hashes plus the actual
sidecar mode and hash. Keep shared cache permissions and Runner security
checks intact.
- Add focused context, identity, concurrency, permission and
secret-exclusion controls. Supply the production physical cap in the
existing release-workflow fixture.
- Fix the existing Kimi cancellation fixture to await the complete
readiness sentinel within the same 100 by 10 ms bound. Keep its
cancellation and cleanup assertions.

## Verification

- The original empty-ID failure reproduces locally on the actual helper
with all 41 standing cases and zero job-run API reads.
- All 63 Node publication, projection, metrics, permission and
workflow-security tests pass. The repaired manual context flows through
the actual selection helper to case 194. Wrong launch, duplicate IDs,
malformed values and command failures still stop selection.
- The original catalog prefix rejects configured 100 / hosted 57 /
requested 2. The repaired prefix admits 2, clamps a missing request to
57, keeps legacy 100, and rejects requests above either ceiling.
- The full release-policy CI command passes locally: 72 release-workflow
tests and 107 companion tests. The first sandbox run could not bind its
loopback fixtures; the same tests passed with local-network access. The
prior-head CI fixture omission is retained and fixed by supplying the
same physical cap as production.
- Both full static Node file groups pass locally: 418 passed and 11
existing opt-in skips, at concurrency 2. Initial runs used stale
dependency links and failed. Correcting only isolated ignored links to
declared cached dependencies and pnpm 9.15.4 removed those setup
failures. Local Node 26.4 differs from CI Node 24; the exact-head CI
below provides canonical verification.
- `actionlint .github/workflows/runner-protocol-live-evals.yml` and `git
diff --check` pass.
- The new staging helper passes all four tests as a nonroot Linux user
with networking disabled. An actual retained Runner comparison
reproduces the qualified-artifact rejection with Node mode 0777. The
same Node bytes at 0500 in a 0700 directory pass that launch boundary
and reach a closed provider session. The local turn still fails without
credentials or network; this is permission proof, not a protocol case
pass. Local Linux Node 24.21 differs from the hosted 24.22.1 executable.
- Exact final head `8e06ee5611` [repository
CI](https://github.com/paperclipai/paperclip/actions/runs/41892896405)
completed successfully. Typecheck, build and all four Runner lanes
passed. Runner package tests passed 4950 tests in 297 files, with 10
existing skips. Fresh exact-head Greptile review is 5/5 with no
actionable findings or open threads. Prior `59378f` CI Kimi
empty-sentinel race and canceled chat worker remain retained.
- The patched manual workflow has not run against live TestOps. A
separate service-triggered probe supplies its own ID. The service probe
used the original concurrency guard with a temporary configured ceiling
of 57. After the probe, the original repository ceiling 100 was restored
with no active campaigns. That probe does not verify this patched
concurrency code. The next launch waits for the protected merge and
source/lock verification.

## Risks

An incompatible official CLI output fails closed. The helper never
evaluates the output or records its unknown variables. The existing job,
workflow URL, source, plan and provider checks remain required. An
omitted concurrency request now uses the lower of the configured and
physical ceilings. Explicit requests above either ceiling still fail.
The worker now executes a private copy of its existing Node binary. A
copy, mode, hash, path, version or sidecar mismatch stops before
credential access. The copy does not make dynamically linked Homebrew
Node portable; this workflow path is Linux only. This change does not
alter the provider roster, policy, limits, retries or result grading.

## Model Used

OpenAI Codex, GPT-6. The exact hosted model ID and context window are
not exposed in this session. Used reasoning, code editing, repository
inspection and tool 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 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
2026-11-04 15:22:59 -06:00
c3715e74a6 perf(ci): let the public-install registry reuse warmed scoped metadata (#16838)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Every pull request runs the `ci / Build` check. This check was the
slowest check in recent green runs: 432s in run 39254079691, 480s in run
39251304886 and 472s in run 39245233967.
> - The slowest step in Build is "Verify built-in Grok from a clean
public npm install" (162s and 207s). Its consumer `npm install` took 69s
and 75s, even though a background step fills the npm cache first.
> - The loopback registry in `scripts/public-install-registry.mjs` sends
packages that it does not serve to npmjs with a 302 redirect. It escaped
scoped names with `encodeURIComponent` (`%40scope%2Fname`). npm requests
and caches the same metadata as `@scope%2fname`, and npm keys cached
metadata by URL.
> - Thus the consumer install could not use the scoped metadata that the
warm-up had just put in the cache. It downloaded and parsed all 291
scoped packuments again (about 285 MB of full packument JSON) inside the
4-vCPU container.
> - This pull request makes the redirect use the same escaping as npm.
> - The benefit is a shorter Build check on every pull request. The
installed bytes do not change.

## Linked Issues or Issue Description

**Describe the bug**
The consumer install in the public-install verifier does not use cached
scoped package metadata. The warm-up step
(`scripts/warm-public-install-npm-cache.mjs`) caches this metadata
through direct npm requests.

**Steps to reproduce**
1. Run `PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE=/tmp/c node
scripts/warm-public-install-npm-cache.mjs`.
2. Install the same dependency set through the loopback registry with
`npm_config_cache=/tmp/c` and `--loglevel http`.
3. Count the `(cache miss)` lines.

**Expected behavior**
No cache misses for metadata that the warm-up already fetched.

**Actual behavior**
291 cache misses. All of them are scoped packages that are requested as
`https://registry.npmjs.org/%40scope%2Fname`. The cache holds them as
`https://registry.npmjs.org/@scope%2fname`.

Related: #16513 changes when the verifier waits for the warm-up. This
pull request does not overlap with it, and the two changes add together.

## What Changed

- `scripts/public-install-registry.mjs`: the 302 redirect for packages
that the registry does not serve now uses npm's own escaping
(`name.replace('/', '%2f')`, the same as npm-package-arg `escapedName`).
The `packageName` check already allows only characters that need no
other escaping.
- `scripts/__tests__/public-install-registry.test.mjs`: the test now
expects `@other%2fpkg` for a scoped redirect. A new assertion covers an
unscoped redirect.

## Verification

- `node --test scripts/__tests__/public-install-registry.test.mjs`: 3 of
3 pass. This includes the real npm install test that compares installed
bytes from file installs and registry installs.
- Local replay (Linux, npm 11.12.1). I filled a fresh cache with the
warm-up script, then installed the same 62-dependency set (627 packages)
through the registry:
  - Before: 291 cache misses, 28.3s.
  - After: 0 cache misses, 19.8s.
- I also ran the real `scripts/public-install-registry.mjs` with a
packed root package that depends on the full public dependency set.
Result: 2 misses (only the loopback package and its tarball, as
expected) and 1438 cache hits.
- To compare on CI, look at `consumer_install_ms` in the
`paperclip.public-npm-install.timing.v1` line of the Build log.
Baseline: 68822 ms (run 39254079691) and 75175 ms (run 39251304886).

## Risks

- Low risk. The redirect target is the same npmjs document. Only the URL
escaping changes. npm still checks every tarball against the integrity
in the packument.
- If the npm cache holds stale scoped metadata, npm revalidates it the
same way as it does for direct requests (`cache-control: max-age=300`).
- The expected time saving comes from a local measurement. The real
Build saving depends on the network and CPU of the runner. I expect
about 10–20s per Build run. The CI timing line will confirm the number.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code as a Paperclip agent with tool use (shell, file edit). I did the
root-cause analysis, the local replays and the code change with this
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
- [ ] I will address all Greptile and reviewer comments before
requesting merge

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

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:10:00 -08:00
Devin FoleyandPaperclip e65c68726b Require published migrators before publishing Core images (#19023)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its release pipeline publishes Core images and exact-source database
migrators.
> - A migrator run can pass admission but skip all build and publication
work.
> - The image observer could stop checking after provisional build
admission.
> - The final image gate checked source proof without checking migrator
publication.
> - This pull request requires the exact migrator publisher to finish
before image publication.
> - Consumers can then use the published image with its matching
migration artifact.

## Linked Issues or Issue Description

**What happened?**

The [migrator run for
e968131](https://github.com/paperclipai/paperclip/actions/runs/41868122685)
passed its admission job but skipped build and publish. Its [Docker
run](https://github.com/paperclipai/paperclip/actions/runs/41868122627)
still published an image. The exact-source migrator artifact was absent.

**Expected behavior**

An image consumer restarts a strictly deferred producer. It waits for
the exact migrator publication before it publishes the image. A
successful admission job alone does not prove publication.

**Steps to reproduce**

1. Let a migrator run defer work while the image consumer starts.
2. Make the producer appear admitted before its build later becomes
skipped.
3. Complete the source verification job.
4. Observe that the old image gate can accept source proof without a
successful migrator publisher.

The regression fixtures model this state. The real producer records
establish the missing publication; the exact transient API response seen
by the original observer is not retained.

**Paperclip version or commit**

The observed source was `e968131efa7e63d321f3bcca8d47a62513c2efb4`. This
patch is based on current master.

No duplicate fix was found in open migrator PRs. PR #18406 concerns npm
canary scheduling and does not provide this image publication gate.

## What Changed

- Require the successful canonical migrator `publish` job for the exact
source and latest attempt. Check complete job pages and re-read the run
after the jobs.
- Continue observing deferred producers until publication, even after a
build appeared admitted. Only a physical runner proves build admission.
- Check migrator publication again in the final read-only
`--check-proof` image gate. Failed producers still require inspection
and are not retried automatically.
- Add regression coverage for deferred recovery, skipped and stale
publication, attempt races, and the final CLI gate. Update release
documentation.

## Verification

- All 57 current-head check contexts are successful or expected skips on
`daed1c756a9fc02b8c15128b96af4da86d447e3f`; both required checks (`ci /
verify`, `ci / e2e`) passed. Greptile is 5/5 with no review threads, and
the branch has no merge conflicts. GitHub master rules require CODEOWNER
review before merging.
- The first CI attempt failed serialized server shard 2/9:
`heartbeat-process-recovery.test.ts` timed out after three seconds
waiting for an adapter execution to start (1 failed, 411 passed).
[Failed
job](https://github.com/paperclipai/paperclip/actions/runs/41894941958/job/126802368555).
This PR changes no server or package files. The exact timeout cause is
not established; this failure is retained and is not described as
infrastructure noise.
- The same first attempt also failed general server shard 6/12:
`heartbeat-stale-queue-invalidation.test.ts` waited five seconds for an
adapter execution after native-owner completion; the run remained
running with no error (1 failed, 2,627 passed, 3 skipped). [Failed
job](https://github.com/paperclipai/paperclip/actions/runs/41894941958/job/126802369329).
Its cause is also not established. The first-attempt failures remain
part of this record.
- The single controlled failed-jobs rerun completed successfully on
attempt 2 at the identical commit. Both previously failed heartbeat
shards and the aggregate verifier passed. This demonstrates recovery on
rerun; the underlying first-attempt failure causes are not established.
[Successful
rerun](https://github.com/paperclipai/paperclip/actions/runs/41894941958/attempts/2).
- Node 24: `node --test scripts/cloud-source-verification.test.mjs
scripts/standard-image-contract.test.mjs` passed: 62 tests, 0 failures,
after rebasing on current master.
- An explicitly gated read-only live probe rejected the real skipped
producer for `e968131` and accepted the published producer for
`afbc5a885b52e96df10c1c6e643bea31b51caac8`.
- `git diff --check` and Gitleaks passed before push.
- A broader run that also included
`scripts/__tests__/release-verify-workflow.test.mjs` failed when local
fixture creation and package installation hit `ENOSPC`. Those failures
remain unverified locally; the focused source and image tests passed
separately, and CI Typecheck + Release Registry subsequently passed.
- Full workspace typecheck, Vitest and app builds were not run locally.
Disk capacity does not permit installing the full workspace in this
separate checkout. No application TypeScript or package manifest
changed. The complete CI run subsequently passed, including typecheck,
build, general and serialized tests, release checks and e2e.
- No live Slack posts, workflow dispatches, producer reruns, deployments
or provider mutations.

## Risks

- Image publication now waits for the exact migrator publisher. A failed
publisher blocks the image and reports a publication failure. Existing
source failure cancellation still applies.
- This adds bounded Actions reads to the existing source observer. The
observer keeps its existing deadline. The final publication runner only
reads evidence and cannot restart producers.
- The publisher job name is part of this gate. A workflow rename must
update the gate.
- No database schema, migration content, image contents, credentials or
application behavior change. Revert this PR to restore the prior
publication gate.

## Model Used

- OpenAI `gpt-6.1-sol`, xhigh reasoning effort, through Codex in T3
Code. Source inspection, tool use and code execution. Context window
size is not exposed by the harness.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass — focused tests pass;
broader local ENOSPC failures are recorded above
- [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-04 11:51:42 -08:00
Devin FoleyandPaperclip cf0ab4f7f4 chore: enable boxd plugin release publishing (#19020)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Sandbox provider plugins let those agents run in remote
environments.
> - The boxd provider exists, but its release entry excludes it from CI
publishing.
> - npm now has the `@paperclipai/plugin-boxd@0.0.0` bootstrap
placeholder.
> - This pull request enables CI publishing and documents npm
installation.
> - A pre-existing OpenCode fixture blocked CI because it qualified a
group-writable host Node binary.
> - This also gives that test its own Linux Node copy with strict
permissions.
> - Operators can install a real release after CI publishes it.

## Linked Issues or Issue Description

Related implementation:
https://github.com/paperclipai/paperclip/pull/17284.
No matching public issue or duplicate release-enrollment PR was found.

**What existing behavior does this improve?**

Publishing and installing the existing boxd sandbox provider.

**Subsystem affected**

`packages/plugins` and the npm release manifest.

**Current behavior**

`publishFromCi` is false. npm has only the `0.0.0` placeholder. The
README says the package is not published and requires a local-path
install.

**Proposed behavior**

Include boxd in CI releases. Document stable installation, explicit
published versions, and the placeholder limit.

**Reason and benefit**

Operators can install the provider through the existing npm plugin
installer after a real release ships.

**Breaking changes**

None. This changes release enrollment and documentation only.

**Additional context**

This follows the existing plugin and sandbox roadmap items. It adds no
new runtime capability.

## What Changed

- Set `publishFromCi: true` for `@paperclipai/plugin-boxd` in the
release manifest.
- Replace the README's unpublished-package notice with npm installation
guidance.
- Explain that the `0.0.0` placeholder has no plugin functionality and
that `latest` can point to it until the first stable release.
- Keep the local development instructions available.
- Fix the existing ingress-retry test to use an owned Linux Node copy
and explicit executable permissions. This follows the existing OpenCode
transport fixture. It does not change production launch checks.

## Verification

- Passed: `node --test scripts/release-package-map.test.mjs
scripts/check-release-package-bootstrap.test.mjs
scripts/bootstrap-npm-package.test.mjs` (31 tests).
- Passed: `node scripts/release-package-map.mjs check`.
- Passed: `PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA=origin/master node
scripts/check-release-package-bootstrap.mjs
scripts/release-package-manifest.json
packages/plugins/sandbox-providers/boxd/README.md`.
- Passed: `git diff --check`.
- Confirmed that npm resolves `@paperclipai/plugin-boxd` to version
`0.0.0`.
- Full workspace typecheck, test suite, and build were not run locally.
CI checks remain required before merge.
- Initial CI failure: [native Runner
job](https://github.com/paperclipai/paperclip/actions/runs/41889969053/job/126785636239),
OpenCode ingress retry, `qualified OpenCode proxy command must not be
group- or world-writable`. The failing fixture is byte-identical to the
base commit `3a2249dd576`. It passed `process.execPath` to
qualification, while other OpenCode tests already use a private Linux
Node copy.
- Local focused run: `pnpm exec vitest run
server/src/services/native-runtime/native-ingress-bootstrap-retry.integration.test.ts`
built the real Rust Runner and passed the Codex case. The OpenCode case
stopped at `runner_remote_provider_artifact_incompatible: OpenCode proxy
path belongs to the controller host` on macOS (`/Users/...`). This does
not exercise the new Linux-only copy.
- An unchanged-base OpenCode comparison was attempted with `-t
"reacquires opencode ingress"`, but PostgreSQL setup failed with
`ENOSPC` before the case ran. The local baseline comparison is therefore
inconclusive. Task-owned Rust build outputs were cleaned to recover disk
space.
- At this stage Linux verification was pending. The successful final run
is recorded below.
- No new tests were needed. The existing release-tool tests cover
manifest enrollment, dependency ordering, and bootstrap checks.

- Current-head CI interruption: [workspaces-a shard
1/2](https://github.com/paperclipai/paperclip/actions/runs/41893808561/job/126799012853)
was cancelled after the runner received a shutdown signal. The log shows
no assertion failure. The job rerun request was rejected with HTTP 403
because the workflow was still running. The subsequent fixture
correction starts a new full CI run and supersedes that pending rerun.

- CI at `7fffa315`: [native Runner
job](https://github.com/paperclipai/paperclip/actions/runs/41893808561/job/126799011711)
passed executable permission checks, then failed because Node tried to
resolve `/memfd:paperclip-verified-launch (deleted)`. The private
executable was named `opencode-node`, which bypassed Runner's Node
interpreter recognition. The fixture now names its copy `node`, matching
the existing transport fixture and selecting the CommonJS descriptor
loader.
- Passed a local smoke test of the production CommonJS descriptor loader
using an open, unlinked script file. This is macOS evidence only; the
successful final Linux run below provides the complete ingress retry
evidence.

- Final verification on `bcacac542aa0a1b641b26c099c1e9fc4a1653ec4`: all
54 reported checks passed; three unrelated checks were skipped. [Native
Runner integration
job](https://github.com/paperclipai/paperclip/actions/runs/41895787712/job/126805844773)
passed, including the OpenCode ingress retry regression. The fresh full
run also passed the workspace shard that was interrupted on the previous
attempt. Greptile reports 5/5 for this exact head with no unresolved
review threads. Earlier failures and local verification limits remain
recorded above.

## Risks

- The maintainer confirmed that npm trusted publishing is configured for
this package. The first real CI publish will verify OIDC authentication.
- Merging enables publication in the next successful canary run. An
installation that uses `latest` can still resolve to the placeholder
until the first stable release includes this change.

## Model Used

OpenAI Codex, exact model ID `gpt-6.1-sol`, with xhigh reasoning effort,
repository tool use, and shell execution. The runtime does not expose
the 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-04 11:36:04 -08:00