Commit Graph
2 Commits
Author SHA1 Message Date
026bdcd046 perf(ci): run the verifier's bundled-dependency installs concurrently (#15868)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Every pull request runs the `Build` check. Its last step is the
public npm install verifier, `scripts/verify-grok-npm-install.mjs`,
which stages the eighteen public packages and installs them in a clean
consumer.
> - The verifier stages the packages in one serial loop. Three packages
bundle dependencies (`@paperclipai/server`,
`@paperclipai/adapter-utils`, `@paperclipai/db`). Each one runs a
host-side `npm install` of its bundled closure in-process, one after the
other, and nothing in the loop overlaps anything else. The staging phase
took 102-116s on recent `Build` jobs.
> - `Build` at 433-530s is the slowest check in most PR runs once the
server shards drop, so its staging phase sets the wall-clock time of the
run.
> - The three installs write only their own directories and read nothing
from each other. There is no reason to run them one after the other.
> - This pull request starts the three installs together as child
processes and copies the other fifteen packages while they run. Packing
stays serial and in order.
> - The benefit is a shorter `Build` job, and a timing line that reports
each install, so the saving is measured on this PR's own run instead of
estimated.

## Linked Issues or Issue Description

Refs #15861. This branch is stacked on the branch of #15861 because the
same execution branch holds that open PR. The diff here is this PR's
three commits. After #15861 merges, this PR is retargeted to `master`
and rebased.

**What existing behavior does this improve?**

The `Build` PR check. Its public npm install verifier stages the
eighteen public packages in one serial loop.

**Subsystem affected**

CI and release scripts (`scripts/verify-grok-npm-install.mjs`,
`scripts/prepare-bundled-package.mjs`, `scripts/prefetch-child.mjs`,
`scripts/prefetch-supervisor.mjs`).

**Current behavior**

The staging loop calls `prepareBundledPackage` in-process for the three
packages that bundle dependencies. Each call runs a host-side `npm
install` of that package's bundled closure, applies the repo patches,
and rewrites the manifest, before the loop moves to the next package.
The timing line reports the whole staging phase as one number, 102-116s
on recent runs. The three installs are the only network work in that
phase.

**Proposed behavior**

Stage the three bundled packages' sources first, start their installs at
the same time as children of the script's own CLI entry (the same path
`release.sh` uses), copy the other fifteen packages while the installs
run, join the installs, and then pack every package in dependency order
as before. Report `stage_sources_ms`, `stage_bundled_ms`,
`stage_pack_ms`, `bundled_wait_ms` and one `stage_bundled_<package>_ms`
per install on the timing line.

**Reason and benefit**

The staging phase shortens by the two shorter installs. Measured on this
PR's first run (below): the three installs sum to 25.9s and take 16.4s
of wall time together, so the overlap removes about 9.5s from the phase,
and the timing line now shows that packing (35.8s) and the
standalone-package build before the loop (about 27s) are the larger
remaining costs. The original estimate of 30-50s was too high because it
assumed the installs were about half of the phase; they are a quarter.

**Breaking changes**

None. The packed tarballs are produced by the same script, in the same
order, and are checked the same way.

## What Changed

- `scripts/stage-bundled-packages.mjs` (new): `startBundledInstalls`
starts one child per bundled package through the prefetch helper from
#15849, with the child's output in a log file. `bundledInstallTimings`
reads each child's wall time for the timing line. `bundledPackageSlug`
maps a package name to its log and timing key.
- `scripts/verify-grok-npm-install.mjs`: the staging loop is split. The
bundled packages' sources are staged first and their installs start
together. The other packages are copied while the installs run. The
install handles are pushed into the `prefetches` list at once, so an
earlier failure stops any install still running before the scratch root
is removed. The join comes before the first `npm pack`. The pack loop
walks every package in `needed` order and checks each tarball as before.
`prepareBundledPackage` is no longer imported.
- Timing line: `stage_sources_ms`, `stage_bundled_ms`, `stage_pack_ms`,
`bundled_wait_ms` and `stage_bundled_server_ms`,
`stage_bundled_adapter_utils_ms`, `stage_bundled_db_ms` are added.
`stage_packages_ms` and the download keys are unchanged, so the before
and after numbers compare directly.
- `scripts/__tests__/stage-bundled-packages.test.mjs` (new): five tests
with local Node children and no network. The slug mapping; the children
run the CLI entry `release.sh` runs and never an in-process call; two
installs run at the same time, each with its own source and target, and
their logs and timing keys are correct (each child exits only after the
other has started, so serial children would fail); a failed install
rejects with its own log tail while the other is still running and is
then stopped; and stopping the installs after one fails also stops the
stand-in npm process the other wrapper is blocked on.
- `scripts/__tests__/grok-public-install-sandbox.test.mjs`: one guard
test on the verifier source. The bundled sources are staged before the
installs start, the installs are registered for cleanup before the other
packages are copied, the join comes before the pack loop, the pack loop
is in order, there is still exactly one package pack loop, and the
timing keys are reported.
- `package.json`: the new test file is in `test:release-registry`.

