* perf(ci): cut the pre-fan-out critical path on canonical runs
Two changes to the run head and matrix shape:
1. runner-admission was a hosted 90s sleep every run queued behind
(observed 1.7min hosted-queue latency before the sleep started). The
debounce now lives at the tail of preflight: heavy jobs all need
preflight, so a superseding main push still cancels the run before
fan-out while only one 4 vCPU runner has been spent, and preflight's
own work usually exceeds the window so the residual sleep is zero.
security-fast (hosted, dependency-free) starts immediately.
2. Canonical main pushes now use the compact bin plan like PRs: the
82-job named matrix drained the runner pool for ~4.5min (job starts
trickled from minute 5.2 to 9.7 in run 29592647843) with no
branch-protection consumer for per-shard names on main. Coverage is
identical; dispatch/release-gate runs keep the full named matrix.
* perf(test): boot TUI PTY suite fixtures concurrently
tsx+TUI startup dominated the harness file's wall time and the three
suite PTYs booted serially. Boot them concurrently in beforeAll
(allSettled so a failed boot still assigns survivors for afterAll
cleanup); the env-specific fixtures never receive input, so their tests
only await their own readiness output. The slow-startup test now proves
frame ordering on the append-only output, which the old sequential
waits did not. File wall 10.2s -> ~4.9s, 3/3 repeat runs green.
* docs(ci): align gate and debounce descriptions with the removed admission job
* test(tooling): wait for readiness file content, not existence
writeFileSync creates the file before its bytes land, so the existence
poll raced the child's write on loaded runners and read an empty ready
file (observed in compact-small-4, run 29615028678). Poll for non-empty
content at both readiness sites.
A landed change removed the no-useless-assignment suppression in
extensions/reef/src/transport.ts without pruning the allowlist tail,
failing checks-node-compact-small-7 on every PR (merge-skew).
* feat(onboarding): recommend plugins and skills from installed apps
Scan installed macOS apps during classic onboarding (TCC-free), gather
candidates from official catalogs + ClawHub search, let the configured
model pick genuine matches, and offer an opt-in multiselect install step.
Adds a device.apps node-host command (default-off sharing, Android-parity
envelope) so remote gateways can request a paired Mac's inventory, and a
wizard.appRecommendations kill switch. Custom setup-inference completions
no longer inherit the 32-token verification-probe output cap.
* feat(onboarding): recommend apps in guided flow
* fix(onboarding): harden app recommendations against ClawHub self-promotion
Third-party ClawHub skills are never pre-selected regardless of model tier
(publisher-controlled listing text reaches the matcher prompt and could
promote itself); their labels now say they install third-party code.
Installed-app scans follow symlinked .app bundles. Matcher output stays
bounded by the resolved model's own maxTokens budget (documented invariant).
* fix(onboarding): key official catalog candidates by resolved plugin id
Real catalog entries are package manifests without a top-level id; keying the
candidate map and channel/provider classification by entry.id collapsed the
whole official catalog into one undefined-keyed entry, so no official plugin
or channel was ever recommended. Regression test runs against the bundled
catalogs.
* fix(onboarding): satisfy lint, types, deadcode, and migration gates
Split the guided-onboarding test into a self-contained custodian suite to stay
under max-lines. Narrow app-recommendation exports (drop dead node-payload
normalizer, unexport internal types/helpers, route candidate tests through the
public API), replace map-spread with a helper, unexport device.apps result
types, add installedAppsSharing to node-host migration expectations, cast the
wizard multiselect mock, and regenerate the docs map.
* test(onboarding): register new live test in the shard classifier
The installation hit Blacksmith's backing-disk cap because sticky keys
embedded PR numbers and content hashes, minting a new disk per PR and
per input change until every mount 429-failed fleet-wide. node-deps
(#109752) and ext-boundary (#109804) were already re-keyed; this
converts the last two minters and audits the third suspect.
Keys changed:
- Vitest fs transform cache: `vitest-fs-v2-<pr-N|protected>-...` ->
the one existing `vitest-fs-v2-protected-<os>-<arch>-node-<ver>`
disk. Entries are content-hash keyed (vitest sha1 over
id+content+NODE_ENV+version+env config), so cross-PR sharing is safe
by construction: PRs now mount the protected snapshot directly
(read-only, commit false) and the separate PR-only seed mount is
deleted. Writers (non-PR shard writer + scheduled warm run) keep
explicit commit true. Disk count: 1 + O(open PRs) -> 1.
- Gradle: `gradle-v1-<task>-<pr-N|protected>-<hashFiles(deps)>` ->
`gradle-v2-<task>`. Task scope stays (light ktlint must not seed
heavy build lanes); PR number and dependency hash leave the key. The
dependency hash moved to an in-job fingerprint marker that makes the
non-PR writer rebuild its snapshot cold when inputs change, bounding
disk growth; PR mounts are read-only and safely reuse stale
snapshots because Gradle caches are content-addressed. Disk count:
O(tasks x PRs x dependency bumps) -> O(tasks) (7 today).
- Docker builder (useblacksmith/setup-docker-builder): audited, out of
scope. The action owns its key internally and always uses the repo
name only (src/setup_builder.ts getStickyDisk), so it is already one
disk per installation repo and cannot mint per-PR disks.
PR warm-path behavior changes:
- Vitest: PRs lose only PR-local warm entries for files the PR itself
changed (previously persisted on the pr-N disk between pushes);
changed files re-transform once per push, unchanged files still hit
the protected snapshot. PRs touching transform inputs
(lockfile/tsconfig/package.json) run cold per push since the
generation wipe is now local to the discarded clone.
- Gradle: dependency-bump PRs get warmer (stale-but-valid protected
snapshot instead of a cold fresh key); other PRs are unchanged.
Deleted `.github/workflows/pr-cache-cleanup.yml`: it existed for the
per-PR cache layer (#109425) and only deleted GitHub actions/cache
archives, which GitHub's own LRU/TTL eviction already handles for the
remaining fork/Windows per-PR archive paths.
Guards: pinned the O(1) vitest key + non-PR commit gate, added a
Gradle per-task key/writer/fingerprint guard, added a repo-wide scan
asserting no useblacksmith/stickydisk key ever contains
github.event.pull_request.number or hashFiles(), and replaced the
cleanup-workflow assertions with a stays-deleted check.
Group walls measured from compact run 29564411446 (post-#109769):
core-unit-fast-isolated runs 78.6s on 4 vCPU because fork-per-file
isolation parallelizes poorly there; pin it to the 8 vCPU class where
the same segment runs ~50s. Unit-fast stripes measure ~25-37s (hinted
100/60) and tooling stripes 71-87s; refresh both so the packer stops
overfilling small bins (the 299s compact tail this run produced).
Cheap stripes may now co-locate in one bin, so the compact test only
requires their presence.
A. check-extension-package-tsc-boundary "force-kills aborted async node
step process groups" (run 29568640449): two mechanisms behind the
expected-false-to-be-true at the child-alive assertion.
- Pid-file existence polls can observe writeFileSync's open-truncate
0-byte window, parse NaN, and process.kill(NaN, 0) throws, so
isProcessAlive reads false (the #109140 class, same as tsdown-build's
b1e6c6883b). Added waitForPidFile (poll until content parses to a
positive pid) and applied it to all three pid-read sites in the file.
- The fail-fast step exited on a 150ms fuse after the sibling's pid file
appeared, racing the test's aliveness assertion: a worker descheduled
>150ms observes the abort chain's SIGKILL first. The step now exits
only after the test writes abort.ack, which happens strictly after the
assertion (event-driven; the step's 5s timeout bounds a wedged run).
Forced proof: injecting a 300ms stall between pid-file wait and the
assertion reproduced the exact CI failure, and passed post-fix with
the same stall in place.
B. telegram-user-crabbox-proof "stops Crabbox recording when the desktop
probe fails" (waitFor timeout on recorder.term): the fake recorder
published its pid file before installing its SIGTERM handler. The probe
throws as soon as the pid file exists and recordProbeVideo SIGTERMs the
recorder in its finally, so a recorder preempted between publish and
process.on dies to the default disposition and never writes
recorder.term. Handler now arms before the pid publish. Forced proof: a
100ms spin between publish and process.on reproduced the exact CI
failure deterministically; moving the spin after the reordered handler
passes. Applied the same handler-before-readiness ordering to the three
sibling fakes in the file, and made the gateway-grandchild pid read
parse-validated inside its poll (a NaN pid skipped both the dead-check
and the finally SIGKILL cleanup, leaking the grandchild).
C. openclaw-live-updater "treats an owner file creation race as a normal
overlap" (run 29569279237): prod bug in acquireMaintenanceLock. Only
ENOENT got the bounded creation-window retry; a reader hitting the
racing writer's open-truncate window read an empty owner.json, JSON.parse
threw SyntaxError, and the script escalated to invalid_lock instead of
overlap. Any owner read/parse failure now re-reads within the same
bounded budget before declaring the lock invalid. New regression test
freezes the window (pre-created empty owner.json, delayed writer):
fails with the exact CI error pre-fix, passes post-fix.
Proof: focused runs green; 10x per-file stress at
OPENCLAW_VITEST_MAX_WORKERS=6 green for all three files (30/30).