@nanobpm/nano-workforce 0.82.0 → 0.83.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.
@@ -7,7 +7,7 @@
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
- import { parsePr, pollIncidentsImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
10
+ import { parsePr, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
11
11
 
12
12
  function memTable(rows: any[], key: string) {
13
13
  return {
@@ -502,3 +502,190 @@ test("parsePr still resolves a well-formed prKey and PR URL", () => {
502
502
  assertEquals(parsePr(" owner/repo#42 ")?.number, 42);
503
503
  assertEquals(parsePr("https://github.com/owner/repo/pull/7")?.repo, "owner/repo");
504
504
  });
505
+
506
+ // Red/green regression for the level-triggered wave-merge barrier (issue #262). The barrier is
507
+ // armed (`plans.gate_wave = W`) at wave handoff, long BEFORE the token traverses the slow
508
+ // `trial-merge` agent job and finally opens the `wait-wave-merged` subscription. The old
509
+ // `pollWaveGates` was edge-triggered: the first pass that saw wave W's PRs merged cleared
510
+ // `gate_wave` and published `wave-merged` EXACTLY ONCE. If that happened while the token was still
511
+ // upstream (no open subscription), the message was dropped and — with `gate_wave` now null — never
512
+ // republished, so the epic wedged forever once the token arrived. The fix reconciles the merged
513
+ // state against the engine's OPEN-subscription state every pass, publishing only into an open
514
+ // subscription and never clearing `gate_wave` optimistically.
515
+ //
516
+ // Stubs `/message-subscriptions/search` (keyed by processInstanceKey) so the subscription can be
517
+ // toggled open between passes, and forces the GitHub transport off — the wave's PRs are tracked
518
+ // `merged` rows, so `isDepMerged` resolves them from the DB with no network.
519
+ function subscriptionFetch(open: Set<string>) {
520
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
521
+ const u = typeof url === "string" ? url : url.toString();
522
+ if (!u.endsWith("/message-subscriptions/search")) {
523
+ throw new Error(`unexpected fetch: ${u}`);
524
+ }
525
+ const body = JSON.parse(String(init?.body ?? "{}")) as {
526
+ filter?: { processInstanceKey?: string };
527
+ };
528
+ const pik = body.filter?.processInstanceKey ?? "";
529
+ const items = open.has(pik)
530
+ ? [{ messageName: "wave-merged", correlationKey: "owner/repo#67", messageSubscriptionState: "CREATED" }]
531
+ : [];
532
+ return Promise.resolve(
533
+ new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
534
+ );
535
+ };
536
+ }
537
+
538
+ test("pollWaveGatesImpl is level-triggered: PRs merged before the token arrives never lose the wave-merged signal (#262)", async () => {
539
+ await withGithubOff(async () => {
540
+ const PLAN_KEY = "owner/repo#67";
541
+ const PI = "PI-13794";
542
+ // Wave 1 opened two PRs; both are already MERGED (tracked rows → isDepMerged resolves from DB).
543
+ const plan = {
544
+ plan_key: PLAN_KEY,
545
+ process_key: PI,
546
+ gate_wave: 1 as number | null,
547
+ updated_at: "t0",
548
+ };
549
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
550
+ plans: { rows: [plan], key: "plan_key" },
551
+ plan_tasks: {
552
+ rows: [
553
+ { id: "owner/repo#67:a", plan_key: PLAN_KEY, wave: 1, status: "opened", pr_key: "owner/repo#68" },
554
+ { id: "owner/repo#67:b", plan_key: PLAN_KEY, wave: 1, status: "opened", pr_key: "owner/repo#69" },
555
+ ],
556
+ key: "id",
557
+ },
558
+ pull_requests: {
559
+ rows: [
560
+ { pr_key: "owner/repo#68", status: "merged" },
561
+ { pr_key: "owner/repo#69", status: "merged" },
562
+ ],
563
+ key: "pr_key",
564
+ },
565
+ };
566
+ const data = {
567
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
568
+ } as any;
569
+
570
+ const published: { name: string; correlationKey?: string }[] = [];
571
+ const engine = {
572
+ publishMessage: (input: { name: string; correlationKey?: string }) => {
573
+ published.push(input);
574
+ return Promise.resolve();
575
+ },
576
+ } as any;
577
+ const headers = { "content-type": "application/json" };
578
+
579
+ const openSubs = new Set<string>(); // token still upstream of wait-wave-merged → NO open subscription
580
+ const prevFetch = globalThis.fetch;
581
+
582
+ // Pass 1 — the losing ordering: wave 1's PRs are all merged, but the token is parked upstream on
583
+ // the slow `trial-merge` job, so there is no open `wait-wave-merged` subscription yet. The old
584
+ // single-shot barrier would publish-into-the-void and CLEAR `gate_wave`, stranding the epic.
585
+ globalThis.fetch = subscriptionFetch(openSubs) as typeof fetch;
586
+ try {
587
+ await pollWaveGatesImpl(data, engine, "", "http://engine/v2", headers);
588
+ } finally {
589
+ globalThis.fetch = prevFetch;
590
+ }
591
+ // The signal must NOT have been fired into the void, and the gate must remain armed (not stranded).
592
+ assertEquals(published.length, 0, "must not publish wave-merged with no open subscription");
593
+ assertEquals(plan.gate_wave, 1, "gate_wave must stay armed until the barrier is actually released");
594
+
595
+ // Pass 2 — the token has now advanced to `wait-wave-merged`, opening the subscription. The
596
+ // level-triggered barrier re-publishes and correlates, releasing the token into wave 2.
597
+ openSubs.add(PI);
598
+ globalThis.fetch = subscriptionFetch(openSubs) as typeof fetch;
599
+ try {
600
+ await pollWaveGatesImpl(data, engine, "", "http://engine/v2", headers);
601
+ } finally {
602
+ globalThis.fetch = prevFetch;
603
+ }
604
+ assertEquals(published.length, 1, "must publish wave-merged once the subscription is open");
605
+ assertEquals(published[0]?.name, "wave-merged");
606
+ assertEquals(published[0]?.correlationKey, PLAN_KEY);
607
+ });
608
+ });
609
+
610
+ // Guards the false-positive failure class flagged in review: `waveMergedSubscriptionOpen` must treat
611
+ // a search item with a missing/null/mismatched `messageName`, `correlationKey`, or
612
+ // `messageSubscriptionState` as NOT-open. Defaulting an unverifiable field to its expected value
613
+ // would publish `wave-merged` into a subscription we never confirmed open — buffering a message that
614
+ // trips a LATER wave's barrier, i.e. re-introducing the exact #262 wedge this change prevents. A
615
+ // false negative only costs a retry next pass; a false positive is a wedge, so unknown ⇒ don't match.
616
+ function ambiguousSubscriptionFetch(items: unknown[]) {
617
+ return (url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
618
+ const u = typeof url === "string" ? url : url.toString();
619
+ if (!u.endsWith("/message-subscriptions/search")) {
620
+ throw new Error(`unexpected fetch: ${u}`);
621
+ }
622
+ return Promise.resolve(
623
+ new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
624
+ );
625
+ };
626
+ }
627
+
628
+ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscription item (missing/null/mismatched fields)", async () => {
629
+ await withGithubOff(async () => {
630
+ const PLAN_KEY = "owner/repo#67";
631
+ const PI = "PI-13794";
632
+ const plan = {
633
+ plan_key: PLAN_KEY,
634
+ process_key: PI,
635
+ gate_wave: 1 as number | null,
636
+ updated_at: "t0",
637
+ };
638
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
639
+ plans: { rows: [plan], key: "plan_key" },
640
+ plan_tasks: {
641
+ rows: [
642
+ { id: "owner/repo#67:a", plan_key: PLAN_KEY, wave: 1, status: "opened", pr_key: "owner/repo#68" },
643
+ { id: "owner/repo#67:b", plan_key: PLAN_KEY, wave: 1, status: "opened", pr_key: "owner/repo#69" },
644
+ ],
645
+ key: "id",
646
+ },
647
+ pull_requests: {
648
+ rows: [
649
+ { pr_key: "owner/repo#68", status: "merged" },
650
+ { pr_key: "owner/repo#69", status: "merged" },
651
+ ],
652
+ key: "pr_key",
653
+ },
654
+ };
655
+ const data = {
656
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
657
+ } as any;
658
+
659
+ const published: { name: string; correlationKey?: string }[] = [];
660
+ const engine = {
661
+ publishMessage: (input: { name: string; correlationKey?: string }) => {
662
+ published.push(input);
663
+ return Promise.resolve();
664
+ },
665
+ } as any;
666
+ const headers = { "content-type": "application/json" };
667
+ const prevFetch = globalThis.fetch;
668
+
669
+ // Each of these items is ambiguous — it omits or mismatches a field the barrier requires. None
670
+ // may be treated as an OPEN subscription for THIS plan, so none may release wave 1's gate.
671
+ const ambiguousItems: unknown[][] = [
672
+ [{}], // empty item — no fields at all
673
+ [{ messageName: "wave-merged", correlationKey: PLAN_KEY }], // missing state
674
+ [{ messageName: "wave-merged", correlationKey: PLAN_KEY, messageSubscriptionState: null }], // null state
675
+ [{ correlationKey: PLAN_KEY, messageSubscriptionState: "CREATED" }], // missing messageName
676
+ [{ messageName: "some-other-message", correlationKey: PLAN_KEY, messageSubscriptionState: "CREATED" }],
677
+ [{ messageName: "wave-merged", messageSubscriptionState: "CREATED" }], // missing correlationKey
678
+ [{ messageName: "wave-merged", correlationKey: "owner/repo#999", messageSubscriptionState: "CREATED" }],
679
+ ];
680
+ for (const items of ambiguousItems) {
681
+ globalThis.fetch = ambiguousSubscriptionFetch(items) as typeof fetch;
682
+ try {
683
+ await pollWaveGatesImpl(data, engine, "", "http://engine/v2", headers);
684
+ } finally {
685
+ globalThis.fetch = prevFetch;
686
+ }
687
+ }
688
+ assertEquals(published.length, 0, "must not publish wave-merged on an unverifiable subscription item");
689
+ assertEquals(plan.gate_wave, 1, "gate_wave must stay armed while no OPEN subscription is confirmed");
690
+ });
691
+ });
package/app/service.ts CHANGED
@@ -12,7 +12,7 @@ import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
12
  import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