### Review fix (second commit): stop a prefetch's whole process tree

Greptile pointed out that `stopPrefetches` signalled only the Node
wrapper of each install, while the wrapper is blocked in
`execFileSync('npm', ['install', ...])`. After one install failed, the
other wrapper died and its npm process kept writing into the scratch
root the verifier was about to remove.

- `scripts/prefetch-child.mjs`: on POSIX every prefetch child is started
`detached`, so it leads its own process group. `stopPrefetches` signals
the group (`process.kill(-pid, ...)`) and waits until no member of the
group exists, with `SIGKILL` after the grace period and a second grace
period before it gives up so cleanup cannot hang. On Windows the child
is signalled directly as before.
- `scripts/__tests__/prefetch-child.test.mjs`: two tests. A stopped
prefetch's grandchild is gone after `stopPrefetches` returns, and a
`SIGINT` to a separate parent process reaches the grandchild while the
parent still exits with `SIGINT`. Both are skipped on Windows.
- `scripts/__tests__/stage-bundled-packages.test.mjs`: one test, the
cancellation case Greptile asked for. The stand-in
`prepare-bundled-package` blocks in `execFileSync` on a stand-in npm
that announces its pid; after the other install fails, `stopPrefetches`
must leave that pid gone.

All three new tests fail against the previous helper (`grandchild <pid>
outlived stopPrefetches`, `outlived the parent's SIGINT`, `stand-in npm
<pid> outlived stopPrefetches`) and pass with the fix.

### Review fix (third commit): the parent runs no signal handler

Greptile pointed out that the second commit forwarded the terminal's
signals to the detached groups through a JavaScript handler in the
parent. The verifier spends most of its time blocked in `execFileSync`
(the standalone builds, every `npm pack`), where no handler can run, and
installing the handler also replaced the parent's immediate default
termination. So a Ctrl-C during a synchronous phase did nothing until
that command returned, and the detached installs and downloads kept
running meanwhile. Confirmed with the new test against the second
commit's helper: the parent survived the `SIGINT`, exited with code 1
once the synchronous command had died, and its handler never ran.

- `scripts/prefetch-supervisor.mjs` (new): every prefetch command now
runs under this small Node script as the leader of its own process
group. The supervisor holds a pipe from the parent on stdin (which the
parent writes to only to request a stop where it cannot signal the
group; see the fourth commit). The kernel closes it when the parent
exits for any reason, including a signal delivered while the parent is
blocked, or `SIGKILL`, and the supervisor then stops its group
(`SIGTERM`, `SIGKILL` after the grace period) and says so in the log. It
passes group-wide signals on to the command and stays alive until the
command exits, so its own exit reports the command's exit code or
signal. Run by hand with a terminal or `/dev/null` on stdin it watches
nothing.
- `scripts/prefetch-child.mjs`: `startPrefetch` spawns the supervisor
with the command, with stdin as the pipe. The forwarding handlers are
gone; the parent installs no signal handler, so its default termination
stays immediate. `stopPrefetches` is unchanged.
- `scripts/__tests__/prefetch-child.test.mjs`: the first test asserts
the parent has no `SIGINT`/`SIGTERM`/`SIGHUP` listener after a prefetch
starts. The "signal to the parent" test now runs for `SIGINT` and
`SIGKILL` and checks the supervisor's log line. One new test gives a
separate parent its own process group, blocks it in `execFileSync` on a
20s command, and sends `SIGINT` to that group the way a terminal sends
Ctrl-C: the parent must die of `SIGINT` within two seconds and the
prefetch's grandchild must be gone. Against the previous helper the
parent exits with code 1 instead, the grandchild outlives `SIGKILL`, and
the handler-count assertion fails.

