From b9d35e2c7032a2ed23a160df5fcee81c9e8ecce1 Mon Sep 17 00:00:00 2001
From: Dylan Trotter
Date: Wed, 28 Oct 2026 19:44:36 +0000
Subject: [PATCH 1/2] stewards: derive blocked from hold, stuck and needs-you
only (BB-109)
An effort whose steward reported a blockedOn was painted red, phrased
"blocked on ...", and moved to the Blocked section, though the field is
the ordinary pipeline wait. Blocked is now a hold, an open stuck escalation
or a needs item only; the wait is a plain muted "Waiting on ..." line.
Rename the report field to waitingOn across the status record, the RPC
shapes, the wake and bb stewards list, keeping blockedOn as an accepted
alias. Stored blockedOn records still parse and surface as waitingOn.
---
plugins/stewards/DESIGN.md | 21 ++++----
plugins/stewards/README.md | 11 +++--
plugins/stewards/app.test.tsx | 17 ++++---
plugins/stewards/app.tsx | 6 +--
plugins/stewards/reconcile.test.ts | 8 ++--
plugins/stewards/reconcile.ts | 10 ++--
plugins/stewards/server.test.ts | 47 +++++++++++-------
plugins/stewards/server.ts | 29 +++++++-----
plugins/stewards/sidebar-app.test.tsx | 22 ++++-----
plugins/stewards/sidebar.test.ts | 58 +++++++++++++----------
plugins/stewards/sidebar.ts | 33 ++++++-------
plugins/stewards/skills/stewards/SKILL.md | 12 +++--
plugins/stewards/store.test.ts | 5 +-
plugins/stewards/store.ts | 37 +++++++++++++--
plugins/stewards/wake.test.ts | 2 +-
plugins/stewards/wake.ts | 2 +-
16 files changed, 196 insertions(+), 124 deletions(-)
diff --git a/plugins/stewards/DESIGN.md b/plugins/stewards/DESIGN.md
index b7d78ae..bb51b2b 100644
--- a/plugins/stewards/DESIGN.md
+++ b/plugins/stewards/DESIGN.md
@@ -478,7 +478,7 @@ which writes the status record:
|---|---|
| phase, summary | Where the entity is, in a sentence. |
| next action, actor | What happens next, and who does it: `user` (the server's owner has to act), `steward` (the steward itself, including its own next step such as re-checking on the next deployment wake) or `other` (someone else). The actor is a party, not a pronoun: BB-95 misread `me` as the steward and filed its own re-check as needing the user. |
-| blocked on | Who or what outside the thread it waits for. |
+| waiting on | 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. The old name `blockedOn` is accepted as an alias for one release (BB-109). |
| requeue after | When to wake it if nothing else does. Defaults to the playbook's `requeue-default`. |
| conditions | Open escalations, `stuck`. |
| resources | Active workers, open pull requests, child stewards. |
@@ -720,7 +720,7 @@ escalations; the plugin-filed gate items (`approve-spec`, `approve-pr`,
changes while one of its escalations is with you, the project steward is
woken with the change, and resolves the escalation or leaves it open.
The steward's own signed description edit does not count.
-- **Waits nobody hears.** A status whose `blockedOn` names the project
+- **Waits nobody hears.** A status whose `waitingOn` names the project
steward or you, with no open escalation, is flagged in `bb stewards list`
and the project steward's wakes, so the nightly digest shows the drift.
- **Backstop:** a status record that has not advanced within the playbook's
@@ -1312,9 +1312,11 @@ The rules that hold:
- **Dedupe.** A need is keyed by (binding, kind, url) while open: a steward
that re-reports updates the wording rather than adding a row. The store
enforces it with a partial unique index.
-- **`blockedOn` stays for third parties.** A `steward_report` whose `blockedOn`
- names the user, the owner or a human with no open needs item is refused and
- told to file one, so the ask reaches the page instead of only the prose.
+- **`waitingOn` stays for third parties.** A `steward_report` whose
+ `waitingOn` names the user, the owner or a human with no open needs item is
+ refused and told to file one, so the ask reaches the page instead of only
+ the prose. The wait is a plain line on the card, not a block: blocked is
+ only a hold, an open `stuck` escalation or a needs item (BB-109).
## 19. Dependency stewards
@@ -1619,7 +1621,8 @@ without the thread actions menu when the thread is no longer in the live view.
The glyph is one of four shapes, in precedence order `you › blocked › running ›
quiet`: a solid yellow dot when it waits on you, an empty red ring when
-blocked (`blockedOn`, a hold or `stuck`), a blue ring that pulses slowly while
+blocked (a hold or an open `stuck` escalation, BB-109 — the free-text
+`waitingOn` never blocks), a blue ring that pulses slowly while
a worker or reviewer runs, and an empty grey ring when quiet. The pulse is CSS
only and stops under `prefers-reduced-motion`. One-off threads use the same four glyphs — yellow
when unread, blue animated while their agent runs, grey otherwise — except a
@@ -1630,8 +1633,8 @@ project steward footer row is the grey ring. Steward rows carry no
thread-briefs ring at all: the single off switch leaves every steward thread
with no brief, so this list's own glyph is the only one they draw.
-**The status phrase (BB-76).** First match wins: `needs you`; `blocked on
-`; `held behind ` when `holdBehind` is non-empty,
+**The status phrase (BB-76, BB-109).** First match wins: `needs you`;
+`held behind ` when `holdBehind` is non-empty,
else `on hold`; `stuck` for an open stuck escalation; `N worker(s) running`
counting only workers whose worker-table status is `running` (a busy steward or
reviewer with no running worker falls through, rather than reading `0 workers
@@ -1649,7 +1652,7 @@ and `phrase` as pure functions and sorts by the section rules; `sidebar.test.ts`
covers every rule and the precedence.
**Sections.** Blocked holds the needs-you (yellow) and blocked (red) efforts,
-whether stewarded or `ops`; a needs-you, blockedOn, held or stuck `ops`
+whether stewarded or `ops`; a needs-you, held or stuck `ops`
binding leaves Monitors for Blocked. In flight holds the rest of the stewarded,
non-ops efforts, running before quiet, then by recency. Pinned rows stay first within their section.
Recently ended holds the ended bindings, newest first, each built by
diff --git a/plugins/stewards/README.md b/plugins/stewards/README.md
index 88d0074..65303ca 100644
--- a/plugins/stewards/README.md
+++ b/plugins/stewards/README.md
@@ -256,7 +256,7 @@ bb. [DESIGN.md](DESIGN.md) is the design; this is how it is built and run.
third round. When the entity changes while one of its
escalations is with you, the project steward is woken with the change too,
since you may have answered on the ticket rather than in its thread. A
- status blocked on the project steward or the user with no open
+ status waiting on the project steward or the user with no open
escalation or needs item is flagged in `bb stewards list` and the digest.
- **"Needs you" items** are the structured things only the user can do. The
plugin files the gate items (`approve-spec`, `approve-pr`, `merge`) from
@@ -276,8 +276,8 @@ bb. [DESIGN.md](DESIGN.md) is the design; this is how it is built and run.
labels (two to six): the page shows one button per choice, a note field
and **Other…** instead of a free-text **Answer**, and a choice is saved as
guidance. `bb stewards done --choice ` records the same from a
- thread. A `blockedOn` naming the user, the owner or a human is refused
- unless an open item says so. The sidebar's **Needs you** block and count
+ thread. A `waitingOn` naming the user, the owner or a human is refused
+ unless an open item says so; it never marks the effort blocked (BB-109). The sidebar's **Needs you** block and count
badge show the same items, so a question reaches you where you already look
(BB-46).
- **One escalation chain (BB-30).** Every hop is one `escalations` row: a
@@ -423,9 +423,10 @@ switchable from where you are.
**The row and the glyphs (BB-76).** An effort row is two lines: the glyph, the
entity key in bold and the title on line one; the kind (`ticket`, `pull
request`, `review`, `ops`), the entity's phase when it has one, and the status
-phrase on line two (first match wins: `needs you`, `blocked on …`, a hold,
+phrase on line two (first match wins: `needs you`, a hold,
`stuck`, `N worker(s) running`, the deployment state, then the report's
-`nextAction` verbatim). A row that needs you adds its prompt and one-click actions
+`nextAction` verbatim). A free-text `waitingOn` is a plain line on the effort
+card, never a block or a phrase prefix (BB-109). A row that needs you adds its prompt and one-click actions
under line two, in the same row. The glyph is one of four, in precedence order
`you › blocked › running › quiet`: a solid yellow dot when it waits on you, an
empty red ring when blocked, a blue ring that pulses slowly while a worker or
diff --git a/plugins/stewards/app.test.tsx b/plugins/stewards/app.test.tsx
index 8b48871..1338c41 100644
--- a/plugins/stewards/app.test.tsx
+++ b/plugins/stewards/app.test.tsx
@@ -429,7 +429,7 @@ const sidebarBinding = (over: Record) => ({
phase: null,
hold: null,
holdBehind: [],
- blockedOn: null,
+ waitingOn: null,
needsUser: false,
stuck: false,
status: null,
@@ -574,7 +574,7 @@ const boardSteward = (over: Record = {}) => ({
summary: null,
nextAction: null,
actor: null,
- blockedOn: null,
+ waitingOn: null,
priority: null,
hold: null,
holdBehind: [],
@@ -640,7 +640,7 @@ describe("the Stewards page's project view and detail views (BB-85)", () => {
const slot = await renderBoardWith({
stewards: [
boardSteward({ key: "ENG-1", title: "Fix login", needsUser: true }),
- boardSteward({ key: "ENG-2", title: "Held up", blockedOn: "bb-gate#88" }),
+ boardSteward({ key: "ENG-2", title: "Held up", hold: "a freeze" }),
boardSteward({ key: "acme/web", title: "Ops watch", kind: "ops" }),
],
questions: [question({ id: "qst_project", key: null, question: "Which release?" })],
@@ -741,13 +741,16 @@ describe("the Stewards page's project view and detail views (BB-85)", () => {
slot.lifecycle.unmount();
});
- it("shows the blocked, hold and stuck lines in the status", async () => {
+ it("shows the waiting, hold and stuck lines in the status", async () => {
const slot = await renderBoardWith({
subPath: "p1/ENG-1",
- stewards: [boardSteward({ key: "ENG-1", title: "Fix login", blockedOn: "bb-gate#88", hold: "a freeze", holdBehind: ["ENG-9"], stuck: true, summary: "x", nextAction: "y" })],
+ stewards: [boardSteward({ key: "ENG-1", title: "Fix login", waitingOn: "bb-gate#88", hold: "a freeze", holdBehind: ["ENG-9"], stuck: true, summary: "x", nextAction: "y" })],
stuck: [{ key: "ENG-1", url: null, threadId: "thr_s", why: "waiting on a decision", since: 1 }],
});
- await waitFor(() => expect(slot.getByText("Blocked on bb-gate#88")).toBeTruthy());
+ // The wait is a plain muted line, not a destructive one (BB-109).
+ const waiting = await waitFor(() => slot.getByText("Waiting on bb-gate#88"));
+ expect(waiting.className).toContain("text-muted-foreground");
+ expect(waiting.className).not.toContain("text-destructive");
expect(slot.getByText(/On hold: a freeze \(behind ENG-9\)/u)).toBeTruthy();
expect(slot.getByText(/Stuck: waiting on a decision/u)).toBeTruthy();
slot.lifecycle.unmount();
@@ -785,7 +788,7 @@ describe("the Stewards page's project view and detail views (BB-85)", () => {
const bindings = [
sidebarBinding({ key: "ENG-1", threadId: "thr_1", needsUser: true, phase: "In Review" }),
sidebarBinding({ key: "ENG-2", threadId: "thr_2", workers: [{ threadId: "thr_w", title: "w", status: "running" }] }),
- sidebarBinding({ key: "ENG-3", threadId: "thr_3", blockedOn: "bb-gate#88" }),
+ sidebarBinding({ key: "ENG-3", threadId: "thr_3", waitingOn: "bb-gate#88" }),
sidebarBinding({ key: "ENG-4", threadId: "thr_4", deployment: "deployed" }),
];
const captured = await loadApp();
diff --git a/plugins/stewards/app.tsx b/plugins/stewards/app.tsx
index 42547af..6b9d563 100644
--- a/plugins/stewards/app.tsx
+++ b/plugins/stewards/app.tsx
@@ -396,7 +396,6 @@ function overviewEffortStatus(s: Steward): EffortStatus {
busy: false,
runningWorkers: s.workers,
needsUser: s.needsUser,
- blockedOn: s.blockedOn,
hold: s.hold,
holdBehind: s.holdBehind,
stuck: s.stuck,
@@ -465,7 +464,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.blockedOn !== null ? Blocked on {s.blockedOn}
: null}
+ {s.waitingOn !== null ? Waiting on {s.waitingOn}
: null}
{s.hold !== null ? On hold: {s.hold}
: null}
{s.escalations.map((x, i) => (
@@ -641,7 +640,6 @@ function boardEffortStatus(s: BoardSteward): EffortStatus {
busy: false,
runningWorkers: s.workers.length,
needsUser: s.needsUser,
- blockedOn: s.blockedOn,
hold: s.hold,
holdBehind: s.holdBehind,
stuck: s.stuck,
@@ -844,7 +842,7 @@ function EffortDetail({ board: b, effortKey, refetch }: { board: Board; effortKe
Next: {s.nextAction ?? "—"}
{s.actor === null ? "" : ` (${ACTOR_LABEL[s.actor] ?? s.actor})`}
- {s.blockedOn !== null ? Blocked on {s.blockedOn}
: null}
+ {s.waitingOn !== null ? Waiting on {s.waitingOn}
: 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/reconcile.test.ts b/plugins/stewards/reconcile.test.ts
index d17e951..8506e30 100644
--- a/plugins/stewards/reconcile.test.ts
+++ b/plugins/stewards/reconcile.test.ts
@@ -238,10 +238,10 @@ describe("a worker in flight between its turns (BB-11)", () => {
});
describe("waits nobody hears", () => {
- const status = (blockedOn: string | null) => ({ phase: "Todo", summary: "s", nextAction: "n", actor: "other" as const, blockedOn, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: 1 });
+ const status = (waitingOn: string | null) => ({ phase: "Todo", summary: "s", nextAction: "n", actor: "other" as const, waitingOn, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: 1 });
const open = [{ id: "esc_1", bindingId: "b", kind: "decision" as const, question: "q", context: null, meanwhile: "m", route: "project" as const, recommendation: null, choices: [], status: "open" as const, resolution: null, createdAt: 1, resolvedAt: null, deliveredAt: null, projectId: null, origin: { threadId: null, bindingId: null, chain: [] }, resolutionKind: null, url: null, data: {}, answeredBy: null, forwardedFrom: null, headSha: null, isQuestion: false, citedLessons: [], correctionsCounted: false, rationaleWanted: null, rationaleAsked: false, rationaleGiven: false, clusterKey: null }];
- it("flags a status blocked on the project steward or the user with no escalation open", () => {
+ it("flags a status waiting on the project steward or the user with no escalation open", () => {
expect(unheardWait(status("project steward's approval of the new spec"), [])).toMatch(/project steward/u);
expect(unheardWait(status("the user's answer on scope"), [])).toMatch(/the user/u);
expect(unheardWait(status("project steward's approval"), open)).toBeNull();
@@ -270,7 +270,7 @@ describe("approval words", () => {
});
it("flags a report that asks for an approval already open, and only that", () => {
- const status = (summary: string, nextAction: string) => ({ phase: "Todo", summary, nextAction, actor: "user" as const, blockedOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: 1 });
+ const status = (summary: string, nextAction: string) => ({ phase: "Todo", summary, nextAction, actor: "user" as const, waitingOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: 1 });
const open = [{ kind: "approval" } as never];
expect(asksForOpenApproval(status("Spec posted.", "Ask the user to approve the spec"), open)).toMatch(/already open/u);
// Waiting on the request, or on the project steward, is not asking again.
@@ -585,7 +585,7 @@ describe("the pull request gate (build-tickets)", () => {
});
});
-describe("a blockedOn naming the user", () => {
+describe("a waitingOn naming the user", () => {
it("is refused without an item and allowed with one", () => {
expect(namesUserAsBlocker("the user's answer on scope")).toBe(true);
expect(namesUserAsBlocker("the owner's approval")).toBe(true);
diff --git a/plugins/stewards/reconcile.ts b/plugins/stewards/reconcile.ts
index 9aaf926..416c062 100644
--- a/plugins/stewards/reconcile.ts
+++ b/plugins/stewards/reconcile.ts
@@ -632,9 +632,9 @@ export function prGate(entity: EntitySnapshot, opts: PrGateOptions = {}): PrGate
}
/**
- * A `blockedOn` that names the user, the owner or a human. A status record
+ * A `waitingOn` that names the user, the owner or a human. A status record
* naming one of them without an open `needs` item is refused: the item is what
- * puts the ask on the Stewards page.
+ * puts the ask on the Stewards page (BB-109 keeps the refusal for `waitingOn`).
*/
export function namesUserAsBlocker(text: string): boolean {
return /(?:^|[^\p{L}\p{N}])(?:user|owner|human)(?:[^\p{L}\p{N}]|$)/iu.test(text);
@@ -776,9 +776,9 @@ export const approvalWanted = (gate: GateState): boolean => !gate.none && gate.c
* none of its escalations is open: a wait nobody above it hears about.
*/
export function unheardWait(status: Binding["status"], open: readonly Escalation[], needs: readonly Pick[] = []): string | null {
- if (status === null || status.blockedOn === null || open.length > 0 || needs.length > 0) return null;
- if (/project steward/iu.test(status.blockedOn)) return "waits on the project steward, with no open escalation";
- if (/\buser\b/iu.test(status.blockedOn)) return "waits on the user, with no open escalation";
+ if (status === null || status.waitingOn === null || open.length > 0 || needs.length > 0) return null;
+ if (/project steward/iu.test(status.waitingOn)) return "waits on the project steward, with no open escalation";
+ if (/\buser\b/iu.test(status.waitingOn)) return "waits on the user, with no open escalation";
return null;
}
diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts
index e2e9887..e53816a 100644
--- a/plugins/stewards/server.test.ts
+++ b/plugins/stewards/server.test.ts
@@ -925,7 +925,7 @@ describe("a Linear ticket steward", () => {
const [project, steward] = t.spawns.map((x) => x.id);
const toProject = () => t.sends.filter((s) => s.threadId === project);
const met = { checklist: [1, 2, 3, 4].map((item) => ({ item, met: true })), substantial: true, why: "two repos" };
- const base = { phase: "Todo", summary: "Spec posted.", nextAction: "Wait for approval", actor: "other", blockedOn: "project steward's approval of the new spec" };
+ const base = { phase: "Todo", summary: "Spec posted.", nextAction: "Wait for approval", actor: "other", waitingOn: "project steward's approval of the new spec" };
const reported = await t.tool("steward_report", { ...base, ready: met }, steward!);
expect(reported.text).toMatch(/Approval of the spec is requested from the project steward \(esc_[\w-]+\); wait for it, and do not escalate for it\./u);
@@ -1061,7 +1061,7 @@ describe("a Linear ticket steward", () => {
t.entities.set("uuid-12", issue());
await t.ring("linear", "uuid-12");
const steward = t.spawns[1]!.id;
- await t.tool("steward_report", { phase: "Todo", summary: "Waiting.", nextAction: "Wait", actor: "other", blockedOn: "the project steward's call on scope" }, steward);
+ await t.tool("steward_report", { phase: "Todo", summary: "Waiting.", nextAction: "Wait", actor: "other", waitingOn: "the project steward's call on scope" }, steward);
expect((await t.harness.behavior.runCli(["list", "--project", "p1"])).stdout).toMatch(/waits on the project steward, with no open escalation/u);
});
@@ -2552,7 +2552,7 @@ describe("pull requests", () => {
});
describe("stewards waiting on others", () => {
- const waiting = { phase: "open", summary: "Reviewed.", nextAction: "Sam addresses the review", actor: "other", blockedOn: "sam" };
+ const waiting = { phase: "open", summary: "Reviewed.", nextAction: "Sam addresses the review", actor: "other", waitingOn: "sam" };
it("keeps a review steward visible after a wake turn that ends waiting", async () => {
const t = await setup();
@@ -2585,7 +2585,7 @@ describe("stewards waiting on others", () => {
t.entities.set("uuid-12", issue({ state: "In Review", stateType: "started" }));
await t.ring("linear", "uuid-12");
const steward = t.spawns[1]!.id;
- await t.tool("steward_report", { ...waiting, phase: "In Review", blockedOn: "reviewers on acme/web#7" }, steward);
+ await t.tool("steward_report", { ...waiting, phase: "In Review", waitingOn: "reviewers on acme/web#7" }, steward);
await t.idle(steward);
expect(t.threads.get(steward)!.visibility).toBe("visible");
});
@@ -2802,7 +2802,7 @@ describe("the status report actor (BB-97)", () => {
expect(status(t)!.actor).toBe("other");
expect(await needsUser(t)).toBe(false);
- await t.tool("steward_report", { phase: "In Review", summary: "Waiting.", nextAction: "On the next deployment wake, re-run the carries check", actor: "user", blockedOn: "a bb-gate deployment" }, t.steward);
+ await t.tool("steward_report", { phase: "In Review", summary: "Waiting.", nextAction: "On the next deployment wake, re-run the carries check", actor: "user", waitingOn: "a bb-gate deployment" }, t.steward);
expect(status(t)!.actor).toBe("user");
expect(await needsUser(t)).toBe(true);
});
@@ -3590,7 +3590,7 @@ describe("questions to the user", () => {
phase: "In Review",
hold: "a freeze",
holdBehind: ["ENG-9"],
- status: { phase: "review", summary: "Waiting on the vendor", nextAction: "the vendor", actor: "other", blockedOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at },
+ status: { phase: "review", summary: "Waiting on the vendor", nextAction: "the vendor", actor: "other", waitingOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at },
deployments: {
"acme/web#7": { key: "acme/web#7", state: "deployed", mergeSha: null, repo: "acme/web", environment: "production", deploymentId: null, deploymentState: null, ref: null, sha: null, url: null, at, deadlineAt: null, note: null, workflowRuns: [] },
},
@@ -3766,7 +3766,7 @@ describe("the stewards sidebar call", () => {
threadId: string | null;
hold: string | null;
holdBehind: string[];
- blockedOn: string | null;
+ waitingOn: string | null;
needsUser: boolean;
stuck: boolean;
status: { actor: string; nextAction: string; at: number } | null;
@@ -3809,7 +3809,7 @@ describe("the stewards sidebar call", () => {
const first = t.hooks.store.find("ENG-12", "p1")!;
// One binding needs the user through its report actor, another through an
// open need; an ended one must not count.
- t.hooks.store.patch(first.id, { status: { phase: "In Review", summary: "Waiting", nextAction: "On the user", actor: "user", blockedOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: t.now() } });
+ t.hooks.store.patch(first.id, { status: { phase: "In Review", summary: "Waiting", nextAction: "On the user", actor: "user", waitingOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: t.now() } });
const second = t.hooks.store.create({ projectId: "p1", source: "linear", entityId: "uuid-13", entityKey: "ENG-13", url: null, title: "Other", kind: "ticket", playbook: "linear-tickets", playbookVersion: "v1", parentBindingId: null, phase: "Todo", stage: "spec" });
t.hooks.store.fileNeed({ bindingId: second.id, kind: "configure", url: "", what: "Enable it", how: null });
const ended = t.hooks.store.create({ projectId: "p1", source: "linear", entityId: "uuid-14", entityKey: "ENG-14", url: null, title: "Ended", kind: "ticket", playbook: "linear-tickets", playbookVersion: "v1", parentBindingId: null, phase: "Todo", stage: "spec" });
@@ -3864,7 +3864,7 @@ describe("the stewards sidebar call", () => {
phase: "In Review",
hold: "a freeze",
holdBehind: ["ENG-9"],
- status: { phase: "review", summary: "Waiting on the vendor", nextAction: "the vendor", actor: "other", blockedOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at },
+ status: { phase: "review", summary: "Waiting on the vendor", nextAction: "the vendor", actor: "other", waitingOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at },
deployments: {
"acme/web#7": { key: "acme/web#7", state: "deployed", mergeSha: null, repo: "acme/web", environment: "production", deploymentId: null, deploymentState: null, ref: null, sha: null, url: null, at, deadlineAt: null, note: null, workflowRuns: [] },
},
@@ -4065,7 +4065,7 @@ describe("the realtime signal on every sidebar-visible change (BB-90)", () => {
const { t, b } = await bound();
const before = t.harness.inspection.realtimeSignals.length;
t.hooks.store.patch(b.id, {
- status: { phase: "Build", summary: "Building.", nextAction: "Wait", actor: "steward", blockedOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: t.now() },
+ status: { phase: "Build", summary: "Building.", nextAction: "Wait", actor: "steward", waitingOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: t.now() },
advancedAt: t.now(),
});
expect(signalsSince(t, before)).toEqual([one]);
@@ -4137,7 +4137,7 @@ describe("the realtime signal on every sidebar-visible change (BB-90)", () => {
await t.hooks.store.coalescingTurn(async () => {
t.hooks.store.patch(b.id, {
stage: "build",
- status: { phase: "Build", summary: "Building.", nextAction: "Wait", actor: "steward", blockedOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: t.now() },
+ status: { phase: "Build", summary: "Building.", nextAction: "Wait", actor: "steward", waitingOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: t.now() },
});
t.hooks.store.addWorker("thr_turn_worker", b.id, "Implement");
});
@@ -4146,7 +4146,7 @@ describe("the realtime signal on every sidebar-visible change (BB-90)", () => {
it("stays silent when a status rewrite leaves the derived phrase unchanged", async () => {
const { t, b } = await bound();
- const status = { phase: "Build", summary: "Building.", nextAction: "Wait", actor: "steward" as const, blockedOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: t.now() };
+ const status = { phase: "Build", summary: "Building.", nextAction: "Wait", actor: "steward" as const, waitingOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: t.now() };
t.hooks.store.patch(b.id, { status, statusAt: t.now() });
const before = t.harness.inspection.realtimeSignals.length;
// Same actor and next action: the row's phrase does not change.
@@ -4711,18 +4711,33 @@ describe("needs you items", () => {
expect((await t.hooks.settings.get()).sentryToken).toBeUndefined();
});
- it("refuses a blockedOn naming the user with no item, and accepts one with an item", async () => {
+ it("refuses a waitingOn naming the user with no item, and accepts one with an item", async () => {
const t = await setup();
t.entities.set("uuid-12", issue());
await t.ring("linear", "uuid-12");
const steward = t.spawns[1]!.id;
- const bad = await t.tool("steward_report", { ...base, actor: "user", blockedOn: "the user's answer" }, steward);
+ const bad = await t.tool("steward_report", { ...base, actor: "user", waitingOn: "the user's answer" }, steward);
expect(bad.error).toBe(true);
expect(bad.text).toMatch(/File it as a needs item/u);
- const ok = await t.tool("steward_report", { ...base, actor: "user", blockedOn: "the user's answer", needs: [{ kind: "decide", what: "Answer the scope question", url: "https://linear.app/acme/issue/ENG-12" }] }, steward);
+ const ok = await t.tool("steward_report", { ...base, actor: "user", waitingOn: "the user's answer", needs: [{ kind: "decide", what: "Answer the scope question", url: "https://linear.app/acme/issue/ENG-12" }] }, steward);
expect(ok.error).toBe(false);
});
+ it("accepts the old blockedOn name as an alias, with waitingOn winning (BB-109)", async () => {
+ const t = await setup();
+ t.entities.set("uuid-12", issue());
+ await t.ring("linear", "uuid-12");
+ const steward = t.spawns[1]!.id;
+ // The old skill text still says blockedOn; it records as waitingOn.
+ const old = await t.tool("steward_report", { ...base, actor: "other", blockedOn: "checks on 24376fd" }, steward);
+ expect(old.error).toBe(false);
+ expect(t.hooks.store.find("ENG-12", "p1")!.status!.waitingOn).toBe("checks on 24376fd");
+ // When both are given, the new name wins.
+ const both = await t.tool("steward_report", { ...base, actor: "other", blockedOn: "the old name", waitingOn: "the new name" }, steward);
+ expect(both.error).toBe(false);
+ expect(t.hooks.store.find("ENG-12", "p1")!.status!.waitingOn).toBe("the new name");
+ });
+
it("shows the items on the board with their actionability", async () => {
const t = await withPull([marker]);
const board = async () => ((await t.harness.callRpc("stewardsBoard", { projectId: "p1" })) as { board: { needs: Array<{ kind: string; key: string; url: string | null; entityUrl: string | null; actionable: boolean; why: string | null }> } }).board;
@@ -7554,7 +7569,7 @@ describe("steward-spawned threads and the sidebar (BB-39)", () => {
const marks = () => t.harness.sdk.callsTo("threads.markRead").map(([args]) => (args as { threadId: string }).threadId);
// Waiting on someone else, nothing for the user: read.
- await t.tool("steward_report", { phase: "open", summary: "Reviewed.", nextAction: "Sam addresses the review", actor: "other", blockedOn: "sam" }, steward);
+ await t.tool("steward_report", { phase: "open", summary: "Reviewed.", nextAction: "Sam addresses the review", actor: "other", waitingOn: "sam" }, steward);
expect(marks()).toContain(steward);
// The user is next: left unread.
diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts
index 0f01150..b883a1b 100644
--- a/plugins/stewards/server.ts
+++ b/plugins/stewards/server.ts
@@ -377,7 +377,7 @@ export const rpcContract = defineRpcContract({
summary: z.string().nullable(),
nextAction: z.string().nullable(),
actor: z.string().nullable(),
- blockedOn: z.string().nullable(),
+ waitingOn: z.string().nullable(),
priority: z.string().nullable(),
hold: z.string().nullable(),
/** Entity keys this hold waits behind, for the row's `held behind ` phrase. */
@@ -467,7 +467,7 @@ export const rpcContract = defineRpcContract({
summary: z.string().nullable(),
nextAction: z.string().nullable(),
actor: z.string().nullable(),
- blockedOn: z.string().nullable(),
+ waitingOn: z.string().nullable(),
priority: z.string().nullable(),
/** The hold reason, for the card's `On hold` line; null when not held. */
hold: z.string().nullable(),
@@ -539,7 +539,7 @@ export const rpcContract = defineRpcContract({
hold: z.string().nullable(),
/** Entity keys this hold waits behind, for `held behind `. */
holdBehind: z.array(z.string()),
- blockedOn: z.string().nullable(),
+ waitingOn: z.string().nullable(),
needsUser: z.boolean(),
/** An open `stuck` escalation: the row is blocked (BB-76). */
stuck: z.boolean(),
@@ -5345,7 +5345,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
summary: b.status?.summary ?? null,
nextAction: b.status?.nextAction ?? null,
actor: b.status?.actor ?? null,
- blockedOn: b.status?.blockedOn ?? null,
+ waitingOn: b.status?.waitingOn ?? null,
priority: b.priority,
hold: b.hold,
holdBehind: b.holdBehind,
@@ -5418,7 +5418,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
summary: b.status?.summary ?? null,
nextAction: b.status?.nextAction ?? null,
actor: b.status?.actor ?? null,
- blockedOn: b.status?.blockedOn ?? null,
+ waitingOn: b.status?.waitingOn ?? null,
priority: b.priority,
hold: b.hold,
holdBehind: b.holdBehind,
@@ -5494,7 +5494,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
threadId: b.threadId,
hold: b.hold,
holdBehind: b.holdBehind,
- blockedOn: b.status?.blockedOn ?? null,
+ waitingOn: b.status?.waitingOn ?? null,
needsUser: needsUser(b),
stuck: store.openEscalations(b.id).some((x) => x.kind === "stuck"),
status: b.status === null ? null : { actor: b.status.actor, nextAction: b.status.nextAction, at: b.status.at },
@@ -6067,7 +6067,8 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
summary: z.string().trim().min(1).max(600).describe("Where the entity is, in a sentence."),
nextAction: z.string().trim().min(1).max(600).describe("What happens next."),
actor: z.enum([...ACTORS, "me"]).describe("Who does the next action. `user` means the server's owner has to act — a decision, a credential, a step outside the repos — and leaves the row needing them (unread and ringing). `steward` is you: your own next step, including re-checking on the next deployment wake, is `steward`, not `user`. On BB-95 a steward filed \"On the next deployment wake, re-run the carries check and report deployed or not-yet\" as `me` and the row wrongly read \"needs you\"; that next step is `steward`. `other` is someone else."),
- blockedOn: z.string().trim().max(300).optional().describe("Who or what outside this thread it waits for."),
+ 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.'),
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
@@ -6245,12 +6246,18 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
};
}
if (input.voidApproval === true && ready.approval !== null) ready = { ...ready, approval: null, voidedAt: now() };
+ // BB-109: `blockedOn` is the old name for `waitingOn`. Accept it for
+ // one release so a steward whose prompt predates the rename still
+ // reports cleanly; `waitingOn` wins when both are given, and only the
+ // new name is stored.
+ const waitingOn = input.waitingOn ?? input.blockedOn ?? null;
+ 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}).`);
// 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.
- if (input.blockedOn !== undefined && namesUserAsBlocker(input.blockedOn) && store.openNeeds(b.id).length === 0 && (input.needs ?? []).length === 0) {
+ if (waitingOn !== null && namesUserAsBlocker(waitingOn) && store.openNeeds(b.id).length === 0 && (input.needs ?? []).length === 0) {
return toolError(
new Error(
- `blockedOn names the user, the owner or a human, but nothing is filed for them. File it as a needs item (steward_report { needs: [{ kind, url, what, how }] }) — or, for a credential, a step outside the repos or a decision, raise it with steward_report { escalate: { kind, question, meanwhile } } — and it appears on the Stewards page; blockedOn stays for third parties.`,
+ `waitingOn names the user, the owner or a human, but nothing is filed for them. File it as a needs item (steward_report { needs: [{ kind, url, what, how }] }) — or, for a credential, a step outside the repos or a decision, raise it with steward_report { escalate: { kind, question, meanwhile } } — and it appears on the Stewards page; waitingOn stays for third parties.`,
),
);
}
@@ -6287,7 +6294,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
summary: input.summary,
nextAction: input.nextAction,
actor,
- blockedOn: input.blockedOn ?? null,
+ waitingOn,
requeueAfterMs: requeueMs,
conditions: [...(input.conditions ?? []), ...open.map((x) => `${x.kind} escalation with ${x.route}`)],
resources: {
@@ -7072,7 +7079,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.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(", ")})`}`, ...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 ?? "?"}${b.threadId === null ? "" : `, thread ${b.threadId}`}\n ${s === null ? "no report yet" : `${s.summary} Next: ${s.nextAction} (${s.actor})${s.blockedOn === null ? "" : `; blocked on ${s.blockedOn}`}`}${flags.length > 0 ? `\n [${flags.join("; ")}]` : ""}`;
+ return `${b.entityKey} — ${b.kind}, ${b.phase ?? "?"}${b.threadId === null ? "" : `, thread ${b.threadId}`}\n ${s === null ? "no report yet" : `${s.summary} Next: ${s.nextAction} (${s.actor})${s.waitingOn === null ? "" : `; waiting on ${s.waitingOn}`}`}${flags.length > 0 ? `\n [${flags.join("; ")}]` : ""}`;
})
.join("\n");
}
diff --git a/plugins/stewards/sidebar-app.test.tsx b/plugins/stewards/sidebar-app.test.tsx
index 9f7bf57..b5f723c 100644
--- a/plugins/stewards/sidebar-app.test.tsx
+++ b/plugins/stewards/sidebar-app.test.tsx
@@ -69,7 +69,7 @@ const binding = (over: Record = {}) => ({
threadId: "thr_steward",
hold: null,
holdBehind: [],
- blockedOn: null,
+ waitingOn: null,
needsUser: false,
stuck: false,
status: null,
@@ -541,15 +541,15 @@ describe("the stewards sidebar sections, glyphs and phrases (BB-76)", () => {
slot.lifecycle.unmount();
});
- it("puts a blocked effort in Blocked with a red ring and the rule's phrase", async () => {
+ it("keeps a waiting effort in In flight, grey, reading the next action (BB-109)", async () => {
const slot = await renderSidebar({
sidebarThreads: { projects, threads: [thread({ id: "thr_steward", displayTitle: "ENG-1: Fix login" })] },
- rpc: { stewardsSidebar: () => sidebarData({ bindings: [binding({ blockedOn: "bb-gate#88", phase: "In Progress" })] }) } as never,
+ rpc: { stewardsSidebar: () => sidebarData({ bindings: [binding({ waitingOn: "bb-gate#88", phase: "In Progress", status: { actor: "steward", nextAction: "Wait for checks", at: 1 } })] }) } as never,
});
await waitFor(() => expect(slot.getByText("Fix login")).toBeTruthy());
- expect(lineTwo(slot, "Fix login")).toBe("ticket · In Progress · blocked on bb-gate#88");
- expect(glyphOf(slot, "Fix login")).toBe("blocked");
- expect(slot.getByRole("heading", { name: /In flight/u }).textContent).toContain("0");
+ expect(lineTwo(slot, "Fix login")).toBe("ticket · In Progress · Wait for checks");
+ expect(glyphOf(slot, "Fix login")).toBe("quiet");
+ expect(slot.getByRole("heading", { name: /In flight/u }).textContent).toContain("1");
slot.lifecycle.unmount();
});
@@ -590,7 +590,7 @@ describe("the stewards sidebar sections, glyphs and phrases (BB-76)", () => {
sidebarData({
bindings: [
binding({ key: "ENG-1", threadId: "thr_you", needsUser: true }),
- binding({ key: "ENG-2", threadId: "thr_blocked", blockedOn: "a review" }),
+ binding({ key: "ENG-2", threadId: "thr_blocked", hold: "a freeze" }),
binding({ key: "ENG-3", threadId: "thr_wait_ticket", status: { actor: "other", nextAction: "an author push", at: 1 } }),
binding({ key: "acme/web", threadId: "thr_wait_ops", kind: "ops", stage: null, status: { actor: "other", nextAction: "Next scheduled look", at: 1 } }),
],
@@ -598,7 +598,7 @@ describe("the stewards sidebar sections, glyphs and phrases (BB-76)", () => {
} as never,
});
await waitFor(() => expect(slot.getByText("Needs you")).toBeTruthy());
- // Blocked holds the yellow needs-you and the red blockedOn row, not the wait.
+ // Blocked holds the yellow needs-you and the red held row, not the wait.
const blocked = slot.getByRole("heading", { name: /Blocked/u }).closest("section")!;
expect(blocked.textContent).toContain("Needs you");
expect(blocked.textContent).toContain("Red row");
@@ -683,7 +683,7 @@ describe("the stewards sidebar sections, glyphs and phrases (BB-76)", () => {
sidebarData({
bindings: [
binding({ key: "ENG-1", threadId: "thr_you", needsUser: true }),
- binding({ key: "ENG-2", threadId: "thr_blocked", blockedOn: "a review" }),
+ binding({ key: "ENG-2", threadId: "thr_blocked", hold: "a freeze" }),
binding({ key: "ENG-3", threadId: "thr_running", workers: [{ threadId: "thr_w", title: "w", status: "running" }] }),
binding({ key: "ENG-4", threadId: "thr_quiet", status: { actor: "steward", nextAction: "wait for CI", at: 1 } }),
],
@@ -769,10 +769,10 @@ describe("the stewards sidebar sections, glyphs and phrases (BB-76)", () => {
slot.lifecycle.unmount();
});
- it("keeps an ops binding out of Monitors when it is blocked, and shows it in Blocked", async () => {
+ it("keeps an ops binding out of Monitors when it is held, and shows it in Blocked", async () => {
const slot = await renderSidebar({
sidebarThreads: { projects, threads: [thread({ id: "thr_ops", displayTitle: "Ops watch: acme/web" })] },
- rpc: { stewardsSidebar: () => sidebarData({ bindings: [binding({ key: "acme/web", kind: "ops", stage: null, threadId: "thr_ops", blockedOn: "a rotation" })] }) } as never,
+ rpc: { stewardsSidebar: () => sidebarData({ bindings: [binding({ key: "acme/web", kind: "ops", stage: null, threadId: "thr_ops", hold: "a rotation" })] }) } as never,
});
await waitFor(() => expect(slot.getByRole("heading", { name: /Blocked/u })).toBeTruthy());
expect(slot.getByText("Ops watch: acme/web")).toBeTruthy();
diff --git a/plugins/stewards/sidebar.test.ts b/plugins/stewards/sidebar.test.ts
index e1b6c05..90397ca 100644
--- a/plugins/stewards/sidebar.test.ts
+++ b/plugins/stewards/sidebar.test.ts
@@ -20,7 +20,7 @@ const binding = (over: Partial & { key:
phase: null,
hold: null,
holdBehind: [],
- blockedOn: null,
+ waitingOn: null,
needsUser: false,
stuck: false,
status: null,
@@ -49,7 +49,7 @@ const effort = (over: Partial & { key: string }): SidebarEffort =
phase: null,
hold: null,
holdBehind: [],
- blockedOn: null,
+ waitingOn: null,
needsUser: false,
stuck: false,
statusActor: null,
@@ -198,7 +198,7 @@ describe("stewards sidebar grouping", () => {
expect(tabs[0]!.monitors.map((e) => e.key)).toEqual(["acme/web"]);
});
- it("moves a blocked or needs-you ops binding into blocked, out of monitors", () => {
+ it("moves a held or needs-you ops binding into blocked, out of monitors", () => {
const tabs = buildSidebar({
projects: [project({ id: "p1" })],
threads: [thread({ id: "thr_ops_blocked" }), thread({ id: "thr_ops_you" }), thread({ id: "thr_ops_quiet" })],
@@ -206,7 +206,7 @@ describe("stewards sidebar grouping", () => {
data({
projectId: "p1",
bindings: [
- binding({ key: "acme/sentry", threadId: "thr_ops_blocked", kind: "ops", stage: null, blockedOn: "a rotation" }),
+ binding({ key: "acme/sentry", threadId: "thr_ops_blocked", kind: "ops", stage: null, hold: "a rotation" }),
binding({ key: "acme/deps", threadId: "thr_ops_you", kind: "ops", stage: null, needsUser: true }),
binding({ key: "acme/ns", threadId: "thr_ops_quiet", kind: "ops", stage: null }),
],
@@ -232,9 +232,10 @@ describe("stewards sidebar grouping", () => {
expect(tabs[0]!.efforts.map((e) => e.key)).toEqual(["ENG-2"]);
});
- // BB-81: an external wait is not a block, so it files by kind and the wait
- // phrase comes after the running-worker count.
- it("files an external wait by kind: ops in monitors grey, ticket and review in flight, only blockedOn and needsUser blocked", () => {
+ // BB-81/BB-109: an external wait is not a block, so it files by kind and the
+ // wait phrase comes after the running-worker count; only a hold, an open
+ // `stuck` escalation or needsUser is blocked.
+ it("files a wait by kind: ops in monitors grey, ticket and review in flight, only needsUser blocked", () => {
const tabs = buildSidebar({
projects: [project({ id: "p1" })],
threads: [thread({ id: "thr_ops_wait" }), thread({ id: "thr_ops_blocked" }), thread({ id: "thr_ops_you" }), thread({ id: "thr_ticket_wait" }), thread({ id: "thr_review_wait" }), thread({ id: "thr_worker" })],
@@ -243,7 +244,7 @@ describe("stewards sidebar grouping", () => {
projectId: "p1",
bindings: [
binding({ key: "acme/deps", threadId: "thr_ops_wait", kind: "ops", stage: null, status: { actor: "other", nextAction: "Next scheduled look", at: 1 } }),
- binding({ key: "acme/sentry", threadId: "thr_ops_blocked", kind: "ops", stage: null, blockedOn: "a rotation" }),
+ binding({ key: "acme/sentry", threadId: "thr_ops_blocked", kind: "ops", stage: null, waitingOn: "a rotation" }),
binding({ key: "acme/ns", threadId: "thr_ops_you", kind: "ops", stage: null, needsUser: true }),
binding({ key: "ENG-1", threadId: "thr_ticket_wait", status: { actor: "other", nextAction: "an author push", at: 1 } }),
binding({ key: "acme/web#728", threadId: "thr_review_wait", kind: "review", status: { actor: "other", nextAction: "an author push", at: 1 }, workers: [{ threadId: "thr_worker", title: "Review", status: "running" }] }),
@@ -252,12 +253,15 @@ describe("stewards sidebar grouping", () => {
],
});
const tab = tabs[0]!;
- // ops + other is a monitor, grey, with the wait phrase.
- expect(tab.monitors.map((e) => e.key)).toEqual(["acme/deps"]);
- expect(tab.monitors[0]!.state).toBe("quiet");
- expect(tab.monitors[0]!.phrase).toBe("Next scheduled look");
- // ops with blockedOn or needsUser is still blocked, out of monitors.
- expect(tab.blocked.map((e) => e.key).sort()).toEqual(["acme/ns", "acme/sentry"]);
+ // ops + other is a monitor, grey, with the wait phrase; an ops wait stays a
+ // monitor too (BB-109).
+ expect(tab.monitors.map((e) => e.key).sort()).toEqual(["acme/deps", "acme/sentry"]);
+ expect(tab.monitors.find((e) => e.key === "acme/deps")!.state).toBe("quiet");
+ expect(tab.monitors.find((e) => e.key === "acme/deps")!.phrase).toBe("Next scheduled look");
+ expect(tab.monitors.find((e) => e.key === "acme/sentry")!.state).toBe("quiet");
+ expect(tab.monitors.find((e) => e.key === "acme/sentry")!.phrase).toBe("no report yet");
+ // needsUser is the only blocked ops.
+ expect(tab.blocked.map((e) => e.key).sort()).toEqual(["acme/ns"]);
// ticket + other is in flight, grey, with the wait phrase; review + other
// with a running worker is blue and reads the count.
expect(tab.efforts.map((e) => e.key).sort()).toEqual(["ENG-1", "acme/web#728"]);
@@ -297,9 +301,9 @@ describe("stewards sidebar ordering", () => {
it("orders blocked: pinned, then needs-you (yellow) before red, then recency", () => {
const you = effort({ key: "you", needsUser: true, state: "you", recency: 1 });
- const blockedOld = effort({ key: "blocked-old", blockedOn: "x", state: "blocked", recency: 5 });
- const blockedNew = effort({ key: "blocked-new", blockedOn: "y", state: "blocked", recency: 50 });
- const pinned = effort({ key: "pinned", pinned: true, blockedOn: "z", state: "blocked", recency: 0 });
+ const blockedOld = effort({ key: "blocked-old", waitingOn: "x", state: "blocked", recency: 5 });
+ const blockedNew = effort({ key: "blocked-new", waitingOn: "y", state: "blocked", recency: 50 });
+ const pinned = effort({ key: "pinned", pinned: true, waitingOn: "z", state: "blocked", recency: 0 });
const ordered = [blockedOld, you, pinned, blockedNew].sort(compareBlocked).map((e) => e.key);
expect(ordered).toEqual(["pinned", "you", "blocked-new", "blocked-old"]);
});
@@ -316,14 +320,15 @@ describe("stewards sidebar ordering", () => {
describe("stewards sidebar state, section and phrase (BB-76)", () => {
it("takes the state in precedence order: you › blocked › running › quiet", () => {
// A running effort that also needs you is yellow.
- expect(effortState(effort({ key: "a", needsUser: true, blockedOn: "x", runningWorkers: 1 }))).toBe("you");
- expect(effortState(effort({ key: "b", blockedOn: "x", runningWorkers: 1 }))).toBe("blocked");
+ expect(effortState(effort({ key: "a", needsUser: true, waitingOn: "x", runningWorkers: 1 }))).toBe("you");
+ // A wait never blocks: with a running worker it is running (BB-109).
+ expect(effortState(effort({ key: "b", waitingOn: "x", runningWorkers: 1 }))).toBe("running");
expect(effortState(effort({ key: "c", runningWorkers: 1 }))).toBe("running");
expect(effortState(effort({ key: "d" }))).toBe("quiet");
});
- it("treats each block signal as blocked: blockedOn, hold and stuck", () => {
- expect(effortState(effort({ key: "a", blockedOn: "a review" }))).toBe("blocked");
+ it("treats a hold and an open stuck escalation as blocked, and a wait as not", () => {
+ expect(effortState(effort({ key: "a", waitingOn: "a review" }))).toBe("quiet");
expect(effortState(effort({ key: "b", hold: "a freeze" }))).toBe("blocked");
expect(effortState(effort({ key: "c", stuck: true }))).toBe("blocked");
});
@@ -340,16 +345,19 @@ describe("stewards sidebar state, section and phrase (BB-76)", () => {
it("puts needs-you and blocked in the blocked section, ops in monitors, the rest in flight", () => {
expect(effortSection(effort({ key: "a", needsUser: true, state: "you" }))).toBe("blocked");
- expect(effortSection(effort({ key: "b", blockedOn: "x", state: "blocked" }))).toBe("blocked");
+ expect(effortSection(effort({ key: "b", waitingOn: "x", state: "blocked" }))).toBe("blocked");
expect(effortSection(effort({ key: "c", kind: "ops", state: "quiet" }))).toBe("monitors");
expect(effortSection(effort({ key: "d", kind: "ticket", state: "running" }))).toBe("inflight");
});
it("phrases every rule, first match wins", () => {
// 1. needs you
- expect(effortPhrase(effort({ key: "a", needsUser: true, blockedOn: "x" }))).toBe("needs you");
- // 2. blockedOn
- expect(effortPhrase(effort({ key: "b", blockedOn: "bb-gate#88" }))).toBe("blocked on bb-gate#88");
+ expect(effortPhrase(effort({ key: "a", needsUser: true, waitingOn: "x" }))).toBe("needs you");
+ // 2. a free-text wait is not a prefix (BB-109): it reads the next action
+ // verbatim, and a running worker reads the count, never `blocked on …`.
+ expect(effortPhrase(effort({ key: "b", waitingOn: "bb-gate#88", statusNextAction: "Wait for checks" }))).toBe("Wait for checks");
+ expect(effortPhrase(effort({ key: "b2", waitingOn: "bb-gate#88" }))).toBe("no report yet");
+ expect(effortPhrase(effort({ key: "b3", waitingOn: "a review", runningWorkers: 1 }))).toBe("1 worker running");
// 3. hold
expect(effortPhrase(effort({ key: "c", hold: "a freeze", holdBehind: ["BB-57", "BB-61"] }))).toBe("held behind BB-57, BB-61");
expect(effortPhrase(effort({ key: "d", hold: "a freeze" }))).toBe("on hold");
diff --git a/plugins/stewards/sidebar.ts b/plugins/stewards/sidebar.ts
index 7b68942..e191d90 100644
--- a/plugins/stewards/sidebar.ts
+++ b/plugins/stewards/sidebar.ts
@@ -24,7 +24,8 @@ export interface SidebarBinding {
hold: string | null;
/** Entity keys this hold waits behind. */
holdBehind: string[];
- blockedOn: string | null;
+ /** The party or artifact the next action waits on; it never marks the effort blocked (BB-109). */
+ waitingOn: string | null;
needsUser: boolean;
/** An open `stuck` escalation (BB-76). */
stuck: boolean;
@@ -115,7 +116,8 @@ export interface SidebarEffort {
phase: string | null;
hold: string | null;
holdBehind: string[];
- blockedOn: string | null;
+ /** The party or artifact the next action waits on; shown as a plain line, never as a block (BB-109). */
+ waitingOn: string | null;
needsUser: boolean;
stuck: boolean;
statusActor: string | null;
@@ -210,7 +212,6 @@ export interface EffortStatus {
busy: boolean;
runningWorkers: number;
needsUser: boolean;
- blockedOn: string | null;
hold: string | null;
holdBehind: string[];
stuck: boolean;
@@ -228,14 +229,14 @@ export const isRunning = (e: Pick): boo
/**
* The effort's glyph, first match wins (BB-76):
- * needs you › blocked › running › quiet. Blocked is `blockedOn`, a hold or
- * `stuck` only; an external wait (`actor = other`) is not a block, so it is
- * running when a worker or reviewer runs and quiet otherwise. A running
- * effort that also needs you is yellow.
+ * needs you › blocked › running › quiet. Blocked is a hold or an open `stuck`
+ * escalation only (BB-109); the free-text wait (`waitingOn`) never makes an
+ * effort blocked, so a waiting effort is running when a worker or reviewer
+ * runs and quiet otherwise. A running effort that also needs you is yellow.
*/
export function effortState(e: EffortStatus): SidebarEffortState {
if (e.needsUser) return "you";
- if (e.blockedOn !== null || e.hold !== null || e.stuck) return "blocked";
+ if (e.hold !== null || e.stuck) return "blocked";
if (isRunning(e)) return "running";
return "quiet";
}
@@ -280,10 +281,11 @@ export function untilPhrase(at: number, now: number): string {
/**
* Line two's status phrase, first match wins (BB-76, BB-86, BB-83):
- * needs you › blockedOn › hold › stuck › running › quiet worker finish ›
- * deployment › nextAction › no report. An external wait is not a prefix: an
- * `other` actor reads its `nextAction` verbatim like any other actor, and
- * `the other party` only stands in when there is no report at all.
+ * needs you › hold › stuck › running › quiet worker finish ›
+ * deployment › nextAction › no report. The free-text `waitingOn` is not a
+ * prefix (BB-109): a running worker reads the count, and otherwise the
+ * `nextAction` reads verbatim, whatever the actor. `the other party` only
+ * stands in when there is no report at all.
*
* The quiet worker finish (BB-83) sits after the running count and before the
* external wait: a binding whose worker finished and has nothing to wait on
@@ -293,7 +295,6 @@ export function untilPhrase(at: number, now: number): string {
*/
export function effortPhrase(e: EffortStatus, now: number = Date.now()): string {
if (e.needsUser) return "needs you";
- if (e.blockedOn !== null) return `blocked on ${e.blockedOn}`;
if (e.hold !== null) return e.holdBehind.length > 0 ? `held behind ${e.holdBehind.join(", ")}` : "on hold";
if (e.stuck) return "stuck";
// A running worker or reviewer paints the blue ring, but the count is of
@@ -337,7 +338,7 @@ export function endedEffort(e: SidebarEnded): SidebarEndedEffort {
phase: e.phase ?? null,
hold: null,
holdBehind: [],
- blockedOn: null,
+ waitingOn: null,
needsUser: false,
stuck: false,
statusActor: null,
@@ -535,7 +536,7 @@ function buildTab(project: SidebarLiveProject, data: SidebarProjectData | undefi
phase: b.phase,
hold: b.hold,
holdBehind: b.holdBehind,
- blockedOn: b.blockedOn,
+ waitingOn: b.waitingOn,
needsUser: b.needsUser,
stuck: b.stuck,
statusActor: b.status?.actor ?? null,
@@ -626,7 +627,7 @@ function buildTab(project: SidebarLiveProject, data: SidebarProjectData | undefi
phase: null,
hold: null,
holdBehind: [],
- blockedOn: null,
+ waitingOn: null,
needsUser: false,
stuck: false,
statusActor: null,
diff --git a/plugins/stewards/skills/stewards/SKILL.md b/plugins/stewards/skills/stewards/SKILL.md
index 4eb4769..60912c5 100644
--- a/plugins/stewards/skills/stewards/SKILL.md
+++ b/plugins/stewards/skills/stewards/SKILL.md
@@ -45,7 +45,8 @@ Two tools are yours; everything else you do with the tools every thread has.
the next action and who does it (`user` = the server's owner has to act,
`steward` = you, including your own next step such as re-checking on the
next deployment wake, `other` = someone else), what
- you are blocked on, and `requeueAfter`. On a ticket, report the ready
+ you are waiting on (`waitingOn`: the party or artifact the next action waits
+ on, such as checks, a review or a deploy), and `requeueAfter`. On a ticket, report the ready
checklist too, each item by its number in the wake
(`{ item: 1, met: true }`); a paraphrase is refused. Three turns in a row
without a report raise `stuck`. When only the user can do something — a
@@ -141,12 +142,15 @@ Two tools are yours; everything else you do with the tools every thread has.
approved by the user before it merges. Strictly additive changes and
tweaks are not, and that a change needs a deploy or restart is not, on its
own, a reason.
-- **Waiting on the user is a needs item.** A `blockedOn` that names the
+- **Waiting on the user is a needs item.** A `waitingOn` that names the
project steward or the user reaches nobody; with no open escalation it is
- flagged in `bb stewards list` and the digest. A `blockedOn` naming the
+ flagged in `bb stewards list` and the digest. A `waitingOn` naming the
user, the owner or a human is refused unless an open needs item says so:
raise it with `steward_report { escalate }` (or file a gate item) instead
- of only saying it. `blockedOn` stays for third parties.
+ of only saying it. `waitingOn` stays for third parties. It never marks the
+ effort blocked: a hold, an open `stuck` escalation or a needs item does
+ (BB-109). The old name `blockedOn` is accepted as an alias for one
+ release.
- **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 d93501e..af12cbd 100644
--- a/plugins/stewards/store.test.ts
+++ b/plugins/stewards/store.test.ts
@@ -131,7 +131,7 @@ describe("countNeedsUser (BB-104)", () => {
const add = (s: ReturnType["s"], n: number) =>
s.create({ projectId: "p1", source: "linear", entityId: `uuid-${n}`, entityKey: `ENG-${n}`, url: null, title: null, kind: "ticket", playbook: "linear-tickets", playbookVersion: "v1", parentBindingId: null, phase: "Todo", stage: "spec" });
- const userStatus = (at: number) => ({ phase: "Todo", summary: "Waiting", nextAction: "On the user", actor: "user" as const, blockedOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at });
+ const userStatus = (at: number) => ({ phase: "Todo", summary: "Waiting", nextAction: "On the user", actor: "user" as const, waitingOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at });
it("counts exactly the open bindings needsUser flags: each condition once, and once when a binding has all four", () => {
const { s, binding } = store();
@@ -409,10 +409,11 @@ describe("the actor rename migration (BB-97)", () => {
it("rewrites `me` to `user` in the stored status, leaving the rest of the record alone", () => {
const db = legacy();
+ // A pre-BB-109 record carries `blockedOn`; it reads back as `waitingOn`.
insert(db, "bnd_1", "ENG-12", JSON.stringify({ phase: "In Review", summary: "Waiting.", nextAction: "On the next deployment wake, re-run the carries check", actor: "me", blockedOn: "a bb-gate deployment", requeueAfterMs: null, conditions: ["a condition"], resources: { workers: ["thr_w1"], pullRequests: [], children: [] }, at: 1_000 }));
for (const sql of ACTOR_MIGRATIONS) db.exec(sql);
const s = createStore(db, () => 2_000);
- expect(s.find("ENG-12", "p1")!.status).toEqual({ phase: "In Review", summary: "Waiting.", nextAction: "On the next deployment wake, re-run the carries check", actor: "user", blockedOn: "a bb-gate deployment", requeueAfterMs: null, conditions: ["a condition"], resources: { workers: ["thr_w1"], pullRequests: [], children: [] }, at: 1_000 });
+ expect(s.find("ENG-12", "p1")!.status).toEqual({ phase: "In Review", summary: "Waiting.", nextAction: "On the next deployment wake, re-run the carries check", actor: "user", waitingOn: "a bb-gate deployment", requeueAfterMs: null, conditions: ["a condition"], resources: { workers: ["thr_w1"], pullRequests: [], children: [] }, at: 1_000 });
});
it("is idempotent, and leaves `steward` and `other` untouched", () => {
diff --git a/plugins/stewards/store.ts b/plugins/stewards/store.ts
index a479f38..33fac17 100644
--- a/plugins/stewards/store.ts
+++ b/plugins/stewards/store.ts
@@ -508,7 +508,8 @@ export const statusRecordSchema = z.object({
summary: z.string(),
nextAction: z.string(),
actor: z.enum(ACTORS),
- blockedOn: z.string().nullable(),
+ /** The party or artifact the next action waits on; it never marks the effort blocked (BB-109). */
+ waitingOn: z.string().nullable(),
requeueAfterMs: z.number().nullable(),
conditions: z.array(z.string()),
resources: z.object({ workers: z.array(z.string()), pullRequests: z.array(z.string()), children: z.array(z.string()) }),
@@ -516,6 +517,37 @@ export const statusRecordSchema = z.object({
});
export type StatusRecord = z.infer;
+/**
+ * Reads a stored status record (BB-109). A record written before the rename
+ * still carries `blockedOn` and no `waitingOn`; it parses and surfaces as
+ * `waitingOn`, with `waitingOn` winning when both are present. New writes use
+ * `statusRecordSchema`, which has no `blockedOn` key.
+ */
+export const storedStatusRecordSchema = z
+ .object({
+ phase: z.string(),
+ summary: z.string(),
+ nextAction: z.string(),
+ actor: z.enum(ACTORS),
+ waitingOn: z.string().nullable().optional(),
+ blockedOn: z.string().nullable().optional(),
+ requeueAfterMs: z.number().nullable(),
+ conditions: z.array(z.string()),
+ resources: z.object({ workers: z.array(z.string()), pullRequests: z.array(z.string()), children: z.array(z.string()) }),
+ at: z.number(),
+ })
+ .transform((s): StatusRecord => ({
+ phase: s.phase,
+ summary: s.summary,
+ nextAction: s.nextAction,
+ actor: s.actor,
+ waitingOn: s.waitingOn ?? s.blockedOn ?? null,
+ requeueAfterMs: s.requeueAfterMs,
+ conditions: s.conditions,
+ resources: s.resources,
+ at: s.at,
+ }));
+
/**
* What the mechanical tier last observed: the entity, and for a ticket the
* pull requests linked to it. Compared with the next observation to decide
@@ -656,7 +688,7 @@ function rowToBinding(r: BindingRow): Binding {
clearedToStart: Number(r.cleared_to_start ?? 0) === 1,
modelSteward: str("model_steward"),
modelWorker: str("model_worker"),
- status: json(statusRecordSchema.nullable(), str("status"), null),
+ status: json(storedStatusRecordSchema.nullable(), str("status"), null),
statusAt: num("status_at"),
advancedAt: num("advanced_at"),
requeueAt: num("requeue_at"),
@@ -1418,7 +1450,6 @@ export function createStore(db: Database.Database, now: () => number, options: S
busy: false,
runningWorkers: 0,
needsUser: needsUserFor(b),
- blockedOn: b.status?.blockedOn ?? null,
hold: b.hold,
holdBehind: b.holdBehind,
stuck: openEscalations.all(b.id).some((x) => x.kind === "stuck"),
diff --git a/plugins/stewards/wake.test.ts b/plugins/stewards/wake.test.ts
index 4e507b7..3f830a4 100644
--- a/plugins/stewards/wake.test.ts
+++ b/plugins/stewards/wake.test.ts
@@ -19,7 +19,7 @@ function world() {
return { s, bind };
}
-const status = (summary: string) => ({ phase: "In Progress", summary, nextAction: "Open the PR", actor: "steward" as const, blockedOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: T });
+const status = (summary: string) => ({ phase: "In Progress", summary, nextAction: "Open the PR", actor: "steward" as const, waitingOn: null, requeueAfterMs: null, conditions: [], resources: { workers: [], pullRequests: [], children: [] }, at: T });
function wakeInput(over: Partial = {}): WakeInput {
const { s, bind } = world();
diff --git a/plugins/stewards/wake.ts b/plugins/stewards/wake.ts
index f95d052..55b49b3 100644
--- a/plugins/stewards/wake.ts
+++ b/plugins/stewards/wake.ts
@@ -1054,7 +1054,7 @@ export function formatProjectWake(input: {
const s = b.status;
const conflicts = conflictsOf(b, allOpen);
const flags = [b.priority === null ? null : `priority ${b.priority}`, b.hold === null ? null : `HOLD: ${b.hold}${b.holdBehind.length === 0 ? "" : ` (behind ${b.holdBehind.join(", ")})`}`, b.touches.length === 0 ? null : `touches ${b.touches.join(", ")}`, conflicts.length === 0 ? null : `overlaps ${conflicts.map((c) => `${c.with.entityKey} on ${c.on.join(", ")}`).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.blockedOn === null ? "" : `; blocked on ${s.blockedOn}`}`}${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}`}`}${flags.length > 0 ? ` [${flags.join("; ")}]` : ""}`);
}
const bindings = out.splice(0);
if (input.digestDue) {
From 8e13711d8a43645ed8240a613e962f13af6425d9 Mon Sep 17 00:00:00 2001
From: Dylan Trotter
Date: Wed, 28 Oct 2026 19:53:40 +0000
Subject: [PATCH 2/2] stewards: address BB-109 review suggestions
Keep the pre-rename test schema honest (omit waitingOn, require blockedOn)
and store null for an empty waitingOn rather than a bare "Waiting on ".
---
plugins/stewards/server.test.ts | 4 ++++
plugins/stewards/server.ts | 6 ++++--
plugins/stewards/store.test.ts | 5 +++--
3 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts
index e53816a..0a9f209 100644
--- a/plugins/stewards/server.test.ts
+++ b/plugins/stewards/server.test.ts
@@ -4736,6 +4736,10 @@ describe("needs you items", () => {
const both = await t.tool("steward_report", { ...base, actor: "other", blockedOn: "the old name", waitingOn: "the new name" }, steward);
expect(both.error).toBe(false);
expect(t.hooks.store.find("ENG-12", "p1")!.status!.waitingOn).toBe("the new name");
+ // An empty wait is no wait: it stores null, not a bare "Waiting on ".
+ const empty = await t.tool("steward_report", { ...base, actor: "other", waitingOn: "" }, steward);
+ expect(empty.error).toBe(false);
+ expect(t.hooks.store.find("ENG-12", "p1")!.status!.waitingOn).toBeNull();
});
it("shows the items on the board with their actionability", async () => {
diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts
index b883a1b..d9dfe76 100644
--- a/plugins/stewards/server.ts
+++ b/plugins/stewards/server.ts
@@ -6249,8 +6249,10 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
// BB-109: `blockedOn` is the old name for `waitingOn`. Accept it for
// one release so a steward whose prompt predates the rename still
// reports cleanly; `waitingOn` wins when both are given, and only the
- // new name is stored.
- const waitingOn = input.waitingOn ?? input.blockedOn ?? null;
+ // new name is stored. An empty string is no wait, so it stores null
+ // rather than rendering a bare "Waiting on ".
+ 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}).`);
// 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.
diff --git a/plugins/stewards/store.test.ts b/plugins/stewards/store.test.ts
index af12cbd..5cf629c 100644
--- a/plugins/stewards/store.test.ts
+++ b/plugins/stewards/store.test.ts
@@ -381,8 +381,9 @@ describe("the lesson tier migration (BB-35 step 3)", () => {
describe("the actor rename migration (BB-97)", () => {
// The pre-rename build parsed `status` with this schema through the same
// `json()` helper (a safeParse inside try/catch, falling back to null). A
- // migrated `user` row must read back as no status there, never throw.
- const preRenameStatusSchema = statusRecordSchema.extend({ actor: z.enum(["me", "steward", "other"]) });
+ // migrated `user` row must read back as no status there, never throw. It has
+ // the old `blockedOn` key and no `waitingOn`, as the pre-BB-109 build did.
+ const preRenameStatusSchema = statusRecordSchema.omit({ waitingOn: true }).extend({ actor: z.enum(["me", "steward", "other"]), blockedOn: z.string().nullable() });
const readAsOldBuild = (raw: string | null) => {
if (raw === null) return null;
try {