diff --git a/plugins/stewards/assessment.test.ts b/plugins/stewards/assessment.test.ts index 1c637045..d3145b23 100644 --- a/plugins/stewards/assessment.test.ts +++ b/plugins/stewards/assessment.test.ts @@ -483,4 +483,23 @@ describe("the typed-wait divergence store (BB-333)", () => { expect(rows.find((r) => r.new === "no-wake-source")!.count).toBe(2); expect(rows.find((r) => r.old === "no-wake-source")!.inputs).toMatchObject({ transition: "stop" }); }); + + it("reads the state table for the wait-divergences command, joining the log", () => { + const { s } = store(); + // A start, then a flip to the other direction: the state points at the + // second row, so the reader must prefer the active direction, not the + // newest row by time (both were touched at the flip). + s.recordWaitDivergence({ bindingId: "b1", derived: true, reported: false, waitKind: "external", waitLive: false, inputs: {} }, 1_000); + s.recordWaitDivergence({ bindingId: "b1", derived: false, reported: true, waitKind: "pull", waitLive: true, inputs: {} }, 2_000); + // A first observation that agrees: a state row with no divergence row. + s.recordWaitDivergence({ bindingId: "b2", derived: false, reported: false, waitKind: null, waitLive: false, inputs: {} }, 3_000); + const rows = s.waitDivergences(); + expect(rows).toHaveLength(2); + expect(rows.find((r) => r.bindingId === "b1")).toEqual({ bindingId: "b1", derived: false, reported: true, kind: "pull", firstSeenAt: 2_000, lastSeenAt: 2_000, count: 1, at: 2_000 }); + expect(rows.find((r) => r.bindingId === "b2")).toEqual({ bindingId: "b2", derived: false, reported: false, kind: null, firstSeenAt: null, lastSeenAt: null, count: 0, at: 3_000 }); + // A stopped divergence keeps its most recent row for the seen window; the + // stop touch overwrites its inputs, so the kind reads back null. + s.recordWaitDivergence({ bindingId: "b1", derived: false, reported: false, waitKind: null, waitLive: false, inputs: {} }, 4_000); + expect(s.waitDivergences({ bindingId: "b1" })).toEqual([{ bindingId: "b1", derived: false, reported: false, kind: null, firstSeenAt: 2_000, lastSeenAt: 4_000, count: 1, at: 4_000 }]); + }); }); diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts index d145354c..54d74ba8 100644 --- a/plugins/stewards/server.test.ts +++ b/plugins/stewards/server.test.ts @@ -15900,6 +15900,50 @@ describe("bb stewards divergences (BB-293)", () => { }); }); +// BB-333: the typed-wait shadow's state table has a read-only reader, `bb +// stewards wait-divergences`, the sibling of the BB-293 divergence log. The +// reconcile writes the state; the command only reads it, and nothing decides +// or renders from what it prints. +describe("bb stewards wait-divergences (BB-333)", () => { + it("lists the derived-vs-noWakeSource state, with a binding filter and --json", async () => { + const t = await setup(); + t.entities.set("uuid-12", issue()); + await t.ring("linear", "uuid-12"); + const b = t.hooks.store.find("ENG-12")!; + // Seed the state the way the shadow reconcile writes it: a typed wait that + // derives a stall the report-time flag did not, and the other direction + // under a second binding. + t.hooks.store.recordWaitDivergence({ bindingId: b.id, derived: true, reported: false, waitKind: "person", waitLive: false, inputs: {} }, 1_000); + t.hooks.store.recordWaitDivergence({ bindingId: "bnd_other", derived: false, reported: true, waitKind: "pull", waitLive: true, inputs: {} }, 2_000); + + const all = await t.harness.behavior.runCli(["wait-divergences"]); + expect(all.exitCode).toBe(0); + expect(all.stdout).toContain(b.id); + expect(all.stdout).toContain("bnd_other"); + expect(all.stdout).toContain("kind=person"); + expect(all.stdout).toContain("kind=pull"); + + // --binding resolves an entity key to the binding and keeps only its rows; + // --project is the same resolution scope the `divergences` command uses. + const one = await t.harness.behavior.runCli(["wait-divergences", "--binding", "ENG-12", "--project", "p1"]); + expect(one.exitCode).toBe(0); + expect(one.stdout).toContain(b.id); + expect(one.stdout).not.toContain("bnd_other"); + + // --json emits the rows, booleans and all. + const json = await t.harness.behavior.runCli(["wait-divergences", "--binding", "ENG-12", "--json"]); + expect(JSON.parse(json.stdout)).toEqual([{ bindingId: b.id, derived: true, reported: false, kind: "person", firstSeenAt: 1_000, lastSeenAt: 1_000, count: 1, at: 1_000 }]); + }); + + it("is registered with its own help text", async () => { + const t = await setup(); + const help = await t.harness.behavior.runCli(["--help"]); + expect(help.exitCode).toBe(0); + expect(help.stdout).toContain("wait-divergences"); + expect(help.stdout).toContain("typed wait's shadow state"); + }); +}); + // BB-300 slice 3: the stand-up. The Stewards page starts one spoken stand-up per // project; the server builds the briefing's instructions, opens the voice diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts index 3cf1e2f0..40b70b97 100644 --- a/plugins/stewards/server.ts +++ b/plugins/stewards/server.ts @@ -128,7 +128,7 @@ import { } from "./source-contract.js"; import { DEPLOY_STATES, allDeployed, applyDeployment, applyDeploymentRead, applyWorkflowRun, awaitingOnRepo, deploymentLines, deploymentReportSchema, isFinalDeploymentState, matchesTarget, matchesWorkflow, mergedPulls, pickWatch, recordMerged, reportDeployment, targetKeyOf, timeOut, timeoutsDue } from "./deploy.js"; import { evaluateCarries, liveAtMergeVerdict, matchCarriesRule, noRuleNoted, type CarriesReader } from "./carries.js"; -import { ACTORS, MIGRATIONS, ESCALATED_NEED_KINDS, NEEDS_KINDS, RESOLUTION_KINDS, createStore, needChoices, newId, type Assumption, type Binding, type DepsDecision, type DivergenceRow, type Escalation, type Fingerprint, type StandupNoteRow, type StandupRow, type StandupTranscriptEntry, type Lesson, type Need, type Observed, type Question, type ReadyState, type Store, type WakeRetry, type Worker } from "./store.js"; +import { ACTORS, MIGRATIONS, ESCALATED_NEED_KINDS, NEEDS_KINDS, RESOLUTION_KINDS, createStore, needChoices, newId, type Assumption, type Binding, type DepsDecision, type DivergenceRow, type Escalation, type Fingerprint, type StandupNoteRow, type StandupRow, type StandupTranscriptEntry, type Lesson, type Need, type Observed, type Question, type ReadyState, type Store, type WaitDivergenceRow, type WakeRetry, type Worker } from "./store.js"; import { hasLessonAnchor, landLessonInstruction, lessonAnchor, lessonLandingMark, planLessonTransitions, planValidations, retiredLessonAnchor, withLessonAnchor } from "./lessons.js"; import { AFTER_THE_FACT_RATIONALE_WHY, REPEAT_RATIONALE_WHY, ROUTINE_RATIONALE_WHY, clusterKey, routineRationaleWanted } from "./rationale.js"; import { countingDatabase, countingGithub, createReadCounts, type ReadCounts } from "./read-counts.js"; @@ -359,6 +359,24 @@ export function formatDivergences(rows: ReadonlyArray): string { + if (rows.length === 0) return "No wait divergences recorded."; + const at = (ms: number): string => new Date(ms).toISOString().replace("T", " ").slice(0, 19); + const result = (v: boolean): string => (v ? "no-wake-source" : "source-or-no-wait"); + return rows + .map((d) => { + const window = d.firstSeenAt === null || d.lastSeenAt === null ? "never" : `${at(d.firstSeenAt)} -> ${at(d.lastSeenAt)}`; + return `${d.bindingId} derived=${result(d.derived)} noWakeSource=${result(d.reported)} kind=${d.kind ?? "-"} x${d.count} ${window}`; + }) + .join("\n"); +} + /** Whitespace-normalised text, so a lesson matches however the playbook wraps it. */ const normalizeLessonText = (text: string) => text.replace(/\s+/gu, " ").trim(); @@ -13949,6 +13967,23 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P }, }), + "wait-divergences": cliCommand({ + summary: "The typed wait's shadow state, derived vs noWakeSource (BB-333), read-only", + description: "One row per binding that has reported a typed wait: the stall derived from the wait kind on the last reconcile, the report-time noWakeSource flag, and the wait kind, first/last seen time and count from the noWakeSource divergence row behind that state. --binding filters by binding id or entity key. --json emits the rows. Read-only: nothing reads the state to decide or render.", + options: { + binding: { type: "string", description: "Only this binding's wait divergences (binding id or entity key)" }, + project: PROJECT_OPTION, + json: JSON_OPTION, + }, + async run(input, ctx) { + const projectId = input.options.project ?? ctx.projectId; + const raw = input.options.binding; + const bindingId = raw === undefined ? undefined : (store.find(raw, projectId)?.id ?? raw); + const rows = store.waitDivergences({ bindingId }); + return { exitCode: 0, stdout: input.options.json === true ? JSON.stringify(rows) : formatWaitDivergences(rows) }; + }, + }), + merge: cliCommand({ summary: "Merge a pull request whose lane gate is met, through the same path as the Stewards page", description: "Re-reads the pull request, re-checks the gate with the project's approvals, and refuses with the gate's reason when it is unmet or a worker's turn is in progress. Otherwise squash-merges, posts the \"Merged:\" comment, and closes the binding's Merge item. Exit 1 on a refusal.", diff --git a/plugins/stewards/store.ts b/plugins/stewards/store.ts index 3cc7fcaa..91a6742e 100644 --- a/plugins/stewards/store.ts +++ b/plugins/stewards/store.ts @@ -1645,6 +1645,26 @@ export type DivergenceRow = { count: number; }; +/** + * BB-333: one binding's typed-wait shadow state, the reader's row for + * `bb stewards wait-divergences`. `derived` is the stall the typed wait derives + * on each reconcile; `reported` is the report-time `noWakeSource` flag. The + * `kind`, `firstSeenAt`, `lastSeenAt` and `count` come from the `noWakeSource` + * divergence row the state last wrote, so a divergence that has stopped still + * reads back with its history. `at` is when the derived/reported pair last + * changed. Read-only: the reconcile writes this table and the divergence rows. + */ +export type WaitDivergenceRow = { + bindingId: string; + derived: boolean; + reported: boolean; + kind: string | null; + firstSeenAt: number | null; + lastSeenAt: number | null; + count: number; + at: number; +}; + /** The requester thread the plugin spawned for an open provide-secret need (BB-22). */ export type SecretRequester = { threadId: string; needId: string; createdAt: number }; @@ -1992,6 +2012,12 @@ export function createStore(db: Database.Database, now: () => number, options: S const touchWaitDivergenceStmt = lazy<[number, string, string, string, string], unknown>( "UPDATE assessment_divergences SET last_seen_at = ?, inputs = ? WHERE binding_id = ? AND surface = 'noWakeSource' AND old = ? AND new = ?", ); + // BB-333: the reader's side of the state table. The `bb stewards + // wait-divergences` command reads the current derived/reported pair per + // binding; the `noWakeSource` divergence row the pair wrote rides along for + // the kind and the seen window. Prepared lazily for the same reason. + const waitDivergenceStatesStmt = lazy<[], Record>("SELECT * FROM wait_divergence_state ORDER BY at DESC, binding_id"); + const waitDivergenceStatesByBindingStmt = lazy<[string], Record>("SELECT * FROM wait_divergence_state WHERE binding_id = ? ORDER BY at DESC"); // BB-251: assumptions, prepared lazily for the same reason: a store from // before this group has no `assumptions` table yet. @@ -3650,6 +3676,37 @@ export function createStore(db: Database.Database, now: () => number, options: S : divergencesAllStmt().all(); return rows.map(toDivergenceRow); }, + /** + * The typed-wait shadow's current derived-vs-flag state, one row per + * binding that has ever reported a typed wait (BB-333), for the + * `bb stewards wait-divergences` command. Read-only. The `noWakeSource` + * divergence row the pair wrote rides along for the wait kind, the + * first/last seen window and the count; the active direction's row is + * preferred, and a stopped divergence keeps its most recent row. + */ + waitDivergences(opts: { bindingId?: string } = {}): WaitDivergenceRow[] { + const states = opts.bindingId === undefined ? waitDivergenceStatesStmt().all() : waitDivergenceStatesByBindingStmt().all(opts.bindingId); + const logs = (opts.bindingId === undefined ? divergencesBySurfaceStmt().all("noWakeSource") : divergencesByBothStmt().all(opts.bindingId, "noWakeSource")).map(toDivergenceRow); + const token = (v: boolean): string => (v ? "no-wake-source" : "source-or-no-wait"); + return states.map((r) => { + const bindingId = String(r.binding_id); + const derived = Number(r.derived) !== 0; + const reported = Number(r.reported) !== 0; + const mine = logs.filter((d) => d.bindingId === bindingId); + const active = derived !== reported ? mine.find((d) => d.old === token(reported) && d.new === token(derived)) : undefined; + const latest = active ?? [...mine].sort((a, b) => b.lastSeenAt - a.lastSeenAt)[0]; + return { + bindingId, + derived, + reported, + kind: latest !== undefined && typeof latest.inputs.waitKind === "string" ? latest.inputs.waitKind : null, + firstSeenAt: latest?.firstSeenAt ?? null, + lastSeenAt: latest?.lastSeenAt ?? null, + count: latest?.count ?? 0, + at: Number(r.at), + }; + }); + }, /** * Bound the divergence log: drop rows last seen before `maxAgeMs` and keep * at most `maxRows` (newest first). Called by the reconcile, never by a