Skip to content

test(cli): pin the daemon's reduction of the server podIds union (TASK-019) - #1760

Merged
lilyshen0722 merged 1 commit into
mainfrom
kai/task019-daemon-pod-binding
Sep 19, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
kai/task019-daemon-pod-binding

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

What this is

The third piece of the same row, on the side the row is actually named for. TASK-019 is "Daemon multi-installation projection" — and until now the daemon's own reduction of the server's multi-installation list was untested.

The server's /assigned row carries podIds as the union of the pods a seat's owner installed it into (backend/routes/agentBinding.ts:417 pushes one per installation). A token record can hold exactly one pod, so the daemon reduces the union at cli/src/lib/daemon-supervisor.js:290:

podId: row.podIds?.[0] || null,

The only fixture in cli/__tests__/daemon-supervisor.test.mjs declares podIds: ['pod-1'] — a one-element union, where the reducer and the accident agree. Two tests added:

  1. binds the local record to the first pod of the server-side union, not the smallest id (TASK-019) — a three-element union in deliberately non-alphabetical order (['pod-b','pod-a','pod-c'], expects pod-b); the record binds the first element it is given.
  2. binds null when the server declares no pod for the seat — both shapes of "declares none" (empty array, absent field).

Mutation proof, one mutation per test

mutation at daemon-supervisor.js:290 reds
row.podIds?.[0]row.podIds?.at(-1) only test 1 — single-element fixtures cannot tell first from last, which is why test 1 exists
|| null|| 'pod-last-resort' only test 2
|| null dropped, so the field is undefined only test 2 (toHaveProperty('podId', null) is doing the work)
row.podIds?.[0][...(row.podIds || [])].sort()[0] only test 1 — after the fixture was reordered (see below)

All four restored; the diff is the test file alone.

The first version of test 1 survived a client-side sort. With the original alphabetical fixture (['pod-oldest','pod-second','pod-third']) podIds[0] and sort()[0] agree, so [...podIds].sort()[0] passed 28/28 — found by @vera while gating this head. The fixture is now out of order and expects pod-b, so a daemon-side re-ordering reds the test. That matters beyond the mutation: if the ruling lands as a server-side ascending sort, a client-side sort would be a second ordering decision, and the alphabetical fixture could not tell the two apart. A sort makes an invention repeatable, not correct — and the daemon takes the first element it is handed.

What this deliberately does NOT pin

Which pod the server puts first. That is the projection's decision, and on main it is an accident: the find at agentBinding.ts:394 carries no .sort(), and :418/:419 take the first non-empty value per field, so runtime and environment can come from different installations. Pinning pod-oldest as "correct" would freeze today's insertion order.

The reducer is the contract; the ordering is the open question. That question is unchanged by this PR and is on the row with a ruling pending: runtime+environment should move as a pair from a single installation (a sorted mix is a repeatable invention — environmentFor overlays runtime model/effort into a declared environment while the adapter comes from runtime.adapter, so per-field mixing runs A's adapter with B's model), podIds stays a union, and the sort at :394 is needed either way.

This PR is a witness for whichever change makes the projection deterministic — merge it before or after that fix and it holds.

A second consumer, found while gating this head (adds a requirement to the fix)

@vera traced podIds and the runtime/environment pair across cli/, commonly-mcp/ and backend/routes|services and found one more consumer, which inverts the precedence in this same file:

  • Spawn pathenvironmentFor (:136-143) treats a declared environment's model authoritative and overlays runtime.model only where the environment is silent.
  • Status path:357-359 sets seat.model = row.runtime?.model || localToken?.environment?.model || …, i.e. runtime wins, and agentStates() (:73) ships that on the machine heartbeat (:381).

Both verified on the tree at 5cea73bd. So with a single installation, runtime.model='opus' + environment.model='sonnet' spawns sonnet and reports opus — the per-field mix makes it worse, but the inversion does not need multiple installations to exist. Consequence for the fix shape: pair-atomic projection is necessary and not sufficient — the spawn path and the status path need one shared precedence rule, or the heartbeat keeps advertising a model that is not running.

Checked and clear, same trace: grantServersForIdentities (agentBinding.ts:189/209) uses podIds as a set (membership / $in), so order does not matter there; the per-grant MCP entries attach to whichever install's environment won, so they follow the pair ruling; cli/src/commands/dev.js:356 is an unrelated local-account podIds; no other CLI or MCP reader of /assigned. Scope limit: that was a git grep, not a live-cluster read.

Why the union is load-bearing at all

Because of this consumer. daemon-supervisor.js:290 collapsing the union by index is the measured reason a whole-row atomicity design (taking everything from one installation) would silently drop a seat's other pods: a seat installed in three of its owner's pods would bind whichever one survived. Verified on main, cited from the TASK-019 row.

Verification

  • cli: 39 suites / 539 passed / 10 skipped, green.
  • lint:cli: clean. node --check on the test file: clean.
  • No version bump: test-only, and the package-version guard watches cli/src — which this does not touch.
  • Branch cut from origin/main (ac544d5c) with git log --oneline origin/main..HEAD run before opening: one commit, one file.

Honest limits

…K-019)

The server's /assigned row carries podIds as the union of the pods a seat's
owner installed it into (backend/routes/agentBinding.ts); a token record holds
one pod, so the daemon reduces the union at daemon-supervisor.js:290 with
row.podIds?.[0] || null. Only the single-element case was covered.

Two tests, each mutation-proven on its own: the reducer takes the FIRST
declared pod (binding the last instead reds only this test, since single-element
fixtures cannot tell), and a seat the server declares no pod for binds null for
both shapes of 'declares none' (defaulting to a pod instead of null reds only
this one).

Deliberately NOT pinned: which pod the server puts first. That ordering is the
projection's decision - the find at :394 carries no sort and taking the first
non-empty value per field is per-field - so this is a witness for whichever
change makes the projection deterministic rather than a lock on the accident.

cli 39 suites / 539 passed / 10 skipped, lint:cli clean. Test-only, so no
version bump: the package-version guard watches cli/src.
@lilyshen0722
lilyshen0722 force-pushed the kai/task019-daemon-pod-binding branch from 5cea73b to 1ffb750 Compare September 19, 2026 15:56
@lilyshen0722
lilyshen0722 merged commit 82e4abc into main Sep 19, 2026
13 checks passed
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