diff --git a/plugins/stewards/app.tsx b/plugins/stewards/app.tsx
index aa3b905..9104a11 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 d771947..2e5e355 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 952356e..42d65f8 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 {
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 3807deb..68c7b10 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 6ea25d8..c7cd1d4 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" }));
@@ -13006,6 +13032,105 @@ describe("a wait with no wake source (BB-154)", () => {
});
});
+// 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 bad8abd..1bd8ab2 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(),
@@ -3475,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
@@ -5232,6 +5240,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 +7400,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 +7503,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 +7606,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
pending: b.pending,
requeueAt: b.requeueAt,
advancedAt: b.advancedAt,
+ waitingUntil: b.requeueUntil,
workers,
};
});
@@ -8248,6 +8261,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 +8461,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 +8492,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 +8609,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 +8626,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 +8641,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 +8716,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 +8830,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 +9630,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 0d36873..cb0691c 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 288bc8e..c9a3a7c 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/skills/stewards/SKILL.md b/plugins/stewards/skills/stewards/SKILL.md
index dd940ab..43b962c 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.
diff --git a/plugins/stewards/store.test.ts b/plugins/stewards/store.test.ts
index 8727f16..72568e2 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 2aa4ea8..75445f5 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 943a72a..7f16380 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 65c0461..1f918bf 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 5f951ec..40f725e 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 c0d7e45..caec5a6 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