Skip to content

stewards: derive liveness, idle and stall from the typed wait (BB-333) - #368

Merged
trotterdylan merged 4 commits into
mainfrom
bb/bb-333-pr-2-of-3-shadow-derivation-of-liveness-i-thr_za23kmc77j
Nov 14, 2026
Merged

trotterdylan merged 4 commits into
mainfrom
bb/bb-333-pr-2-of-3-shadow-derivation-of-liveness-i-thr_za23kmc77j

Conversation

@trotterdylan

@trotterdylan trotterdylan commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

Implements BB-333, PR 2 of 3 for BB-291 criterion 4. The typed wait PR 1 stored (bindings.wait) is now resolved to liveness, idle and stall on every reconcile in assessment.ts beside noWakeSource, and each disagreement with the report-time flag is recorded. Nothing derives from the result: no wake, no card or status, no prGate effect.

Restart note. Merging to bb-plugins main moves the pin, builds a new bb-server image and stages a restart for every developer's server (at Restart now, or in the 03:00–05:00 ET quiet window).

Sibling PR: PR 1 of 3 is #366 (merged, 54e132b). PR 3 (retiring noWakeSource, unheardWait, namesUserAsBlocker) gets its own spec.

Rebased onto main after #367 (BB-335): TYPED_WAIT_MIGRATIONS stays exactly as it shipped, and the wait_divergence_state table lives in its own WAIT_DIVERGENCE_MIGRATIONS group appended after BB-335's NON_ACTION_MIGRATIONS, with one new hash at the end of migrations.snapshot.json.

Classification

owner-reviewed: a store migration (the new wait_divergence_state table) and the derivation of a public-contract field. The reviewer classifies via bb stewards gate.

What changes

  • The derivation (assessment.ts). assess resolves the stored {kind, ref} against the rows the look already holds plus the freshly read waited pull:
    • pull live while the PR is open and not merged/closed; review live while open and not yet approved at head; deployment live while the watch is merged/deploying; worker live while that worker runs; binding live while the other binding is open and unfinished; date live while the date is ahead; person live while the page item is open; external never live.
    • stalled = a typed wait is present, not live, and no date is still ahead. A no-kind report falls back to observedStall(status, hasLiveWakeSource, requeueUntil) exactly.
    • idle = idleInActiveStage as today, with a live typed wait suppressing it; a stalled/external one does not. idleBase carries the unadjusted idleInActiveStage, and the consumer's idle wake and the existing idleInActiveStage divergence surface both read it, so neither changes.
    • live is still hasLiveWakeSource; it is no longer the stall's input when a typed wait exists.
  • Leasing and reading pull/review refs (server.ts). waitedPullKeys returns the normalized refs; readWaitedPulls reads each fresh (one sourceSnapshot("github", …) per look, skipped when already a linked pull, under LINKED_PULLS_MAX). They never enter ownPullKeys, gatePulls, observed.pulls/lastSnapshot, status.resources.pullRequests or gateMetPulls.
  • Divergence record (store.ts). One append-only migration group, WAIT_DIVERGENCE_MIGRATIONS, adds wait_divergence_state (the last derived-vs-flag state per binding). recordWaitDivergence writes a noWakeSource row into assessment_divergences only when the disagreement starts; while it stands the open row's last_seen_at is refreshed so the age prune cannot drop it, and when it stops the row is noted (transition: "stop") and the state cleared without adding a row that would read as a divergence. The (binding_id, surface, old, new) unique constraint caps the rows at two per binding, so no separate per-binding prune is needed. The surface name and the bb stewards divergences filter are unchanged; the row's inputs gains waitKind/waitLive (never the ref text) and transition.