13
13
  import { agentSlaTimeout } from "./agentSla.ts";
14
14
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
15
- import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
15
+ import { backfillFeatureStages, deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
16
16
  import {
17
17
  classifyMergeability,
18
18
  coalesceTitle,
@@ -1141,17 +1141,104 @@ export async function pollIncidentsImpl(
1141
1141
  }
1142
1142
  }
1143
1143
 
1144
- /** Wave-merge barrier poll pass. After `record-wave` hands off a wave that has a successor, the
1145
- * plan-fanout instance parks at the `wait-wave-merged` catch event and `plans.gate_wave` records
1146
- * that wave's index. Here we check whether every OPENED PR in that wave has MERGED and, if so,
1147
- * publish `wave-merged` (correlated on the plan key) to release the next wave's implementation.
1144
+ /** The `wave-merged` message name (`resources/processes/plan-fanout.bpmn`): the `wait-wave-merged`
1145
+ * catch event opens a subscription for it (correlated on `=planKey`) once the token arrives, and
1146
+ * the poller publishes it to release the next wave. Single source of truth for the string shared by
1147
+ * the publish and the subscription probe. */
1148
+ const WAVE_MERGED_MESSAGE = "wave-merged";
1149
+
1150
+ /** The subset of a Camunda-8 `/v2/message-subscriptions/search` result item this app reads to tell
1151
+ * whether the plan-fanout instance is *currently parked* at `wait-wave-merged`. `messageName` is the
1152
+ * awaited message; `correlationKey` is the plan key the catch event binds; `messageSubscriptionState`
1153
+ * is `CREATED` while the subscription is open (waiting) and `CORRELATED`/`DELETED` once consumed. */
1154
+ interface MessageSubscriptionSearchItem {
1155
+ messageName?: string;
1156
+ correlationKey?: string | null;
1157
+ messageSubscriptionState?: string;
1158
+ }
1159
+
1160
+ /** Is the plan-fanout instance `processKey` right now parked at `wait-wave-merged` with an OPEN
1161
+ * (`CREATED`) subscription correlated on `planKey`? Reads the engine's Camunda-8
1162
+ * `/v2/message-subscriptions/search` (the same raw-REST search surface `pollIncidents`/
1163
+ * `pollJobActivation` use). Returns `true` (open — safe to release), `false` (no open subscription —
1164
+ * the token is either upstream of the wait or has already passed through it), or `null` (transport
1165
+ * unhappy / unparseable body — "unknown", so the caller neither publishes nor acts on a guess and
1166
+ * simply retries next tick). This is the load-bearing check that makes the barrier level-triggered:
1167
+ * we only ever publish `wave-merged` into a subscription we've observed OPEN, so a signal can never
1168
+ * be dropped into the void (the #262 wedge) nor buffered to trip a *later* wave's barrier. */
1169
+ async function waveMergedSubscriptionOpen(
1170
+ base: string,
1171
+ headers: Record<string, string>,
1172
+ processKey: string,
1173
+ planKey: string,
1174
+ ): Promise<boolean | null> {
1175
+ try {
1176
+ const res = await fetch(`${base}/message-subscriptions/search`, {
1177
+ method: "POST",
1178
+ headers,
1179
+ body: JSON.stringify({
1180
+ filter: {
1181
+ processInstanceKey: processKey,
1182
+ messageName: WAVE_MERGED_MESSAGE,
1183
+ messageSubscriptionState: "CREATED",
1184
+ },
1185
+ page: { limit: 20 },
1186
+ }),
1187
+ });
1188
+ if (!res.ok) return null; // engine unhappy → "unknown", retry next pass
1189
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1190
+ const body = (await res.json()) as { items?: MessageSubscriptionSearchItem[] };
1191
+ // Re-filter defensively in case the engine ignores a filter field: an OPEN (`CREATED`)
1192
+ // subscription for the `wave-merged` message, correlated on THIS plan key, is the barrier we may
1193
+ // publish into. Every field must be PRESENT and match explicitly — we never default a
1194
+ // missing/null `messageName`, `correlationKey`, or `messageSubscriptionState` to its expected
1195
+ // value. Doing so would treat an unverifiable item as OPEN and re-introduce the exact #262
1196
+ // failure class (publishing into a subscription we never confirmed open, buffering a message that
1197
+ // trips a later wave's barrier). An item that omits a field is "unknown", so we simply don't
1198
+ // match it: a false negative only costs a retry next pass, whereas a false positive is a wedge.
1199
+ // State is compared case-insensitively so a future casing tweak can't silently drop the match.
1200
+ return (body.items ?? []).some(
1201
+ (it) =>
1202
+ it.messageName === WAVE_MERGED_MESSAGE &&
1203
+ it.correlationKey === planKey &&
1204
+ typeof it.messageSubscriptionState === "string" &&
1205
+ it.messageSubscriptionState.toUpperCase() === "CREATED",
1206
+ );
1207
+ } catch (err) {
1208
+ console.error(`[poller] wave-merged subscription ${planKey}: ${err}`);
1209
+ return null; // transport threw → "unknown", retry next pass
1210
+ }
1211
+ }
1212
+
1213
+ /** Wave-merge barrier poll pass (issue #262). After `record-wave` hands off a wave that has a
1214
+ * successor, the plan-fanout instance eventually parks at the `wait-wave-merged` catch event and
1215
+ * `plans.gate_wave` records that wave's index. Here we reconcile, on EVERY pass and idempotently,
1216
+ * the external GitHub fact "every OPENED PR in that wave has MERGED" against the engine fact "is
1217
+ * there an OPEN `wait-wave-merged` subscription for this plan right now?", publishing `wave-merged`
1218
+ * (correlated on the plan key) to release the next wave's implementation whenever BOTH hold.
1148
1219
  *
1149
- * `gate_wave` is cleared single-shot BEFORE publishing (and restored if the publish fails —
1150
- * mirroring `flipToMergingThenPublish`) so a slow pass can't double-signal and a later wave's
1151
- * barrier can't be tripped by a stale message reusing the same plan-key correlation. A wave whose
1152
- * tasks all ended `blocked`/`skipped` (no opened PR to wait on) clears vacuously there is
1153
- * nothing to merge, and that failure has already cascaded to dependents in `select-wave`. */
1154
- async function pollWaveGates(data: DataLayer, engine: EngineClient, token: string) {
1220
+ * This is deliberately LEVEL-triggered, not the old single-shot edge trigger. The gate is armed at
1221
+ * wave handoff long before the token traverses `select-wave trial-merge (a slow agent job) →
1222
+ * wait-wave-merged` and opens the subscription. If the wave's PRs merged while the token was
1223
+ * still upstream, the old code published its one `wave-merged` into NO open subscription (dropped)
1224
+ * AND cleared `gate_wave`, so it never republished and the epic wedged forever once the token
1225
+ * finally arrived (#262). We fix the class:
1226
+ * • We publish ONLY when {@link waveMergedSubscriptionOpen} confirms the token is parked at the
1227
+ * wait — never into the void — so a merged-before-arrival wave simply waits, and a later pass
1228
+ * (once the token arrives) republishes and correlates.
1229
+ * • We NEVER clear `gate_wave` here. `record-wave` owns its lifecycle (it re-arms it to the next
1230
+ * wave, or clears it to `null` on the final wave), so a signal published into the void can't
1231
+ * strand the gate, and double-advance is guarded by the open-subscription check — which, by the
1232
+ * handoff ordering, always matches the wave whose wait is currently open — not by a premature
1233
+ * clear. A wave whose tasks all ended `blocked`/`skipped` (no opened PR to wait on) has an empty
1234
+ * merge-target set and so is treated as merged; `record-wave` advances it on the next pass. */
1235
+ export async function pollWaveGatesImpl(
1236
+ data: DataLayer,
1237
+ engine: EngineClient,
1238
+ token: string,
1239
+ base: string,
1240
+ headers: Record<string, string>,
1241
+ ) {
1155
1242
  for (const plan of await plans(data).all()) {
1156
1243
  const gateWave = plan.gate_wave;
1157
1244
  if (gateWave == null) continue;
@@ -1165,18 +1252,14 @@ async function pollWaveGates(data: DataLayer, engine: EngineClient, token: strin
1165
1252
  break;
1166
1253
  }
1167
1254
  }
1168
- if (!allMerged) continue;
1169
- await plans(data).update(planKey, { gate_wave: null, updated_at: now() });
1170
- try {
1171
- await engine.publishMessage({ name: "wave-merged", correlationKey: planKey, variables: {} });
1172
- } catch (err) {
1173
- try {
1174
- await plans(data).update(planKey, { gate_wave: gateWave, updated_at: now() });
1175
- } catch (revertErr) {
1176
- console.error(`[poller] revert wave-gate ${planKey} -> ${gateWave} failed: ${revertErr}`);
1177
- }
1178
- throw err;
1179
- }
1255
+ if (!allMerged) continue; // wave not landed yet → leave the gate armed, retry next pass
1256
+ // The wave has landed on GitHub. Release the barrier ONLY if the token is actually parked at
1257
+ // `wait-wave-merged` (an OPEN subscription for this plan): otherwise publishing would be a
1258
+ // silent no-op that the old code paired with a gate clear — the edge-triggered wedge (#262).
1259
+ if (!plan.process_key) continue; // no instance key to correlate against yet
1260
+ const open = await waveMergedSubscriptionOpen(base, headers, plan.process_key, planKey);
1261
+ if (open !== true) continue; // not parked here yet / already released / unknown → NEVER clear the gate; retry next pass
1262
+ await engine.publishMessage({ name: WAVE_MERGED_MESSAGE, correlationKey: planKey, variables: {} });
1180
1263
  console.log(`[poller] wave ${gateWave} merged -> ${planKey}`);
1181
1264
  } catch (err) {
1182
1265
  console.error(`[poller] wave-gate ${planKey}: ${err}`);
@@ -1559,16 +1642,34 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1559
1642
 
1560
1643
  /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
1561
1644
  * (when the engine REST endpoint is supplied) the job-activation visibility pass and the
1562
- * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
1645
+ * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`.
1646
+ *
1647
+ * The wave-merge barrier is now level-triggered and probes the engine's message-subscription state
1648
+ * over the same raw-REST search surface, so it runs only when `engineRest` is supplied (as in
1649
+ * production — `main.ts` always passes it). */
1650
+ /** One-shot guard so the feature-stage backfill (`backfillFeatureStages`) runs at most once per
1651
+ * process, on the first `pollOnce`. Idempotent regardless, but there is no need to re-scan every row
1652
+ * on every poll. */
1653
+ let featureStagesBackfilled = false;
1654
+
1563
1655
  export async function pollOnce(
1564
1656
  data: DataLayer,
1565
1657
  engine: EngineClient,
1566
1658
  token: string,
1567
1659
  engineRest?: { restAddress: string; token?: string },
1568
1660
  ) {
1661
+ // One-shot: re-project any pre-#254 feature_runs rows whose pipeline columns are still NULL. The
1662
+ // gateway keeps every future write fresh, so this only needs to run once per process and is safe to
1663
+ // re-run (it re-derives from each row's own stored fields).
1664
+ if (!featureStagesBackfilled) {
1665
+ // Only arm the one-shot guard AFTER a successful backfill: setting it first would swallow a
1666
+ // transient failure (e.g. a DB blip) and leave legacy rows unprojected forever, since every later
1667
+ // pass would skip. On a throw the guard stays false and the next `pollOnce` retries.
1668
+ await backfillFeatureStages(data);
1669
+ featureStagesBackfilled = true;
1670
+ }
1569
1671
  await pollReviews(data, engine, token);
1570
1672
  await pollMerges(data, engine, token);
1571
- await pollWaveGates(data, engine, token);
1572
1673
  await pollDelivery(data);
1573
1674
  await pollFeatureDelivery(data);
1574
1675
  await pollLineage(data);
@@ -1576,6 +1677,10 @@ export async function pollOnce(
1576
1677
  await pollFeatureBlocked(data, engine);
1577
1678
  await pollUserTasks(data, engine);
1578
1679
  if (engineRest) {
1680
+ const base = engineRest.restAddress.replace(/\/+$/, "");
1681
+ const headers: Record<string, string> = { "content-type": "application/json" };
1682
+ if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
1683
+ await pollWaveGatesImpl(data, engine, token, base, headers);
1579
1684
  await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1580
1685
  await pollIncidents(data, engineRest.restAddress, engineRest.token);
1581
1686
  }
@@ -0,0 +1,107 @@
1
+ // Unit tests for the canonical feature-run pipeline stage model (issue #254 §1). `deriveStage` is the
2
+ // ONE source of truth the feature_runs gateway projects onto the stored pipeline columns, so the
3
+ // mapping must be TOTAL and DETERMINISTIC over every FEATURE_RUN_STATUS and emit the urban 0.53.0
4
+ // `kind:"pipeline"` renderer's EXACT vocabulary (state `ok|failed|blocked|null`).
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import { FEATURE_RUN_STATUSES } from "./feature.ts";
8
+ import { deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
9
+
10
+ const base = (over: Partial<StageInput> & { status: string }): StageInput => ({
11
+ pr_key: null,
12
+ converge: 1,
13
+ auto_merge: 1,
14
+ escalation_question: null,
15
+ escalation_user_task_key: null,
16
+ blocked_user_task_key: null,
17
+ ...over,
18
+ });
19
+
20
+ test("deriveStage is TOTAL: every one of the 11 statuses maps to a defined stage and state", () => {
21
+ assertEquals(FEATURE_RUN_STATUSES.length, 11);
22
+ for (const status of FEATURE_RUN_STATUSES) {
23
+ const d = deriveStage(base({ status }));
24
+ assert(d.stage !== undefined, `stage undefined for ${status}`);
25
+ assert(
26
+ ["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"].includes(d.stage),
27
+ `stage out of range for ${status}: ${d.stage}`,
28
+ );
29
+ // state is one of the renderer's exact values (null allowed)
30
+ assert([null, "ok", "failed", "blocked"].includes(d.state), `state out of range for ${status}: ${d.state}`);
31
+ }
32
+ });
33
+
34
+ test("status -> stage mapping (each row)", () => {
35
+ // Terminal tier → Done.
36
+ for (const status of ["merged", "converged", "blocked", "failed", "skipped", "abandoned"]) {
37
+ assertEquals(deriveStage(base({ status })).stage, "Done", status);
38
+ }
39
+ // Live/parked tier.
40
+ assertEquals(deriveStage(base({ status: "converging" })).stage, "Converging");
41
+ assertEquals(deriveStage(base({ status: "opened" })).stage, "PR open");
42
+ assertEquals(deriveStage(base({ status: "running", pr_key: "o/r#9" })).stage, "PR open");
43
+ assertEquals(deriveStage(base({ status: "running" })).stage, "Implementing");
44
+ // The pre-start/created initial state (unknown non-terminal, no pr_key) → Requested.
45
+ assertEquals(deriveStage(base({ status: "created" })).stage, "Requested");
46
+ });
47
+
48
+ test("state: the three non-null values plus the null in-progress case", () => {
49
+ assertEquals(deriveStage(base({ status: "merged" })).state, "ok");
50
+ assertEquals(deriveStage(base({ status: "converged" })).state, "ok");
51
+ assertEquals(deriveStage(base({ status: "failed" })).state, "failed");
52
+ assertEquals(deriveStage(base({ status: "skipped" })).state, "failed");
53
+ assertEquals(deriveStage(base({ status: "abandoned" })).state, "failed");
54
+ assertEquals(deriveStage(base({ status: "blocked" })).state, "blocked");
55
+ // Every non-terminal status → null (in-progress).
56
+ for (const status of ["running", "opened", "converging", "escalated", "awaiting_operator"]) {
57
+ assertEquals(deriveStage(base({ status })).state, null, status);
58
+ }
59
+ });
60
+
61
+ test("state emits 'failed' (not 'fail') so the renderer does not degrade a failure to active", () => {
62
+ assertEquals(deriveStage(base({ status: "failed" })).state, "failed");
63
+ });
64
+
65
+ test("skipped: the three converge/auto_merge cases", () => {
66
+ // converge off → skip both.
67
+ assertEquals(deriveStage(base({ status: "running", converge: 0, auto_merge: 0 })).skipped, "Converging Merging");
68
+ // converge on, auto_merge off → skip Merging only.
69
+ assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 0 })).skipped, "Merging");
70
+ // both on → empty.
71
+ assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 1 })).skipped, "");
72
+ });
73
+
74
+ test("attention: blocked, escalation, none", () => {
75
+ assertEquals(deriveStage(base({ status: "awaiting_operator", blocked_user_task_key: "ut-1" })).attention, "blocked");
76
+ assertEquals(deriveStage(base({ status: "escalated", escalation_user_task_key: "ut-2" })).attention, "⚠");
77
+ assertEquals(deriveStage(base({ status: "escalated", escalation_question: "which base?" })).attention, "⚠");
78
+ assertEquals(deriveStage(base({ status: "running" })).attention, null);
79
+ });
80
+
81
+ // The three parked-status rows called out by the plan review.
82
+ test("escalated WITH pr_key → PR open / null", () => {
83
+ const d = deriveStage(base({ status: "escalated", pr_key: "o/r#5" }));
84
+ assertEquals(d.stage, "PR open");
85
+ assertEquals(d.state, null);
86
+ });
87
+
88
+ test("escalated WITHOUT pr_key → Implementing / null", () => {
89
+ const d = deriveStage(base({ status: "escalated", pr_key: null }));
90
+ assertEquals(d.stage, "Implementing");
91
+ assertEquals(d.state, null);
92
+ });
93
+
94
+ test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when parked", () => {
95
+ const d = deriveStage(base({ status: "awaiting_operator", pr_key: null, blocked_user_task_key: "ut-3" }));
96
+ assertEquals(d.stage, "Implementing");
97
+ assertEquals(d.state, null);
98
+ assertEquals(d.attention, "blocked");
99
+ });
100
+
101
+ test("deriveListBucket: history iff terminal AND acknowledged, else active", () => {
102
+ assertEquals(deriveListBucket("merged", null), "active");
103
+ assertEquals(deriveListBucket("merged", "2024-01-01T00:00:00Z"), "history");
104
+ // A non-terminal status is always active, even if (spuriously) acknowledged.
105
+ assertEquals(deriveListBucket("running", "2024-01-01T00:00:00Z"), "active");
106
+ assertEquals(deriveListBucket("blocked", "2024-01-01T00:00:00Z"), "history");
107
+ });
package/app/stage.ts ADDED
@@ -0,0 +1,111 @@
1
+ // Canonical feature-run pipeline stage model (issue #254 §1) — the ONE source of truth for the
2
+ // derived pipeline surface the Feature view renders. Mirrors the single-source-of-truth style of
3
+ // `deriveDelivery` (app/delivery.ts) and `classifyEscalation` (app/escalationTaxonomy.ts): a PURE,
4
+ // read-only function with no data access. The feature_runs gateway (app/feature.ts) projects its
5
+ // output onto the stored `stage`/`stage_state`/`stage_skipped`/`attention` columns at write time,
6
+ // exactly as `delivery_label` is projected — so the declarative dataGrid page consumes ready, stored
7
+ // columns (bound by `{"field":…}`) and never has to call TS or express OR/null in its flat filter DSL.
8
+ //
9
+ // The mapping is TOTAL and DETERMINISTIC over all 11 FEATURE_RUN_STATUSES, computed from ONLY the
10
+ // fields stored on the row — never a "previous"/"underlying" stage, because a FeatureRun stores only
11
+ // its CURRENT status (any prior stage was overwritten on transition). Do NOT duplicate this mapping
12
+ // anywhere (not in SQL, not in the page, not in each poller/worker): every writer flows through the
13
+ // gateway, which is the single caller.
14
+
15
+ /** The canonical pipeline stage keys, in path order. `Merging` is a path/visual stage the renderer
16
+ * fills as upcoming — no status maps to it as the ACTIVE stage (intentional). */
17
+ export const STAGE_KEYS = ["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"] as const;
18
+ export type StageKey = (typeof STAGE_KEYS)[number];
19
+
20
+ /** The active stage's render state, in the urban 0.53.0 `kind:"pipeline"` column's EXACT vocabulary:
21
+ * `ok` (Done ✓ success), `failed` (Done ✕ failure), `blocked` (blocked glyph), or `null` (in-progress
22
+ * → the renderer treats it as `active`). Any OTHER string silently degrades to `active` in the
23
+ * renderer, so a failed run MUST emit `'failed'` (not `'fail'`) to render as a failure. */
24
+ export type StageState = "ok" | "failed" | "blocked" | null;
25
+
26
+ /** The 6 TRULY-terminal statuses that map to the `Done` stage. Distinct from
27
+ * `FEATURE_TERMINAL_STATUSES` (app/feature.ts), which is the redispatch-settled set and also counts
28
+ * `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/`Converging`), NOT
29
+ * Done, so this list must stay separate. Also the basis of the `list_bucket` history partition. */
30
+ export const STAGE_DONE_STATUSES: readonly string[] = [
31
+ "merged",
32
+ "converged",
33
+ "blocked",
34
+ "failed",
35
+ "skipped",
36
+ "abandoned",
37
+ ];
38
+
39
+ /** The subset of a FeatureRun `deriveStage` reads. FeatureRun (app/feature.ts) structurally satisfies
40
+ * this; keeping the input structural avoids a stage.ts ↔ feature.ts import cycle. */
41
+ export interface StageInput {
42
+ status: string;
43
+ pr_key?: string | null;
44
+ converge?: number | boolean | null;
45
+ auto_merge?: number | boolean | null;
46
+ escalation_question?: string | null;
47
+ escalation_user_task_key?: string | null;
48
+ blocked_user_task_key?: string | null;
49
+ }
50
+
51
+ /** The derived pipeline projection for one run. `skipped` is a space-separated set of stage keys not
52
+ * in this row's path (bound to the renderer's `notInPathField`). */
53
+ export interface DerivedStage {
54
+ stage: StageKey;
55
+ state: StageState;
56
+ skipped: string;
57
+ attention: string | null;
58
+ }
59
+
60
+ const truthy = (v: number | boolean | null | undefined): boolean => v === true || (typeof v === "number" && v !== 0);
61
+
62
+ /** Derive the canonical pipeline stage, its render state, its not-in-path set, and its attention badge
63
+ * for one feature run. Pure and read-only — TOTAL over all 11 statuses (never returns undefined). */
64
+ export function deriveStage(run: StageInput): DerivedStage {
65
+ const { status } = run;
66
+
67
+ // TERMINAL tier — the 6 truly-terminal statuses collapse to Done. Unconditional: terminal `blocked`
68
+ // is the issue §1 'Done ✕' row (state `blocked`), NOT Implementing.
69
+ let stage: StageKey;
70
+ let state: StageState;
71
+ if (STAGE_DONE_STATUSES.includes(status)) {
72
+ stage = "Done";
73
+ state =
74
+ status === "merged" || status === "converged"
75
+ ? "ok"
76
+ : status === "blocked"
77
+ ? "blocked"
78
+ : "failed"; // failed / skipped / abandoned
79
+ } else {
80
+ // LIVE/PARKED tier — one shared rule for every non-terminal status. `escalated`/`awaiting_operator`
81
+ // are parked but their stored fields still describe WHERE in the pipeline they stalled, so they run
82
+ // through the same rule as a live run; their attention comes from the badge (below), not the stage.
83
+ if (status === "converging") stage = "Converging";
84
+ else if ((run.pr_key ?? "") !== "" || status === "opened") stage = "PR open";
85
+ else if (status === "running" || status === "escalated" || status === "awaiting_operator") stage = "Implementing";
86
+ else stage = "Requested";
87
+ state = null;
88
+ }
89
+
90
+ // `skipped`: stages not in this row's path, purely from converge/auto_merge.
91
+ const converge = truthy(run.converge);
92
+ const autoMerge = truthy(run.auto_merge);
93
+ const skippedKeys: StageKey[] = !converge ? ["Converging", "Merging"] : !autoMerge ? ["Merging"] : [];
94
+
95
+ // `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
96
+ // a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
97
+ const attention = run.blocked_user_task_key
98
+ ? "blocked"
99
+ : run.escalation_user_task_key || run.escalation_question
100
+ ? "⚠"
101
+ : null;
102
+
103
+ return { stage, state, skipped: skippedKeys.join(" "), attention };
104
+ }
105
+
106
+ /** The Active/History partition label (§5), maintained at write time so the flat-DSL page tabs filter
107
+ * on a stored `list_bucket` column with only `in` clauses. `history` iff the row is in a truly-terminal
108
+ * status AND acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). */
109
+ export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
110
+ return STAGE_DONE_STATUSES.includes(status) && acknowledgedAt != null ? "history" : "active";
111
+ }
@@ -10,7 +10,10 @@
10
10
  --
11
11
  -- `plans.gate_wave` is the durable marker for the barrier: when `record-wave` hands
12
12
  -- off a wave that has a successor, it records that wave's index here and the process
13
- -- parks at the `wait-wave-merged` catch event. The poller (`pollWaveGates`) clears it
14
- -- and publishes `wave-merged` once every opened PR in that wave has merged. NULL means
15
- -- the plan is not currently parked at the wave barrier.
13
+ -- parks at the `wait-wave-merged` catch event. The poller (`pollWaveGatesImpl`) is
14
+ -- level-triggered: it publishes `wave-merged` once every opened PR in that wave has
15
+ -- merged AND it observes an OPEN `wait-wave-merged` subscription for the plan, and it
16
+ -- NEVER clears `gate_wave` — `record-wave` owns the marker's lifecycle (re-arming it to
17
+ -- the next wave, or clearing it to NULL on the final wave). NULL means the plan is not
18
+ -- currently parked at the wave barrier.
16
19
  ALTER TABLE plans ADD COLUMN gate_wave INTEGER;