### Review fix (fourth commit): a deliberate stop on Windows goes
through the pipe

Greptile pointed out that on Windows, where `startPrefetch` now returns
the supervisor, `stopPrefetches` called `child.kill('SIGTERM')` on it.
Windows has no process groups and `kill` there ends a process outright,
so the supervisor died without running its handler and its command kept
running after cleanup returned.

- `scripts/prefetch-child.mjs`: without process groups, the graceful
stop no longer signals the supervisor. `stopPrefetches` writes a stop
request to the pipe the supervisor already watches for the parent's exit
(`stdin.end('stop\n')`). Only the `SIGKILL` escalation targets the
supervisor itself, after it has had its grace period. POSIX keeps the
group signal, so `stopPrefetches` and the existing stop and failure
tests are unchanged there.
- `scripts/prefetch-supervisor.mjs`: data on stdin is a stop request and
EOF is the parent's exit; both end the group the same way (`SIGTERM`,
`SIGKILL` after the grace period) and the log line names which one it
was.
- `scripts/__tests__/prefetch-child.test.mjs`: one new test writes the
stop request to a running prefetch's pipe instead of calling
`stopPrefetches`. The supervisor must exit with `SIGTERM`, the command's
grandchild must be gone, and the log must say `stop requested` and not
`parent exited`. The previous supervisor fails it on the log line, since
it treated the request as the parent's exit. The Windows kill itself
cannot be reproduced on the Linux runners, so the Windows branch of
`signalTree` is verified by that supervisor test plus reading its two
lines. The stand-in that ignores `SIGTERM` is now skipped on Windows,
where `kill` cannot be ignored.

## Verification

