Skip to content

test(ai): clear the memoized proxy cache between proxy tests (RIG-3393) - #45

Open
rigel-mintaka wants to merge 9 commits into
mainfrom
harness/rig-3393-proxy-cache-reset
Open

rigel-mintaka wants to merge 9 commits into
mainfrom
harness/rig-3393-proxy-cache-reset

Conversation

@rigel-mintaka

@rigel-mintaka rigel-mintaka commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

proxy.test.ts passed alone and failed in full-suite runs. The cause is a
memoized miss, not the env handling the suite already does carefully.

getProxyForProvider memoizes into a module-level Map, including a miss:

cold miss                  -> undefined
set PI_PROXY_GITHUB_COPILOT, retry -> undefined   (stale, never re-resolved)

So a provider resolved before its variable is set stays undefined for the
rest of the module however the environment changes afterwards. The suite's
beforeEach snapshots and clears every proxy env var but leaves the cache
intact, which makes each test's outcome depend on whether anything resolved
that same provider id earlier in the file.

The old comment reasoned that unique provider ids made cross-contamination
impossible. That holds for invented ids like sakana, but the normalization
test uses github-copilot — a real provider id reachable from any other test
in the file.

__resetProxyCache() already exists as a test seam and two other test files
call it. This file, which owns the cached function, did not.

Verification

Red → green on the arrangement that reproduces it (a prior lookup of the same
id, before the env var is set):

without the reset call   45 pass / 1 fail   (fail) normalizes hyphenated provider ids [0.76ms]
with the reset call      46 pass / 0 fail

The failure is Expected: "http://127.0.0.1:24560" / Received: undefined,
matching what the full-suite runs showed.

  • Mutant: deleting the __resetProxyCache() line reddens that arrangement
    again (45/1), so the added line is the sole cause of the pass.
  • proxy.test.ts alone: 45 pass / 0 fail.
  • The two other seam callers (openai-codex-zstd, openai-codex-stream):
    98 pass / 0 fail.
  • Full packages/ai suite, credential-free, CI shape
    (--parallel=8 --timeout=30000): 4436 pass / 332 skip / 0 fail.
  • oxfmt --check rc=0; tsgo -p tsconfig.json --noEmit clean.

Test-only change; no production behaviour is altered. The cache itself is
correct for its stated purpose (env values are static for the process
lifetime) — it is only tests that mutate the environment mid-process.

Spec-impact: none — test-only fix to a suite's cache teardown, no specified
behaviour changes.