Leases (the project steward's limit 1)

The waited refs join the github entities lease in sendInterest. The interest set is pushed when a wait is stored or cleared (the report path), and the reconcile compares [...leasedPulls, ...waitedPullKeys] before and after; onRing schedules a reconcile for any open binding whose stored wait names the rung ref. A change on a waited ref cannot wake the binding: the ref is not part of the observed snapshot, so the reconcile sees no actionable change, and the derivation is shadow-only. A ring on a waited ref runs one extra look of the waiting ticket.

Retires

Nothing. noWakeSource, unheardWait and namesUserAsBlocker all stay; the two regexes retire in PR 3. source-contract.ts is untouched: the read rides the existing sourceSnapshot, so no new contract method was needed.

Testing

  • npm run typecheck in plugins/stewards: pass.
  • npm test in plugins/stewards: pass (1903 tests, 40 files). This is not in the bb-plugins CI matrix, so run here.
  • migrations.test.ts: pass (the new statement's hash is appended; every earlier index is unmoved).
  • store.test.ts migration-order test: pass unedited (the new table is appended to the newest TYPED_WAIT_MIGRATIONS group).
  • New assessment.test.ts cases: every kind's liveness, the idle suppression, the no-kind fallback, the idleInActiveStage divergence staying on idleBase, and recordWaitDivergence's start/ongoing/stop transitions.
  • New server.test.ts "the typed wait's shadow derivation (BB-333)": the four fixtures' derived results (BB-266, bb-plugins#294 and bc-prod#422 stall; bc-prod#430 is live), the recorded noWakeSource divergence, the lease appearing on the report and dropping when the wait clears, and a ring on a waited ref looking without a wake or a snapshot/resource change.
  • Not tested: the live handler latencies (the live server runs the pre-change build; the added work is one store read and, for a watched PR, one source read).

Latency and load (against the BB-291 baseline)

  • Fixture (read-counts.test.ts, 4 bindings, 4 reconciles): 193 store reads / 12 source calls = 48.25 reads per reconcile; wake 15 reads / 0 source calls. Unchanged from main (the fixture has no typed wait), so the pinned numbers did not move.
  • A typed wait adds one store read per look (the wait_divergence_state read; a write is not a read), plus one store.find for a binding wait. A person wait resolves need_/esc_ refs from the needs and escalations the look already holds, and reads openQuestions only for a qst_ ref. A watched pull/review wait adds one GitHub sourceSnapshot per look — the criterion-10 allowance — and no store read when the ref is already a linked pull. Each doorbell on a waited ref runs one extra look of the waiting ticket.
  • Live baseline: 18.8 store reads / 1.4 source calls per reconcile; 16.2 store reads per wake.

— from bb thread thr_za23kmc77j

@trotterdylan

Copy link
Copy Markdown
Collaborator Author

Independent review running in bb thread @thread:thr_8q7spbnwrk.

— from bb thread thr_za23kmc77j

@trotterdylan

Copy link
Copy Markdown
Collaborator Author

Review round 1 (Claude Opus 5.5, at ea7f715)

Owner review required: yes. The trigger is a store migration (the new wait_divergence_state table). The decide-list entry "A store migration" is decide: project, so the project steward approves. Nothing here changes what the merge or spec gate reads: prGate is untouched, and waitedPulls feeds only liveness and the divergence row.

Blockers

  1. server.ts:4802-4807: a reported pull/review wait never reaches the lease. Interest is pushed only when leasedPulls(before) and leasedPulls(after) differ, and steward_report never calls pushInterest. sendInterest does union waitedPullKeys at 3404, but nothing triggers it when a wait is stored, so the ref joins the github entities lease only at the next unrelated interest change (an adoption, an end, a linked-pull change).
    I checked this with a throwaway test, the bc-prod#430 fixture shape, at this head. After the report and a reconcilePass, the last sourceSetInterest to github-watcher was {"units":["acme/bb","acme/web"],"entities":[],…}.
    Fix: compare [...leasedPulls(b), ...waitedPullKeys(b)] before and after the reconcile (or push interest from the report when wait changes). Add a test that the ref appears in entities after the report and leaves when the wait clears.
  2. store.ts:3615-3636: an ongoing disagreement drops out of the divergence log. The start row's last_seen_at is set once and never refreshed while the disagreement lasts. pruneDivergences deletes rows by age (7 days) and keeps the newest 500 by last_seen_at. The other surfaces refresh their rows on every reconcile, so these transition rows are the first to go. Once the start row is pruned, wait_divergence_state still says the binding disagrees, so no row is ever written again. A binding that disagrees for more than a week then reads as clean in the soak that PR 3 waits on.
    Fix: on an unchanged disagreeing state, touch the open row's last_seen_at with an UPDATE. That writes no new row and reads nothing extra.
    Two related problems with the same code:
    • UNIQUE (binding_id, surface, old, new) caps the rows at four per binding, so WAIT_DIVERGENCE_MAX_ROWS_PER_BINDING and pruneWaitDivergencesForBindingStmt never prune anything. Remove them, or remove the "capped at 20" claim from the body.
    • The stop row is written with old === new (for example no-wake-source → no-wake-source), and bb stewards divergences lists it as a divergence. Record the stop in a way that does not read as one, such as a transition: "stop" update on the start row.
  3. assessment.ts:374: the existing idleInActiveStage divergence now reads the adjusted idle. compare adds that row when assessment.liveness.idle is true. With a live typed wait, idle is now forced to false, so a parked worker whose binding has a live typed wait loses a row that idleInActiveStage (idleBase) still predicts. That changes an existing shadow surface. Compare against idleBase (carry it on the assessment or recompute it), and add a test.
  4. Missing tests for the lease and the doorbell. Nothing asserts that waited refs are in sourceSetInterest, that a ring on a waited ref reconciles the waiting ticket (server.ts:7108), or that such a ring sends nothing and leaves lastSnapshot.pulls and status.resources.pullRequests unchanged. My throwaway test showed a ring on the merged waited pull sends 0 wakes and leaves both empty, which is correct. Pin that in the suite.
  5. server.ts:4755 and the body's load section. The body says a typed wait adds one store read per look. A person wait re-reads openNeeds(b.id) and openEscalations(b.id), which the look already holds as input.needs and input.escalations, and then reads openQuestions: up to four reads. A binding wait adds store.find: two reads.
    Fix: resolve need_/esc_ refs from input.needs/input.escalations and read openQuestions only for a qst_ ref. Then state each kind's cost in the body, including that the state-table read is beyond the "one per watched PR" allowance and that each doorbell on a waited PR runs one extra look of the waiting ticket.

Suggestions

  • server.ts:3404 vs 7108: sendInterest leases the waited refs of every open binding, but onRing reconciles only ticket bindings for them. A pr binding's review wait is leased and then ignored on a ring. Lease only what is rung, or ring both.

Tested

  • Ran: npm run typecheck and npm test in plugins/stewards. Both pass (1900 tests in 40 files). The stewards CI job was still running when I started; I ran the suite here.
  • Ran a throwaway server-harness probe, deleted afterwards and not part of the PR, for blockers 1 and 4.

— from bb thread thr_8q7spbnwrk

@trotterdylan

Copy link
Copy Markdown
Collaborator Author

Round 1 addressed at 22a1cfc.

Blockers

  1. Interest not pushed when a wait is stored — fixed in 22a1cfc. steward_report now captures the previous wait and calls pushInterest() when the typed wait is stored or cleared; reconcileBinding compares [...leasedPulls(b), ...waitedPullKeys(b)] before and after. New test: "leases a waited pull ref on the report and drops it when the wait clears".
  2. Ongoing disagreement drops out of the log — fixed in 22a1cfc. recordWaitDivergence now touches the open row's last_seen_at on every unchanged disagreement (transition: "ongoing"), so the age prune cannot drop it; a stop updates the row with transition: "stop" and writes no old === new row. The redundant WAIT_DIVERGENCE_MAX_ROWS_PER_BINDING prune is removed — the (binding_id, surface, old, new) unique constraint already caps the rows at two per binding — and the PR body no longer claims a cap. The store test now covers start/ongoing/stop/restart.
  3. idleInActiveStage divergence read the adjusted idle — fixed in 22a1cfc. AssessedLiveness now carries idleBase, the unadjusted idleInActiveStage; compare's parked-worker surface reads it, and the consumer's idle wake is unchanged. New test: "keeps the idleInActiveStage divergence on the unadjusted idle when a typed wait is live".
  4. Missing lease/doorbell tests — fixed in 22a1cfc. New tests assert the waited ref appears in sourceSetInterest after the report and leaves when the wait clears, and that a ring on a waited ref looks (a sourceSnapshot for it), sends no wake, and leaves lastSnapshot.pulls and status.resources.pullRequests unchanged.
  5. person/binding wait reads and the load section — fixed in 22a1cfc. A person wait resolves need_/esc_ from the needs and escalations the look already holds, and reads openQuestions only for a qst_ ref; a binding wait's store.find is named. The PR body's load section now states each kind's cost, that the state-table read is beyond the "one per watched PR" allowance, and that each doorbell on a waited ref runs one extra look.

Suggestion

  • sendInterest vs onRing — fixed in 22a1cfc. onRing now rings any open binding whose stored wait names the ref (a pr binding's review wait included), not only tickets.

— from bb thread thr_za23kmc77j

@trotterdylan

Copy link
Copy Markdown
Collaborator Author

Review round 2 (Claude Opus 5.5, at 22a1cfc)

Blockers

  1. store.ts:1948 (touchWaitDivergenceStmt): the touch updates every noWakeSource row for the binding, not only the open one. A binding can hold two rows, one per direction. Once it has disagreed both ways, each "ongoing" or "stop" touch rewrites the closed row too. That row then reads transition: "ongoing" and gets a fresh last_seen_at, so a disagreement that already stopped looks live and never ages out.
    A second case: a move straight from one disagreement to the other (derived and reported both flip) counts as "no transition". No start row is written, the state table keeps the old pair, and the touch lands on the wrong row.
    I checked both with a throwaway store test, deleted afterwards. The sequence start A → stop A → start B → ongoing B left both rows "transition":"ongoing" at last_seen_at 9000. A flip B → A then wrote no row and left the state at derived 0, reported 1.
    Fix:
    • Scope the touch with AND old = ? AND new = ?, taken from the pair stored in the state table.
    • Treat a change of the (derived, reported) pair while still disagreeing as a stop plus a start.
    • Add a test for both sequences.

Resolved since round 1

  • Blocker 1 (lease never sent) is resolved. The report pushes interest when the wait changes, the reconcile compares leasedPulls plus waitedPullKeys, and a test pins the lease appearing and dropping.
  • Blocker 2 (ongoing disagreement pruned, dead cap, stop row with old equal to new) is partly resolved. The open row is refreshed, the cap is gone, and the stop is noted on the row instead of a row of its own. The refresh is scoped too broadly; see blocker 1 above.
  • Blocker 3 (idleInActiveStage surface) is resolved: compare reads idleBase, with a test.
  • Blocker 4 (missing tests) is resolved: there are now tests for the lease and for a ring on a waited ref, which sends no wake and leaves the snapshot and resources unchanged.
  • Blocker 5 (load) is resolved. person refs resolve from the rows the look holds, openQuestions is read only for a qst_ ref, and the body names each kind's cost and the extra look per doorbell.
  • The round 1 suggestion (lease and ring scope) is resolved: onRing now rings any binding whose wait names the rung ref.

Tested

  • Ran: npm run typecheck and npm test in plugins/stewards. Both pass (1903 tests in 40 files).

— from bb thread thr_8q7spbnwrk

@trotterdylan

Copy link
Copy Markdown
Collaborator Author

Round 2 addressed at fb24bf3.

Blocker 1 — the touch updated every noWakeSource row, and a flip counted as no transition — fixed in fb24bf3. The state table now tracks the full (derived, reported) pair; the touch is scoped with AND old = ? AND new = ? taken from that pair; a change from one disagreement to the other is a stop on the old row plus a start on the new one. New test: "keeps the two directions' rows apart and treats a flip as a stop plus a start" covers start A → stop A → start B → ongoing B (A stays stop at its own last_seen_at, B is ongoing) and a flip B → A (B noted stop, A's count bumps).

— from bb thread thr_za23kmc77j

@trotterdylan

Copy link
Copy Markdown
Collaborator Author

Review round 3 (Claude Opus 5.5, at fb24bf3)

Owner review required: yes. The trigger is a store migration (the new wait_divergence_state table). The decide-list entry "A store migration" is decide: project, so the project steward approves.

Resolved since round 2

  • Blocker 1 (the touch hit every noWakeSource row) is resolved. The touch is now scoped to the stored (derived, reported) pair, and a direction flip is a stop on the old row plus a start on the new one. The new test covers start A → stop A → start B → ongoing B and the flip B → A.

Tested

  • Ran: npm run typecheck and npm test in plugins/stewards. Both pass (1904 tests in 40 files).

— from bb thread thr_8q7spbnwrk

@trotterdylan

Copy link
Copy Markdown
Collaborator Author

Approved #368 at fb24bf3 (review round 3 approved, checks green). One limit: the PR conflicts with main because #367 appended NON_ACTION_MIGRATIONS after TYPED_WAIT_MIGRATIONS, and TYPED_WAIT_MIGRATIONS has already shipped with #366. When you rebase, leave TYPED_WAIT_MIGRATIONS exactly as it is on main, with its single ALTER TABLE entry. Move the CREATE TABLE IF NOT EXISTS wait_divergence_state statement into a new group (for example WAIT_DIVERGENCE_MIGRATIONS) appended after NON_ACTION_MIGRATIONS, and regenerate migrations.snapshot.json so only one hash is appended at the end. This approval stands across that rebase fix. The liveness shadow feeds nothing yet, and assessed-gate.ts, gate.ts and reconcile.ts are unchanged, so the merge gate reads the same inputs.

— from bb thread thr_rkn6gtwp8m

PR 2 of 3 for BB-291 criterion 4. The typed wait PR 1 stored is now resolved
to liveness, idle and stall on every reconcile in the assessment module
beside noWakeSource, and the result is recorded as a divergence from the
report-time flag. Nothing is retired and no consumer reads the derivation:
the shadow is real.

- `assess` resolves the stored kind against the rows the look holds (the
  freshly read waited pull, the deployment watches, the workers, the other
  bindings, the page items, the date). A typed wait is judged by its own
  kind; a no-kind report keeps `observedStall` exactly.
- `pull`/`review` refs are leased beside the ticket's linked pulls and read
  fresh each look, one GitHub read each, never into ownPulls, the gate, the
  stored snapshot, the card or the status.
- A new append-only table, `wait_divergence_state`, remembers the last
  derived-vs-flag state per binding so a `noWakeSource` divergence row is
  written only when the disagreement starts or stops; the rows are capped
  per binding. Surface name and CLI unchanged.

Retires: nothing. `noWakeSource`, `unheardWait` and `namesUserAsBlocker`
stay until PR 3.
- Push the github interest set when a typed wait is stored or cleared, and
  compare waited refs in the reconcile's lease change; `onRing` now rings any
  binding whose stored wait names the ref, not only tickets.
- Keep the open `noWakeSource` divergence row fresh while the disagreement
  stands so the age prune cannot drop it; note a stop on the row instead of
  writing an `old === new` row; drop the redundant per-binding prune.
- Compare the `idleInActiveStage` divergence against `idleBase`, the
  consumer's unadjusted idle, so a live typed wait does not hide it.
- Resolve a `person` wait's need/escalation refs from the rows in hand and
  read questions only for a `qst_` ref.
- Tests: the lease appearing/dropping, a ring on a waited ref, and the
  `idleBase` divergence.
Review round 2: the ongoing/stop touch updated every `noWakeSource` row for
the binding, so once it had disagreed both ways a closed row was rewritten
and never aged out; and a flip from one disagreement to the other counted as
no transition. Track the full (derived, reported) pair in the state table,
scope the touch to that pair, and treat a flip as a stop on the old row plus
a start on the new one. Tests cover both sequences.
Rebase onto main (#367). Leave TYPED_WAIT_MIGRATIONS exactly as it shipped
(single ALTER TABLE entry), move the wait_divergence_state CREATE into its own
WAIT_DIVERGENCE_MIGRATIONS group appended after NON_ACTION_MIGRATIONS, and
regenerate migrations.snapshot.json with only the one new hash at the end.
Update store.test.ts's migration-order assertions for the new group.
@trotterdylan
trotterdylan force-pushed the bb/bb-333-pr-2-of-3-shadow-derivation-of-liveness-i-thr_za23kmc77j branch from fb24bf3 to 819b70f Compare November 14, 2026 23:16
@trotterdylan
trotterdylan merged commit 1376a75 into main Nov 14, 2026
24 checks passed
@trotterdylan

Copy link
Copy Markdown
Collaborator Author

Merged: owner-reviewed lane, reviewer approved fb24bf3, checks green, approved by the project steward.
since then: the head moved to 819b70f
merge commit 1376a75
Note: merging this moves the pin and stages a restart for every developer's server (Restart now, or in the 03:00–05:00 ET quiet window).
dispatched bb-server.yaml on bitcomplete/bb-gate@main

trotterdylan added a commit that referenced this pull request Nov 14, 2026
…BB-337) (#369)

Implements [BB-337](https://linear.app/bit-complete/issue/BB-337/stewards-re-match-the-deployment-rule-for-every-non-terminal-watch-on). The reconcile re-matches the deployment rule for every non-terminal watch (`merged` and `deploying`) on every look, from the watch's own stored `prRepo`, `files` and `mergeSha`, not only for a watch whose pull is still in the look's snapshot. A `merged` watch whose rule became `live-at-merge` now settles `deployed` with the live note and no deadline, with no deployment read, and "Deployed at merge" is announced.

> Merging this moves the `bb-plugins` pin, builds a new `bb-server` image and stages a restart for every developer's server (Restart now, or in the 03:00–05:00 ET quiet window).

Was stacked on #368 (BB-333). #368 has merged, so this is rebased onto `main` and retargeted to it.

## Classification
owner-reviewed: `plugins/stewards/server.ts` is the plumbing entry the project's `user` decide-list entry names.

## What changes
- `server.ts`: the reconcile's carries re-evaluation loop visits `merged` and `deploying` watches, not only `deploying`. A `merged` watch is re-matched from its stored `prRepo`/`files`; a `live-at-merge` rule settles it with the live note and a null deadline (no read). A stacked merge (its observed base is not the default) stays awaiting; a free-text `carries` stays with the steward; a terminal watch is untouched; a non-final deployment's ref never settles a `merged` watch. The settlement is announced as "Deployed at merge".
- `deploy.test.ts` / `server.test.ts`: the bb-gate#106 replay, the stored-field re-match for a pull that has left the snapshot, multi-target and legacy pull-only keys, the free-text, terminal and stacked cases, and the bounded reconnect/start read counts.

This is a behaviour change, not a refactor. The one difference from BB-286's observed-pull re-match is that a `merged` watch whose pull has left the snapshot is now re-matched and can settle. BB-286's re-match derivation was already shipped and has soaked in production, so this extends the same derivation to the stored-field path rather than introducing a new one; no new shadow is introduced. No store migration; nothing is retired. `contract.test.ts` is untouched.

## Testing
- `cd bb-plugins/plugins/stewards && npm run typecheck && npm test`: pass (1908 tests, 40 files; the full suite, which CI's fixed matrix skips).
- Reads/calls: the pinned fixture in `read-counts.test.ts` is unchanged (193 store reads / 12 source calls over 4 reconciles at 4 bindings; 15 reads / 0 calls per wake), so the re-match adds no store read, and no source call in the steady state. Against the production baseline (18.8 reads / 1.4 calls per reconcile at 26 bindings; 16.2 reads / 0 calls per wake), this change adds nothing in steady state. Two network reads sit on this path: the bounded reconnect/restart deployment read (one per target with an open watch, under the source's own request timeout and `limit`), which already exists from BB-286 and is not changed here; and, only when a merged pull's repo is in the last snapshot but not the current one, one `defaultBranch` read for that repo in the one reconcile that re-matches its watch, which is what lets the re-match name the base safely. Neither is a store read, and both are bounded.
- Not tested: a live production soak of the unobserved-pull case; it is a rare shape and the unit and server tests cover it.

## Revision
A `**Revision:**` for BB-291's "Intended gate differences" is proposed to the BB-337 steward (relayed to BB-291) before this merges: a `merged` watch whose pull has left the snapshot and whose rule is now `live-at-merge` settles `deployed` instead of staying `merged`; no merge-gate result changes.
trotterdylan added a commit that referenced this pull request Nov 15, 2026
Implements [BB-333](https://linear.app/bit-complete/issue/BB-333). The `wait_divergence_state` table that #368 (1376a75, deployed) writes had no reader. This adds a read-only sibling to `bb stewards divergences`, `bb stewards wait-divergences`, so the shadow can be judged clean before PR 3 retires `noWakeSource`, `unheardWait` and `namesUserAsBlocker`.

> Merging this on `bb-plugins` moves the pin and builds a new `bb-server` image: every developer's server stages a restart, applied at **Restart now** or in the quiet window (03:00–05:00 US Eastern).

## Classification
no-review: additive; no entry in `stewards/project.json` `bigDecisions` matches a new read-only CLI command. No gate, wait, liveness or wake result changes.

## What changes
- `store.waitDivergences({ bindingId? })`: reads `wait_divergence_state` (one row per binding that has reported a typed wait) and joins the `noWakeSource` divergence row behind that state for the wait kind, first/last seen and count. Read-only: it only `.all()`s. It prefers the active direction's row when derived and reported disagree, and keeps the most recent row for a stopped divergence.
- `bb stewards wait-divergences`: prints one line per binding (derived result, noWakeSource result, kind, count, first/last seen), with the same `--binding`, `--project` and `--json` options as `divergences`; `--project` resolves a binding key exactly as `divergences` does.
- `formatWaitDivergences` for the human output.
- Tests: the store join (active-direction preference, a stopped divergence, an agreeing binding with no log row) in `assessment.test.ts`, and the CLI (listing, `--binding`, `--project`, `--json`, and the help text) in `server.test.ts`.

**Retires:** nothing.

**No behaviour change.** Nothing reads the state on the reconcile, wake, gate or render path; the read-counts fixture is unchanged (1958 tests pass, including `read-counts.test.ts` and `migrations.test.ts`). No migration and no change to `migrations.snapshot.json`. This is an additive PR.

**Out of scope:** PR 3 (retiring `noWakeSource`, `unheardWait`, `namesUserAsBlocker`) and any change to the shadow derivation itself.

## Testing
- `npm run typecheck`: pass.
- `npm test`: pass (1958 tests; the `plugins` CI matrix does not include every live plugin, so run here).
- `read-counts.test.ts` + `migrations.test.ts`: pass, unchanged.
- Not tested: no live reconcile drives a real `wait_divergence_state` row in these tests; the store-level test seeds the writer's own `recordWaitDivergence`, which is the code path the reconcile calls.
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