Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions plugins/stewards/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -687,7 +688,7 @@ function StewardRow({ steward: s, now }: { steward: Steward; now: number }) {
{s.summary} <span className="text-muted-foreground">Next: {s.nextAction}{s.actor === null ? "" : ` (${ACTOR_LABEL[s.actor] ?? s.actor})`}</span>
</p>
)}
{s.waitingOn !== null ? <p className="text-muted-foreground">Waiting on {s.waitingOn}</p> : null}
{s.waitingOn !== null && (s.waitingUntil === null || s.waitingUntil === undefined) ? <p className="text-muted-foreground">Waiting on {s.waitingOn}</p> : null}
{s.hold !== null ? <p className="text-attention">On hold: {s.hold}</p> : null}
{s.escalations.map((x, i) => (
<p key={i} className={x.route === "user" ? "text-destructive" : "text-muted-foreground"}>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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})`}
</p>
{s.waitingOn !== null ? <p className="text-muted-foreground">Waiting on {s.waitingOn}</p> : null}
{s.waitingOn !== null && (s.waitingUntil === null || s.waitingUntil === undefined) ? <p className="text-muted-foreground">Waiting on {s.waitingOn}</p> : null}
{s.roles !== undefined && s.roles.length > 0 ? <p className="text-muted-foreground">Role: {s.roles.join(", ")}</p> : null}
{s.hold !== null ? <p className="text-attention">On hold: {s.hold}{s.holdBehind.length > 0 ? ` (behind ${s.holdBehind.join(", ")})` : ""}</p> : null}
{s.stuck ? <p className="text-destructive">Stuck{stuck === null ? "" : `: ${stuck.why}`}</p> : null}
Expand Down
4 changes: 4 additions & 0 deletions plugins/stewards/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
15 changes: 15 additions & 0 deletions plugins/stewards/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1347,6 +1361,7 @@ export function parsePlaybook(text: string, expectedName: string): Parsed<Playbo
may: fm.may,
models: { steward: fm.models.steward ?? null, worker: fm.models.worker ?? null, reviewer: fm.models.reviewer ?? null },
requeueDefaultMs: fm["requeue-default"],
maxWaitMs: fm["max-wait"],
idleGraceMs: fm["idle-grace"],
followThroughMs: fm["follow-through"],
maxAssumptions: fm["max-assumptions"],
Expand Down
4 changes: 3 additions & 1 deletion plugins/stewards/migrations.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,7 @@
"12fcce40313bcb18c0b5afee12b28f58fe7e715bf5073e446307c206dc92bf8f",
"ca237e70600c717ad9d14cd388dd3e92495ffbc2aebd06177a667f91e93b2ea1",
"a6d67bb6b0c56aee3c53f81f404ae4e0d37257455eca07f2ba845f62b656d1d5",
"c3fd4173fe8b57b138775f54c85324c030f9ba6562b9f3273f6c95ca52634e63"
"c3fd4173fe8b57b138775f54c85324c030f9ba6562b9f3273f6c95ca52634e63",
"d2ea8392b2f6d6ebdef23695c874c327f5de8948e7df1d7429e88783e1f14614",
"a4c47117eadbd16a6521f838e277fe6b56dd463a81bcf64e26907ea56ae68361"
]
6 changes: 6 additions & 0 deletions plugins/stewards/reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -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", () => {
Expand Down
18 changes: 11 additions & 7 deletions plugins/stewards/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Binding, "stage" | "ending" | "hold" | "deployments"> & {
export type IdleBinding = Pick<Binding, "stage" | "ending" | "hold" | "deployments" | "requeueUntil"> & {
workers: readonly Pick<Worker, "status">[];
ownPulls: ReadonlySet<string>;
escalations: readonly Pick<Escalation, "status" | "route">[];
Expand All @@ -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;
Expand Down
125 changes: 125 additions & 0 deletions plugins/stewards/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand Down Expand Up @@ -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<string, unknown> = {}) => ({ 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.
Expand Down
Loading
Loading