@nanobpm/nano-workforce 0.171.1 → 0.171.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.171.2](https://github.com/nanobpm/nano-workforce/compare/v0.171.1...v0.171.2) (2026-08-31)
2
+
3
+ ### Bug Fixes
4
+
5
+ * surface + complete readiness/preflight escalations in the Tasks inbox ([#675](https://github.com/nanobpm/nano-workforce/issues/675)) ([9d874e8](https://github.com/nanobpm/nano-workforce/commit/9d874e889f3155b7887bb48032e39c5f78e33ce3)), closes [#674](https://github.com/nanobpm/nano-workforce/issues/674)
6
+
1
7
  ## [0.171.1](https://github.com/nanobpm/nano-workforce/compare/v0.171.0...v0.171.1) (2026-08-31)
2
8
 
3
9
  ### Code Refactoring
@@ -286,6 +286,38 @@ test("empty-plan-escalation is HUMAN-completable but NOT agent-completable (issu
286
286
  assertEquals(completed[0].variables, { directive: "revise", notes: "look again" });
287
287
  });
288
288
 
289
+ test("readiness-escalation(-pf) is HUMAN-completable but NOT agent-completable (issue #674)", async () => {
290
+ // A readiness/preflight gate adjudicates whether upstream is ACTUALLY ready (proceed) or the gate
291
+ // should be abandoned. Like feature-blocked/conformance/empty-plan it is a HUMAN operator decision —
292
+ // an agent must never auto-answer it, or the fleet would silently defeat the very readiness gate the
293
+ // task exists to enforce. So both ids stay OUTSIDE `ESCALATION_TASK_ELEMENTS` (agent-refused) but are
294
+ // retired by the HUMAN completer via the one canonical `complete-user-task` door.
295
+ for (const elementId of ["readiness-escalation-pf", "readiness-escalation"] as const) {
296
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
297
+ const data = memData(stores);
298
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-r", elementId }]);
299
+
300
+ const asAgent = await completeEscalationAsAgent(data, engine, {
301
+ userTaskKey: "ut-r",
302
+ agentId: "bot",
303
+ variables: { resolution: "acknowledge" },
304
+ });
305
+ assertEquals(asAgent.ok, false, `the agent completer refuses ${elementId}`);
306
+ assertEquals(asAgent.reason, "not a completable task");
307
+ assertEquals(completed.length, 0);
308
+
309
+ const asHuman = await completeEscalationAsHuman(data, engine, {
310
+ userTaskKey: "ut-r",
311
+ operatorId: "alice",
312
+ variables: { resolution: "abandon", answer: "upstream never published" },
313
+ });
314
+ assertEquals(asHuman.ok, true, `the human completer retires ${elementId}`);
315
+ assertEquals(asHuman.elementId, elementId);
316
+ assertEquals(completed.length, 1);
317
+ assertEquals(completed[0].variables, { resolution: "abandon", answer: "upstream never published" });
318
+ }
319
+ });
320
+
289
321
  test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
290
322
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
291
323
  const data = memData(stores);
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
25
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
26
26
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
27
- import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT } from "./userTasks.ts";
27
+ import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
28
28
 
29
29
  const now = () => new Date().toISOString();
30
30
 
@@ -101,6 +101,16 @@ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMEN
101
101
  * Re-exported from the canonical `EMPTY_PLAN_ELEMENT` (app/userTasks.ts) — one source of truth. */
102
102
  export const EMPTY_PLAN_TASK_ELEMENT = EMPTY_PLAN_ELEMENT;
103
103
 
104
+ /** The readiness/preflight escalation user-task element ids (`readiness-escalation-pf` in feature.bpmn's
105
+ * readiness preflight + plan-fanout.bpmn's producer-capability preflight; `readiness-escalation` in
106
+ * readiness-gate.bpmn / wait-gate.bpmn). Like `feature-blocked`, `conformance-escalation` and
107
+ * `empty-plan-escalation` these are HUMAN-only decisions — an agent must never auto-answer a readiness
108
+ * gate (that would silently defeat the "is upstream actually ready?" adjudication the gate exists for),
109
+ * so they live OUTSIDE `ESCALATION_TASK_ELEMENTS` and only the HUMAN completer accepts them (issue
110
+ * #674). Re-exported from the canonical constants in app/userTasks.ts — one source of truth. */
111
+ export const READINESS_ESCALATION_PF_TASK_ELEMENT = READINESS_ESCALATION_PF_ELEMENT;
112
+ export const READINESS_ESCALATION_TASK_ELEMENT = READINESS_ESCALATION_ELEMENT;
113
+
104
114
  /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
105
115
  * `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
106
116
  * and `conformance-escalation` acknowledgements, PLUS the advisory ACP permission prompt
@@ -115,6 +125,8 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
115
125
  FEATURE_BLOCKED_TASK_ELEMENT,
116
126
  CONFORMANCE_ESCALATION_TASK_ELEMENT,
117
127
  EMPTY_PLAN_TASK_ELEMENT,
128
+ READINESS_ESCALATION_PF_TASK_ELEMENT,
129
+ READINESS_ESCALATION_TASK_ELEMENT,
118
130
  ACP_PERMISSION_ELEMENT,
119
131
  ]);
120
132
 
@@ -131,6 +143,8 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
131
143
  "feature-blocked": "feature-blocked",
132
144
  [EMPTY_PLAN_TASK_ELEMENT]: "empty-plan-escalation",
133
145
  [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
146
+ [READINESS_ESCALATION_PF_TASK_ELEMENT]: "readiness-escalation",
147
+ [READINESS_ESCALATION_TASK_ELEMENT]: "readiness-escalation",
134
148
  // NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
135
149
  // ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
136
150
  // to render DIFFERENT forms per node (explicit → category → generic → agent-router, `app/deliveryHuman.ts`
@@ -161,6 +161,69 @@ test("pollUserTasks: projects a feature-escalation that lands on a plan-fanout p
161
161
  assertEquals(byKey["ut-embedded-feat"].question, "the agent returned no machine-readable result — enrol the PR?");
162
162
  });
163
163
 
164
+ test("pollUserTasks: projects a readiness-escalation-pf preflight task on a feature run (issue #674)", async () => {
165
+ // The leading readiness preflight (feature.bpmn `pf_*` embedded subprocess) parks on the run's OWN
166
+ // engine instance when it times out before its ReadinessProbe goes green. Before #674 the element id
167
+ // was absent from USER_TASK_KIND_LABELS, so `contextFor`'s leak guard dropped it and the parked run
168
+ // was invisible + uncompletable in the Tasks surface. It must now project as an "Upstream readiness
169
+ // stalled" row on the feature subject, carrying the run's wait rollup as the question.
170
+ const { data, stores } = memData({
171
+ feature_runs: [
172
+ {
173
+ feature_key: "o/r#674",
174
+ status: "running",
175
+ process_key: "fp-674",
176
+ issue_url: "https://github.com/o/r/issues/674",
177
+ title: "Ship the widget",
178
+ delivery_label: "waiting on @scope/upstream@1.2.0 · re-checks every 30s",
179
+ },
180
+ ],
181
+ });
182
+ const engine = fakeEngine({ "fp-674": [{ userTaskKey: "ut-readiness-pf", elementId: "readiness-escalation-pf", formKey: "26" }] });
183
+
184
+ await pollUserTasks(data, engine);
185
+
186
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
187
+ assertEquals(Object.keys(byKey), ["ut-readiness-pf"]);
188
+ assertEquals(byKey["ut-readiness-pf"].element_id, "readiness-escalation-pf");
189
+ assertEquals(byKey["ut-readiness-pf"].kind_label, "Upstream readiness stalled");
190
+ assertEquals(byKey["ut-readiness-pf"].subject_type, "feature");
191
+ assertEquals(byKey["ut-readiness-pf"].subject_key, "o/r#674");
192
+ assertEquals(byKey["ut-readiness-pf"].subject_title, "Ship the widget");
193
+ assertEquals(byKey["ut-readiness-pf"].question, "waiting on @scope/upstream@1.2.0 · re-checks every 30s");
194
+ assertEquals(byKey["ut-readiness-pf"].form_key, "26");
195
+ });
196
+
197
+ test("pollUserTasks: projects a readiness-escalation wait-gate task on a plan, with the wait-gate label as question (issue #674)", async () => {
198
+ // The standalone inter-epic wait-gate cell (readiness-gate.bpmn / wait-gate.bpmn) parks a dependent
199
+ // epic on `readiness-escalation` when its bounded capability wait elapses. It surfaces on the plan
200
+ // subject; the question derivation leans on the wait-gate projection (`plans.wait_gate_label`).
201
+ const { data, stores } = memData({
202
+ plans: [
203
+ {
204
+ plan_key: "o/r#700",
205
+ status: "dispatched",
206
+ process_key: "pp-700",
207
+ issue_url: "https://github.com/o/r/issues/700",
208
+ title: "Dependent epic",
209
+ wait_gate: "escalated",
210
+ wait_gate_label: "escalated · still waiting on @scope/producer@2.0.0 after 24h",
211
+ },
212
+ ],
213
+ });
214
+ const engine = fakeEngine({ "pp-700": [{ userTaskKey: "ut-readiness", elementId: "readiness-escalation" }] });
215
+
216
+ await pollUserTasks(data, engine);
217
+
218
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
219
+ assertEquals(Object.keys(byKey), ["ut-readiness"]);
220
+ assertEquals(byKey["ut-readiness"].element_id, "readiness-escalation");
221
+ assertEquals(byKey["ut-readiness"].kind_label, "Readiness escalation");
222
+ assertEquals(byKey["ut-readiness"].subject_type, "plan");
223
+ assertEquals(byKey["ut-readiness"].subject_key, "o/r#700");
224
+ assertEquals(byKey["ut-readiness"].question, "escalated · still waiting on @scope/producer@2.0.0 after 24h");
225
+ });
226
+
164
227
  test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
165
228
  // During the merge phase a PR's process_key points at its merge-loop instance; the merge escalation
166
229
  // parks on a native `wait-merge-answer` userTask (#256) and writes the SAME `escalations` row the
package/app/service.ts CHANGED
@@ -99,6 +99,9 @@ import {
99
99
  PR_WAIT_ANSWER_ELEMENT,
100
100
  PR_WAIT_MERGE_ANSWER_ELEMENT,
101
101
  prEscalations,
102
+ READINESS_ESCALATION_ELEMENT,
103
+ READINESS_ESCALATION_PF_ELEMENT,
104
+ readinessEscalationQuestion,
102
105
  reconcileUserTasks,
103
106
  TRIAL_MERGE_ELEMENT,
104
107
  toOpenEscalation,
@@ -2652,15 +2655,19 @@ export async function pollUserTasks(
2652
2655
  url?: string | null;
2653
2656
  deliveryLabel?: string | null;
2654
2657
  conformanceSummary?: string | null;
2658
+ /** The subject's at-a-glance "waiting on <capability> · …" rollup, denormalised for the readiness
2659
+ * escalation question (issue #674): the wait-gate projection on `plans.wait_gate_label`, or the
2660
+ * feature run's `delivery_label` for the inline preflight. */
2661
+ waitGateLabel?: string | null;
2655
2662
  }
2656
2663
  const subjectByInstance = new Map<string, Subject>();
2657
2664
  for (const run of await featureRuns(data).all()) {
2658
2665
  if (run.process_key) {
2659
- subjectByInstance.set(run.process_key, { type: "feature", key: run.feature_key, title: run.title, url: run.issue_url, deliveryLabel: run.delivery_label });
2666
+ subjectByInstance.set(run.process_key, { type: "feature", key: run.feature_key, title: run.title, url: run.issue_url, deliveryLabel: run.delivery_label, waitGateLabel: run.delivery_label });
2660
2667
  }
2661
2668
  }
2662
2669
  for (const plan of await plans(data).all()) {
2663
- if (plan.process_key) subjectByInstance.set(plan.process_key, { type: "plan", key: plan.plan_key, title: plan.title, url: plan.issue_url });
2670
+ if (plan.process_key) subjectByInstance.set(plan.process_key, { type: "plan", key: plan.plan_key, title: plan.title, url: plan.issue_url, waitGateLabel: plan.wait_gate_label });
2664
2671
  }
2665
2672
  for (const pr of await prs(data).all()) {
2666
2673
  if (pr.process_key) subjectByInstance.set(pr.process_key, { type: "pr", key: pr.pr_key, title: pr.title, url: pr.url });
@@ -2687,6 +2694,8 @@ export async function pollUserTasks(
2687
2694
  [PLAN_REVIEW_ELEMENT]: "plan",
2688
2695
  [TRIAL_MERGE_ELEMENT]: "plan",
2689
2696
  [CONFORMANCE_ESCALATION_ELEMENT]: "plan",
2697
+ [READINESS_ESCALATION_PF_ELEMENT]: "feature",
2698
+ [READINESS_ESCALATION_ELEMENT]: "plan",
2690
2699
  [PR_WAIT_ANSWER_ELEMENT]: "pr",
2691
2700
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "pr",
2692
2701
  };
@@ -2744,6 +2753,13 @@ export async function pollUserTasks(
2744
2753
  case CONFORMANCE_ESCALATION_ELEMENT:
2745
2754
  question = conformanceEscalationQuestion(subj ? { summary: subj.conformanceSummary } : undefined);
2746
2755
  break;
2756
+ case READINESS_ESCALATION_PF_ELEMENT:
2757
+ case READINESS_ESCALATION_ELEMENT:
2758
+ // The leading readiness/capability gate stalled: surface WHAT it is waiting on. The wait-gate
2759
+ // projection already rendered that clause on the subject row (`plans.wait_gate_label`, or the
2760
+ // feature preflight's `delivery_label`), with a static readiness-stalled fallback (#674).
2761
+ question = readinessEscalationQuestion(subj?.waitGateLabel ?? null);
2762
+ break;
2747
2763
  }
2748
2764
  return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
2749
2765
  };
@@ -0,0 +1,102 @@
1
+ // Drift guard for the Tasks-inbox closed sets (issue #674 — fix the class, not the bug).
2
+ //
3
+ // Root cause of #674: the readiness/preflight escalation user tasks (`readiness-escalation-pf`,
4
+ // `readiness-escalation`) were deployed BPMN `<bpmn:userTask>`s with a linked `.form`, but their
5
+ // element ids were never added to the app-tier closed sets that gate surfacing (`USER_TASK_KIND_LABELS`,
6
+ // app/userTasks.ts) and completion (`HUMAN_COMPLETABLE_ELEMENTS` / `ESCALATION_FORM_BY_ELEMENT`,
7
+ // app/agentCompletion.ts). The poller's leak guard (`userTaskKindLabel(id) === undefined`) silently
8
+ // DROPPED every such task, so a parked run went invisible in the Tasks surface AND uncompletable
9
+ // through the one canonical `complete-user-task` door.
10
+ //
11
+ // This test makes that drift structurally impossible: it enumerates EVERY human `<bpmn:userTask>`
12
+ // (bearing a `<zeebe:userTask />`) in the deployed processes (`resources/processes/*.bpmn`) and asserts
13
+ // each element id is a KNOWN Tasks-inbox kind, and — where it declares a static `zeebe:formDefinition`
14
+ // form and is completable — that the completer's form contract resolves to the SAME `.form` the BPMN
15
+ // declares. So a future BPMN user task can never again vanish from the inbox by being absent from the
16
+ // hand-maintained closed set: the closed set's completeness is DERIVED from the deployed BPMN, not
17
+ // asserted blind.
18
+ //
19
+ // On `main` (before #674's registration) this test FAILS on `readiness-escalation-pf` /
20
+ // `readiness-escalation`; it passes once both are registered.
21
+ import { readdirSync, readFileSync } from "node:fs";
22
+ import { test } from "node:test";
23
+ import { assert, assertEquals } from "#test-assert";
24
+ import { escalationFormId, HUMAN_COMPLETABLE_ELEMENTS } from "./agentCompletion.ts";
25
+ import { isDeliveryHumanElement } from "./deliveryHuman.ts";
26
+ import { userTaskKindLabel } from "./userTasks.ts";
27
+
28
+ const PROCESS_DIR = "resources/processes";
29
+
30
+ /** Demo/fixture processes that are deployed for the engine-spine e2e (`e2e/user-task-spine.e2e.ts`)
31
+ * but are NOT part of the workforce escalation surface — their user tasks are deliberately not
32
+ * Tasks-inbox kinds. Kept as a tiny, explicitly-documented file allowlist (not a per-id one) so a new
33
+ * REAL escalation process is still fully guarded. */
34
+ const DEMO_PROCESS_FILES: ReadonlySet<string> = new Set(["spine-demo.bpmn"]);
35
+
36
+ interface DeployedUserTask {
37
+ file: string;
38
+ elementId: string;
39
+ /** The `zeebe:formDefinition formId`, when the task declares a static form. */
40
+ formId: string | null;
41
+ }
42
+
43
+ /** Parse every human `<bpmn:userTask>` (one bearing a `<zeebe:userTask />`, i.e. a native user task an
44
+ * operator answers — not a job-worker task) out of the deployed process BPMN. Text parsing, matching
45
+ * the repo's lightweight model-guard style (convergenceEscalationGuard.test.ts et al.). */
46
+ function deployedHumanUserTasks(): DeployedUserTask[] {
47
+ const out: DeployedUserTask[] = [];
48
+ for (const file of readdirSync(PROCESS_DIR).filter((f) => f.endsWith(".bpmn"))) {
49
+ if (DEMO_PROCESS_FILES.has(file)) continue;
50
+ const xml = readFileSync(`${PROCESS_DIR}/${file}`, "utf8");
51
+ for (const m of xml.matchAll(/<bpmn:userTask\b[^>]*\bid="([^"]+)"([\s\S]*?)<\/bpmn:userTask>/g)) {
52
+ const [, elementId, body] = m;
53
+ if (!/<zeebe:userTask\b/.test(body)) continue; // not a native human user task (no <zeebe:userTask/>)
54
+ const form = body.match(/<zeebe:formDefinition\b[^>]*\bformId="([^"]+)"/);
55
+ out.push({ file, elementId, formId: form ? form[1] : null });
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ test("drift guard: every deployed human user task is a known Tasks-inbox kind (issue #674)", () => {
62
+ const tasks = deployedHumanUserTasks();
63
+ // Sanity: the sweep actually found the deployed escalations (guards against a parser that silently
64
+ // matches nothing and vacuously passes).
65
+ assert(tasks.length > 0, `expected the process sweep to find the deployed user tasks, got ${tasks.length}`);
66
+ assert(
67
+ tasks.some((t) => t.elementId === "readiness-escalation-pf"),
68
+ "expected readiness-escalation-pf among the deployed user tasks",
69
+ );
70
+ assert(
71
+ tasks.some((t) => t.elementId === "readiness-escalation"),
72
+ "expected readiness-escalation among the deployed user tasks",
73
+ );
74
+
75
+ const unsurfaced = tasks.filter((t) => userTaskKindLabel(t.elementId) === undefined);
76
+ assertEquals(
77
+ unsurfaced.map((t) => `${t.file}:${t.elementId}`),
78
+ [],
79
+ "deployed user task(s) are not registered in USER_TASK_KIND_LABELS — pollUserTasks' leak guard would drop them from the Tasks inbox (issue #674)",
80
+ );
81
+ });
82
+
83
+ test("drift guard: every deployed fixed-form user task is human-completable through the canonical door (issue #674)", () => {
84
+ // The delivery-graph `human` node renders DIFFERENT forms per node (variable form, resolved at
85
+ // activation), so it is intentionally absent from the static `ESCALATION_FORM_BY_ELEMENT` contract —
86
+ // exclude it from the fixed-form assertion (it is still asserted to be a KNOWN kind above).
87
+ const fixedForm = deployedHumanUserTasks().filter((t) => t.formId && !isDeliveryHumanElement(t.elementId));
88
+
89
+ const notCompletable = fixedForm.filter((t) => !HUMAN_COMPLETABLE_ELEMENTS.has(t.elementId));
90
+ assertEquals(
91
+ notCompletable.map((t) => `${t.file}:${t.elementId}`),
92
+ [],
93
+ "deployed fixed-form user task(s) are not in HUMAN_COMPLETABLE_ELEMENTS — the canonical complete-user-task door would reject them",
94
+ );
95
+
96
+ const formMismatch = fixedForm.filter((t) => escalationFormId(t.elementId) !== t.formId);
97
+ assertEquals(
98
+ formMismatch.map((t) => `${t.file}:${t.elementId} bpmn=${t.formId} app=${escalationFormId(t.elementId) ?? "undefined"}`),
99
+ [],
100
+ "deployed user task(s) map to a different .form contract in ESCALATION_FORM_BY_ELEMENT than the BPMN declares",
101
+ );
102
+ });
package/app/userTasks.ts CHANGED
@@ -72,6 +72,24 @@ export const ACP_PERMISSION_ELEMENT = "acp-permission";
72
72
  * escalation is filtered out by the unknown-kind guard and silently vanishes from the Tasks inbox. */
73
73
  export const HUMAN_ESCALATION_ELEMENT = "escalation";
74
74
 
75
+ /** The readiness/preflight escalation user task parked when a LEADING readiness/capability PREFLIGHT
76
+ * times out before its `ReadinessProbe` went green — a human decision (acknowledge/proceed vs abandon).
77
+ * This is the `pf_*` embedded-subprocess variant that appears both in `feature.bpmn` (the feature-run
78
+ * readiness preflight) and `plan-fanout.bpmn` (the producer-capability preflight). It parks on the
79
+ * run's OWN engine instance (an embedded subprocess, not a callActivity child), renders the deployed
80
+ * `readiness-escalation` `.form`, and is HUMAN-only: an agent must NOT auto-answer a readiness gate
81
+ * (that would silently defeat the very "is upstream actually ready?" decision the gate exists to make),
82
+ * so it lives in `HUMAN_COMPLETABLE_ELEMENTS`, never in `ESCALATION_TASK_ELEMENTS`. Without this the
83
+ * task's kind is `undefined`, the poller's leak guard drops it, and the run wedges invisibly + unanswerably
84
+ * (issue #674). */
85
+ export const READINESS_ESCALATION_PF_ELEMENT = "readiness-escalation-pf";
86
+
87
+ /** The readiness/wait-gate escalation user task (`readiness-gate.bpmn`, `wait-gate.bpmn`) — the same
88
+ * human readiness decision on the standalone inter-epic wait-gate cell rather than the inline preflight.
89
+ * Same `readiness-escalation` `.form`, same HUMAN-only policy as `READINESS_ESCALATION_PF_ELEMENT`
90
+ * (issue #674). */
91
+ export const READINESS_ESCALATION_ELEMENT = "readiness-escalation";
92
+
75
93
  /** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
76
94
  * the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
77
95
  * task open; `pollUserTasks` deletes it once the task is gone. */
@@ -186,6 +204,8 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
186
204
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
187
205
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
188
206
  [CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
207
+ [READINESS_ESCALATION_PF_ELEMENT]: "Upstream readiness stalled",
208
+ [READINESS_ESCALATION_ELEMENT]: "Readiness escalation",
189
209
  [DELIVERY_HUMAN_ELEMENT]: "Delivery: human step",
190
210
  [ACP_PERMISSION_ELEMENT]: "Agent permission",
191
211
  };
@@ -342,8 +362,21 @@ export function latestTrialMergeQuestion(audits: readonly TrialMergeAuditRow[]):
342
362
  return latest?.summary ?? null;
343
363
  }
344
364
 
345
- /** One PR review-loop escalation (001_init.sql `escalations`). `status` is open | answered; the poller
346
- * reads the OPEN row's `question`. */
365
+ /** Pure: the "what is the run waiting on" question line for a readiness/preflight escalation
366
+ * (`readiness-escalation-pf` / `readiness-escalation`, issue #674) — the unresolved probe/capability
367
+ * the leading readiness gate stalled on. The wait-gate projection already renders that clause on the
368
+ * subject row: a plan gated behind a producer capability carries it on `plans.wait_gate_label`
369
+ * ("waiting on <clause> · re-checks …", app/waitGate.ts), and a feature preflight carries its rollup
370
+ * on `feature_runs.delivery_label`. Prefer whichever the subject supplies; fall back to a static
371
+ * readiness-stalled line so the Tasks grid never renders a blank question for a parked gate.
372
+ * Always returns a non-empty string — the static fallback guarantees a question is never blank. */
373
+ export function readinessEscalationQuestion(label?: string | null): string {
374
+ const t = typeof label === "string" ? label.trim() : "";
375
+ return (
376
+ t ||
377
+ "The readiness preflight timed out before its ReadinessProbe went green. Proceed against the (now-published) upstream, or abandon the gate."
378
+ );
379
+ }
347
380
  export interface PrEscalationRow {
348
381
  id: number;
349
382
  pr_key: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.171.1",
3
+ "version": "0.171.2",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",