- `node --test scripts/__tests__/stage-bundled-packages.test.mjs
scripts/__tests__/grok-public-install-sandbox.test.mjs
scripts/__tests__/prefetch-child.test.mjs`: 30 tests pass (was 19 before
this PR, 24 after the first commit, 27 after the second, 29 after the
third). The three files pass three consecutive runs.
- `node --check` passes on all four scripts.
- The verifier itself needs Docker and a network, which this workspace
does not have. This PR's own `Build` job runs the branch's verifier.
Measured on all four commits' runs
([38132868803](https://github.com/paperclipai/paperclip/actions/runs/38132868803):
`Build` 428s, `pnpm build` 112s, verifier step 214s;
[38136912842](https://github.com/paperclipai/paperclip/actions/runs/38136912842):
`Build` 447s;
[38139871529](https://github.com/paperclipai/paperclip/actions/runs/38139871529):
`Build` 432s;
[38142762538](https://github.com/paperclipai/paperclip/actions/runs/38142762538):
`Build` 457s, `pnpm build` 123s, verifier step 231s):

| Key | Run 1 | Run 2 | Run 3 | Run 4 | Before |
| --- | --- | --- | --- | --- | --- |
| `stage_packages_ms` | 80.4s | 84.9s | 81.5s | 89.6s | 102-116s on
master runs 38112764439, 38111191543, 38110932348, 38110914319,
38110897745, 38127611057; 84.5s on #15849's run on a runner as fast as
these |
| `stage_sources_ms` | 1.4s | 0.7s | 0.9s | 0.9s | (inside the number
above) |
| `stage_bundled_server_ms` / `adapter_utils` / `db` | 16.3s / 5.0s /
4.6s | 14.9s / 3.6s / 3.3s | 16.4s / 5.2s / 4.7s | 17.1s / 4.4s / 3.8s |
ran one after another, 21.8-25.9s together |
| `stage_bundled_ms` (wall, all three) | 16.4s | 15.0s | 16.5s | 17.2s |
21.8-25.9s |
| `bundled_wait_ms` | 15.3s | 14.7s | 16.1s | 16.7s | — |
| `stage_pack_ms` | 35.8s | 37.1s | 35.6s | 37.9s | (inside the number
above) |
| `consumer_install_ms` | 94.4s | 97.8s | 95.7s | 99.8s | 97-134s |
| `image_prefetch_ms` / `image_wait_ms` | 33.7s / 0ms | 31.8s / 0ms |
34.7s / 0ms | 33.7s / 0ms | unchanged |
| `grok_prefetch_ms` / `grok_wait_ms` | 2.1s / 0ms | 2.1s / 0ms | 2.0s /
0ms | 2.1s / 0ms | unchanged |

The overlap removed about 7-10s from the staging phase on each of the
four runs (21.8-25.9s of installs in 15.0-17.2s of wall time). The third
and fourth commits changed no timing: both waits stay at 0ms and the
staging phase sits inside the band of the first two runs. The rest of
the phase is the standalone-package build and the server UI dist prep
before the loop (about 27-32s) and the eighteen serial `npm pack` plus
`tar -tzf` checks (36-37s).

- `docker-runner-check.yml` runs the same script after `pnpm build` and
needs no change.

## Risks

- Three `npm install` processes share the host npm cache at the same
time. npm's cache (`cacache`) is designed for concurrent writers, and
the three installs have disjoint target directories. If a shared-cache
race appears, the failing install's log tail is in the error and the fix
is to serialize again. The first CI run showed none.
- The install output moves from the CI log into per-install log files in
the scratch root. A failure reports the last 2000 characters of the
failing install's log; a successful install's output is discarded with
the scratch root.
- Memory: three concurrent installs on the `Build` runner instead of
one. The closures are small (the server's eight bundled packages are the
largest), so this stays well under the runner's memory.
- Process groups: a detached child no longer receives the terminal's
signals on its own. The supervisor stops its group when the parent is
gone, which it learns from the kernel closing the stdin pipe, so this
holds for a Ctrl-C while the parent is blocked in synchronous work and
for `SIGKILL` alike. The parent keeps Node's default signal handling. If
the supervisor itself is killed with `SIGKILL` before its command, the
command is orphaned and finishes on its own (each install ends within
seconds; the image pull within a minute), the same as any orphaned
grandchild before this PR.
- One extra Node process per prefetch (five per verifier run). Each
costs about 40ms to start, inside the time the parent spends staging
anyway.
- Low risk otherwise. Packing, the tarball checks, the consumer install,
and every probe are unchanged.

> 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 Fable 5.1 (Anthropic), model ID `claude-fable-5-1`, run as a
Paperclip agent through the Claude Code local adapter with tool use
(shell, file edits, GitHub CLI). Reasoning mode: 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 on all four commits
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(on the fourth commit Greptile reviewed 8 files and added no comments;
all three earlier threads are resolved)
- [x] I will address all Greptile and reviewer comments before
requesting merge

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

---------

Co-authored-by: Bender (Fable) <bender-fable@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
2026-10-11 11:19:44 -07:00
42ecc96556 perf(ci): prefetch the consumer image and Grok binary during package staging (#15849)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Pull requests to this repository run a large CI matrix. The slowest
check sets the time a contributor waits for a green run.
> - After #15826 leveled the server shards and #15831 split
`workspaces-b`, the `Build` lane is the slowest check: 634s in run
38107578322 (2026-10-11 03:11Z), ahead of the next server shard at 574s.
It was 533s in run 38107430861 and 543s to 568s in earlier runs that
night.
> - `Build` is about 100s of setup, 148s to 195s of `pnpm build`, and
278s to 320s of `scripts/verify-grok-npm-install.mjs`. The verifier
stages and packs the public packages first (84s to 100s of CPU work),
and only then pulls the pinned `node:24-trixie` consumer image (8
layers, 440 MB compressed) and downloads the Grok binary (65 MB gzip,
166 MB verified). Both network waits sit after the staging work on the
critical path.
> - Neither download depends on the packed packages. The image is pinned
by digest and the Grok binary is checked against its pinned sha256 by
the provisioning script, so starting them early changes no verified
property.
> - This pull request starts both downloads as background children
before staging and joins them where they are needed: the image before
the first sandbox, the binary after the negative probe. It also prints
one timing line per phase so the next slow part is visible in the CI
log.
> - The benefit is that the whole pull and the whole Grok download hide
behind the staging phase. The verifier should shed the full pull time on
a fresh runner, and the next cycle can read the phase split instead of
inferring it from log gaps.

## Linked Issues or Issue Description

**Describe the enhancement**

The `Build` PR check is now the slowest check in the run. Its last step,
the public npm install verifier, pulls the consumer Docker image and
downloads the Grok binary only after it has finished staging and packing
the eighteen public packages. Both downloads could run while the
packages are staged.

**Why is this needed**

The slowest check decides the wall-clock time of every PR run. In the
most recent fully green run (38107578322), `Build` took 634s: `pnpm
build` 195s and the verifier 320s. The verifier's log shows the staging
phase ends at +100s and the Grok result arrives at +267s. The image pull
and the Grok download are inside that second window, in series with the
consumer install and the probes.

**Proposed solution**

Start `docker pull --platform linux/amd64 <pinned image>` and
`provision-grok.mjs` as background children at the top of the verifier,
before the staging loop. Await the image right before the first sandbox
call. Provision Grok into a prefetch directory and rename it into
`native/grok` only after the "npm must not provision Grok" assertion and
the negative probe, so that assertion keeps its meaning and only the
positive probe sees the binary. Print a
`paperclip.public-npm-install.timing.v1` line with wall time per phase
and the time the main flow waited on each prefetch.

**Alternatives considered**

- Run the verifier as its own job in parallel with `Build`. It needs the
full `pnpm build` output, so that job would be as long as `Build` is
now.
- Warm the consumer npm cache from an Actions cache. That changes the
"clean public npm install" property the verifier reports. Not done here;
the new timing line will show whether the consumer install is worth that
discussion.
- Pack the eighteen packages concurrently. The three bundled packages
run their own npm installs in series inside `prepareBundledPackage`,
which is synchronous. A larger restructure; left for a later cycle with
timing data.
- Overlap the Pi runtime setup with the Grok probes. Both use the same
consumer directory from separate containers, and the probes after
provisioning are short. Not worth the concurrency on shared state.

## What Changed

- `scripts/prefetch-child.mjs` (new): `startPrefetch({ name, cmd, args,
cwd, env, logPath })` spawns a background child with no inherited
terminal and its stdout and stderr in a log file, and returns `{ name,
child, done, durationMs }`. `done` resolves on exit 0 and otherwise
rejects with the tail of the log; the rejection is observed inside the
helper, so an unawaited failure never becomes an unhandled rejection.
`durationMs` ends at the child's last write to its log (the exit event
is observed late when the caller is blocked in synchronous work; both
downloads print their final status line as they complete).
`stopPrefetches(prefetches, { graceMs })` sends `SIGTERM` to every child
still running, escalates to `SIGKILL` after the grace period, and
resolves only once all of them have exited.
- `scripts/verify-grok-npm-install.mjs`: the image pull and the Grok
provisioning start through the helper inside the `try` block, before the
staging loop. The main flow awaits the image before the first `npm
install` sandbox and awaits Grok after the negative probe, then renames
the staged binary into `native/grok` before the positive probe. The
`finally` block awaits `stopPrefetches` and only then removes the
scratch root, so cleanup never races a child's writes. The script prints
one timing JSON line after the Pi probe, with the per-phase wall times,
the two `*_wait_ms` values, and the corrected `*_prefetch_ms` values.
- `scripts/__tests__/prefetch-child.test.mjs` (new): seven tests run
small local Node children, with no Docker or network: a successful
prefetch resolves and keeps its output; the duration ends at the
download even when the parent is blocked for 1.5s; a nonzero exit
rejects with the log tail; a command that cannot start rejects instead
of hanging the join or the stop; stopping a running child waits for its
exit; a child that ignores `SIGTERM` is killed after the grace period;
an unawaited failure does not produce an unhandled rejection in a fresh
process.
- `scripts/__tests__/grok-public-install-sandbox.test.mjs`: the source
test asserts that exactly the pinned image is pulled for `linux/amd64`,
that Grok is provisioned into the prefetch directory and never straight
into `native/grok`, that both prefetches start before staging, that the
image is joined before the first sandbox, that the rename happens after
the npm assertion and the negative probe and before the positive probe,
that the verifier imports the helper, and that `stopPrefetches` is
awaited before the single `rmSync(root, ...)`.
- `package.json`: `test:release-registry` runs the new test file.

## Verification

- `node --test scripts/__tests__/prefetch-child.test.mjs
scripts/__tests__/grok-public-install-sandbox.test.mjs
scripts/__tests__/cursor-public-install-sandbox.test.mjs
scripts/__tests__/retain-runner-qualification-packages.test.mjs`: 22
pass (7 new in `prefetch-child.test.mjs`, 12 in the sandbox file). The
new file passed on three consecutive runs.
- `node --check scripts/verify-grok-npm-install.mjs` passes.
- `node packages/paperclip-runner/scripts/provision-grok.mjs
<scratch>/prefetch/grok` on this host: `Verified Grok 1.0.13
(linux-x64)` in 1.0s, `native/grok` absent before the move, 166079904
bytes in place after it.
- Docker is not installed on the host that produced this PR, so the
verifier was not run end to end locally. This PR's own `Build` job runs
`node scripts/verify-grok-npm-install.mjs` from the branch checkout, so
it exercises the change in full.

**Measured on this PR's first CI run**
([38108964834](https://github.com/paperclipai/paperclip/actions/runs/38108964834),
created 03:37Z), against two `master` runs created in the same twelve
minutes:

| | 38108878166 (`master`, 03:35Z) | 38108995800 (`master`, 03:37Z) |
38108964834 (this PR, 03:37Z) |
| --- | --- | --- | --- |
| Verifier step | 307s | 248s | 219s |
| `Build` job | 555s | 484s | 459s |
| Slowest check in the run | `Build` 555s | server (8/12) 535s | server
(12/12) 547s |

The PR run's timing line: `stage_packages_ms 84464`, `image_wait_ms 1`,
`consumer_install_ms 97305`, `lifecycle_rebuild_ms 3545`,
`codex_probe_ms 1211`, `grok_wait_ms 0`, `grok_probes_ms 626`,
`pi_setup_ms 22194`, `pi_probe_ms 7753`. Both waits are at most 1ms, so
the staging phase hid both downloads in full. The window from the end of
staging to the Grok result went from 161s to 167s on the two runs that
motivated this PR to 102s here, so the hidden downloads were about 60s
on a fresh `runs-on` spot instance. Against the two same-window `master`
runs, the verifier step is 29s to 88s shorter and `Build` is 25s to 96s
shorter; the spread is runner variance in `pnpm build` and the consumer
install.

The `image_prefetch_ms` and `grok_prefetch_ms` values on that first run
(84550 and 186696) were the parent's late observation of each exit, not
download times; that is the first Greptile finding, fixed by the second
commit, which measures from the download's last output instead.

## Risks

- Low risk. The verifier proves the same properties in the same order.
The only ordering change is that the downloads start earlier; every
assertion, probe, and sandbox argument is unchanged.
- The Grok binary is written under the scratch root in a `prefetch`
directory during staging. That directory is never mounted into a
container, and the `native/grok` assertion still runs against an absent
path.
- A prefetch that fails early now fails the step at its join point,
where the old code would have failed at the same download a few seconds
later. Same outcome, same message source.
- `*_prefetch_ms` is a last-output time. A child that printed early and
then kept working silently would be under-reported; neither `docker
pull` nor `provision-grok.mjs` behaves that way (both print their final
status as they finish), and a silent child falls back to the observed
exit time.
- Cleanup now waits up to 5s for a child that ignores `SIGTERM` before
killing it, so a failing run can end up to 5s later than before. Both
children exit promptly on `SIGTERM` in practice.
- The timing line is one more stdout line. Nothing in the repository
parses the verifier's stdout; the two workflows that run it write the
output to the job log or to a file.

## Model Used

- Claude (Anthropic), model ID `claude-fable-5-1`, running as a
Paperclip agent through the Claude local adapter with tool use (shell,
file edits, GitHub CLI). The agent collected the CI data, made the
change, ran the tests, and wrote this description.

## 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
- [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: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 21:10:13 -07:00