rigel-mintaka and others added 5 commits September 5, 2026 11:02
…() (RIG-3255) (#37)

* fix(eval): stream one coalesced agent progress event from handle.wait() (RIG-3255)

`runEvalWait` emitted agent progress three ways: eagerly before waiting, on a
1-second interval, and once after settlement. The eager pre-wait emit surfaced
whatever `latestDetails.progress` held at that instant, so a fast `agent()` that
had already reported interim "running" progress before the wait ran would stream
that interim snapshot as well as the final "completed" one: two `op:"agent"`
events instead of the single coalesced "latest" the caller expects. The ordering
is load-sensitive. In isolation the job has not scheduled its first progress
callback by the time the eager emit runs (the snapshot guard returns), so only
the final emit fires; under parallel test-bucket load the scheduling shifts and
both leak through. That is the flake behind the intermittent
`agent-bridge-policy` "streams the latest enriched agent progress" failure
(`toHaveLength(1)` receiving 2).

Drop the eager pre-wait emit. The interval covers heartbeats for a long wait and
the post-settlement emit carries the final snapshot, so a wait that settles
inside the first interval window emits exactly once (the settled state) and a
long wait still streams periodic snapshots. Deterministic, and matches the
test's stated "stream the latest" contract.

This is upstream code (handle-bridge.ts is byte-identical to can1357/oh-my-pi's
current tip); the same fix will be proposed upstream.

Spec-impact: none. Refs RIG-3255

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* docs(eval): scope the handle.wait() single-emit comment to what holds

The removed eager emit left a comment claiming a settling wait "emits exactly
once (the settled state)". Neither half is unconditional: on the failure path
the final progress report never runs, so the post-settlement emit carries the
last interim snapshot, not the settled state; and a subagent that never reported
progress emits nothing. Scope the claim to "at most once" and name both sub-paths
so a maintainer does not build on an invariant the code does not hold.

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* test(eval): add deterministic guard for coalesced agent progress emit

runEvalWait's single-emit contract had no red->green guard: the existing
agent-bridge-policy assertion is load-dependent and passes unloaded whether or
not the eager pre-wait emit is present, so a re-added eager emit would slip
through local runs and most CI runs.

Drive runEvalWait directly against a controlled AsyncJobManager job under fake
timers, so the 1s heartbeat interval provably cannot tick. The job reports an
interim "running" snapshot, parks until released, then reports the final
"completed" snapshot; awaiting the interim signal guarantees latestDetails holds
a snapshot before the wait runs. A re-added eager pre-wait emit fires with the
interim snapshot (two agent events); the settle-only path emits once.

Co-authored-by: Matt Wilkinson <matt@rigel.build>

---------

Co-authored-by: Matt Wilkinson <mattwilki17@gmail.com>
Co-authored-by: Matt Wilkinson <matt@rigel.build>
…rt timeout (#38)

The "resumes the long-lived GET stream with Last-Event-ID" test intermittently
hung under CPU-starved CI oversubscription, tripping the pending-promise guard.

Root cause is a timeout misconfiguration, not reconnect latency. The test built
its transport through connectedTransport(), which sets timeout: 50ms (the value
the negative-path timeout tests need). startSSEListener derives its GET startup
budget as min(1000, floor(requestTimeout / 4)), so a 50ms request timeout yields
a 12ms connect budget. Under load the initial GET crosses 12ms, the listener is
aborted, and the second notification never arrives, an unbounded hang the guard
then surfaces as a timeout.

Give this positive-path test its own transport with a generous timeout (matching
the sibling resume tests), lifting the SSE startup budget to the full 1000ms
cap. Stress at 40-way oversubscription goes from intermittent hangs to 60/60
clean. The guard constant moves 500 to 4000 (staying below bun's 5000ms default
per-test timeout so the labeled failure wins) and its comment is corrected to
describe the measured behavior: a healthy localhost resume finishes in well under
100ms, so a trip indicates a stalled listener rather than a slow one.

Co-authored-by: Matt Wilkinson <mattwilki17@gmail.com>
…4) (#36)

* ci: run release and main-event jobs on GitHub-hosted runners (RIG-3144)

The `omp-kata` self-hosted ARC runner scale set exists only in the upstream `can1357/oh-my-pi` org and was never registered in RigelBuild, so every non-pull_request job that selected it queues forever with no runner. Pull request runs already land on `ubuntu-22.04`; releases and main pushes did not, which is why the CI-gated npm publish never completed.

Collapse the per-job runner selector to `ubuntu-22.04` at all eleven sites (`check`, `rust_validate`, `native_addons`, the six TS test buckets, and `install_methods`) so the GitHub-hosted branch is the only branch. No cache or native-build wiring changes: the `bazel-cache`, `bazel-natives`, and `bun-install` composite actions already auto-select the GitHub-hosted `actions/cache` backend whenever `BAZEL_REMOTE_USER`/`BAZEL_REMOTE_PASSWORD` are absent, which they are off cluster, and `bazel-cache-warm.yml` already seeds the hosted darwin scopes and the bun store. The step-level `github.event_name` branching that picks prebuilt-from-npm addons on pull requests versus a bazel build on main is unchanged.

Delete `.github/actionlint.yaml`, whose only purpose was registering the now unused `omp-kata` label; actionlint passes clean without it, and the four `.github/actions/*` composites keep their `omp-kata` mentions only in descriptive comments (no `runs-on:`), so nothing else needs the registration.

Re-laid onto upstream v18.1.7 as part of the fork re-sync (see docs/fork-resync.md, Task 2).

Refs RIG-3144, RIG-2777

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: fetch fork-built natives on PRs instead of upstream's npm scope (RIG-3144) (#33)

* ci: fetch fork-built natives on PRs instead of upstream's npm scope (RIG-3144)

The native_addons PR path fetched @oh-my-pi/pi-natives-linux-x64@latest, which
is upstream can1357's npm scope. Upstream advanced that package to 18.1.7, whose
Linux binding surface no longer matches the fork's 18.0.x test tree, so every
fork PR started reddening two required checks (Test TS native/integration, Test
coding-agent native/unit) with "MacOSPowerAssertion.start is undefined". The
main path was unaffected because it builds natives fresh from the fork commit.

The fetch string was missed by the fork scope-rename (it is a hardcoded literal
in the workflow, not a rewritten manifest), so fork PRs were validating against
upstream's moving registry instead of the fork's own published addons.

- Repoint the PR-path fetch and its explanatory comment to the fork's own
  @rigelbuild/omp-natives-linux-x64 leaf. Its tarball layout is identical
  (package/pi_natives.linux-x64-{baseline,modern}.node), so the copy steps are
  unchanged, and its version tracks the fork's own release cadence.
- Guard the MacOSPowerAssertion test with a darwin-only early return, matching
  the platform-gated tests already in the file. A macOS-only power-assertion
  binding must not run on Linux, and this keeps the test green even if a future
  fork Linux build drops the macOS export the way upstream's 18.1.7 did.

Verified: the fork's @rigelbuild/omp-natives-linux-x64@18.0.3 addon exports
MacOSPowerAssertion.start on Linux (reproduced locally: upstream's build does
not, fork's does), so the repoint alone turns the checks green; the guard is
defense in depth. actionlint clean; the guarded test passes under bun:test.

Spec-impact: none

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* test(natives): drop the darwin guard on the power-assertion test (RIG-3144)

The prior commit added a `process.platform !== "darwin"` early-return to the
MacOSPowerAssertion test on the premise that the binding is macOS-only. That
premise is wrong: the class is compiled unconditionally and start() has an
explicit non-macOS arm returning a no-op handle (crates/pi-natives/src/power.rs
start/stop cfg arms), and the published contract documents the cross-platform
no-op handle (packages/natives/native/index.d.ts). The addon exports it on
Linux.

Because every bun-test job runs on ubuntu, the guard was unconditional dead
code that skipped the body on 100% of CI runs, deleting coverage rather than
gating it, and would let a future fork Linux build that drops the export pass
green instead of reddening. The scope repoint in the parent commit is the
complete root-cause fix on its own; the unguarded body passes against the
fork's published addon on Linux.

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: persist the linux bazel disk cache across main runs (RIG-3144) (#32)

* ci: persist the linux bazel disk cache across main runs (RIG-3144)

## Problem

The two main-event Rust/Bazel jobs, `rust_validate` (workspace tests + clippy + rustfmt over `//crates/...`) and `native_addons` (the six cross-compiled `//:natives-*` addon links), both requested the `linux` disk-cache scope but nothing ever **saved** it. The only Bazel cache-save step lives in the `bazel-natives` composite action, which only the `release-*` and warm scopes use. So the `linux` scope had a restore side with no producer: every main push rebuilt the entire Rust workspace and every native addon fully cold.

Confirmed at source: the repo's Actions cache held zero `bazel-disk-v3-linux-*` archives, only `release-darwin-*` and `bun-*`. The cold native build is the roughly hour-long long pole on every main run, and every main push also recompiled the Rust workspace from scratch for `rust_validate`.

## Change

Give each job its own save. The two jobs build under different Bazel configs (test + clippy vs release-codegen addon links), so their actions are keyed differently and cannot share one archive. Splitting the scope keeps each job's archive coherent:

- `rust_validate` uses scope `linux-rust` and saves on `save-needed`.
- `native_addons` uses scope `linux-natives` and saves on `save-needed` (guarded, like its build steps, to the non-PR path).

Each save mirrors the existing `bazel-natives` save exactly: same `actions/cache/save` pin, same `~/.cache/omp-bazel-disk` + `~/.cache/omp-bazel-repo` path list, keyed by the `bazel-cache` action's `cache-key` output. The `save-needed` output is already false on an exact-key hit, so an unchanged run is a pure cache replay that saves nothing; a first run on a new key (or after a crates change) restores via the prefix/bare fallback, builds, and saves the refreshed archive.

## Effect

Between upstream re-syncs `crates/**` does not change, which is the common case, so a later main run replays the cached Rust actions and the cross-compiled addon links instead of paying the multi-GiB rustc cost cold. This is the fork-side lever that makes frequent releases cheap: the Rust closure only rebuilds when it actually changes.

Job names are unchanged, so the branch-protection required checks are unaffected. No warm workflow is needed for these scopes because both jobs run on every main push and self-seed continuously, unlike the darwin release scopes that only build at release time.

Scope note: on the shared repo cache quota, the toolchain half of the repo cache now exists under two linux scopes rather than one. Eviction is graceful content-addressed LRU and self-heals, and a real remote cache (reachable from our own CI) removes the quota ceiling entirely as the durable follow-up.

Spec-impact: none. Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: document the linux bazel cache quota budget and path-mirror invariant (RIG-3144)

Self-review follow-up on the linux bazel cache persistence change.

- Add a quota note above both new save steps: four GiB-scale bazel scopes
  (linux-rust, linux-natives, release-darwin-x64, release-darwin-arm64) now
  share the 10 GiB repo cache, so a fifth scope needs an eviction plan first.
  GitHub evicts least-recently-used archives silently on overflow, and a
  quota-driven save failure only degrades to a warning line, so the symptom
  would be a silently slow CI rather than a red build.
- Add the "must mirror the restore path list" warning above the path list in
  both new save steps, matching the note the bazel-cache restore action and
  the bazel-natives save already carry. The restore path list is now
  replicated at four sites; without the note the two new copies can silently
  drift and produce a partial cache, the same defect class this change fixes.

Spec-impact: none

Co-authored-by: Matt Wilkinson <matt@rigel.build>

---------

Co-authored-by: Matt Wilkinson <mattwilki17@gmail.com>
Co-authored-by: Matt Wilkinson <matt@rigel.build>

---------

Co-authored-by: Matt Wilkinson <mattwilki17@gmail.com>
Co-authored-by: Matt Wilkinson <matt@rigel.build>

* test(pi-shell): disable job-control pipeline kill test on hosted CI (#29)

`kill_builtin_signals_every_process_in_a_jobspec_pipeline` self-suspends a
two-process pipeline with `kill -STOP` and expects the shell's job-control to
observe the stop within a 5s timeout. Its SIGSTOP/SIGCONT and process-group
semantics do not hold under the bazel linux-sandbox on GitHub-hosted runners, so
`run_string` never returns and the test times out (`pipeline did not stop`). It
passed only on the previous self-hosted infrastructure; it has never completed on
a hosted runner.

Mark it `#[ignore]` so the `rust_validate` gate is green on hosted CI. The test
stays compiled (no bitrot) and is trivially re-enabled with `--include-ignored`
or by removing the attribute if the shell's job-control or the CI process model
changes. The other 862 pi-shell tests are unaffected; primary coverage is intact.

Co-authored-by: Matt Wilkinson <mattwilki17@gmail.com>
Co-authored-by: Matt Wilkinson <matt@rigel.build>

* docs: re-add the fork re-sync design record after the reset (RIG-3144)

The reset to upstream v18.1.7 (Task 1) discards `docs/fork-resync.md`, which lived only on the pre-reset fork history: the record is absent from upstream `main` (the reset target) and from the pre-reset fork tip, so a bare reset leaves the frozen contract off `main`. Re-lay it here as the first thing on the reset base so Tasks 3 through 8 execute against the record on `main`, matching the design's Task 2 step 0 (T1b).

The content is byte-for-byte the record frozen by PR #35; no edits.

Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: pin PR-path native fetch to the checkout's upstream base version (RIG-3144)

The reset to upstream tip (v18.1.10) inverted the premise of the fork-scope
natives repoint. The PR path fetched `@rigelbuild/omp-natives-linux-x64@latest`,
the fork's own scope, whose only published addon is `18.0.3` (the fork's last
release, roughly a hundred commits behind the reset tree). The v18.1.10 TS tree
calls native functions that addon predates (`vcsGitDiscover`, `editDescription`,
`extractInlineSloppyRegions`), so every native-backed TS test job died with
"X is not a function" and the required `CI` check stayed red.

Immediately after an upstream re-sync the fork's `crates/` tree equals
upstream's (the reset base has zero `crates/` drift from the v18.1.10 tag), so
upstream's own `@oh-my-pi/pi-natives-linux-x64@18.1.10` is the exact binding
match and exports all three functions. Point the PR-path fetch back at the
upstream scope, pinned to the checkout's own upstream base version rather than a
floating `@latest`.

The version is read from `packages/natives/package.json` and any `-rigel.N` fork
suffix is stripped, so the pin resolves the upstream base for both a plain
upstream version (`18.1.10` -> `18.1.10`) and a post-release fork version
(`18.1.10-rigel.1` -> `18.1.10`). This tracks future re-syncs without another
edit: the fork's own native line only diverges once it accrues `crates/` changes
of its own, at which point the durable fix is publishing a fork addon at the
fork version, tracked as the post-reset native-release follow-up.

Verified: upstream's 18.1.10 addon exports the three functions (116 exports
total); staged into the CI `bazel-bin/` layout it passes the job's own smoke
check; the reset tree has zero `crates/` drift from the v18.1.10 tag.
actionlint rc=0.

Spec-impact: none. Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: correct the addon-safety comment and bound the hosted bazel jobs (RIG-3144)

Review follow-ups on the hosted-runner + natives-fetch changes.

- Narrow the native_addons comment to the guarantee that actually holds. The
  workspace loader skips its version sentinel for workspace loads
  (packages/natives loader-state), so an upstream addon under a fork checkout is
  not validated against the fork's own bindings. Visible failure is guaranteed
  only for a newly-added export; a change to the behavior of an existing export
  tests silently against upstream semantics until the fork publishes its own
  @RigelBuild addon. The old wording claimed any fork-only native change "fails
  visibly", which overstates the protection.
- Add timeout-minutes to the three bazel jobs relocated onto GitHub-hosted
  runners (check 30, rust_validate 90, native_addons 90). They were the only
  jobs in the workflow without a bound, and the diff's own premise is that a
  hosted bazel run can hang rather than fail (the job-control test times out
  under the linux-sandbox). rust_validate gates the release chain, so an
  unbounded hang would stall it for the GitHub default of 360 minutes; a bounded
  fail-loud is the house posture.

Spec-impact: none. Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* test(pi-shell): re-run the ignored kill test's body in the isolated child (RIG-3144)

The re-lay disabled `kill_builtin_signals_every_process_in_a_jobspec_pipeline`
with `#[ignore]` (it times out under the CI bazel sandbox, which lacks full
job-control stop/continue semantics). That test is a self-re-exec harness: the
outer invocation calls `run_isolated_kill_test`, which spawns the test binary
with `--exact <name>` and a marker env var, and the real body only runs in that
child.

The child argv carried `--exact` but not `--include-ignored`, so once the outer
test is `#[ignore]`d the child filters it out, runs zero tests, exits 0, and the
outer `assert!(status.success())` passes vacuously. Anyone re-enabling the test
by running with `--include-ignored` would get a green result while the
assertions never execute, and a regression in `kill %1` pipeline signalling
(the sole assertion of `process_ids()` in the crate) would ship undetected.

Pass `--include-ignored` in `run_isolated_kill_test` so the re-exec child
actually runs an ignored body. It is a no-op for the three non-ignored callers
(`kill_builtin_refuses_own_pid`, `kill_builtin_refuses_own_process_group`,
`pkill_builtin_excludes_own_pid`). This mirrors the existing precedent in this
file: `process_test_sleeper` is `#[ignore]`d and its spawner already passes
`--ignored`.

Also cite RIG-3249 in the `#[ignore]` reason: that issue owns re-enabling the
test (root-causing the sandbox job-control gap or making it deterministic), so
the temporary ignore has a tracked owner rather than becoming permanent.

Verified: the three non-ignored isolated kill tests still pass, and with the
fix the ignored test's body executes and passes when run outside the sandbox
with `--include-ignored` (it stays ignored inside the CI bazel sandbox).

Spec-impact: none. Refs RIG-3144, RIG-3249

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* docs: reconcile the frozen re-sync record with the executed reset (RIG-3144)

The record froze via PR #35 naming reset target v18.1.7 (c4da0d0). Upstream
advanced before the reset ran, so main actually reset to v18.1.10 (ddde7db).
Left unreconciled, the on-main contract that Tasks 3/7/8 execute from would
instruct the opposite of what shipped and carry a live, copy-pasteable
force-push command naming a three-tags-stale SHA.

- Add a dated post-execution reconciliation note after the status line: reset
  target is v18.1.10 (ddde7db); the PR-path natives fetch ships from the
  upstream scope pinned to base version (the @RigelBuild scope has only 18.0.3,
  which lacks v18.1.10 bindings) and flips back to @RigelBuild once the fork
  cuts its first post-reset native release; version scheme is the Matt-ruled
  <upstream-version>-rigel.N, superseding the old npm-floor-18.1.8 + rigel-v*
  proposal.
- Neutralize the Task-1 force-push runbook: it has already executed, so its
  commands are commented out and marked DO NOT RE-RUN, with the SHA corrected to
  the real target for provenance.
- Title v18.1.7 -> v18.1.10; Status Draft -> Frozen (it merged as PR #35).

Spec-impact: none. Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: drop the dead kata clause from the warm-cache header comment (RIG-3144)

The bazel-cache-warm.yml header still justified per-image warming with "a
kata-produced disk cache misses every action on a hosted image". With no
omp-kata runners in the fork, that clause describes nothing real. Keep the
still-true constraint (hosted runners see only default-branch actions/cache
entries, and bazel action keys do not transfer across runner images) and drop
the kata reference, matching the same rewrite already applied to the warm_bun
rationale lower in the file.

Spec-impact: none. Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: raise native_addons timeout above the measured cold build (RIG-3144)

Review round 2 follow-ups.

- `native_addons` timeout-minutes 90 -> 150. The prior 90 sat below this repo's
  measured 84-minute cold cross-compile (the six addon links, run 33789708655),
  and a job-level timeout kills the job before its `Save bazel disk cache` step
  runs, so a killed build persists nothing and every retry starts equally cold
  and times out again. 150 gives ~1.8x margin over the measured cold run while
  still bounding a genuine hang inside GitHub's 360-minute ceiling. The first
  post-reset main run is cold, so this is load-bearing for Task 2's acceptance
  gate.
- Correct the ignored kill test's header comment: "Disabled on GitHub-hosted CI"
  -> "Disabled under the CI bazel sandbox", matching the actual mechanism (the
  bazel linux-sandbox, which applies on any runner bazel executes on) and the
  #[ignore] reason string.

Spec-impact: none. Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* docs: correct the reconciliation note's blast-radius and supersession scope (RIG-3144)

Review round 2 follow-ups on the re-sync record's reconciliation note.

- The runbook's `sha=` was corrected to the executed target in the prior commit,
  which falsified the note's own prose. Item 1 and the runbook preamble said the
  SHA was "stale" and that re-running "would roll main back three tags", both
  describing the old v18.1.7 value. With the corrected SHA, re-running would reset
  main to the reset point and discard every post-reset re-lay, a strictly larger
  blast radius. Reword both to say the SHA is the executed target and re-running
  discards the re-lays.
- Broaden item 3's OQ1 supersession from the one Open-Questions entry to a
  blanket rewrite, matching item 1: the Global Constraints "Version + tag scheme"
  bullet and Task 3's "Depends on OQ1" paragraph both still stated a hard block
  on a question that has landed, so an agent picking up Task 3 would park work
  that is actually unblocked. State that Tasks 3/7 are no longer gated and the
  `--match v*` globs need no change under the `-rigel.N` scheme.
- Add item 4: the pre-reset safety tags (OQ6) are pushed, so OQ6 no longer reads
  as outstanding.

Spec-impact: none. Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: re-add the aggregate CI gate the ruleset requires as its status context

The branch ruleset requires a single status context named `CI`, but the
re-sync's ci.yml carried only the per-job check-runs (their display names), not
the aggregate job whose check-run posts that context. With nothing emitting
`CI`, every PR sat mergeStateStatus=BLOCKED on a forever-pending required check
and could only be merged by org-owner override.

Re-add the `ci` job (name `CI`) that `needs` every PR-relevant job and fails
closed on any non-success result. rust_validate is excluded because it is
`if: github.event_name != 'pull_request'` and a skipped need would strand the
gate; native_addons is excluded because every test job already needs it, so its
failure propagates transitively. `if: !cancelled()` makes the gate run even when
a need fails, so it posts `CI` = failure rather than a never-created, pending
context.

This is a fork-only gate: upstream defines no aggregate CI job, so it stays out
of the upstream-bound branches.

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: bound the CI gate's runtime and scope its comment to what holds

Two review nits on the aggregate CI gate. Add timeout-minutes: 5 so the job
holding the sole required context cannot pin CI pending for the 360-minute
default if a runner wedges (every other job in the file sets an explicit bound).
Narrow the comment's "always posts a CI check-run" to "whenever the workflow
runs it posts", since a run excluded by the pull_request paths filter posts
nothing and a genuine cancel posts a cancelled conclusion, not a pass/fail.

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: always-trigger ci.yml with a change-detection gate (RIG-3144)

The branch ruleset requires the `CI` rollup on every PR, but ci.yml
carried a `pull_request.paths` filter, so a docs-only PR never triggered
the workflow and the required `CI` check sat forever pending, unmergeable
(PR #35 hit exactly this). Drop the trigger's `paths:` filter so the
workflow always runs on PRs, and recreate the filter one level down as a
per-job gate: a new `setup` job runs `scripts/ci-paths-affected.ts`
against the PR diff and outputs `affected`; `check` and `native_addons`
gate on it (the eight test/install jobs already cascade off
`native_addons.result == 'success'`), so a docs-only PR skips the
expensive matrix while the cheap rollup still posts `CI`.

The change detector is fail-safe by construction: a push/dispatch event,
an unresolvable base ref, or any git failure resolves to affected=true,
so the only way to skip the heavy jobs is a clean diff that provably
touches no code path. A bug can waste CI minutes; it can never let
untested code merge.

The rollup now treats a gated job's `skipped` as pass ONLY when
`setup.outputs.affected == 'false'`; a failed setup leaves that output
empty, so an excused skip is never granted and the rollup reds. Never a
blanket skipped-is-green.

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: fix two change-detector fail-opens found in review (RIG-3144)

Review of the always-trigger CI gate (commit c93871c) surfaced two ways the
change detector could wrongly report `affected=false` and skip the matrix on a
PR that does change code, which is the one failure mode the gate must never have.
Both are fixed here as an additive commit on the reviewed head.

### `git diff` rename detection hid moved-out code (fail-open)

`git diff --name-only` runs with rename detection on by default, so a rename
prints only its destination path. A PR that moved code out of a gated directory
(for example `packages/x.ts` to `docs/x.md`) reported only the docs destination
and yielded `affected=false`, skipping every test while deleting code the
workspace still imports. Verified against real history: commit 2e45297 moves
36 files out of `packages/` and reported 0 `packages/` hits under the old flags.

Fix: pass `--no-renames` so every rename decomposes into its delete+add pair and
the source path is seen. The same commit now reports 36 `packages/` hits.

### `CODE_PATHS` omitted config the `check` job reads (fail-open)

The path list was a faithful mirror of the old `pull_request.paths` trigger, but
that inherited list omitted root config the gated `check` job consumes
(`tsconfig*.json`, `.oxlintrc.json`, `.oxfmtrc.json`, the `types/` root). As a
trigger the gap was benign: such a PR simply never started CI, and the missing
required check blocked the merge. As a job-level gate it became load-bearing. A
config-only regression now yielded `affected=false`, skipped `check`, and the
rollup excused the skip, so a merge-blocking gap turned into a silent green.

Fix: add those inputs to `CODE_PATHS` and document that a gate must cover every
gated job's inputs, not just mirror the old trigger.

### Also

- Empty diff now fails safe to `affected=true`. An empty file list is doubt (a
  force-push race or botched merge-base), not proof nothing changed, so this
  restores the module's stated "any doubt runs the matrix" invariant.
- `writeOutput` appends to `$GITHUB_OUTPUT` directly instead of read-modify-write.
- Rollup comment: fix `reding` typo; document the three-edit sync a new gated job
  needs; note nix.yml deliberately keeps its own path filter (not a required
  context).
- Tests now defend the fail-closed contract end to end. A temp-repo suite drives
  `main()` for the rename, docs-only, code, push-event, and unset-SHA cases
  (red-green on the rename guard), plus config-input assertions.

Verified: bun test 14/14 (rename guard proven red without `--no-renames`);
check:tools (oxlint + oxfmt) clean; actionlint rc=0; tsgo reports zero errors in
both files; real-ref smokes (2e45297 to true, docs-only to false, code to
true).

Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: cover two more gated-job inputs in the change detector (RIG-3144)

The confirming review of the fail-open fixes (commit 77fb62e) found the same
class of bug on two more inputs the gated jobs read but the detector's path list
did not cover, so a PR touching only those paths was classified affected=false,
the job skipped, and the rollup excused it: CI green on a change that would have
failed.

### `.cargo/config.toml` feeds the gated `check` job

The `check` job runs `cargo fetch --locked` then `cargo deny --locked --offline`
(ci.yml, gated on affected=='true'). Cargo reads `.cargo/config.toml` from the
workspace root before doing anything, and a malformed or semantically-wrong one
reds `cargo fetch` (verified: exit 101 on a broken table). The file pins the
CMake policy version, the generator, and `embed-metadata`, and its own comments
tie it to the rust-toolchain pin, so it is exactly the kind of file a toolchain
PR edits. nix.yml already lists `.cargo/**` in its own paths filter, so the gate
was strictly less careful than the workflow it is told not to align with. Fixed
by adding a `.cargo/` prefix to CODE_PATHS.

### `python/robomp/web` is a real bun workspace member

Root package.json declares it in `workspaces.packages` and bun.lock carries it,
so every gated job's `bun install --frozen-lockfile` reads its manifest. A
dependency edit to that manifest without a regenerated lockfile fails the frozen
install (verified: exit 1, "lockfile had changes, but lockfile is frozen"), yet
nothing under `python/` matched CODE_PATHS. Fixed with a `python/robomp/web/`
prefix, scoped to the web member so the 149-file python tree (whose pytest suites
this workflow never runs) does not force the matrix.

### The detector's own test now runs in CI

`test_workspace` invokes a hardcoded test list, and ci-paths-affected.test.ts
was not on it, so the red-green rename guard added last commit was never enforced
on any future PR: a deletion of `--no-renames` would have stayed green in CI.
Added the file to that list. It is fast (no native artifacts) and, because
editing the detector matches the `scripts/` prefix, the gated job that runs it is
never skipped on a PR that touches it.

### Test-suite integrity and fail-safe coverage

- GITHUB_OUTPUT moved to a temp dir OUTSIDE the fixture repo; inside it, the
  harness's own output file was tracked by `git add -A` and showed up in the diff
  under test.
- The fixture git env is now hermetic (GIT_CONFIG_GLOBAL/SYSTEM/NOSYSTEM), so a
  host `commit.gpgsign` or `diff.renames` config cannot skew a run.
- `headSha` now checks the git exit code, so a rev-parse failure throws instead
  of silently returning an empty SHA that would mask a real result.
- Added end-to-end cases for the highest-value fail-safe paths: a cargo-config
  change (affected=true), an unresolvable base SHA hitting the catch
  (affected=true), and an empty same-sha diff (affected=true).

Verified: bun test 17/17 (the cargo-config guard is red-green: removing `.cargo/`
from CODE_PATHS fails exactly the two cargo assertions); check:tools clean;
actionlint rc=0; tsgo zero errors in both files; real-ref smokes correct
(rename to true, docs-only to false, code to true).

Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* ci: gate the last two cross-root fixtures the change detector missed (RIG-3144)

A confirming review of the prior fold proved the same fail-open class one
directory over: two gated test suites read repo-root fixtures outside their
package, and neither path was in the detector's allowlist, so a fixture-only
change was classified affected=false, its suite skipped, and the rollup
excused the skip green.

- docs/tools/: the tool-doc coverage test asserts a page exists for every
  builtin tool, so a docs-only rename reds test_coding_agent_runtime.
- assets/: the terminal-image test reads assets/python.webp, so an
  assets-only change reds test_coding_agent_native.

Both added to CODE_PATHS with matcher + end-to-end guards proven red-green.
Also hardened the test fixture the detector relies on: the GITHUB_OUTPUT
relocation now has a guarding assertion, runMain defaults the SHA/event vars
so an ambient PR_BASE_SHA/PR_HEAD_SHA can't invert a fail-safe case, headSha
shares the hermetic git env, and the unset-SHA fail-safe is split into its
three shapes. Registered the suite in package.json test:scripts so the local
aggregate matches CI.

Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

* test: name the F3 output-file guard and drop a vacuous diff assertion (RIG-3144)

Round-3 review of the change-detector reworking flagged two test-quality lows
on the M2 fixture guards:

- The `diffNames(base, head)` assertion in the docs-only test ran before the
  first `runMain()` wrote GITHUB_OUTPUT, so it stayed green even when the output
  file was moved back inside the fixture worktree. It never defended F3 and its
  comment claimed otherwise. Removed the assertion and the now-unused helper; the
  test's own `affected=false` expectation already fails if a stray file leaks
  into the diff.
- The real F3 defense lived in `beforeAll`, so a regression reported an unnamed
  failure. Extracted it into a named `it("keeps GITHUB_OUTPUT outside the fixture
  worktree (F3 guard)")` so a future break points at itself.

Red-green: moving the output file back inside the repo now reds that named test
specifically. Suite 22 pass / 0 fail; oxlint, oxfmt, actionlint, tsgo clean.

Refs RIG-3144

Co-authored-by: Matt Wilkinson <matt@rigel.build>

---------

Co-authored-by: Matt Wilkinson <mattwilki17@gmail.com>
Co-authored-by: Matt Wilkinson <matt@rigel.build>
…ng/tier on resume (RIG-3225) (#34)

## Problem

On `--resume`, an explicit `--config`/`--profile` overlay's `modelRoles` were inert: the resumed session always restored the model, thinking level, and service tier baked into it at its original launch, so an updated profile never took effect. This is why the 2026-09-03 dream-team relaunch (`spawn-agent --resume <sid> --profile dream-team`) ran the fleet on each session's stale baked model instead of the profile's opus-5 stack.

The restore is deliberate for a bare resume — a conversation should not be silently moved off the model it is running — so the fix is an explicit opt-in, not a behavior change to the default resume path.

## Change

Add a `--reapply-config` CLI flag and a `reapplyConfig` SDK option (`CreateAgentSessionOptions`). When set, a resume adopts the config-resolved default **model**, its **thinking level** (including a `:high`/`:xhigh`/`:max` selector suffix on the default role), and its **service tier** instead of the values baked into the session. Default (unset) preserves the existing resume-restores behavior exactly.

Adoption is **per-knob**: a knob is taken from config only when config actually specifies it, otherwise the session's own value is kept. So an overlay that only retunes a non-default role (or only a tier, or tombstones the default) never silently yanks a resumed conversation onto an arbitrary fallback model — config with nothing to say for a knob leaves that knob on the session's value.

Locus is `createAgentSession` in `sdk.ts`, where both the CLI and SDK embedders (Compass) converge, so one option fixes both surfaces. Three restore sites are gated:

- session-model restore array construction is always populated, and its early restore + the later reclaim loop are skipped only when `adoptConfigModel` (`reapplyConfig && !hasExplicitModel && config names a default role`); the session model therefore stays available as the fallback. "Config names a default" mirrors the resolver's own notion — a blank value, the bare `default` sentinel, or a tombstoned/absent `modelRoles.default` all count as no default, so an overlay that only retunes a non-default role (or only a tier) keeps the session model. When config names a default it cannot resolve (a typo), a post-`tryResolveDefaultRole` block restores the baked session model + thinking rather than dropping to an arbitrary `pickDefaultAvailableModel`. Extension-provided `litellm/*` defaults resolve on the post-extension path.
- persisted `thinking_level_change` is skipped only when `adoptConfigThinking` (`adoptConfigModel && the default role carries an explicit selector`) — so `--model --reapply-config` keeps the session's level rather than dropping to a bare model default
- session `service_tier_change` is merged per family when config specifies any `tier.*`: config's specified families override, families the config omits keep the session's baked tier. Unlike model/thinking, the tier is NOT gated on `--model` — `--model` pins only the model, and the dedicated `--service-tier` override still wins over the merge.

A resume under `--reapply-config` is otherwise silent, so it now surfaces one notice via `modelFallbackMessage`: naming the adopted model when config swaps the session onto a different one; naming the unresolved config default (with its resolver warning) when a broken default falls back to the session's own model; and naming both the unresolved default and the unrestorable session model when neither resolves and the model comes from an arbitrary availability pick (the case a bare resume also warns about). The notice is gated on a baked session model existing, so a resume that adopts the same model the session already ran — or a fresh/no-model session — stays silent.

Like `--model`, the adopted values are a per-run intent and are not persisted back as `*_change` entries, so a later bare resume still restores the session's own values; `spawn-agent` passes `--reapply-config` on every relaunch to keep the fleet on the profile.

## Tests

`agent-session-model-persistence.test.ts`: model, thinking, and service tier each adopt the config value on resume with `reapplyConfig`, and each restore the baked session value on a bare resume without it; the session model/thinking are kept (silently) when config specifies no default, tombstones it (`null`), names an empty string, or names the bare `default` sentinel, and are kept with a notice when config names an unresolvable/typo'd default; a double failure (unresolvable config default AND unrestorable baked model) is reported naming both; a session with no `model_change` entry stays silent (no notice naming a nonexistent session model); `--model` + `reapplyConfig` keeps the session's thinking level; service tier merges per family (session `openai:priority` + config `google:flex` keeps both); the swap and broken-config notices are each asserted on their own branch (`resumed on` vs `kept the session's`, neither matching the double-failure `could not be restored`) so an inverted discrimination fails; and a `reapplyConfig` resume appends no new `model_change`/`thinking_level_change` (the per-run non-persistence contract). `flag-tables.test.ts`: `--reapply-config` parses to `reapplyConfig` without consuming the initial message. `profile-bootstrap.test.ts`: `--reapply-config` is exempted as a valueless flag so a trailing `--profile` still activates.

Spec-impact: none. Refs RIG-3225

Co-authored-by: Matt Wilkinson <matt@rigel.build>
getProxyForProvider memoizes a miss, so a resolution that happens before
its env var is set is cached as undefined and never re-read. The suite's
beforeEach restores env but not the cache, so the outcome depends on
whether anything resolved that provider id earlier in the module:

  cold miss              -> undefined
  set PI_PROXY_<ID>, retry -> undefined   (stale, never re-resolved)

The module already exports __resetProxyCache as a test seam and two other
test files call it; this one, which owns the cached function, did not.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
@linear-code

linear-code Bot commented Sep 6, 2026

Copy link
Copy Markdown

RIG-3393

rigel-mintaka and others added 2 commits September 6, 2026 10:23
The `beforeEach` reset added in the previous commit was inert: the cache is
keyed by provider id, and all 15 ids resolved anywhere in the file were
distinct, so no cached entry was ever read back. Suppressing the reset left
the suite fully green, which meant a future edit deleting the call — or an
author removing an apparently unused import — would silently reintroduce the
order-dependence the commit set out to remove.

Adds a `resolver cache isolation` describe that shares one provider id across
two cases, the only shape that can observe a stale entry. Red-green measured
on this tree: with the reset suppressed, 46 pass / 1 fail (the first case
reads back the miss memoized by the second's predecessor); with it active,
47 pass / 0 fail.

Also corrects the block comment above `beforeEach`. It had claimed
`github-copilot` was "reachable from any other test in the file" as present
fact, which was not true of the file as written — the id was resolved exactly
once. The comment now points at the isolation tests that make the guard
observable.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
…3393)

The witness added in the previous commit fired off `github-copilot`, an id it
shared with `normalizes hyphenated provider ids to underscores`. That made it
work for a reason it did not document and did not own: the entry its first
case read back was a memoized hit planted by that unrelated test, not the
memoized miss the case name claimed. Re-idding or deleting the other test
moved which case failed, and the case that looked like the real assertion was
a verbatim duplicate of it — so deleting the odd-looking one as cleanup would
have restored the inertness silently.

The block now owns `cache-probe-hit` and `cache-probe-miss` outright, planting
its own entry and reading it back in the next case. Each direction needs its
own id: whichever entry is planted first is what every later case for that id
reads back, so a single shared id lets the second direction pass on the exact
leak it exists to catch. Measured on this tree with one id — deleting the hit
observer left the miss case green under a suppressed reset, which is the
original defect in miniature.

Red-green, reset suppressed vs active, both in full-file order and under
`-t 'resolver cache isolation'`: 2 fail / 0 fail in both orders, the same two
cases each time. Deleting either observer still leaves the other failing, so
no single-case cleanup blinds the guard.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
rigel-mintaka and others added 2 commits September 6, 2026 11:12
…RIG-3393)

The previous commit claimed no single-case cleanup could blind the guard. That
generalized from the two observer cases without measuring the other two. Both
`memoizes ...` cases plant the entry their successor reads back, so deleting
one blinded that direction, and deleting both left the reset deletable with a
fully green suite — the original inertness, restored by an edit that looked
like deduplication in either direction.

They were tempting to delete because they asserted nothing the file did not
already cover: each was a one-liner differing from an existing case only in
the provider id, and neither failed when memoization was removed from
`getProxyForProvider` outright. A case named for memoization that survives the
cache's deletion is not defending it.

Each planter now resolves twice across an env change, which is the assertion
its name always implied: the hit planter deletes the variable between the two
resolutions and still expects the proxy; the miss planter sets the variable
and still expects undefined. Both fail against a cache-free
`getProxyForProvider`, so pruning them now costs visible coverage.

Measured on this tree. Reset active: 49 pass / 0 fail full-file, 4 pass /
0 fail under `-t 'resolver cache isolation'`. Reset suppressed: 2 fail in both
orders, the same two observer cases, unchanged from before. Against a
`getProxyForProvider` with the memoization read deleted: both planters fail
where previously the whole file stayed green. Deleting any single case in the
block leaves at least one failure under a suppressed reset.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
Every sentence of the preceding comment is true per case, and together they
still leave the wrong impression. Deleting one `memoizes ...` case blinds that
direction loudly, but deleting both goes quiet: 47 pass / 0 fail with the
reset suppressed, and the two observers survive in a vacuous state, still
named for an entry nothing plants.

That hazard cannot be closed inside a test file — removing every test for a
behavior always removes its coverage silently. The tractable part was the
motive, and the previous commit handled it: the planters no longer look like
deletable duplicates. What was left was the over-inference, so the comment now
says the quiet case out loud.

Comment only. 49 pass / 0 fail, oxfmt/oxlint/tsgo clean.

Co-authored-by: Matt Wilkinson <matt@rigel.build>
@rigel-mintaka
rigel-mintaka marked this pull request as ready for review September 6, 2026 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant