@nanobpm/nano-workforce 0.84.0 → 0.85.0

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,10 @@
1
+ # [0.85.0](https://github.com/nanobpm/nano-workforce/compare/v0.84.0...v0.85.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * **readiness:** capability probe kind — resolve capability→version from publish provenance, late-bind + pin ([#274](https://github.com/nanobpm/nano-workforce/issues/274)) ([#275](https://github.com/nanobpm/nano-workforce/issues/275)) ([33171b7](https://github.com/nanobpm/nano-workforce/commit/33171b73a69d5b130f205be12e7f0988b3f268a6)), closes [#258](https://github.com/nanobpm/nano-workforce/issues/258)
7
+
1
8
  # [0.84.0](https://github.com/nanobpm/nano-workforce/compare/v0.83.0...v0.84.0) (2026-08-17)
2
9
 
3
10
 
package/app/feature.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
18
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
19
19
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
20
- import { deriveListBucket, deriveStage } from "./stage.ts";
20
+ import { deriveEscalationOpen, deriveListBucket, deriveStage } from "./stage.ts";
21
21
 
22
22
  /** The BPMN process this module drives (resources/processes/feature.bpmn). */
23
23
  export const FEATURE_PROCESS_ID = "feature";
@@ -63,6 +63,16 @@ export interface FeatureRun {
63
63
  * (`completeUserTaskAttributed`) and the pages gate the answer controls on (`showWhenField`). Set by
64
64
  * `pollFeatureEscalations` while parked; NULL otherwise. */
65
65
  escalation_user_task_key: string | null;
66
+ /** Gateway projection (issue #272): the single fail-closed "open escalation" display signal, `1` iff
67
+ * ALL THREE escalation columns agree the run is parked at an answerable escalation
68
+ * (`status='escalated'` AND `escalation_user_task_key` non-NULL AND `escalation_question` non-NULL),
69
+ * else `0`. Derived by `deriveEscalationOpen` at write time. The escalation tuple is spread across
70
+ * three independently-written columns, so an interim read can see a TORN state (a live pointer with a
71
+ * blank question, or a status lagging behind a cleared question); the pages gate the Abandon action
72
+ * and the answer form on THIS conjunction instead of on `escalation_user_task_key` alone, so a torn
73
+ * tuple renders as not-escalated rather than escalated-but-blank. NULL only on legacy rows before the
74
+ * projection reached them (backfilled once at boot). */
75
+ escalation_open: number | null;
66
76
  /** The completable native `feature-blocked` user-task key the "Acknowledge blocked" affordance posts
67
77
  * to (`completeUserTaskAttributed`) and the pages gate the acknowledge control on (`showWhenField`).
68
78
  * Kept DISTINCT from `escalation_user_task_key` so the two human tasks (an escalation answer vs a
@@ -296,6 +306,7 @@ const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
296
306
  "stage_skipped",
297
307
  "attention",
298
308
  "list_bucket",
309
+ "escalation_open",
299
310
  ];
300
311
 
301
312
  /** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
@@ -326,6 +337,13 @@ function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
326
337
  stage_skipped: skipped,
327
338
  attention,
328
339
  list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
340
+ escalation_open: deriveEscalationOpen({
341
+ status: row.status,
342
+ escalation_question: row.escalation_question ?? null,
343
+ escalation_user_task_key: row.escalation_user_task_key ?? null,
344
+ })
345
+ ? 1
346
+ : 0,
329
347
  };
330
348
  }
331
349
 
@@ -370,21 +388,25 @@ export const featureRuns = (data: DataLayer) => {
370
388
  });
371
389
  };
372
390
 
373
- /** Re-project every feature_runs row through the gateway so rows written before migration 039 (whose
374
- * projection columns are NULL) get correct `stage`/`stage_state`/`stage_skipped`/`attention`/
375
- * `list_bucket` values. Idempotent and safe to re-run: it re-derives from each row's own stored fields,
376
- * so a second pass is a no-op. Runs once at boot (pollOnce) the gateway keeps every future write
377
- * fresh, so this only needs to catch legacy rows once. */
391
+ /** Re-project every feature_runs row through the gateway so rows missing any projection column get
392
+ * correct `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`/`escalation_open` values.
393
+ * Catches rows written before migration 039 (the pipeline columns) AND rows written before migration
394
+ * 040 (whose `escalation_open` column is NULL, issue #272). Idempotent and safe to re-run: it
395
+ * re-derives from each row's own stored fields, so a second pass is a no-op. Runs once at boot
396
+ * (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy rows.
397
+ * Reprojecting on a missing `escalation_open` matters for a run parked at a LIVE escalation when
398
+ * migration 040 lands: `pollFeatureEscalations` writes nothing while it stays parked (no change), so
399
+ * without this the fail-closed signal would stay NULL and hide a genuinely-open escalation. */
378
400
  export async function backfillFeatureStages(data: DataLayer): Promise<number> {
379
401
  const table = featureRuns(data);
380
402
  const rows = await table.all();
381
403
  let stamped = 0;
382
404
  for (const row of rows) {
383
- // Only touch rows the projection has never reached — a legacy pre-039 row whose `stage` column is
384
- // still NULL. The gateway keeps every write fresh, so an already-projected row needs no re-write;
385
- // skipping them avoids a full-table rewrite on every boot and keeps `stamped` an honest count of
386
- // rows actually backfilled (not the total row count).
387
- if (row.stage != null) continue;
405
+ // Only touch rows the projection has never fully reached — a legacy row whose `stage` (pre-039) or
406
+ // `escalation_open` (pre-040) column is still NULL. The gateway keeps every write fresh, so a
407
+ // fully-projected row needs no re-write; skipping them avoids a full-table rewrite on every boot and
408
+ // keeps `stamped` an honest count of rows actually backfilled (not the total row count).
409
+ if (row.stage != null && row.escalation_open != null) continue;
388
410
  // Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
389
411
  // would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
390
412
  // so backfill projects explicitly rather than relying on an empty-patch reproject.)
@@ -72,6 +72,56 @@ test("update {status:'escalated'} on a row with no pr_key projects Implementing
72
72
  assertEquals(rows[0].stage_state, null);
73
73
  });
74
74
 
75
+ // issue #272: the fail-closed open-escalation projection. The escalation tuple is spread across three
76
+ // independently-written columns; the gateway projects `escalation_open=1` ONLY when all three agree.
77
+ test("escalation_open is 1 only when status=escalated AND pointer AND question all present", async () => {
78
+ const { data, rows } = memData();
79
+ rows.push({ feature_key: "o/r#esc", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
80
+ // A complete, answerable escalation → 1.
81
+ await featureRuns(data).update("o/r#esc", {
82
+ status: "escalated",
83
+ escalation_user_task_key: "ut-9",
84
+ escalation_question: "which base branch?",
85
+ });
86
+ assertEquals(rows[0].escalation_open, 1);
87
+ });
88
+
89
+ test("a torn escalation tuple projects escalation_open=0 (renders not-escalated, fail closed)", async () => {
90
+ const { data, rows } = memData();
91
+ // Simulate the exit-path/poller race: the question was cleared while status + pointer still lag in the
92
+ // escalated projection (the mirror tear observed on nwf#270). The page must NOT show an escalation.
93
+ rows.push({
94
+ feature_key: "o/r#torn",
95
+ status: "escalated",
96
+ pr_key: null,
97
+ converge: 1,
98
+ auto_merge: 1,
99
+ escalation_user_task_key: "ut-9",
100
+ escalation_question: "which base?",
101
+ escalation_open: 1,
102
+ });
103
+ await featureRuns(data).update("o/r#torn", { escalation_question: null });
104
+ assertEquals(rows[0].escalation_open, 0);
105
+ });
106
+
107
+ test("clearing the escalation tuple flips escalation_open to 0 without waiting a poll pass", async () => {
108
+ const { data, rows } = memData();
109
+ // The answer operation eagerly clears pointer + question; the gateway reprojects on that same write,
110
+ // so the affordance disappears immediately (issue #272 acceptance: no poll-pass lag).
111
+ rows.push({
112
+ feature_key: "o/r#ans",
113
+ status: "escalated",
114
+ pr_key: null,
115
+ converge: 1,
116
+ auto_merge: 1,
117
+ escalation_user_task_key: "ut-9",
118
+ escalation_question: "which base?",
119
+ escalation_open: 1,
120
+ });
121
+ await featureRuns(data).update("o/r#ans", { escalation_user_task_key: null, escalation_question: null });
122
+ assertEquals(rows[0].escalation_open, 0);
123
+ });
124
+
75
125
  test("a run with converge=false projects stage_skipped containing Converging and Merging", async () => {
76
126
  const { data, rows } = memData();
77
127
  rows.push({ feature_key: "o/r#5", status: "running", converge: 0, auto_merge: 0 });
@@ -162,9 +212,12 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
162
212
  // Legacy rows written before migration 039 → projection columns absent/NULL.
163
213
  rows.push({ feature_key: "o/r#8", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: "2024-01-01T00:00:00Z" });
164
214
  rows.push({ feature_key: "o/r#9", status: "running", pr_key: null, converge: 0, auto_merge: 0 });
215
+ // A legacy row parked at a LIVE escalation when migration 040 lands: escalation_open is NULL and the
216
+ // poller won't re-write it while it stays parked, so backfill MUST reproject it to 1 (issue #272).
217
+ rows.push({ feature_key: "o/r#esc", status: "escalated", pr_key: null, converge: 1, auto_merge: 1, escalation_user_task_key: "ut-9", escalation_question: "which base?" });
165
218
 
166
219
  const stamped = await backfillFeatureStages(data);
167
- assertEquals(stamped, 2);
220
+ assertEquals(stamped, 3);
168
221
 
169
222
  const terminal = rows.find((r) => r.feature_key === "o/r#8");
170
223
  assertEquals(terminal.stage, "Done");
@@ -176,19 +229,24 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
176
229
  assertEquals(live.stage_state, null);
177
230
  assertEquals(live.stage_skipped, "Converging Merging");
178
231
  assertEquals(live.list_bucket, "active");
232
+ assertEquals(live.escalation_open, 0);
233
+
234
+ const escalated = rows.find((r) => r.feature_key === "o/r#esc");
235
+ assertEquals(escalated.escalation_open, 1);
179
236
  });
180
237
 
181
238
  test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
182
239
  const { data, rows } = memData();
183
240
  // One legacy row (no projection) + one already-projected row (gateway kept it fresh).
184
241
  rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
185
- rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", updated_at: "0" });
242
+ rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", escalation_open: 0, updated_at: "0" });
186
243
 
187
244
  const stamped = await backfillFeatureStages(data);
188
245
  // Only the legacy row is stamped; the already-projected row is skipped.
189
246
  assertEquals(stamped, 1);
190
247
  const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
191
248
  assertEquals(legacy.stage, "Done");
249
+ assertEquals(legacy.escalation_open, 0);
192
250
  // The already-projected row was not re-written (its sentinel updated_at is untouched).
193
251
  const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
194
252
  assertEquals(fresh.updated_at, "0");
@@ -7,20 +7,27 @@ import { test } from "node:test";
7
7
  import { assert, assertEquals, assertRejects, assertStringIncludes, assertThrows } from "#test-assert";
8
8
  import {
9
9
  type CommandResult,
10
+ cmpVersion,
10
11
  DEFAULT_ATTEMPT_TIMEOUT_MS,
11
12
  DEFAULT_EVERY_MS,
12
13
  DEFAULT_TIMEOUT_MS,
13
14
  defaultProbeExec,
15
+ type GithubRelease,
14
16
  type HttpResponse,
17
+ makeCapabilityFallback,
15
18
  MAX_EVERY_MS,
19
+ matchCapability,
16
20
  matchCommand,
17
21
  matchGithubCheck,
18
22
  matchHttp,
19
23
  matchNpm,
20
24
  msToIsoDuration,
25
+ newestPublishedVersion,
21
26
  nextDelay,
22
27
  normalizePoll,
23
28
  parseProbe,
29
+ parseReleases,
30
+ parseReleasesTarget,
24
31
  parseRepoRef,
25
32
  probeBudgetMs,
26
33
  probeOnce,
@@ -102,6 +109,186 @@ test("parseProbe: accepts a declared credentialEnv (http) and parses nested matc
102
109
  assertEquals(p.poll?.backoff, "fixed");
103
110
  });
104
111
 
112
+ // ── parseProbe: capability kind (#274 — required capabilityRef + package, fail loudly on blank) ──
113
+ test("parseProbe: a capability probe with a blank capabilityRef throws (a never-resolvable edge must fail loudly)", () => {
114
+ assertThrows(
115
+ () => parseProbe({ kind: "capability", target: "github-releases:nanobpm/nano-ide", match: { package: "@nanobpm/urban" } }),
116
+ Error,
117
+ "capabilityRef' is required",
118
+ );
119
+ });
120
+
121
+ test("parseProbe: a capability probe with a blank package throws (provenance is per-package scoped)", () => {
122
+ assertThrows(
123
+ () => parseProbe({ kind: "capability", target: "github-releases:nanobpm/nano-ide", match: { capabilityRef: "#274" } }),
124
+ Error,
125
+ "package' is required",
126
+ );
127
+ });
128
+
129
+ test("parseProbe: a capability probe whose capabilityRef carries no numeric id throws (never resolvable)", () => {
130
+ assertThrows(
131
+ () =>
132
+ parseProbe({
133
+ kind: "capability",
134
+ target: "github-releases:nanobpm/nano-ide",
135
+ match: { capabilityRef: "nano-ide#", package: "@nanobpm/urban" },
136
+ }),
137
+ Error,
138
+ "must carry a",
139
+ );
140
+ });
141
+
142
+ test("parseProbe: a valid capability probe round-trips its required match fields", () => {
143
+ const p = parseProbe({
144
+ kind: "capability",
145
+ target: "github-releases:nanobpm/nano-ide",
146
+ match: { capabilityRef: "nano-ide#274", package: "@nanobpm/urban", verifyCommand: "node -e 0" },
147
+ });
148
+ assertEquals(p.kind, "capability");
149
+ assertEquals(p.match?.capabilityRef, "nano-ide#274");
150
+ assertEquals(p.match?.package, "@nanobpm/urban");
151
+ assertEquals(p.match?.verifyCommand, "node -e 0");
152
+ assertEquals(p.onTimeout, "escalate");
153
+ });
154
+
155
+ // ── matchCapability: the deterministic lowest-version-per-package resolver (#274 Gap A) ──────────
156
+ const rel = (tag: string, refs: number[]): GithubRelease => ({
157
+ tag,
158
+ body: `Automated release of \`${tag}\`.\n\n## Provenance\n${refs.map((n) => `- #${n}`).join("\n")}\n`,
159
+ });
160
+ const capMatch = { capabilityRef: "nano-ide#274", package: "@nanobpm/urban" };
161
+
162
+ test("matchCapability: a capability in exactly one release resolves that version and binds resolvedArtifact", () => {
163
+ const res = matchCapability(capMatch, [rel("@nanobpm/urban@0.54.0", [273, 274, 275])]);
164
+ assert(res.ready);
165
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.54.0");
166
+ });
167
+
168
+ test("matchCapability: present in multiple releases resolves the LOWEST version (first-carries)", () => {
169
+ const releases = [
170
+ rel("@nanobpm/urban@0.60.0", [274]),
171
+ rel("@nanobpm/urban@0.54.0", [274]),
172
+ rel("@nanobpm/urban@0.9.0", [274]), // 0.9 < 0.54 numerically — cmpVersion, not lexicographic
173
+ ];
174
+ const res = matchCapability(capMatch, releases);
175
+ assert(res.ready);
176
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.9.0");
177
+ });
178
+
179
+ test("matchCapability: an absent capability is not-ready (still waiting), never throws", () => {
180
+ const res = matchCapability(capMatch, [rel("@nanobpm/urban@0.54.0", [200, 201])]);
181
+ assert(!res.ready);
182
+ assertEquals(res.bind, undefined);
183
+ });
184
+
185
+ test("matchCapability: the same #C in another package resolves ONLY within the named package", () => {
186
+ const releases = [
187
+ rel("@nanobpm/other@1.0.0", [274]), // same #274, wrong package — must be ignored
188
+ rel("@nanobpm/urban@0.55.0", [274]),
189
+ ];
190
+ const res = matchCapability(capMatch, releases);
191
+ assert(res.ready);
192
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.55.0");
193
+ });
194
+
195
+ test("matchCapability: a prefix ref (#27) never spuriously satisfies #274", () => {
196
+ assert(!matchCapability(capMatch, [rel("@nanobpm/urban@0.54.0", [27])]).ready);
197
+ });
198
+
199
+ test("matchCapability: a malformed/empty releases list is not-ready and never throws", () => {
200
+ assert(!matchCapability(capMatch, []).ready);
201
+ // biome-ignore lint/suspicious/noExplicitAny: deliberately malformed rows exercise the tolerant guard.
202
+ assert(!matchCapability(capMatch, [{ tag: 123, body: null } as any, null as any]).ready);
203
+ });
204
+
205
+ test("matchCapability: the bare '#274' ref form resolves identically to 'nano-ide#274'", () => {
206
+ const res = matchCapability({ capabilityRef: "#274", package: "@nanobpm/urban" }, [rel("@nanobpm/urban@1.2.3", [274])]);
207
+ assert(res.ready);
208
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@1.2.3");
209
+ });
210
+
211
+ test("cmpVersion: numeric dotted compare (0.9 < 0.54 < 0.60), matching nano-ide publish.mjs", () => {
212
+ assert(cmpVersion("0.9.0", "0.54.0") < 0);
213
+ assert(cmpVersion("0.54.0", "0.60.0") < 0);
214
+ assertEquals(cmpVersion("1.2", "1.2.0"), 0);
215
+ });
216
+
217
+ test("newestPublishedVersion: picks the highest version of the named package only", () => {
218
+ const releases = [rel("@nanobpm/urban@0.9.0", []), rel("@nanobpm/urban@0.54.0", []), rel("@nanobpm/other@9.9.9", [])];
219
+ assertEquals(newestPublishedVersion("@nanobpm/urban", releases), "0.54.0");
220
+ assertEquals(newestPublishedVersion("@nanobpm/missing", releases), undefined);
221
+ });
222
+
223
+ test("parseReleases: reduces a gh api payload to {tag, body}; non-array input yields []", () => {
224
+ const parsed = parseReleases([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274" }, { nope: 1 }]);
225
+ assertEquals(parsed[0]?.tag, "@nanobpm/urban@0.54.0");
226
+ assertEquals(parseReleases("not-an-array").length, 0);
227
+ assertEquals(parseReleases(null).length, 0);
228
+ });
229
+
230
+ test("parseReleases: flattens the --paginate --slurp array-of-pages shape (>100 releases are seen)", () => {
231
+ const slurped = [
232
+ [{ tag_name: "@nanobpm/urban@0.54.0", body: "- #274" }],
233
+ [{ tag_name: "@nanobpm/urban@0.9.0", body: "- #274" }],
234
+ ];
235
+ const tags = parseReleases(slurped).map((r) => r.tag);
236
+ assertEquals(tags.includes("@nanobpm/urban@0.54.0"), true, "first page's release is seen");
237
+ assertEquals(tags.includes("@nanobpm/urban@0.9.0"), true, "a later page's release is seen too");
238
+ });
239
+
240
+ test("parseReleasesTarget: strips the optional github-releases: scheme, else passes owner/repo through", () => {
241
+ assertEquals(parseReleasesTarget("github-releases:nanobpm/nano-ide"), "nanobpm/nano-ide");
242
+ assertEquals(parseReleasesTarget("nanobpm/nano-ide"), "nanobpm/nano-ide");
243
+ });
244
+
245
+ // ── probeOnce capability dispatch + gated fallback (#274 decision 5) ─────────────────────────────
246
+ test("probeOnce capability: queries the repo's releases and binds the resolved artifact", async () => {
247
+ const cap: { cmd?: string } = {};
248
+ const payload = JSON.stringify([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274\n" }]);
249
+ const exec = stubExec({ command: { code: 0, stdout: payload, stderr: "" }, capture: cap });
250
+ const res = await probeOnce(parseProbe({ kind: "capability", target: "github-releases:nanobpm/nano-ide", match: capMatch }), exec, {});
251
+ assert(res.ready);
252
+ assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@0.54.0");
253
+ assertStringIncludes(cap.cmd ?? "", "repos/nanobpm/nano-ide/releases");
254
+ });
255
+
256
+ test("probeOnce capability: a failed gh api call is not-ready (never throws)", async () => {
257
+ const exec = stubExec({ command: { code: 1, stdout: "", stderr: "boom" } });
258
+ const res = await probeOnce(parseProbe({ kind: "capability", target: "nanobpm/nano-ide", match: capMatch }), exec, {});
259
+ assert(!res.ready);
260
+ });
261
+
262
+ test("makeCapabilityFallback: null for a capability probe with no verifyCommand (deterministic-only)", async () => {
263
+ const probe = parseProbe({ kind: "capability", target: "nanobpm/nano-ide", match: capMatch });
264
+ const fb = makeCapabilityFallback(probe, stubExec({}), {});
265
+ assertEquals(await fb(), null);
266
+ });
267
+
268
+ test("makeCapabilityFallback: verifies the NEWEST version empirically and binds it when the verifier passes", async () => {
269
+ const payload = JSON.stringify([
270
+ { tag_name: "@nanobpm/urban@0.54.0", body: "no ref" },
271
+ { tag_name: "@nanobpm/urban@0.60.0", body: "no ref" },
272
+ ]);
273
+ const seen: string[] = [];
274
+ const exec: ProbeExec = {
275
+ async httpGet() {
276
+ return { status: 0, body: "" };
277
+ },
278
+ async run(command, env) {
279
+ seen.push(command);
280
+ if (command.includes("gh api")) return { code: 0, stdout: payload, stderr: "" };
281
+ // the verifier: assert the newest version was handed to it via the env
282
+ return { code: env.RESOLVED_VERSION === "0.60.0" ? 0 : 1, stdout: "", stderr: "" };
283
+ },
284
+ };
285
+ const probe = parseProbe({ kind: "capability", target: "nanobpm/nano-ide", match: { ...capMatch, verifyCommand: "verify.sh" } });
286
+ const res = await makeCapabilityFallback(probe, exec, {})();
287
+ assert(res?.ready);
288
+ assertEquals(res?.bind?.resolvedArtifact, "@nanobpm/urban@0.60.0");
289
+ assert(seen.some((c) => c === "verify.sh"), "the verifier command was run at the boundary");
290
+ });
291
+
105
292
  // ── matchers ────────────────────────────────────────────────────────────────────────────────
106
293
  test("matchHttp: any 2xx is ready by default; a 503 is not", () => {
107
294
  assert(matchHttp(undefined, { status: 204, body: "" }).ready);
package/app/readiness.ts CHANGED
@@ -9,18 +9,23 @@
9
9
  //
10
10
  // The probe is DATA, not code — a {@link ReadinessProbe} descriptor with a `kind` and a per-kind
11
11
  // `match` predicate. Authors add a readiness source by adding a `kind`'s matcher, never by editing
12
- // the BPMN or the worker's control flow. Four built-in kinds ship (`http`, `command`, `npm`,
13
- // `github-check`); everything else is reached through the `command` escape hatch (ADR 0001 §2
14
- // pinned decision 1). A probe carries NO secret material any credential is read at execution
15
- // time from the typed env-contract (`credentialEnv` names a declared {@link EnvKey}; ADR 0004
16
- // pinned decision 2) and is redacted from every log line.
12
+ // the BPMN or the worker's control flow. Five built-in kinds ship (`http`, `command`, `npm`,
13
+ // `github-check`, `capability`); everything else is reached through the `command` escape hatch
14
+ // (ADR 0001 §2 pinned decision 1). The `capability` kind (ADR 0001 §4, issue #274) resolves a
15
+ // cross-repo capability edge "which published version first carries capability C?" from the
16
+ // publish-provenance substrate and late-binds the discovered `pkg@version` back through the gate
17
+ // via {@link ProbeResult.bind}, the reusable emit primitive. A probe carries NO secret material —
18
+ // any credential is read at execution time from the typed env-contract (`credentialEnv` names a
19
+ // declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
17
20
  import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
18
21
  import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
19
22
 
20
23
  /** The built-in readiness sources. `command` is the escape hatch that subsumes the long tail
21
24
  * (`gh`, `curl`, `docker manifest inspect`, a custom probe) — adding a first-class kind later is
22
- * an additive matcher, not a schema change. */
23
- export type ProbeKind = "http" | "command" | "npm" | "github-check";
25
+ * an additive matcher, not a schema change. `capability` is the first such additive kind (#274):
26
+ * it resolves "which published version first carries capability C?" from the publish-provenance
27
+ * substrate and binds the discovered `pkg@version` back through the gate (see {@link matchCapability}). */
28
+ export type ProbeKind = "http" | "command" | "npm" | "github-check" | "capability";
24
29
 
25
30
  /** What the gate does when the bounded wait times out (the engine timer arm fires). */
26
31
  export type OnTimeout = "escalate" | "fail" | "continue";
@@ -28,7 +33,7 @@ export type OnTimeout = "escalate" | "fail" | "continue";
28
33
  /** Backoff policy between poll attempts. */
29
34
  export type Backoff = "fixed" | "exponential";
30
35
 
31
- const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check"];
36
+ const PROBE_KINDS: readonly ProbeKind[] = ["http", "command", "npm", "github-check", "capability"];
32
37
  const ON_TIMEOUTS: readonly OnTimeout[] = ["escalate", "fail", "continue"];
33
38
  const BACKOFFS: readonly Backoff[] = ["fixed", "exponential"];
34
39
 
@@ -49,6 +54,18 @@ export interface ProbeMatch {
49
54
  readonly conclusion?: string;
50
55
  /** github-check: restrict the predicate to the named check run (default: every check run). */
51
56
  readonly checkName?: string;
57
+ /** capability: the upstream issue/PR handle the resolved version must carry in its publish
58
+ * provenance — `nano-ide#274` or the bare `#274`. Required for the `capability` kind. */
59
+ readonly capabilityRef?: string;
60
+ /** capability: the package whose releases are scanned (e.g. `@nanobpm/urban`). Provenance is
61
+ * per-package scoped — the same `#C` may appear in two packages — so this is required. */
62
+ readonly package?: string;
63
+ /** capability: an OPTIONAL empirical verifier command for the gated fallback (decision 5). Run
64
+ * ONCE at the gate boundary (poll budget exhausted) against the newest published `package`
65
+ * version when deterministic provenance resolved nothing; exit 0 binds that newest version. Left
66
+ * unset, the capability edge is deterministic-only. The resolved `pkg@version` and bare version
67
+ * are exposed to the command as `RESOLVED_ARTIFACT` / `RESOLVED_VERSION`. */
68
+ readonly verifyCommand?: string;
52
69
  }
53
70
 
54
71
  /** The poll cadence: how often to re-probe, how long to keep trying, and the backoff shape. */
@@ -75,10 +92,24 @@ export interface ReadinessProbe {
75
92
  readonly credentialEnv?: string;
76
93
  }
77
94
 
78
- /** The result of a single probe attempt. `detail` is a short, already-redacted human note. */
95
+ /** The result of a single probe attempt. `detail` is a short, already-redacted human note.
96
+ * `bind` is the OPTIONAL late-bound value a matcher discovered (the reusable "emit" primitive,
97
+ * #274 Gap B): a kind-agnostic `key → value` map the worker forwards into the `readiness-ready`
98
+ * message so the gate can surface it as an output process variable (e.g. the `capability` kind
99
+ * binds `{ resolvedArtifact: "@nanobpm/urban@0.54.0" }`). Provenance is public, so nothing in
100
+ * `bind` is redacted; keep values free of any secret material by construction. */
79
101
  export interface ProbeResult {
80
102
  readonly ready: boolean;
81
103
  readonly detail: string;
104
+ readonly bind?: Record<string, string>;
105
+ }
106
+
107
+ /** A single published GitHub Release, reduced to the two fields the capability resolver reads: the
108
+ * `<package>@<version>` tag and the release body carrying the `## Provenance` `#NNN` refs. Kept
109
+ * separate from I/O so {@link matchCapability} is pure/unit-testable. */
110
+ export interface GithubRelease {
111
+ readonly tag: string;
112
+ readonly body: string;
82
113
  }
83
114
 
84
115
  /** A raw HTTP response the http matcher inspects (kept separate from I/O so it is pure-testable). */
@@ -148,6 +179,26 @@ export function parseProbe(raw: unknown): ReadinessProbe {
148
179
  const onTimeout: OnTimeout = onTimeoutRaw === "" ? "escalate" : onTimeoutRaw;
149
180
 
150
181
  const match = isRecord(raw.match) ? parseMatch(raw.match) : undefined;
182
+ // A capability edge whose ref or package is blank can never resolve — fail loudly here rather than
183
+ // wait forever (mirroring the blank-`target` guard above). Both are required for this kind.
184
+ if (kind === "capability") {
185
+ if (!match?.capabilityRef) {
186
+ throw new Error("readiness probe (capability): 'match.capabilityRef' is required (e.g. 'nano-ide#274' or '#274')");
187
+ }
188
+ // A ref that carries no numeric issue/PR id (e.g. 'cap274' has a number, but 'nano-ide#' or a
189
+ // bare word does not) can never resolve — `matchCapability` would only surface it as a timeout
190
+ // much later. Reject it now, via the SAME canonical parser the resolver uses, so a malformed
191
+ // edge fails loudly at parse (mirroring the intent of the blank-ref guard above).
192
+ if (!capabilityNumber(match.capabilityRef)) {
193
+ throw new Error(
194
+ `readiness probe (capability): 'match.capabilityRef' ('${match.capabilityRef}') must carry a ` +
195
+ "numeric issue/PR id (e.g. 'nano-ide#274' or '#274')",
196
+ );
197
+ }
198
+ if (!match?.package) {
199
+ throw new Error("readiness probe (capability): 'match.package' is required (provenance is per-package scoped)");
200
+ }
201
+ }
151
202
  const poll = isRecord(raw.poll) ? parsePoll(raw.poll) : undefined;
152
203
  const credentialEnv = str(raw.credentialEnv).trim() || undefined;
153
204
  if (credentialEnv !== undefined && !isEnvKey(credentialEnv)) {
@@ -197,6 +248,9 @@ function parseMatch(raw: Record<string, unknown>): ProbeMatch {
197
248
  version: str(raw.version).trim() || undefined,
198
249
  conclusion: str(raw.conclusion).trim() || undefined,
199
250
  checkName: str(raw.checkName).trim() || undefined,
251
+ capabilityRef: str(raw.capabilityRef).trim() || undefined,
252
+ package: str(raw.package).trim() || undefined,
253
+ verifyCommand: str(raw.verifyCommand).trim() || undefined,
200
254
  };
201
255
  }
202
256
 
@@ -304,6 +358,126 @@ function versionOf(target: string): string | undefined {
304
358
  return v === "" ? undefined : v;
305
359
  }
306
360
 
361
+ // ── Capability resolver (#274 Gap A — a stable, pure "which version first carries C?" matcher) ───
362
+
363
+ /** Compare two dotted numeric version strings (`major.minor.patch…`), reusing the exact semantics of
364
+ * nano-ide `scripts/publish.mjs` `cmpVersion` so the resolver and the publisher agree on ordering.
365
+ * Missing trailing segments count as 0; returns <0, 0, or >0. */
366
+ export function cmpVersion(a: string, b: string): number {
367
+ const pa = a.split(".").map(Number);
368
+ const pb = b.split(".").map(Number);
369
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
370
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
371
+ if (d !== 0) return d;
372
+ }
373
+ return 0;
374
+ }
375
+
376
+ /** The bare, purely numeric `#NNN` number from a capability handle — `nano-ide#274`, `#274`, or a
377
+ * naked `274` all normalise to `274`. Returns undefined for anything without a number, so a blank/
378
+ * malformed ref never accidentally matches. */
379
+ function capabilityNumber(ref: string): string | undefined {
380
+ const m = ref.match(/(\d+)\s*$/);
381
+ return m ? m[1] : undefined;
382
+ }
383
+
384
+ /** Does a release body's `## Provenance` reference `#NNN`? Matched on a `#`-prefixed word boundary so
385
+ * `#27` never spuriously satisfies `#274`. */
386
+ function bodyReferences(body: string, num: string): boolean {
387
+ return new RegExp(`#${num}(?!\\d)`).test(body);
388
+ }
389
+
390
+ /** The `<version>` of a release tagged exactly `<package>@<version>` (numeric-dotted), or undefined
391
+ * when the tag belongs to another package or is not a version tag. Per-package scoping is enforced
392
+ * here: a sibling package's provenance can never leak into this package's resolution. */
393
+ function versionForPackage(tag: string, pkg: string): string | undefined {
394
+ const prefix = `${pkg}@`;
395
+ if (!tag.startsWith(prefix)) return undefined;
396
+ const v = tag.slice(prefix.length).trim();
397
+ return /^\d+(\.\d+)*$/.test(v) ? v : undefined;
398
+ }
399
+
400
+ /** capability readiness (#274 Gap A): among GitHub Releases tagged `<match.package>@*` whose body
401
+ * references `match.capabilityRef`, resolve the **lowest** SemVer version — that is the version that
402
+ * *first* carries the capability (`firstVersion`). Late-binds it as `{ resolvedArtifact }` so the
403
+ * gate can hand the exact `pkg@version` to the consumer (#274 Gap B). PURE: it operates on an
404
+ * already-fetched, parsed releases list and NEVER throws — a malformed/empty list is simply
405
+ * "not ready yet" (keep waiting), so a transient provenance read cannot crash the poll loop. */
406
+ export function matchCapability(match: ProbeMatch | undefined, releases: readonly GithubRelease[]): ProbeResult {
407
+ const pkg = match?.package;
408
+ const ref = match?.capabilityRef;
409
+ if (!pkg || !ref) return { ready: false, detail: "capability: missing package/capabilityRef" };
410
+ const num = capabilityNumber(ref);
411
+ if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)" };
412
+
413
+ let firstVersion: string | undefined;
414
+ for (const rel of releases) {
415
+ if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue;
416
+ const version = versionForPackage(rel.tag, pkg);
417
+ if (!version) continue;
418
+ if (!bodyReferences(rel.body, num)) continue;
419
+ if (firstVersion === undefined || cmpVersion(version, firstVersion) < 0) firstVersion = version;
420
+ }
421
+ if (firstVersion === undefined) return { ready: false, detail: `capability #${num} not published in ${pkg} yet` };
422
+ const resolvedArtifact = `${pkg}@${firstVersion}`;
423
+ return { ready: true, detail: `capability #${num} carried by ${resolvedArtifact}`, bind: { resolvedArtifact } };
424
+ }
425
+
426
+ /** The **newest** published SemVer version of `pkg` across `releases` — the target of the gated
427
+ * empirical fallback (decision 5), which installs the latest release and verifies the capability
428
+ * behaviourally when deterministic provenance resolved nothing. PURE / never throws. */
429
+ export function newestPublishedVersion(pkg: string | undefined, releases: readonly GithubRelease[]): string | undefined {
430
+ if (!pkg) return undefined;
431
+ let newest: string | undefined;
432
+ for (const rel of releases) {
433
+ if (!rel || typeof rel.tag !== "string") continue;
434
+ const version = versionForPackage(rel.tag, pkg);
435
+ if (!version) continue;
436
+ if (newest === undefined || cmpVersion(version, newest) > 0) newest = version;
437
+ }
438
+ return newest;
439
+ }
440
+
441
+ /** Parse a raw `gh api .../releases` payload (already JSON-decoded) into the minimal
442
+ * {@link GithubRelease} list the resolver reads. Tolerant: non-array/malformed input yields `[]`,
443
+ * so a bad provenance read degrades to "not ready", never a throw. Also accepts the `--paginate
444
+ * --slurp` shape — an array whose elements are themselves per-page arrays — flattening one level so
445
+ * releases beyond the first 100 (the true lowest version that first carried a capability) are seen. */
446
+ export function parseReleases(payload: unknown): GithubRelease[] {
447
+ if (!Array.isArray(payload)) return [];
448
+ const out: GithubRelease[] = [];
449
+ const push = (r: unknown): void => {
450
+ if (!isRecord(r)) return;
451
+ out.push({ tag: str(r.tag_name), body: str(r.body) });
452
+ };
453
+ for (const el of payload) {
454
+ if (Array.isArray(el)) {
455
+ for (const r of el) push(r);
456
+ } else {
457
+ push(el);
458
+ }
459
+ }
460
+ return out;
461
+ }
462
+
463
+ /** Split a `capability` target (`github-releases:owner/repo`) into the provenance source repo. The
464
+ * `github-releases:` scheme prefix is optional — a bare `owner/repo` is accepted too. */
465
+ export function parseReleasesTarget(target: string): string {
466
+ const t = target.trim();
467
+ const scheme = "github-releases:";
468
+ return t.startsWith(scheme) ? t.slice(scheme.length).trim() : t;
469
+ }
470
+
471
+ /** Build the `gh api` command that lists a repo's releases (the provenance substrate). `gh` reads
472
+ * its token from the ambient env, exactly like the `github-check` kind — no `credentialEnv`.
473
+ * `--paginate --slurp` walks the FULL release history (not just the first `per_page=100` page), so a
474
+ * repo with >100 releases can still surface the lowest version that first carried a capability;
475
+ * `--slurp` wraps the pages in an outer array that {@link parseReleases} flattens. */
476
+ export function githubReleasesCommand(repo: string): string {
477
+ return `gh api --paginate --slurp ${shellQuote(`repos/${repo}/releases?per_page=100`)} -H ${shellQuote("Accept: application/vnd.github+json")}`;
478
+ }
479
+
480
+
307
481
  // ── Single probe attempt (does I/O via the injected {@link ProbeExec}) ──────────────────────────
308
482
 
309
483
  /** Run ONE probe attempt for `probe`, resolving any credential from the typed env-contract and
@@ -332,9 +506,46 @@ export async function probeOnce(
332
506
  if (out.code !== 0) return { ready: false, detail: "github-check: gh api failed (not ready)" };
333
507
  return matchGithubCheck(probe.match, parseJson(out.stdout));
334
508
  }
509
+ case "capability": {
510
+ const repo = parseReleasesTarget(probe.target);
511
+ const out = await exec.run(githubReleasesCommand(repo), env);
512
+ if (out.code !== 0) return { ready: false, detail: "capability: gh api failed (not ready)" };
513
+ return matchCapability(probe.match, parseReleases(parseJson(out.stdout)));
514
+ }
335
515
  }
336
516
  }
337
517
 
518
+ /** Build the gated empirical fallback for a `capability` probe (decision 5) — a thunk the poll loop
519
+ * runs ONCE at the gate boundary (local budget exhausted) when deterministic provenance resolved
520
+ * nothing. It installs nothing itself: it fetches releases, picks the NEWEST published version, and
521
+ * runs the descriptor's `match.verifyCommand` against it (with `RESOLVED_ARTIFACT`/`RESOLVED_VERSION`
522
+ * in the env) — exit 0 binds that newest version, letting a capability that provenance under-reported
523
+ * still resolve empirically. Returns `null` (no fallback) for a non-capability probe, a capability
524
+ * probe with no `verifyCommand` (deterministic-only), or when no version/releases are available — so
525
+ * the default path stays a pure deterministic lookup and the agent judgment is the gated exception. */
526
+ export function makeCapabilityFallback(
527
+ probe: ReadinessProbe,
528
+ exec: ProbeExec,
529
+ env: Record<string, string | undefined>,
530
+ ): () => Promise<ProbeResult | null> {
531
+ return async () => {
532
+ if (probe.kind !== "capability") return null;
533
+ const verify = probe.match?.verifyCommand;
534
+ const pkg = probe.match?.package;
535
+ if (!verify || !pkg) return null;
536
+ const listed = await exec.run(githubReleasesCommand(parseReleasesTarget(probe.target)), env);
537
+ if (listed.code !== 0) return null;
538
+ const newest = newestPublishedVersion(pkg, parseReleases(parseJson(listed.stdout)));
539
+ if (!newest) return null;
540
+ const artifact = `${pkg}@${newest}`;
541
+ const res = await exec.run(verify, { ...env, RESOLVED_ARTIFACT: artifact, RESOLVED_VERSION: newest });
542
+ if (res.code === 0) {
543
+ return { ready: true, detail: `capability verified empirically at ${artifact}`, bind: { resolvedArtifact: artifact } };
544
+ }
545
+ return { ready: false, detail: "capability: empirical verification failed at gate boundary" };
546
+ };
547
+ }
548
+
338
549
  /** Resolve the credential a probe declares, from the typed env-contract only. Returns undefined
339
550
  * when no `credentialEnv` is declared or the key is unset — never a value from the descriptor. */
340
551
  function credentialFor(probe: ReadinessProbe, env: Record<string, string | undefined>): string | undefined {
package/app/stage.test.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import { test } from "node:test";
6
6
  import { assert, assertEquals } from "#test-assert";
7
7
  import { FEATURE_RUN_STATUSES } from "./feature.ts";
8
- import { deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
8
+ import { deriveEscalationOpen, deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
9
9
 
10
10
  const base = (over: Partial<StageInput> & { status: string }): StageInput => ({
11
11
  pr_key: null,
@@ -105,3 +105,47 @@ test("deriveListBucket: history iff terminal AND acknowledged, else active", ()
105
105
  assertEquals(deriveListBucket("running", "2024-01-01T00:00:00Z"), "active");
106
106
  assertEquals(deriveListBucket("blocked", "2024-01-01T00:00:00Z"), "history");
107
107
  });
108
+
109
+ // deriveEscalationOpen (issue #272): the single fail-closed "open escalation" display signal. TRUE iff
110
+ // all three independently-written escalation columns AGREE the run is parked at an answerable
111
+ // escalation; any single missing/torn field yields FALSE so the pages render not-escalated.
112
+ test("deriveEscalationOpen: true only when status, pointer AND question all present", () => {
113
+ assertEquals(
114
+ deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: "which base?" }),
115
+ true,
116
+ );
117
+ });
118
+
119
+ test("deriveEscalationOpen: torn tuple (pointer set, question blank) renders as NOT escalated", () => {
120
+ // The mirror tear observed on nwf#270: status=escalated + live pointer + blank question.
121
+ assertEquals(
122
+ deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: null }),
123
+ false,
124
+ );
125
+ assertEquals(
126
+ deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: "" }),
127
+ false,
128
+ );
129
+ });
130
+
131
+ test("deriveEscalationOpen: torn tuple (question set, pointer null) renders as NOT escalated", () => {
132
+ // The entry-window tear: record-feature-escalation persisted the question but the poller has not yet
133
+ // denormalised the pointer.
134
+ assertEquals(
135
+ deriveEscalationOpen({ status: "escalated", escalation_user_task_key: null, escalation_question: "which base?" }),
136
+ false,
137
+ );
138
+ });
139
+
140
+ test("deriveEscalationOpen: a resumed run whose status lags behind a cleared tuple is NOT escalated", () => {
141
+ // status still 'escalated' but the answer op already cleared pointer + question → fail closed.
142
+ assertEquals(
143
+ deriveEscalationOpen({ status: "escalated", escalation_user_task_key: null, escalation_question: null }),
144
+ false,
145
+ );
146
+ // A non-escalated status can never be open regardless of stray column values.
147
+ assertEquals(
148
+ deriveEscalationOpen({ status: "running", escalation_user_task_key: "ut-7", escalation_question: "which base?" }),
149
+ false,
150
+ );
151
+ });
package/app/stage.ts CHANGED
@@ -103,6 +103,35 @@ export function deriveStage(run: StageInput): DerivedStage {
103
103
  return { stage, state, skipped: skippedKeys.join(" "), attention };
104
104
  }
105
105
 
106
+ /** Derive the single fail-closed "open escalation" display signal for one feature run (issue #272).
107
+ *
108
+ * The open-escalation condition is jointly encoded by THREE independently-written columns —
109
+ * `status='escalated'`, `escalation_user_task_key` (the completable pointer), and `escalation_question`
110
+ * — owned by different writers on different schedules (the `record-feature-escalation` service task
111
+ * sets the question; `pollFeatureEscalations`/`deriveFeatureEscalationPatch` sets status + pointer; the
112
+ * answer operation clears the tuple). Because they are not written as one atomic tuple, a reader can
113
+ * observe a TORN interim state (e.g. `status=escalated` + pointer set + `question=null`) and render a
114
+ * self-contradictory escalation — an "answer me" affordance with nothing to answer.
115
+ *
116
+ * Collapse that class at the consumer: the pages gate the escalation affordances (Abandon / answer
117
+ * form) on this ONE derived conjunction rather than on any single column, so a torn tuple renders as
118
+ * NOT escalated (fail closed) instead of escalated-but-blank. `true` iff ALL THREE fields agree the run
119
+ * is parked at an answerable escalation; any missing field yields `false`. Maintained as a write-time
120
+ * projection by the feature_runs gateway (like `stage`/`list_bucket`), so it stays fresh on every write
121
+ * — including the answer operation's eager tuple-clear, which makes the affordance disappear WITHOUT
122
+ * waiting a poll pass. Pure and read-only. */
123
+ export function deriveEscalationOpen(run: {
124
+ status: string;
125
+ escalation_question?: string | null;
126
+ escalation_user_task_key?: string | null;
127
+ }): boolean {
128
+ return (
129
+ run.status === "escalated" &&
130
+ (run.escalation_user_task_key ?? "") !== "" &&
131
+ (run.escalation_question ?? "") !== ""
132
+ );
133
+ }
134
+
106
135
  /** The Active/History partition label (§5), maintained at write time so the flat-DSL page tabs filter
107
136
  * on a stored `list_bucket` column with only `in` clauses. `history` iff the row is in a truly-terminal
108
137
  * status AND acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). */
@@ -0,0 +1,33 @@
1
+ -- 040_feature_escalation_open.sql — issue #272: collapse the torn open-escalation projection.
2
+ --
3
+ -- A feature run's "open escalation" condition is jointly encoded by THREE independently-written
4
+ -- columns — `status='escalated'`, `escalation_user_task_key` (the completable pointer), and
5
+ -- `escalation_question` — owned by DIFFERENT writers on DIFFERENT schedules:
6
+ -- • `record-feature-escalation` (service task) persists `escalation_question` (pointer still NULL).
7
+ -- • `pollFeatureEscalations` / `deriveFeatureEscalationPatch` (async poller) sets `status='escalated'`
8
+ -- + denormalises `escalation_user_task_key` on the next pass, and clears the tuple when the task
9
+ -- is gone — but only on the next pass.
10
+ -- • the answer/complete operation (`answerFeatureEscalation`) clears the pointer + question eagerly.
11
+ -- Because they are never written as one atomic tuple, a reader can observe a TORN interim state (e.g.
12
+ -- `status=escalated` + pointer set + `question=null`) and the page renders a self-contradictory
13
+ -- escalation — an "answer me" affordance (Abandon action, answer form) for a run with nothing to
14
+ -- answer (observed on nwf#270).
15
+ --
16
+ -- Fix: derive the display-state, don't denormalise it, and FAIL CLOSED. `escalation_open` is a single
17
+ -- write-time-projected signal — `1` iff ALL THREE columns agree the run is parked at an answerable
18
+ -- escalation (`status='escalated'` AND `escalation_user_task_key` non-NULL AND `escalation_question`
19
+ -- non-NULL), else `0`. The pages gate the escalation affordances on THIS conjunction instead of on
20
+ -- `escalation_user_task_key` alone, so a torn tuple renders as NOT escalated rather than
21
+ -- escalated-but-blank. It mirrors the existing `stage` / `list_bucket` display projections: maintained
22
+ -- by the feature_runs gateway (app/feature.ts) from the pure `deriveEscalationOpen` helper (app/stage.ts)
23
+ -- on every write — never hand-derived in SQL, the page, or a poller. Because the gateway reprojects on
24
+ -- the answer operation's eager tuple-clear (which touches projection inputs), the affordance disappears
25
+ -- WITHOUT waiting a poll pass.
26
+ --
27
+ -- Forward-only, additive (expand): nullable with no default, so pre-#272 rows grandfather in as NULL
28
+ -- and never gate control flow. `backfillFeatureStages` (app/feature.ts) stamps rows whose
29
+ -- `escalation_open` is still NULL once at boot — including a run parked at a LIVE escalation when this
30
+ -- lands, which the poller would otherwise never re-write while it stays parked — and the gateway keeps
31
+ -- every future write fresh. Numbered after the current highest prefix on origin/main (039); the runner
32
+ -- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
33
+ ALTER TABLE feature_runs ADD COLUMN escalation_open INTEGER;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
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",
@@ -96,7 +96,7 @@
96
96
  {
97
97
  "label": "Abandon",
98
98
  "confirm": "Abandon this escalated task? The run gives up on it (no PR).",
99
- "showWhenField": "escalation_user_task_key",
99
+ "showWhenField": "escalation_open",
100
100
  "action": {
101
101
  "path": "/app/api/actions/answer-escalation",
102
102
  "body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
@@ -129,7 +129,7 @@
129
129
  { "field": "delivery_label", "label": "Delivery" }
130
130
  ],
131
131
  "form": {
132
- "showWhenField": "escalation_user_task_key",
132
+ "showWhenField": "escalation_open",
133
133
  "title": "Answer escalation",
134
134
  "promptField": "escalation_question",
135
135
  "inputKey": "answer",
@@ -123,7 +123,7 @@
123
123
  {
124
124
  "label": "Abandon",
125
125
  "confirm": "Abandon this escalated task? The run gives up on it (no PR).",
126
- "showWhenField": "escalation_user_task_key",
126
+ "showWhenField": "escalation_open",
127
127
  "action": {
128
128
  "path": "/app/api/actions/answer-escalation",
129
129
  "body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
@@ -145,7 +145,7 @@
145
145
  { "field": "outcome", "label": "Outcome" }
146
146
  ],
147
147
  "form": {
148
- "showWhenField": "escalation_user_task_key",
148
+ "showWhenField": "escalation_open",
149
149
  "title": "Answer escalation",
150
150
  "promptField": "escalation_question",
151
151
  "inputKey": "answer",
@@ -19,6 +19,9 @@
19
19
  <nano:extend name="version" type="string" optional="true" />
20
20
  <nano:extend name="conclusion" type="string" optional="true" />
21
21
  <nano:extend name="checkName" type="string" optional="true" />
22
+ <nano:extend name="capabilityRef" type="string" optional="true" />
23
+ <nano:extend name="package" type="string" optional="true" />
24
+ <nano:extend name="verifyCommand" type="string" optional="true" />
22
25
  </nano:shape>
23
26
  <nano:shape id="ReadinessProbePoll" name="Readiness probe — poll policy">
24
27
  <nano:extend name="everyMs" type="integer" optional="true" />
@@ -42,10 +45,12 @@
42
45
  <nano:shape id="ReadinessProbeOut" name="Readiness probe — result">
43
46
  <nano:extend name="ready" type="boolean" />
44
47
  <nano:extend name="detail" type="string" optional="true" />
48
+ <nano:extend name="resolvedArtifact" type="string" optional="true" />
45
49
  </nano:shape>
46
50
  <nano:shape id="ReadinessReady" name="readiness-ready message payload">
47
51
  <nano:extend name="ready" type="boolean" />
48
52
  <nano:extend name="detail" type="string" optional="true" />
53
+ <nano:extend name="resolvedArtifact" type="string" optional="true" />
49
54
  </nano:shape>
50
55
  </nano:shapes>
51
56
  </bpmn:extensionElements>
@@ -9,7 +9,7 @@ import { test } from "node:test";
9
9
  import { assert, assertEquals, assertRejects } from "#test-assert";
10
10
  import type { CommandResult, HttpResponse, ProbeExec, ReadinessProbe } from "../../app/readiness.ts";
11
11
  import { parseProbe } from "../../app/readiness.ts";
12
- import handler, { pollUntilReady, READINESS_READY_MESSAGE, readGateVars } from "./worker.ts";
12
+ import handler, { pollUntilReady, READINESS_READY_MESSAGE, readGateVars, safeBind } from "./worker.ts";
13
13
 
14
14
  // A virtual clock: `now()` advances only when the loop's `wait(ms)` is called, so a never-ready
15
15
  // probe races to its deadline in zero real time (no setTimeout) and the test can never hang.
@@ -40,6 +40,40 @@ function execReturning(seq: Array<HttpResponse>): ProbeExec {
40
40
  const httpProbe = (poll: ReadinessProbe["poll"]): ReadinessProbe =>
41
41
  parseProbe({ kind: "http", target: "https://x/health", poll });
42
42
 
43
+ test("safeBind: strips reserved keys (ready/detail) so a bind can only ADD outputs, never shadow the payload", () => {
44
+ const cleaned = safeBind({ resolvedArtifact: "@nanobpm/urban@0.54.0", ready: "false", detail: "spoofed" });
45
+ assertEquals(cleaned.resolvedArtifact, "@nanobpm/urban@0.54.0");
46
+ assertEquals("ready" in cleaned, false, "a bound 'ready' can never override the canonical payload");
47
+ assertEquals("detail" in cleaned, false, "a bound 'detail' can never override the canonical payload");
48
+ assertEquals(Object.keys(safeBind(undefined)).length, 0, "an absent bind yields an empty object");
49
+ });
50
+
51
+ test("pollUntilReady: a fallback that throws is caught, logged by class name (no leak), and stays not-ready", async () => {
52
+ const clock = fakeClock();
53
+ const seen: string[] = [];
54
+ let publishes = 0;
55
+ const res = await pollUntilReady({
56
+ probe: httpProbe({ everyMs: 5, timeoutMs: 30, backoff: "fixed" }),
57
+ gateKey: "gate-fallback-throws",
58
+ exec: execReturning([{ status: 503, body: "" }]),
59
+ env: {},
60
+ now: clock.now,
61
+ wait: clock.wait,
62
+ publish: async () => {
63
+ publishes += 1;
64
+ },
65
+ fallback: async () => {
66
+ throw new Error("boom at https://h/p?token=s3cr3t");
67
+ },
68
+ log: (msg) => seen.push(msg),
69
+ });
70
+ assert(!res.ready, "a throwing fallback keeps the not-ready outcome for the engine timer");
71
+ assertEquals(publishes, 0, "nothing is published when the fallback throws");
72
+ const all = seen.join("\n");
73
+ assert(all.includes("fallback error: Error"), "the fallback error is logged by class name");
74
+ assert(!all.includes("s3cr3t"), "the raw error message (with its secret) must not leak");
75
+ });
76
+
43
77
  test("pollUntilReady: publishes readiness-ready once and returns ready when a probe goes green", async () => {
44
78
  const clock = fakeClock();
45
79
  const published: Array<{ detail: string }> = [];
@@ -59,6 +93,90 @@ test("pollUntilReady: publishes readiness-ready once and returns ready when a pr
59
93
  assertEquals(published.length, 1, "exactly one readiness message was published");
60
94
  });
61
95
 
96
+ test("pollUntilReady: forwards a matcher's bind through publish into the message variables (#274 Gap B)", async () => {
97
+ // A capability probe resolves a version; its bind must flow through publish so the gate can surface
98
+ // resolvedArtifact as an output. The gh-api stub returns a release whose provenance carries #274.
99
+ const clock = fakeClock();
100
+ const published: Array<{ detail: string; bind?: Record<string, string> }> = [];
101
+ const payload = JSON.stringify([{ tag_name: "@nanobpm/urban@0.54.0", body: "## Provenance\n- #274\n" }]);
102
+ const exec: ProbeExec = {
103
+ async httpGet() {
104
+ return { status: 0, body: "" };
105
+ },
106
+ async run(): Promise<CommandResult> {
107
+ return { code: 0, stdout: payload, stderr: "" };
108
+ },
109
+ };
110
+ const res = await pollUntilReady({
111
+ probe: parseProbe({
112
+ kind: "capability",
113
+ target: "github-releases:nanobpm/nano-ide",
114
+ match: { capabilityRef: "nano-ide#274", package: "@nanobpm/urban" },
115
+ poll: { everyMs: 5, timeoutMs: 5000, backoff: "fixed" },
116
+ }),
117
+ gateKey: "gate-cap",
118
+ exec,
119
+ env: {},
120
+ now: clock.now,
121
+ wait: clock.wait,
122
+ publish: async (detail, bind) => {
123
+ published.push({ detail, bind });
124
+ },
125
+ });
126
+ assert(res.ready, "the capability edge resolved");
127
+ assertEquals(published.length, 1, "exactly one readiness message was published");
128
+ assertEquals(published[0]?.bind?.resolvedArtifact, "@nanobpm/urban@0.54.0", "the resolved artifact flowed through the bind");
129
+ });
130
+
131
+ test("pollUntilReady: the gated fallback fires ONCE at budget exhaustion and can still resolve+publish", async () => {
132
+ // Deterministic provenance never resolves (no matching release), so the loop exhausts its budget —
133
+ // the gate boundary. The fallback thunk then verifies empirically and publishes a bound version.
134
+ const clock = fakeClock();
135
+ const published: Array<{ bind?: Record<string, string> }> = [];
136
+ let fallbackCalls = 0;
137
+ const res = await pollUntilReady({
138
+ probe: httpProbe({ everyMs: 5, timeoutMs: 30, backoff: "fixed" }),
139
+ gateKey: "gate-fallback",
140
+ exec: execReturning([{ status: 503, body: "" }]),
141
+ env: {},
142
+ now: clock.now,
143
+ wait: clock.wait,
144
+ publish: async (_detail, bind) => {
145
+ published.push({ bind });
146
+ },
147
+ fallback: async () => {
148
+ fallbackCalls += 1;
149
+ return { ready: true, detail: "verified empirically", bind: { resolvedArtifact: "@nanobpm/urban@0.60.0" } };
150
+ },
151
+ });
152
+ assert(res.ready, "the boundary fallback resolved the edge");
153
+ assertEquals(fallbackCalls, 1, "the fallback fires exactly once, at the boundary — never per attempt");
154
+ assertEquals(published[0]?.bind?.resolvedArtifact, "@nanobpm/urban@0.60.0");
155
+ });
156
+
157
+ test("pollUntilReady: a fallback that does not resolve leaves the not-ready outcome for the engine timer", async () => {
158
+ const clock = fakeClock();
159
+ let publishes = 0;
160
+ const res = await pollUntilReady({
161
+ probe: httpProbe({ everyMs: 5, timeoutMs: 30, backoff: "fixed" }),
162
+ gateKey: "gate-fallback-noop",
163
+ exec: execReturning([{ status: 503, body: "" }]),
164
+ env: {},
165
+ now: clock.now,
166
+ wait: clock.wait,
167
+ publish: async () => {
168
+ publishes += 1;
169
+ },
170
+ fallback: async () => ({ ready: false, detail: "still nothing" }),
171
+ });
172
+ assert(!res.ready, "an inconclusive fallback keeps the wait bounded by the engine timer");
173
+ assertEquals(publishes, 0, "no readiness signal is published when the fallback does not resolve");
174
+ assert(
175
+ res.detail.includes("still nothing"),
176
+ "the inconclusive fallback's (redacted) diagnostic is surfaced in the returned detail, not discarded",
177
+ );
178
+ });
179
+
62
180
  test("pollUntilReady: a never-green probe exhausts its budget and returns not-ready WITHOUT publishing", async () => {
63
181
  const clock = fakeClock();
64
182
  let publishes = 0;
@@ -79,6 +197,34 @@ test("pollUntilReady: a never-green probe exhausts its budget and returns not-re
79
197
  assert(clock.now() <= 100, "the loop stopped at (or before) its declared budget");
80
198
  });
81
199
 
200
+ test("pollUntilReady: keeps probing up to the deadline — a flip-to-ready in the final backoff window is caught, not missed", async () => {
201
+ // everyMs 10, budget 25: three deterministic probes land at t=0,10,20. A full-backoff sleep from
202
+ // t=20 would jump to t=30 (past the 25ms bound) and stop probing early, missing a green at t=25 and
203
+ // forcing a spurious timeout escalation. The clamp keeps probing to the same bound the engine holds.
204
+ const clock = fakeClock();
205
+ let publishes = 0;
206
+ const exec = execReturning([
207
+ { status: 503, body: "" },
208
+ { status: 503, body: "" },
209
+ { status: 503, body: "" },
210
+ { status: 200, body: "ok" },
211
+ ]);
212
+ const res = await pollUntilReady({
213
+ probe: httpProbe({ everyMs: 10, timeoutMs: 25, backoff: "fixed" }),
214
+ gateKey: "gate-final-window",
215
+ exec,
216
+ env: {},
217
+ now: clock.now,
218
+ wait: clock.wait,
219
+ publish: async () => {
220
+ publishes += 1;
221
+ },
222
+ });
223
+ assert(res.ready, "the flip-to-ready inside the final backoff window was probed and caught");
224
+ assertEquals(publishes, 1, "the readiness signal was published exactly once");
225
+ assert(clock.now() <= 25, "the worker never probed past the engine-enforced deadline");
226
+ });
227
+
82
228
  test("pollUntilReady: an I/O throw is caught and treated as not-ready (never rejects), and its raw message is not leaked", async () => {
83
229
  const clock = fakeClock();
84
230
  const seen: string[] = [];
@@ -16,6 +16,7 @@ import { readEnvOr } from "../../app/contracts.ts";
16
16
  import {
17
17
  DEFAULT_EVERY_MS,
18
18
  defaultProbeExec,
19
+ makeCapabilityFallback,
19
20
  nextDelay,
20
21
  normalizePoll,
21
22
  type ProbeExec,
@@ -40,6 +41,22 @@ export const READINESS_READY_MESSAGE = "readiness-ready";
40
41
 
41
42
  const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
42
43
 
44
+ /** The canonical gate-payload keys the matcher's `bind` must never override. `bind` is the
45
+ * kind-agnostic emit primitive (#274 Gap B), but it flows from matcher output into both the
46
+ * `readiness-ready` message variables and the worker output — so a matcher that (accidentally or
47
+ * maliciously) binds `ready`/`detail` could shadow the canonical payload and break the gate
48
+ * contract. Strip them before spreading so a matcher can only ADD outputs, never overwrite the
49
+ * shape the gate correlates on. */
50
+ const RESERVED_BIND_KEYS: ReadonlySet<string> = new Set(["ready", "detail"]);
51
+ export function safeBind(bind?: Record<string, string>): Record<string, string> {
52
+ if (!bind) return {};
53
+ const out: Record<string, string> = {};
54
+ for (const [k, v] of Object.entries(bind)) {
55
+ if (!RESERVED_BIND_KEYS.has(k)) out[k] = v;
56
+ }
57
+ return out;
58
+ }
59
+
43
60
  /** The effective poll cadence: the descriptor's values, with `everyMs` defaulting through the env
44
61
  * contract (`NANO_READINESS_POLL_EVERY_MS`) when the descriptor omits it, then the built-in
45
62
  * defaults/clamps in {@link normalizePoll}. Reads the env value from the injected `env` (not the
@@ -64,7 +81,12 @@ export async function pollUntilReady(deps: {
64
81
  env: Record<string, string | undefined>;
65
82
  now: () => number;
66
83
  wait: (ms: number) => Promise<void>;
67
- publish: (detail: string) => Promise<void>;
84
+ publish: (detail: string, bind?: Record<string, string>) => Promise<void>;
85
+ /** An OPTIONAL last-attempt thunk run ONCE at the gate boundary (local budget exhausted) before
86
+ * giving up — the seam for the gated empirical fallback (decision 5). A ready result is published
87
+ * (with its bind) and returned; anything else keeps the not-ready outcome so the engine timer
88
+ * bounds the wait as usual. Kept generic so the loop stays kind-agnostic. */
89
+ fallback?: () => Promise<ProbeResult | null>;
68
90
  log?: (msg: string) => void;
69
91
  }): Promise<ProbeResult> {
70
92
  const poll = effectivePoll(deps.probe.poll, deps.env);
@@ -84,15 +106,46 @@ export async function pollUntilReady(deps: {
84
106
  }));
85
107
  deps.log?.(`readiness probe ${label} attempt ${attempt + 1}: ${res.detail}`);
86
108
  if (res.ready) {
87
- await deps.publish(res.detail);
109
+ await deps.publish(res.detail, res.bind);
88
110
  return res;
89
111
  }
90
112
  attempt += 1;
91
- const wait = nextDelay(attempt, poll);
92
- if (deps.now() + wait >= deadline) {
93
- return { ready: false, detail: "probe budget exhausted; engine timer bounds the wait" };
113
+ const remaining = deadline - deps.now();
114
+ if (remaining <= 0) {
115
+ // The gate boundary: the deterministic poll is exhausted. Give the gated fallback (if any) ONE
116
+ // empirical attempt before conceding to the engine timer — a capability provenance under-reports
117
+ // can still resolve here, exactly once, never per unrelated release.
118
+ const settled = deps.fallback
119
+ ? await deps.fallback().catch((err) => {
120
+ // Never swallow a fallback failure silently — it degrades to "not ready" and is hard
121
+ // to diagnose. Log only the error class name (no message), consistent with the main
122
+ // probeOnce error handling, so a target URL/token in the message never leaks.
123
+ deps.log?.(
124
+ `readiness probe ${label} fallback error: ${err instanceof Error ? err.name : "Error"}`,
125
+ );
126
+ return null;
127
+ })
128
+ : null;
129
+ if (settled?.ready) {
130
+ deps.log?.(`readiness probe ${label} fallback: ${settled.detail}`);
131
+ await deps.publish(settled.detail, settled.bind);
132
+ return settled;
133
+ }
134
+ // Surface the fallback's (already-redacted) diagnostic when one ran and reported not-ready, so a
135
+ // timeout escalation is actionable instead of a generic "budget exhausted". `settled` is null when
136
+ // there is no fallback or it threw (logged above), in which case only the generic detail applies.
137
+ return {
138
+ ready: false,
139
+ detail: settled
140
+ ? `probe budget exhausted; engine timer bounds the wait (fallback: ${settled.detail})`
141
+ : "probe budget exhausted; engine timer bounds the wait",
142
+ };
94
143
  }
95
- await deps.wait(wait);
144
+ // Clamp the sleep to the time left until `deadline` so the worker keeps probing right up to the
145
+ // SAME bound the engine timer enforces. Sleeping a full `nextDelay` unconditionally would stop
146
+ // probing up to one backoff early — a window where readiness could flip to ready but no
147
+ // `readiness-ready` message is published, forcing a spurious timeout escalation.
148
+ await deps.wait(Math.min(nextDelay(attempt, poll), remaining));
96
149
  }
97
150
  }
98
151
 
@@ -123,24 +176,31 @@ export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }
123
176
  const handler: AppJobHandler<In, Out> = async (job, app) => {
124
177
  const probe = parseProbe(job.variables.probe);
125
178
  const { gateKey, probeTimeout } = readGateVars(job.variables);
179
+ const exec = defaultProbeExec();
126
180
  const result = await pollUntilReady({
127
181
  probe,
128
182
  gateKey,
129
183
  probeTimeout,
130
- exec: defaultProbeExec(),
184
+ exec,
131
185
  env: process.env,
132
186
  now: () => Date.now(),
133
187
  wait: sleep,
134
- publish: async (detail) => {
188
+ // The gated empirical fallback (decision 5) a no-op for every kind but a `capability` probe
189
+ // that declares a `verifyCommand`, so the deterministic provenance lookup stays the default.
190
+ fallback: makeCapabilityFallback(probe, exec, process.env),
191
+ publish: async (detail, bind) => {
135
192
  await app.engine.publishMessage({
136
193
  name: READINESS_READY_MESSAGE,
137
194
  correlationKey: gateKey,
138
- variables: { ready: true, detail },
195
+ // `bind` is the kind-agnostic emit primitive (#274 Gap B): forward whatever the matcher
196
+ // discovered (e.g. `resolvedArtifact`) into the message so the gate surfaces it as output.
197
+ // Reserved keys are stripped so a bind can only ADD outputs, never shadow `ready`/`detail`.
198
+ variables: { ready: true, detail, ...safeBind(bind) },
139
199
  });
140
200
  },
141
201
  log: (msg) => app.log.info(msg),
142
202
  });
143
- return { ready: result.ready, detail: result.detail };
203
+ return { ready: result.ready, detail: result.detail, ...safeBind(result.bind) };
144
204
  };
145
205
 
146
206
  export default handler;