From 240c123f337d607249d6a0ecb1203da5f6012ee7 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Mon, 9 Nov 2026 00:43:47 +0000 Subject: [PATCH 1/3] stewards: dated waits park a binding until a date (BB-247) --- plugins/stewards/app.tsx | 6 +- plugins/stewards/config.test.ts | 4 + plugins/stewards/config.ts | 15 ++++ plugins/stewards/migrations.snapshot.json | 4 +- plugins/stewards/server.test.ts | 99 +++++++++++++++++++++++ plugins/stewards/server.ts | 80 +++++++++++++++--- plugins/stewards/sidebar.test.ts | 24 ++++++ plugins/stewards/sidebar.ts | 17 ++++ plugins/stewards/store.test.ts | 14 ++-- plugins/stewards/store.ts | 21 ++++- plugins/stewards/wake-source.test.ts | 27 ++++++- plugins/stewards/wake-source.ts | 34 ++++++-- plugins/stewards/wake.test.ts | 18 +++++ plugins/stewards/wake.ts | 10 ++- 14 files changed, 347 insertions(+), 26 deletions(-) diff --git a/plugins/stewards/app.tsx b/plugins/stewards/app.tsx index aa3b9057..9104a116 100644 --- a/plugins/stewards/app.tsx +++ b/plugins/stewards/app.tsx @@ -621,6 +621,7 @@ function overviewEffortStatus(s: Steward): EffortStatus { stuck: s.stuck, statusActor: s.actor, statusNextAction: s.nextAction, + waitingUntil: s.waitingUntil, deployment: s.deployment, pending: s.pending, requeueAt: s.requeueAt, @@ -687,7 +688,7 @@ function StewardRow({ steward: s, now }: { steward: Steward; now: number }) { {s.summary} Next: {s.nextAction}{s.actor === null ? "" : ` (${ACTOR_LABEL[s.actor] ?? s.actor})`}

)} - {s.waitingOn !== null ?

Waiting on {s.waitingOn}

: null} + {s.waitingOn !== null && (s.waitingUntil === null || s.waitingUntil === undefined) ?

Waiting on {s.waitingOn}

: null} {s.hold !== null ?

On hold: {s.hold}

: null} {s.escalations.map((x, i) => (

@@ -877,6 +878,7 @@ function boardEffortStatus(s: BoardSteward): EffortStatus { stuck: s.stuck, statusActor: s.actor, statusNextAction: s.nextAction, + waitingUntil: s.waitingUntil, deployment: s.deployment, pending: s.pending, requeueAt: s.requeueAt, @@ -1127,7 +1129,7 @@ function EffortDetail({ board: b, effortKey, refetch }: { board: Board; effortKe Next: {s.nextAction ?? "—"} {s.actor === null ? "" : ` (${ACTOR_LABEL[s.actor] ?? s.actor})`}

- {s.waitingOn !== null ?

Waiting on {s.waitingOn}

: null} + {s.waitingOn !== null && (s.waitingUntil === null || s.waitingUntil === undefined) ?

Waiting on {s.waitingOn}

: null} {s.roles !== undefined && s.roles.length > 0 ?

Role: {s.roles.join(", ")}

: null} {s.hold !== null ?

On hold: {s.hold}{s.holdBehind.length > 0 ? ` (behind ${s.holdBehind.join(", ")})` : ""}

: null} {s.stuck ?

Stuck{stuck === null ? "" : `: ${stuck.why}`}

: null} diff --git a/plugins/stewards/config.test.ts b/plugins/stewards/config.test.ts index d7719471..2e5e3553 100644 --- a/plugins/stewards/config.test.ts +++ b/plugins/stewards/config.test.ts @@ -344,6 +344,10 @@ describe("playbooks", () => { expect(p.followThroughMs).toBe(6 * 3_600_000); // BB-83: the idle grace defaults to 15m and a playbook may override it. expect(p.idleGraceMs).toBe(15 * 60_000); + // BB-247: the dated-wait cap defaults to 90d and a playbook may override it. + expect(p.maxWaitMs).toBe(90 * 86_400_000); + const maxWait = parsePlaybook(LINEAR_TICKETS.replace("requeue-default: 2d", "requeue-default: 2d\nmax-wait: 30d"), "linear-tickets"); + expect(maxWait.ok && maxWait.value.maxWaitMs).toBe(30 * 86_400_000); const shorter = parsePlaybook(LINEAR_TICKETS.replace("requeue-default: 2d", "requeue-default: 2d\nfollow-through: 90m"), "linear-tickets"); expect(shorter.ok && shorter.value.followThroughMs).toBe(90 * 60_000); const idleGrace = parsePlaybook(LINEAR_TICKETS.replace("requeue-default: 2d", "requeue-default: 2d\nidle-grace: 5m"), "linear-tickets"); diff --git a/plugins/stewards/config.ts b/plugins/stewards/config.ts index 952356e5..42d65f8e 100644 --- a/plugins/stewards/config.ts +++ b/plugins/stewards/config.ts @@ -62,6 +62,12 @@ export function formatDuration(ms: number): string { return `${Math.round(ms / UNIT_MS.m!)}m`; } +/** + * BB-247: how far out a dated wait may be before the project steward is told + * once. The playbook's `max-wait` overrides it; the default is 90 days. + */ +export const DEFAULT_MAX_WAIT_MS = 90 * 86_400_000; + // ---- models --------------------------------------------------------------------- /** "claude" is how people write the Claude Code provider; bb calls it claude-code. */ @@ -1222,6 +1228,12 @@ export const frontMatterSchema = z may: z.array(z.string().trim().min(1).max(60)).default([]), models: z.object({ steward: modelSpecSchema.optional(), worker: modelSpecSchema.optional(), reviewer: modelSpecSchema.optional() }).strict().default({}), "requeue-default": durationSchema.default(2 * 86_400_000), + /** + * BB-247: the longest dated wait (`steward_report { until }`) a steward may + * park a binding for without the project steward being told once. The wait + * is still accepted; the note is informational, not a refusal. Default 90d. + */ + "max-wait": durationSchema.default(DEFAULT_MAX_WAIT_MS), /** * How long a binding in a build or review stage may sit with nothing to * wait on before the plugin wakes its steward (BB-83). The idle wake fires @@ -1261,6 +1273,8 @@ export type Playbook = { may: string[]; models: { steward: ModelSpec | null; worker: ModelSpec | null; reviewer: ModelSpec | null }; requeueDefaultMs: number; + /** BB-247: the longest dated wait accepted without one note to the project steward. */ + maxWaitMs: number; /** How long a binding may sit idle in an active stage before its steward is woken (BB-83). */ idleGraceMs: number; followThroughMs: number; @@ -1347,6 +1361,7 @@ export function parsePlaybook(text: string, expectedName: string): Parsed { }); }); +// BB-247: a dated wait (`steward_report { until }`) parks the binding at the +// date, counts no stall while it is ahead, is visible on the row and +// `bb stewards show`, and is listed in its own digest section. +describe("a dated wait (BB-247)", () => { + const waitReport = (waitingOn: string, over: Record = {}) => ({ phase: "In Progress", summary: "Waiting.", nextAction: "Wait", actor: "other" as const, waitingOn, ...over }); + + async function bound() { + const t = await setup(); + t.entities.set("uuid-12", issue({ state: "In Progress", stateType: "started" })); + await t.ring("linear", "uuid-12"); + return { t, steward: t.spawns[1]!.id }; + } + + it("parks the binding at the date, ignores requeueAfter, and is not marked no-wake-source", async () => { + const { t, steward } = await bound(); + const at = Date.parse("2026-12-01T00:00:00Z"); + const res = await t.tool("steward_report", waitReport("the vendor's reply", { until: "2026-12-01", requeueAfter: "1m" }), steward); + expect(res.error).toBe(false); + const b = t.hooks.store.find("ENG-12")!; + expect(b.requeueUntil).toBe(at); + expect(b.requeueAt).toBe(at); + expect(b.noWakeSource).toBe(false); + // The date is on `bb stewards show` (the row half is `effortPhrase`). + expect((await t.harness.behavior.runCli(["show", "ENG-12", "--project", "p1"])).stdout).toContain("waiting on the vendor's reply until 2026-12-01"); + expect(res.text).toContain("at 2026-12-01T00:00:00Z"); + }); + + it("accepts an ISO datetime as the date", async () => { + const { t, steward } = await bound(); + await t.tool("steward_report", waitReport("the deployment window", { until: "2026-12-01T15:30:00Z" }), steward); + expect(t.hooks.store.find("ENG-12")!.requeueUntil).toBe(Date.parse("2026-12-01T15:30:00Z")); + }); + + it("refuses an invalid date, and an until with no waitingOn", async () => { + const { t, steward } = await bound(); + const noWait = await t.tool("steward_report", waitReport("", { until: "2026-12-01" }), steward); + expect(noWait.error).toBe(true); + expect(noWait.text).toMatch(/until needs waitingOn/u); + const bad = await t.tool("steward_report", waitReport("the vendor", { until: "next Tuesday" }), steward); + expect(bad.error).toBe(true); + expect(bad.text).toMatch(/until must be a date \(YYYY-MM-DD\) or an ISO datetime/u); + // A date that does not exist is refused rather than rolled into March. + const rollover = await t.tool("steward_report", waitReport("the vendor", { until: "2026-02-30" }), steward); + expect(rollover.error).toBe(true); + expect(t.hooks.store.find("ENG-12")!.requeueUntil).toBeNull(); + }); + + it("ignores a past until with a note and requeues on the playbook default", async () => { + const { t, steward } = await bound(); + const res = await t.tool("steward_report", waitReport("the vendor's reply", { until: "2026-01-01" }), steward); + expect(res.error).toBe(false); + expect(res.text).toMatch(/in the past, so it was ignored/u); + const b = t.hooks.store.find("ENG-12")!; + expect(b.requeueUntil).toBeNull(); + expect(b.requeueAt).toBe(t.now() + 2 * 86_400_000); + }); + + it("replaces the dated wait on every report: a report without one clears it", async () => { + const { t, steward } = await bound(); + await t.tool("steward_report", waitReport("the vendor's reply", { until: "2026-12-01" }), steward); + expect(t.hooks.store.find("ENG-12")!.requeueUntil).toBe(Date.parse("2026-12-01T00:00:00Z")); + await t.tool("steward_report", waitReport("the vendor's reply", { requeueAfter: "1d" }), steward); + const b = t.hooks.store.find("ENG-12")!; + expect(b.requeueUntil).toBeNull(); + expect(b.requeueAt).toBe(t.now() + 86_400_000); + }); + + it("does not count a stall while the date is ahead, and counting resumes after it passes", async () => { + const { t, steward } = await bound(); + const b0 = t.hooks.store.find("ENG-12")!; + // A run is already under way from an earlier wait, and this report is a + // requeue wake; the future date must still reset the run. + t.hooks.store.patch(b0.id, { requeueWakeCount: 1, requeueWaitText: "the vendor's reply", requeueFirstAt: t.now(), lastWakeReason: "requeue" }); + await t.tool("steward_report", waitReport("the vendor's reply", { until: "2026-12-01" }), steward); + expect(t.hooks.store.find("ENG-12")!.requeueWakeCount).toBe(0); + + // Past the date, a requeue-only report with the same wait counts from + // zero; the date no longer suppresses the run. + t.advance(50 * 86_400_000); + t.hooks.store.patch(t.hooks.store.find("ENG-12")!.id, { lastWakeReason: "requeue" }); + await t.tool("steward_report", waitReport("the vendor's reply"), steward); + expect(t.hooks.store.find("ENG-12")!.requeueWakeCount).toBe(1); + }); + + it("notes a wait beyond max-wait once to the project steward, and not again for the same date", async () => { + const { t, steward } = await bound(); + const res = await t.tool("steward_report", waitReport("the pilot cycle", { until: "2027-06-01" }), steward); + expect(res.error).toBe(false); + const notes = () => t.hooks.store.peekProjectNotes("p1"); + expect(notes().filter((n) => n.includes("2027-06-01") && n.includes("max-wait"))).toHaveLength(1); + expect(t.hooks.store.find("ENG-12")!.requeueUntilNoted).toBe(Date.parse("2027-06-01T00:00:00Z")); + // The same date again adds nothing; a different long date is a new note. + await t.tool("steward_report", waitReport("the pilot cycle", { until: "2027-06-01" }), steward); + expect(notes().filter((n) => n.includes("2027-06-01") && n.includes("max-wait"))).toHaveLength(1); + await t.tool("steward_report", waitReport("the pilot cycle", { until: "2027-07-01" }), steward); + expect(notes().filter((n) => n.includes("2027-07-01") && n.includes("max-wait"))).toHaveLength(1); + }); +}); + // BB-203: the plugin writes the binding's status from events it observed // itself, before the steward's next turn, and quiets a running worker's own // pull request activity only while nothing the user must act on is open. diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts index bad8abd2..47d3378f 100644 --- a/plugins/stewards/server.ts +++ b/plugins/stewards/server.ts @@ -20,6 +20,7 @@ import { DEFAULT_FOLLOW_THROUGH_MS, DEFAULT_GITHUB_TICKET_PLAYBOOK, DEFAULT_LABEL, + DEFAULT_MAX_WAIT_MS, DEFAULT_PLAYBOOKS, DEPENDENCY_BOTS, DEPS_ALERTS_PLAYBOOK, @@ -118,7 +119,7 @@ import { createGhWriter, parsePullRef, type BranchProtection, type GithubWriter, import { allPullsMerged, atWork, conflictsOf, finished, normalizeTouches, openPull, overlapping, touchesOverlap, type Conflict } from "./schedule.js"; import { AUTOMATIC_NAVIGATION_PROVIDER, AUTOMATIC_THREAD_LIST_PROVIDER, BUILTIN_NAVIGATION_ID, BUILTIN_THREAD_LIST_ID, NAVIGATION_PROVIDER_KEY, THREAD_LIST_PROVIDER_KEY } from "./sidebar-provider.js"; import { waitingOnNamesWorker } from "./sidebar.js"; -import { advanceStall, hasLiveWakeSource, type WakeSourceInput } from "./wake-source.js"; +import { advanceStall, hasLiveWakeSource, parseUntil, type WakeSourceInput } from "./wake-source.js"; import { PROJECT_CHANGED } from "./channels.js"; import { LINEAR_TICKETS, PLAYBOOK_TEMPLATES, projectFileTemplate } from "./templates.js"; import type { DeploymentTarget } from "./config.js"; @@ -615,6 +616,8 @@ export const rpcContract = defineRpcContract({ nextAction: z.string().nullable(), actor: z.string().nullable(), waitingOn: z.string().nullable(), + /** When a dated wait parks the binding, for `waiting on until ` (BB-247). */ + waitingUntil: z.number().nullable(), /** Waiting with nothing the plugin tracks to wake it (BB-154). */ noWakeSource: z.boolean(), priority: z.string().nullable(), @@ -736,6 +739,8 @@ export const rpcContract = defineRpcContract({ nextAction: z.string().nullable(), actor: z.string().nullable(), waitingOn: z.string().nullable(), + /** When a dated wait parks the binding, for `waiting on until ` (BB-247). */ + waitingUntil: z.number().nullable(), /** Waiting with nothing the plugin tracks to wake it (BB-154). */ noWakeSource: z.boolean(), priority: z.string().nullable(), @@ -833,6 +838,8 @@ export const rpcContract = defineRpcContract({ /** Entity keys this stack-wait waits for a pull request to open (BB-215). */ holdStack: z.array(z.string()), waitingOn: z.string().nullable(), + /** When a dated wait parks the binding, for `waiting on until ` (BB-247). */ + waitingUntil: z.number().nullable(), /** Waiting with nothing the plugin tracks to wake it (BB-154). */ noWakeSource: z.boolean(), needsUser: z.boolean(), @@ -5232,6 +5239,8 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P stuck: openWith((x) => x.kind === "stuck"), noWakeSource: open.filter((b) => b.noWakeSource), stall: openWith((x) => x.kind === "stall"), + // BB-247: the dated waits still ahead, listed separately from stalls. + datedWaits: open.filter((b) => b.requeueUntil !== null && b.requeueUntil > now()), held: open.filter((b) => b.hold !== null), }; } @@ -7390,6 +7399,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P statusAt: b.statusAt, createdAt: b.createdAt, workers: store.workers(b.id).filter((w) => w.status === "running").length, + waitingUntil: b.requeueUntil, escalations: store.openEscalations(b.id).map((x) => ({ kind: x.kind, route: x.route, question: x.question })), })), }; @@ -7492,6 +7502,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P createdAt: b.createdAt, wokeAt: b.wokeAt, advancedAt: b.advancedAt, + waitingUntil: b.requeueUntil, workers: store.workers(b.id).filter((w) => w.status === "running").map((w) => ({ threadId: w.threadId, title: w.title })), assumptions: store.openAssumptions(b.id).map((a) => ({ id: a.id, text: a.text, context: a.context, recordedAt: a.recordedAt, recordedBy: a.recordedBy })), })), @@ -7594,6 +7605,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P pending: b.pending, requeueAt: b.requeueAt, advancedAt: b.advancedAt, + waitingUntil: b.requeueUntil, workers, }; }); @@ -8248,6 +8260,13 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P waitingOn: z.string().trim().max(300).optional().describe("The party or artifact the next action waits on: checks, a review, a deployment, another team. It never marks the effort blocked — a hold, an open `stuck` escalation or a needs item does."), blockedOn: z.string().trim().max(300).optional().describe("Deprecated alias for `waitingOn`, accepted for one release; `waitingOn` wins when both are given."), requeueAfter: durationSchema.optional().describe('When to wake you if nothing else does, such as "4h" or "2d". The playbook\'s default when left out.'), + until: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe('A dated wait: when the thing you wait on has a known end. A date ("2026-12-01", read as 00:00 UTC) or an ISO datetime. It requeues you then instead of requeueAfter, no stall is counted while it is ahead, and it needs waitingOn. A date in the past is ignored with a note. Beyond the playbook\'s `max-wait` (default 90d) it is accepted and noted once to the project steward.'), conditions: z.array(z.string().trim().min(1).max(200)).max(10).optional().describe("Anything else a reader of the status should know at a glance."), ready: z .object({ @@ -8441,7 +8460,6 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P // Follow-through has one deadline from when it was first declared; a // requeue past it is a requeue at it. const followThroughUntil = b.ending !== "terminal" ? null : (b.followThroughUntil ?? (followThrough === null ? null : now() + (playbook?.followThroughMs ?? DEFAULT_FOLLOW_THROUGH_MS))); - const requeueMs = Math.min(input.requeueAfter ?? playbook?.requeueDefaultMs ?? 2 * 86_400_000, followThroughUntil === null ? Infinity : Math.max(60_000, followThroughUntil - now())); let ready = b.ready; if (input.ready !== undefined) { // Stored in the playbook's own words, so the gate matches what was meant. @@ -8473,6 +8491,34 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const rawWaitingOn = input.waitingOn ?? input.blockedOn ?? null; const waitingOn = rawWaitingOn === "" ? null : rawWaitingOn; if (input.blockedOn !== undefined && input.waitingOn === undefined) bb.log.info(`stewards: steward_report blockedOn is renamed to waitingOn; recording this report's wait (thread ${threadId}).`); + // BB-247: a dated wait. Parse it before anything is written, so an + // invalid value or an `until` with no `waitingOn` is refused whole. + // A date in the past is ignored with a note, and a future date parks + // the binding there: it is the requeue, and no stall is counted while + // it is ahead. Each report replaces the date (a report without one + // clears it), so an old date can never suppress counting or delay a + // requeue. + let untilAt: number | null = null; + let untilNote = ""; + if (input.until !== undefined) { + if (waitingOn === null || waitingOn.trim() === "") { + return toolError(new Error("until needs waitingOn: report the wait it is for, or leave until out.")); + } + const parsed = parseUntil(input.until); + if (parsed === null) { + return toolError(new Error(`until must be a date (YYYY-MM-DD) or an ISO datetime; ${JSON.stringify(input.until)} is not.`)); + } + if (parsed <= now()) { + untilNote = ` The until date ${iso(parsed)} is in the past, so it was ignored; the wait requeues on the playbook default.`; + } else { + untilAt = parsed; + } + } + // `until` wins over an explicit `requeueAfter` in the same report. The + // deployment-deadline min below is untouched; a dated wait is not + // capped by `requeue-default`. + const requeueMs = untilAt !== null ? untilAt - now() : Math.min(input.requeueAfter ?? playbook?.requeueDefaultMs ?? 2 * 86_400_000, followThroughUntil === null ? Infinity : Math.max(60_000, followThroughUntil - now())); + const requeuePhrase = untilAt !== null ? `at ${iso(untilAt)}` : `in ${formatDuration(requeueMs)}`; // A status that waits on the user, the owner or a human has to say so // with an item, not only in prose: the item is what reaches the page. // An auto-filed need does not satisfy this: it is filed from prose, @@ -8562,6 +8608,13 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P // requeue-only run are decided after this report's own needs are // filed, so an item the turn files counts as a source. const wakeReason = b.lastWakeReason; + // BB-247: a wait beyond the playbook's `max-wait` is accepted but the + // project steward is told once for that date. The flag is the date + // already noted, so re-reporting the same date adds nothing; a + // different date is a new note. + const maxWaitMs = playbook?.maxWaitMs ?? DEFAULT_MAX_WAIT_MS; + const noteBeyondMaxWait = untilAt !== null && untilAt - now() > maxWaitMs && b.requeueUntilNoted !== untilAt; + if (noteBeyondMaxWait) untilNote += ` The date is beyond the playbook's max-wait (${formatDuration(maxWaitMs)}); the project steward is told once.`; b = store.patch(b.id, { status, statusAt: now(), @@ -8572,6 +8625,8 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const deadline = deploymentDeadline(deployments); return deadline === null ? at : Math.min(at, deadline); })(), + requeueUntil: untilAt, + ...(noteBeyondMaxWait ? { requeueUntilNoted: untilAt } : {}), awaitingReport: false, missedReports: 0, ready, @@ -8585,6 +8640,9 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P ...(landedIn === undefined || landedIn === null ? {} : { landedIn: `${landedIn.repo}#${landedIn.number}` }), ...(b.ending === "terminal" ? { followThrough, followThroughUntil } : {}), }); + if (noteBeyondMaxWait) { + store.noteProject(b.projectId, `${b.entityKey} reported a dated wait beyond the playbook's max-wait (${formatDuration(maxWaitMs)}): waiting on ${JSON.stringify(waitingOn ?? "")} until ${iso(untilAt!)}.`); + } const requested = playbook === null ? null : await syncApprovalRequest(b, playbook); const scheduling = playbook === null ? null : await syncScheduleRequest(b, playbook); // An `approve-pr` or `merge` item is filed only when the lane gate @@ -8657,10 +8715,14 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P // gate item syncNeeds filed, a worker it started). const current = store.binding(b.id) ?? b; const liveSource = liveWakeSourceFor(current); - const noWakeSource = waitingOn !== null && waitingOn.trim() !== "" && !liveSource; + // BB-247: a dated wait is the plugin's own wake source while the date + // is ahead, so it is not marked waiting with no source (and is listed + // as a dated wait in the digest instead). + const datedFuture = current.requeueUntil !== null && current.requeueUntil > now(); + const noWakeSource = waitingOn !== null && waitingOn.trim() !== "" && !liveSource && !datedFuture; const stall = advanceStall( { count: current.requeueWakeCount, waitText: current.requeueWaitText, firstAt: current.requeueFirstAt }, - { reason: wakeReason, waitingOn, liveSource, now: now() }, + { reason: wakeReason, waitingOn, liveSource, now: now(), until: current.requeueUntil }, ); b = store.patch(b.id, { noWakeSource, requeueWakeCount: stall.state.count, requeueWaitText: stall.state.waitText, requeueFirstAt: stall.state.firstAt }); const requests = [requested === null ? null : "approval", scheduling === null ? null : "scheduling"].filter((r) => r !== null); @@ -8767,14 +8829,14 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P // correction: an unknown id is ignored and named, never a failure. const citesNote = input.cites === undefined ? "" : ` ${citationLines(await countCitations(b.projectId, b.id, input.cites)).join("; ")}.`; if (b.ending === "terminal") { - if (b.followThrough === null) return `Reported.${replacedNote}${filingNote}${escalated}${deploymentNote}${citesNote} The binding ends when this turn does: running workers are stopped and the thread is archived. End the turn now.`; - return `Reported.${replacedNote}${filingNote}${escalated}${deploymentNote}${citesNote} The binding stays open to follow through until ${iso(followThroughUntil!)}; running workers keep going. You will be woken when a worker finishes or something actionable changes, or in ${formatDuration(requeueMs)}. End the turn now.`; + if (b.followThrough === null) return `Reported.${untilNote}${replacedNote}${filingNote}${escalated}${deploymentNote}${citesNote} The binding ends when this turn does: running workers are stopped and the thread is archived. End the turn now.`; + return `Reported.${untilNote}${replacedNote}${filingNote}${escalated}${deploymentNote}${citesNote} The binding stays open to follow through until ${iso(followThroughUntil!)}; running workers keep going. You will be woken when a worker finishes or something actionable changes, or ${requeuePhrase}. End the turn now.`; } const recorded = opsTickets.length === 0 ? "" : ` Recorded ${opsTickets.map((t) => `${t.ticket} (${t.did})`).join(", ")} against ${opsTickets.length === 1 ? "its fingerprint" : "their fingerprints"}.`; if (gateJustPassed && nothingPending) { - return `Reported.${replacedNote}${recorded}${filingNote}${escalated}${citesNote} Ready gate: passed and nothing schedules you. Move the ticket to In Progress and start the worker now, in this turn, with the task in the In Progress instructions; do not end the turn first.`; + return `Reported.${untilNote}${replacedNote}${recorded}${filingNote}${escalated}${citesNote} Ready gate: passed and nothing schedules you. Move the ticket to In Progress and start the worker now, in this turn, with the task in the In Progress instructions; do not end the turn first.`; } - return `Reported.${replacedNote}${recorded}${filingNote}${gateNote}${escalated}${citesNote} You will be woken when something actionable changes, or in ${formatDuration(requeueMs)}. End the turn now.`; + return `Reported.${untilNote}${replacedNote}${recorded}${filingNote}${gateNote}${escalated}${citesNote} You will be woken when something actionable changes, or ${requeuePhrase}. End the turn now.`; } catch (error) { return toolError(error); } @@ -9567,7 +9629,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P : `looked ${iso(sentry.at)}: ${sentry.findings.length === 0 ? "nothing new" : `${sentry.findings.length} issue(s) new, regressed or rung`}`; const needs = store.openNeeds(b.id); const flags = [b.endedAt === null ? null : `ended: ${b.endReason}`, watch, b.adoptedBy === null ? null : `adopted by ${b.adoptedBy}`, b.priority === null ? null : `priority ${b.priority}`, b.noMorePulls === true ? "no more pull requests" : null, b.hold === null ? null : `HOLD: ${b.hold}${b.holdBehind.length === 0 ? "" : ` (behind ${b.holdBehind.join(", ")})`}${b.holdStack.length === 0 ? "" : ` (stacked on ${b.holdStack.join(", ")})`}`, ...open.map((x) => `${x.kind} escalation ${x.id} with ${x.route}`), ...needs.map((n) => `${n.kind} need ${n.id}${n.url === "" ? "" : ` (${n.url})`}`), unheardWait(s, open, needs), asksForOpenApproval(s, open, needs)].filter((f) => f !== null); - return `${b.entityKey} — ${b.kind}, ${b.phase ?? "?"}${roles.length === 0 ? "" : `, role ${roles.join(", ")}`}${b.threadId === null ? "" : `, thread ${b.threadId}`}\n ${s === null ? "no report yet" : `${s.summary} Next: ${s.nextAction} (${s.actor})${b.noWakeSource ? "; waiting, no wake source" : s.waitingOn === null ? "" : `; waiting on ${s.waitingOn}`}`}${flags.length > 0 ? `\n [${flags.join("; ")}]` : ""}`; + return `${b.entityKey} — ${b.kind}, ${b.phase ?? "?"}${roles.length === 0 ? "" : `, role ${roles.join(", ")}`}${b.threadId === null ? "" : `, thread ${b.threadId}`}\n ${s === null ? "no report yet" : `${s.summary} Next: ${s.nextAction} (${s.actor})${b.noWakeSource ? "; waiting, no wake source" : s.waitingOn === null ? "" : `; waiting on ${s.waitingOn}${b.requeueUntil === null ? "" : ` until ${iso(b.requeueUntil).slice(0, 10)}`}`}`}${flags.length > 0 ? `\n [${flags.join("; ")}]` : ""}`; }) .join("\n"); } diff --git a/plugins/stewards/sidebar.test.ts b/plugins/stewards/sidebar.test.ts index 0d36873c..cb0691c6 100644 --- a/plugins/stewards/sidebar.test.ts +++ b/plugins/stewards/sidebar.test.ts @@ -22,6 +22,7 @@ const binding = (over: Partial & { key: hold: null, holdBehind: [], waitingOn: null, + waitingUntil: null, noWakeSource: false, needsUser: false, stuck: false, @@ -52,6 +53,7 @@ const effort = (over: Partial & { key: string }): SidebarEffort = hold: null, holdBehind: [], waitingOn: null, + waitingUntil: null, noWakeSource: false, workers: [], needsUser: false, @@ -86,6 +88,17 @@ describe("stewards sidebar grouping", () => { expect(tabs[0]!.efforts[0]!.children).toMatchObject([{ threadId: "thr_worker", role: "worker" }]); }); + it("carries a dated wait onto the effort (BB-247)", () => { + const at = Date.parse("2026-12-01T00:00:00Z"); + const tabs = buildSidebar({ + projects: [project({ id: "p1" })], + threads: [thread({ id: "thr_steward" })], + data: [data({ projectId: "p1", bindings: [binding({ key: "ENG-1", threadId: "thr_steward", waitingOn: "the vendor", waitingUntil: at })] })], + }); + expect(tabs[0]!.efforts[0]!.waitingUntil).toBe(at); + expect(tabs[0]!.efforts[0]!.phrase).toBe("waiting on the vendor until 2026-12-01"); + }); + it("puts a reviewer under its worker through the thread tree, with no reviewers field in the data (BB-100)", () => { const tabs = buildSidebar({ projects: [project({ id: "p1" })], @@ -494,6 +507,17 @@ describe("stewards sidebar state, section and phrase (BB-76)", () => { expect(effortPhrase(effort({ key: "w5", waitingOn: "the vendor", statusNextAction: "the vendor" }))).toBe("the vendor"); }); + // BB-247: a dated wait reads its date on line two, so the row says when the + // plugin will wake the steward. + it("reads a dated wait as waiting on until (BB-247)", () => { + const at = Date.parse("2026-12-01T00:00:00Z"); + expect(effortPhrase(effort({ key: "d1", waitingOn: "the vendor", waitingUntil: at }))).toBe("waiting on the vendor until 2026-12-01"); + // An ISO datetime keeps its UTC date. + expect(effortPhrase(effort({ key: "d2", waitingOn: "the window", waitingUntil: Date.parse("2026-12-01T15:30:00Z") }))).toBe("waiting on the window until 2026-12-01"); + // A running worker is a live source, so the count still wins. + expect(effortPhrase(effort({ key: "d3", waitingOn: "the vendor", waitingUntil: at, runningWorkers: 1 }))).toBe("1 worker running"); + }); + it("shows a quiet worker finish over a stale summary, before the external wait (BB-83)", () => { const now = Date.parse("2026-10-14T15:00:00Z"); const finish = { text: 'Worker "ENG-12: Implement" finished its turn.', quiet: true }; diff --git a/plugins/stewards/sidebar.ts b/plugins/stewards/sidebar.ts index 288bc8ee..c9a3a7c7 100644 --- a/plugins/stewards/sidebar.ts +++ b/plugins/stewards/sidebar.ts @@ -28,6 +28,8 @@ export interface SidebarBinding { holdBehind: string[]; /** The party or artifact the next action waits on; it never marks the effort blocked (BB-109). */ waitingOn: string | null; + /** When a dated wait parks the binding, for `waiting on until ` (BB-247). */ + waitingUntil: number | null; /** Waiting with nothing the plugin tracks to wake it (BB-154). */ noWakeSource: boolean; needsUser: boolean; @@ -122,6 +124,8 @@ export interface SidebarEffort { holdBehind: string[]; /** The party or artifact the next action waits on; shown as a plain line, never as a block (BB-109). */ waitingOn: string | null; + /** When a dated wait parks the binding (BB-247). */ + waitingUntil: number | null; /** Waiting with nothing the plugin tracks to wake it (BB-154). */ noWakeSource: boolean; /** The binding's worker records, so a wait on a finished one reads as a lost hand-off (BB-152). */ @@ -246,6 +250,8 @@ export interface EffortStatus { requeueAt: number | null; /** The party or artifact the next action waits on (BB-109). */ waitingOn?: string | null; + /** When a dated wait parks the binding (BB-247). */ + waitingUntil?: number | null; /** Waiting with nothing the plugin tracks to wake it (BB-154). */ noWakeSource?: boolean; /** The binding's workers, so a wait on a finished one reads as a lost hand-off (BB-152). */ @@ -341,6 +347,9 @@ export function untilPhrase(at: number, now: number): string { return `${Math.round(ms / 86_400_000)}d`; } +/** The UTC date of a dated wait, as `YYYY-MM-DD`, for line two's `until ` (BB-247). */ +export const waitingUntilDate = (at: number): string => new Date(at).toISOString().slice(0, 10); + /** * Line two's status phrase, first match wins (BB-76, BB-86, BB-83, BB-152): * needs you › hold › stuck › running › lost hand-off › quiet worker finish › @@ -379,6 +388,11 @@ export function effortPhrase(e: EffortStatus, now: number = Date.now()): string // ahead of the deployment phrase and the next action, which can look live // when nothing the plugin tracks will deliver the wait. if (e.noWakeSource) return "waiting, no wake source"; + // BB-247: a dated wait reads its date, so the row says when the plugin will + // wake the steward even though nothing else is live on the binding. + if (e.waitingUntil !== null && e.waitingUntil !== undefined && e.waitingOn !== null && e.waitingOn !== undefined && e.waitingOn !== "") { + return `waiting on ${e.waitingOn} until ${waitingUntilDate(e.waitingUntil)}`; + } if (e.deployment !== null) return deploymentPhrase(e.deployment); if (e.statusNextAction !== null) return e.statusNextAction; if (e.statusActor === "other") return "the other party"; @@ -412,6 +426,7 @@ export function endedEffort(e: SidebarEnded): SidebarEndedEffort { hold: null, holdBehind: [], waitingOn: null, + waitingUntil: null, noWakeSource: false, workers: [], needsUser: false, @@ -629,6 +644,7 @@ function buildTab(project: SidebarLiveProject, data: SidebarProjectData | undefi hold: b.hold, holdBehind: b.holdBehind, waitingOn: b.waitingOn, + waitingUntil: b.waitingUntil, noWakeSource: b.noWakeSource, workers: b.workers, needsUser: b.needsUser, @@ -725,6 +741,7 @@ function buildTab(project: SidebarLiveProject, data: SidebarProjectData | undefi hold: null, holdBehind: [], waitingOn: null, + waitingUntil: null, noWakeSource: false, workers: [], needsUser: false, diff --git a/plugins/stewards/store.test.ts b/plugins/stewards/store.test.ts index 8727f160..72568e22 100644 --- a/plugins/stewards/store.test.ts +++ b/plugins/stewards/store.test.ts @@ -1,7 +1,7 @@ import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { ACTOR_MIGRATIONS, APPROVAL_MARKER_MIGRATIONS, ASSUMPTION_MIGRATIONS, BASE_MIGRATIONS, CHAIN_MIGRATIONS, DEPS_DECISION_MIGRATIONS, ESCALATION_ROUTE_MIGRATIONS, GUIDANCE_MIGRATIONS, IDLE_MIGRATIONS, LESSON_COUNTER_MIGRATIONS, LESSON_PR_MIGRATIONS, LESSON_TIER_MIGRATIONS, MIGRATIONS, PULL_WATCH_MIGRATIONS, RATIONALE_MIGRATIONS, REVIEWER_MODEL_MIGRATIONS, SERVER_SETTINGS_MIGRATIONS, STACK_MIGRATIONS, VALIDATION_MIGRATIONS, WAKE_SOURCE_MIGRATIONS, WORKER_CHECK_MIGRATIONS, WORKER_STOP_REASON_MIGRATIONS, createStore, lessonScope, statusRecordSchema, type Binding } from "./store.js"; +import { ACTOR_MIGRATIONS, APPROVAL_MARKER_MIGRATIONS, ASSUMPTION_MIGRATIONS, BASE_MIGRATIONS, CHAIN_MIGRATIONS, DATED_WAIT_MIGRATIONS, DEPS_DECISION_MIGRATIONS, ESCALATION_ROUTE_MIGRATIONS, GUIDANCE_MIGRATIONS, IDLE_MIGRATIONS, LESSON_COUNTER_MIGRATIONS, LESSON_PR_MIGRATIONS, LESSON_TIER_MIGRATIONS, MIGRATIONS, PULL_WATCH_MIGRATIONS, RATIONALE_MIGRATIONS, REVIEWER_MODEL_MIGRATIONS, SERVER_SETTINGS_MIGRATIONS, STACK_MIGRATIONS, VALIDATION_MIGRATIONS, WAKE_SOURCE_MIGRATIONS, WORKER_CHECK_MIGRATIONS, WORKER_STOP_REASON_MIGRATIONS, createStore, lessonScope, statusRecordSchema, type Binding } from "./store.js"; /** MIGRATIONS without the groups appended after the ones these order tests assert. */ const beforeActorMigrations = MIGRATIONS.slice(0, MIGRATIONS.indexOf(ACTOR_MIGRATIONS[0]!)); @@ -485,7 +485,7 @@ describe("the actor rename migration (BB-97)", () => { // it. BB-164 appended the pull-watch group after BB-184's server settings, // and BB-215's stack group follows it (migrations.test.ts pins the whole // list, this only checks the group order). - const groups = [ASSUMPTION_MIGRATIONS, WORKER_STOP_REASON_MIGRATIONS, DEPS_DECISION_MIGRATIONS, ESCALATION_ROUTE_MIGRATIONS, STACK_MIGRATIONS, PULL_WATCH_MIGRATIONS, SERVER_SETTINGS_MIGRATIONS, WAKE_SOURCE_MIGRATIONS, REVIEWER_MODEL_MIGRATIONS, LESSON_PR_MIGRATIONS, ACTOR_MIGRATIONS, APPROVAL_MARKER_MIGRATIONS]; + const groups = [DATED_WAIT_MIGRATIONS, ASSUMPTION_MIGRATIONS, WORKER_STOP_REASON_MIGRATIONS, DEPS_DECISION_MIGRATIONS, ESCALATION_ROUTE_MIGRATIONS, STACK_MIGRATIONS, PULL_WATCH_MIGRATIONS, SERVER_SETTINGS_MIGRATIONS, WAKE_SOURCE_MIGRATIONS, REVIEWER_MODEL_MIGRATIONS, LESSON_PR_MIGRATIONS, ACTOR_MIGRATIONS, APPROVAL_MARKER_MIGRATIONS]; let end = MIGRATIONS.length; for (const group of groups) { expect(MIGRATIONS.slice(end - group.length, end)).toEqual([...group]); @@ -571,10 +571,12 @@ describe("the escalation route rename migration (BB-209)", () => { it("is appended at the end of MIGRATIONS, so a shipped statement index is never reused", () => { // The deps-decision group (the adoption-decision table and the fingerprint // provenance column) was appended after this one, BB-242's - // worker-stop-reason column after that, and BB-251's assumptions table - // after that. - const withAssumptions = MIGRATIONS.slice(0, -ASSUMPTION_MIGRATIONS.length); - expect(MIGRATIONS.slice(-ASSUMPTION_MIGRATIONS.length)).toEqual([...ASSUMPTION_MIGRATIONS]); + // worker-stop-reason column after that, BB-251's assumptions table after + // that, and BB-247's dated-wait columns after that. + const withDated = MIGRATIONS.slice(0, -DATED_WAIT_MIGRATIONS.length); + expect(MIGRATIONS.slice(-DATED_WAIT_MIGRATIONS.length)).toEqual([...DATED_WAIT_MIGRATIONS]); + const withAssumptions = withDated.slice(0, -ASSUMPTION_MIGRATIONS.length); + expect(withDated.slice(-ASSUMPTION_MIGRATIONS.length)).toEqual([...ASSUMPTION_MIGRATIONS]); const tail = withAssumptions.slice(0, -WORKER_STOP_REASON_MIGRATIONS.length); expect(withAssumptions.slice(-WORKER_STOP_REASON_MIGRATIONS.length)).toEqual([...WORKER_STOP_REASON_MIGRATIONS]); const depsTail = tail.slice(0, -DEPS_DECISION_MIGRATIONS.length); diff --git a/plugins/stewards/store.ts b/plugins/stewards/store.ts index 2aa4ea84..75445f53 100644 --- a/plugins/stewards/store.ts +++ b/plugins/stewards/store.ts @@ -589,7 +589,16 @@ export const ASSUMPTION_MIGRATIONS = [ `CREATE INDEX IF NOT EXISTS assumptions_project ON assumptions (project_id, state)`, ]; -export const MIGRATIONS = [...BASE_MIGRATIONS, ...CHAIN_MIGRATIONS, ...LESSON_COUNTER_MIGRATIONS, ...WORKER_CHECK_MIGRATIONS, ...DECLINED_PR_MIGRATIONS, ...DEPLOYMENT_MIGRATIONS, ...FINISHED_AND_DIGEST_MIGRATIONS, ...LESSON_TIER_MIGRATIONS, ...RATIONALE_MIGRATIONS, ...VALIDATION_MIGRATIONS, ...GUIDANCE_MIGRATIONS, ...IDLE_MIGRATIONS, ...APPROVAL_MARKER_MIGRATIONS, ...ACTOR_MIGRATIONS, ...LESSON_PR_MIGRATIONS, ...REVIEWER_MODEL_MIGRATIONS, ...WAKE_SOURCE_MIGRATIONS, ...SERVER_SETTINGS_MIGRATIONS, ...PULL_WATCH_MIGRATIONS, ...STACK_MIGRATIONS, ...ESCALATION_ROUTE_MIGRATIONS, ...DEPS_DECISION_MIGRATIONS, ...WORKER_STOP_REASON_MIGRATIONS, ...ASSUMPTION_MIGRATIONS]; +// BB-247: a dated wait. `requeue_until` is the moment a report's `until` parks +// the binding; it is set only while the date is ahead, cleared by every report +// that does not repeat it, and never pulled forward by `requeue-default`. +// `requeue_until_noted` is the `until` the project steward was last told was +// beyond the playbook's `max-wait`, so the one informational note is not +// repeated for the same date. Additive and defaulted: an old row reads as no +// dated wait and nothing noted. +export const DATED_WAIT_MIGRATIONS = [`ALTER TABLE bindings ADD COLUMN requeue_until INTEGER`, `ALTER TABLE bindings ADD COLUMN requeue_until_noted INTEGER`]; + +export const MIGRATIONS = [...BASE_MIGRATIONS, ...CHAIN_MIGRATIONS, ...LESSON_COUNTER_MIGRATIONS, ...WORKER_CHECK_MIGRATIONS, ...DECLINED_PR_MIGRATIONS, ...DEPLOYMENT_MIGRATIONS, ...FINISHED_AND_DIGEST_MIGRATIONS, ...LESSON_TIER_MIGRATIONS, ...RATIONALE_MIGRATIONS, ...VALIDATION_MIGRATIONS, ...GUIDANCE_MIGRATIONS, ...IDLE_MIGRATIONS, ...APPROVAL_MARKER_MIGRATIONS, ...ACTOR_MIGRATIONS, ...LESSON_PR_MIGRATIONS, ...REVIEWER_MODEL_MIGRATIONS, ...WAKE_SOURCE_MIGRATIONS, ...SERVER_SETTINGS_MIGRATIONS, ...PULL_WATCH_MIGRATIONS, ...STACK_MIGRATIONS, ...ESCALATION_ROUTE_MIGRATIONS, ...DEPS_DECISION_MIGRATIONS, ...WORKER_STOP_REASON_MIGRATIONS, ...ASSUMPTION_MIGRATIONS, ...DATED_WAIT_MIGRATIONS]; // ---- values kept as JSON ------------------------------------------------------------ @@ -794,6 +803,10 @@ export type Binding = { requeueWaitText: string | null; /** When the run's first wait was reported, or null (BB-154). */ requeueFirstAt: number | null; + /** When a dated wait parks the binding, or null (BB-247). */ + requeueUntil: number | null; + /** The `until` last noted to the project steward as beyond `max-wait`, or null (BB-247). */ + requeueUntilNoted: number | null; createdAt: number; endedAt: number | null; endReason: string | null; @@ -868,6 +881,8 @@ function rowToBinding(r: BindingRow): Binding { requeueWakeCount: Number(r.requeue_wake_count ?? 0), requeueWaitText: str("requeue_wait_text"), requeueFirstAt: num("requeue_first_at"), + requeueUntil: num("requeue_until"), + requeueUntilNoted: num("requeue_until_noted"), createdAt: Number(r.created_at), endedAt: num("ended_at"), endReason: str("end_reason"), @@ -933,6 +948,8 @@ const COLUMNS = { requeueWakeCount: "requeue_wake_count", requeueWaitText: "requeue_wait_text", requeueFirstAt: "requeue_first_at", + requeueUntil: "requeue_until", + requeueUntilNoted: "requeue_until_noted", endedAt: "ended_at", endReason: "end_reason", } as const satisfies Partial>; @@ -967,6 +984,8 @@ const VISIBLE_COLUMNS = [ "thread_id", // BB-154: the mark changes line two's phrase, so its write rings the channel. "no_wake_source", + // BB-247: a dated wait changes line two's phrase too. + "requeue_until", ] as const satisfies ReadonlyArray<(typeof COLUMNS)[keyof typeof COLUMNS]>; function toColumn(value: unknown): unknown { diff --git a/plugins/stewards/wake-source.test.ts b/plugins/stewards/wake-source.test.ts index 943a72a1..7f16380c 100644 --- a/plugins/stewards/wake-source.test.ts +++ b/plugins/stewards/wake-source.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { NO_STALL, STALL_WAKE_THRESHOLD, advanceStall, hasLiveWakeSource, type WakeSourceInput } from "./wake-source.js"; +import { NO_STALL, STALL_WAKE_THRESHOLD, advanceStall, hasLiveWakeSource, parseUntil, type WakeSourceInput } from "./wake-source.js"; const noSources: WakeSourceInput = { hold: null, @@ -100,4 +100,29 @@ describe("the requeue-only stall counter (BB-154)", () => { const second = advanceStall(first.state, { reason: "requeue", waitingOn: "the vendor", liveSource: false, now: 9_000 }); expect(second.state.firstAt).toBe(1_000); }); + + it("skips and resets while a dated wait is ahead, and counts once it has passed (BB-247)", () => { + const first = at(); + const future = advanceStall(first.state, { reason: "requeue", waitingOn: "the vendor", liveSource: false, now: 2_000, until: 5_000 }); + expect(future.state).toEqual(NO_STALL); + expect(future.escalate).toBe(false); + // Past the date, the same wait starts a fresh run from zero. + const after = advanceStall(future.state, { reason: "requeue", waitingOn: "the vendor", liveSource: false, now: 6_000, until: 5_000 }); + expect(after.state).toEqual({ count: 1, waitText: "the vendor", firstAt: 6_000 }); + expect(after.escalate).toBe(false); + }); +}); + +describe("a report's `until` date (BB-247)", () => { + it("reads a date as 00:00 UTC and an ISO datetime as itself", () => { + expect(parseUntil("2026-12-01")).toBe(Date.parse("2026-12-01T00:00:00Z")); + expect(parseUntil("2026-12-01T15:30:00Z")).toBe(Date.parse("2026-12-01T15:30:00Z")); + }); + + it("refuses a non-date and a date that does not exist", () => { + expect(parseUntil("next Tuesday")).toBeNull(); + expect(parseUntil("2026-02-30")).toBeNull(); + expect(parseUntil("2026-13-01")).toBeNull(); + expect(parseUntil("")).toBeNull(); + }); }); diff --git a/plugins/stewards/wake-source.ts b/plugins/stewards/wake-source.ts index 65c04613..1f918bfe 100644 --- a/plugins/stewards/wake-source.ts +++ b/plugins/stewards/wake-source.ts @@ -54,6 +54,27 @@ export function hasLiveWakeSource(s: WakeSourceInput): boolean { ); } +/** + * Parse a report's `until` (BB-247): a `YYYY-MM-DD` date read as 00:00 UTC, + * or an ISO datetime. Returns the epoch ms, or null when the value is not a + * valid date. A date-only value is checked against the calendar, so + * `2026-02-30` is refused rather than rolled over into March. + */ +export function parseUntil(value: string): number | null { + const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(value); + if (dateOnly !== null) { + const year = Number(dateOnly[1]); + const month = Number(dateOnly[2]); + const day = Number(dateOnly[3]); + const at = Date.UTC(year, month - 1, day); + const d = new Date(at); + if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) return null; + return at; + } + const at = Date.parse(value); + return Number.isNaN(at) ? null : at; +} + /** The stored requeue-only counter for one binding. */ export type StallState = { /** Consecutive requeue-only wakes with the same wait. */ @@ -70,15 +91,18 @@ export const NO_STALL: StallState = { count: 0, waitText: null, firstAt: null }; * What a report does to the requeue-only counter. It advances only when the * wake that preceded the report was a `requeue`, the report ended with a * non-empty `waitingOn`, and no structured source is live; any other wake - * reason, a changed wait, or a source resets it. `escalate` is true once the - * run reaches the threshold; the caller suppresses a second escalation while - * one is already open, which the live-source test also does (an open `stall` - * is an open escalation). + * reason, a changed wait, or a source resets it. A future `until` (BB-247) + * skips and resets the run while it is ahead, so a dated wait is never counted + * as stalled; once it passes, the next same-text report starts from zero. + * `escalate` is true once the run reaches the threshold; the caller suppresses + * a second escalation while one is already open, which the live-source test + * also does (an open `stall` is an open escalation). */ export function advanceStall( prev: StallState, - opts: { reason: string | null; waitingOn: string | null; liveSource: boolean; now: number }, + opts: { reason: string | null; waitingOn: string | null; liveSource: boolean; now: number; until?: number | null }, ): { state: StallState; escalate: boolean } { + if (opts.until !== null && opts.until !== undefined && opts.until > opts.now) return { state: NO_STALL, escalate: false }; const text = opts.waitingOn === null ? null : opts.waitingOn.trim(); if (text === null || text === "" || opts.reason !== "requeue" || opts.liveSource) { return { state: NO_STALL, escalate: false }; diff --git a/plugins/stewards/wake.test.ts b/plugins/stewards/wake.test.ts index 5f951ecb..40f725ec 100644 --- a/plugins/stewards/wake.test.ts +++ b/plugins/stewards/wake.test.ts @@ -427,6 +427,24 @@ describe("the nightly prompt", () => { expect(wake).toContain(`- ENG-12: stall escalation ${stall.id} with the project steward, since 2026-10-18: ENG-12 has woken by requeue 2 times with the same wait.`); }); + // BB-247: a dated wait is listed in its own digest section, separate from + // the stalls and the no-wake-source marks. + it("lists dated waits in their own digest section (BB-247)", () => { + const { s, bind } = world(); + const dated = bind("ENG-12"); + s.patch(dated.id, { + requeueUntil: Date.parse("2026-12-01T00:00:00Z"), + status: { phase: "In Progress", summary: "Waiting.", nextAction: "the vendor", actor: "other", waitingOn: "the vendor's reply", requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: T }, + }); + const current = s.binding(dated.id)!; + const records: DigestRecords = { previous: null, since: T - DAY, moved: [], ended: [], answered: [], withUser: [], stuck: [], noWakeSource: [], stall: [], datedWaits: [current], held: [] }; + const digest = formatRecords(records, []).join("\n"); + expect(digest).toContain("### Dated waits"); + expect(digest).toContain("- ENG-12 (ticket, In Progress): waiting on the vendor's reply until 2026-12-01"); + // A dated wait is not also in the no-wake-source list. + expect(digest).toContain("### Waiting with no wake source\n\nNothing."); + }); + it("shows the project's standing-rules list in an approval wake, and works with none configured (BB-54)", () => { const { s, bind } = world(); const b = bind("ENG-12"); diff --git a/plugins/stewards/wake.ts b/plugins/stewards/wake.ts index c0d7e454..caec5a6b 100644 --- a/plugins/stewards/wake.ts +++ b/plugins/stewards/wake.ts @@ -1451,7 +1451,7 @@ export function formatProjectWake(input: { const s = b.status; const conflicts = conflictsOf(b, allOpen); const flags = [goalProgressFlag(b), b.priority === null ? null : `priority ${b.priority}`, b.hold === null ? null : `HOLD: ${b.hold}${b.holdBehind.length === 0 ? "" : ` (behind ${b.holdBehind.join(", ")})`}${b.holdStack.length === 0 ? "" : ` (stacked on ${b.holdStack.join(", ")})`}`, b.touches.length === 0 ? null : `touches ${b.touches.join(", ")}`, conflicts.length === 0 ? null : `file-level overlap: ${conflicts.map((c) => { const open = c.with.lastSnapshot === null ? null : openPull(c.with, c.with.lastSnapshot); return `${c.with.entityKey} on ${c.on.join(", ")}${open === null || open.head === null || open.head === "" ? "" : ` (its pull request ${open.key} is open on ${open.head})`}`; }).join("; ")}`, openEscalations.length > 0 ? `${openEscalations.length} escalation(s): ${openEscalations.map((x) => `${x.kind}→${x.route}`).join(", ")}` : null, unheardWait(s, openEscalations)].filter((f): f is string => f !== null); - out.push(`- ${b.entityKey} (${b.kind}, ${b.phase ?? "?"}): ${s === null ? "no report yet" : `${s.summary} Next: ${s.nextAction} (${s.actor})${s.waitingOn === null ? "" : `; waiting on ${s.waitingOn}`}`}${flags.length > 0 ? ` [${flags.join("; ")}]` : ""}`); + out.push(`- ${b.entityKey} (${b.kind}, ${b.phase ?? "?"}): ${s === null ? "no report yet" : `${s.summary} Next: ${s.nextAction} (${s.actor})${s.waitingOn === null ? "" : `; waiting on ${s.waitingOn}${b.requeueUntil === null ? "" : ` until ${day(b.requeueUntil)}`}`}`}${flags.length > 0 ? ` [${flags.join("; ")}]` : ""}`); } const bindings = out.splice(0); if (input.digestDue) { @@ -1581,6 +1581,8 @@ export type DigestRecords = { noWakeSource: Binding[]; /** Open `stall` escalations: repeated requeue-only wakes with the same wait (BB-154). */ stall: Array<{ escalation: Escalation; from: Binding }>; + /** Dated waits still ahead, listed separately from stalls (BB-247). */ + datedWaits?: Binding[]; /** Open bindings on hold. */ held: Binding[]; /** Silent notes queued for this digest (a hold that lifted on finished work), one line each. */ @@ -1655,6 +1657,12 @@ export function formatRecords(r: DigestRecords, questions: Array<{ question: Que ]; out.push("### Stuck or held", "", ...capped(stuck, "Nothing."), ""); + // BB-247: a dated wait is not a quiet stall: the plugin will wake the steward + // at the date, so it is listed in its own section rather than under "waiting + // with no wake source". + const dated = (r.datedWaits ?? []).map((b) => `- ${b.entityKey} (${b.kind}, ${b.phase ?? "?"}): waiting on ${oneLine(b.status?.waitingOn ?? "")} until ${day(b.requeueUntil!)}${goalFlag(b)}`); + out.push("### Dated waits", "", ...capped(dated, "Nothing."), ""); + // BB-154: the quiet stall, visible once a day even before the requeue // threshold. A binding can be marked without a stall (the first report), and // a stall can be open with the mark already cleared (the stall is itself a From 4c9433d971f3322d69ac700ae12dd71f3ea18f81 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Mon, 9 Nov 2026 00:52:07 +0000 Subject: [PATCH 2/3] stewards: a dated wait is not idle before its date (BB-247) --- plugins/stewards/reconcile.test.ts | 6 ++++++ plugins/stewards/reconcile.ts | 18 +++++++++++------- plugins/stewards/server.test.ts | 26 ++++++++++++++++++++++++++ plugins/stewards/server.ts | 3 ++- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/plugins/stewards/reconcile.test.ts b/plugins/stewards/reconcile.test.ts index ff40c3b3..20cd497c 100644 --- a/plugins/stewards/reconcile.test.ts +++ b/plugins/stewards/reconcile.test.ts @@ -1005,6 +1005,7 @@ describe("idle in an active stage (BB-83)", () => { ending: null, hold: null, deployments: {}, + requeueUntil: null, workers: [{ status: "finished" }], ownPulls: own, escalations: [], @@ -1036,6 +1037,11 @@ describe("idle in an active stage (BB-83)", () => { // is nothing to wait on. expect(idleInActiveStage(binding({ stage: "spec" }), merged)).toBe(false); expect(idleInActiveStage(binding({ deployments: { "acme/web#7": { state: "deployed" } as never } }), merged)).toBe(true); + // BB-247: a dated wait still ahead is something to wait on, so the binding + // is not idle before its date; once the date passes it can be. + const at = Date.parse("2026-12-01T00:00:00Z"); + expect(idleInActiveStage(binding({ requeueUntil: at }), merged, at - 1)).toBe(false); + expect(idleInActiveStage(binding({ requeueUntil: at }), merged, at + 1)).toBe(true); }); it("starts a new idle period when an own pull request closes", () => { diff --git a/plugins/stewards/reconcile.ts b/plugins/stewards/reconcile.ts index 3807debf..68c7b10f 100644 --- a/plugins/stewards/reconcile.ts +++ b/plugins/stewards/reconcile.ts @@ -832,7 +832,7 @@ export function unheardWait(status: Binding["status"], open: readonly Escalation * pull requests its own workers opened (BB-52 rule 0), and what waits on the * user. `server.ts` assembles these from the store; nothing here reads one. */ -export type IdleBinding = Pick & { +export type IdleBinding = Pick & { workers: readonly Pick[]; ownPulls: ReadonlySet; escalations: readonly Pick[]; @@ -848,15 +848,19 @@ const pullOpen = (e: EntitySnapshot): boolean => e.pull !== null && e.state !== /** * Whether a binding in an active stage has nothing left to wait on (BB-83): - * its playbook stage is `build` or `review`, it is not ending or held, no - * worker is running, no own pull request (BB-52 rule 0) is open, no deployment - * watch is merged or deploying, and no escalation or need waits on the user. - * It says nothing about the grace or the once-per-idle-period guard; the - * server measures those. + * its playbook stage is `build` or `review`, it is not ending or held, it has + * no dated wait still ahead (BB-247), no worker is running, no own pull + * request (BB-52 rule 0) is open, no deployment watch is merged or deploying, + * and no escalation or need waits on the user. It says nothing about the grace + * or the once-per-idle-period guard; the server measures those. */ -export function idleInActiveStage(b: IdleBinding, observed: Observed): boolean { +export function idleInActiveStage(b: IdleBinding, observed: Observed, now: number = Date.now()): boolean { if (b.stage !== "build" && b.stage !== "review") return false; if (b.ending !== null || b.hold !== null) return false; + // BB-247: a dated wait is something the plugin waits on. The binding is + // parked until the date, so it is not idle and must not be woken with + // "nothing is being waited on" before then. + if (b.requeueUntil !== null && b.requeueUntil > now) return false; if (b.workers.some((w) => w.status === "running")) return false; if ([observed.entity, ...observed.pulls].some((e) => pullOpen(e) && ownPull(e.key, e.url, b.ownPulls))) return false; if (Object.values(b.deployments).some((w) => w.state === "merged" || w.state === "deploying")) return false; diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts index 7a3ee9c0..c7cd1d4b 100644 --- a/plugins/stewards/server.test.ts +++ b/plugins/stewards/server.test.ts @@ -11884,6 +11884,32 @@ describe("a steward idle in an active stage (BB-83)", () => { expect(t.sends.at(-1)!.text).toMatch(idleLine); }); + // BB-247: a dated wait is something to wait on, so a binding parked until a + // date is not woken with the "nothing is being waited on" idle line before it. + it("does not wake a binding idle past the grace while a dated wait is ahead", async () => { + const t = await setup(); + t.entities.set("uuid-12", issue({ state: "In Progress", stateType: "started" })); + await t.ring("linear", "uuid-12"); + const steward = t.spawns[1]!.id; + await t.created("thr_w1", steward, "ENG-12: Implement"); + await t.idle("thr_w1", "Waiting on the vendor."); + await t.tool("steward_report", { phase: "In Progress", summary: "Waiting.", nextAction: "Wait", actor: "other", waitingOn: "the vendor's decision", until: "2026-12-01" }, steward); + const before = t.sends.length; + + // Past the idle grace but before the date: no idle wake. + t.advance(16 * 60_000); + await t.hooks.sweep(); + await t.flush(); + expect(t.sends).toHaveLength(before); + expect(t.sends.every((s) => !idleLine.test(s.text))).toBe(true); + + // Past the date, the binding can be idle again. + t.advance(60 * 86_400_000); + await t.hooks.sweep(); + await t.flush(); + expect(t.sends.some((s) => idleLine.test(s.text))).toBe(true); + }); + it("starts a new idle period when the steward starts a worker that finishes", async () => { const t = await setup(); t.entities.set("uuid-12", issue({ state: "In Progress", stateType: "started" })); diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts index 47d3378f..1bd8ab21 100644 --- a/plugins/stewards/server.ts +++ b/plugins/stewards/server.ts @@ -3482,8 +3482,9 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P // the last wake; a worker finish asks for the look at once, without the // grace. An idle wake ends the period until one of those happens again. const idle = idleInActiveStage( - { stage, ending: b.ending, hold: b.hold, deployments, workers, ownPulls, escalations: store.openEscalations(b.id), needs: store.openNeeds(b.id) }, + { stage, ending: b.ending, hold: b.hold, deployments, workers, ownPulls, escalations: store.openEscalations(b.id), needs: store.openNeeds(b.id), requeueUntil: b.requeueUntil }, observed, + now(), ); // Re-read the idle fields from the store: a worker finish may have set // `idleWakeNow` (or a deployment verdict reset the period) after this From f3ccb8f0b0b460ed47900cb80e65ec1185c77567 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Mon, 9 Nov 2026 00:52:07 +0000 Subject: [PATCH 3/3] stewards: document dated waits in the skill (BB-247) --- plugins/stewards/skills/stewards/SKILL.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/plugins/stewards/skills/stewards/SKILL.md b/plugins/stewards/skills/stewards/SKILL.md index dd940ab3..43b962c8 100644 --- a/plugins/stewards/skills/stewards/SKILL.md +++ b/plugins/stewards/skills/stewards/SKILL.md @@ -57,8 +57,12 @@ Two tools are yours; everything else you do with the tools every thread has. `steward` = you, including your own next step such as re-checking on the next deployment wake, `other` = someone else), what you are waiting on (`waitingOn`: the party or artifact the next action waits - on, such as checks, a review or a deploy), and `requeueAfter`. When the next - action waits on one of your own workers, name that worker in `waitingOn` + on, such as checks, a review or a deploy), `requeueAfter`, and, when the wait + has a known end, `until` (a date like `2026-12-01`, read as 00:00 UTC, or an + ISO datetime). `until` needs `waitingOn`, requeues you then instead of + `requeueAfter`, and stops the stall counter while the date is ahead; a date + in the past is ignored with a note. When the next action waits on one of your + own workers, name that worker in `waitingOn` (its thread id or its title): the plugin wakes you the moment it finishes, even while its pull request is still open, and a `waitingOn` you leave on a finished worker reads `hand-off lost: worker finished`. `cites` names the @@ -249,6 +253,17 @@ Two tools are yours; everything else you do with the tools every thread has. first reported it and the wake count. A wake for another reason, a changed wait or a new source resets the count, and a stall already open is not raised again. +- **A wait with a known end is a dated wait (BB-247).** When the wait has a + date — a tax year closing, a deployment window, a pilot cycle finishing, a + decision promised for Monday — report it as `steward_report { waitingOn, + until }`: the binding is requeued at the date rather than on + `requeue-default`, no stall is counted while the date is ahead (counting + resumes from zero after it passes), and the row and `bb stewards show` read + `waiting on until `. An actionable change on the entity still + wakes you early. Each report replaces the date: a report without `until`, or + with different wait text, clears it, so an old date never delays a requeue. + A date more than the playbook's `max-wait` (default 90d) out is accepted and + noted once to the project steward. - **Do not repeat yourself.** Before acting, look for evidence it is done: a signed comment, a worker, a pull request, a review. - **Holds.** A held binding takes no new actions and starts no workers.