@nanobpm/nano-workforce 0.48.1 → 0.49.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.
@@ -241,11 +241,12 @@
241
241
  <bpmn:incoming>f_ci_go</bpmn:incoming>
242
242
  <bpmn:outgoing>f_ci_done</bpmn:outgoing>
243
243
  </bpmn:serviceTask>
244
- <bpmn:exclusiveGateway id="gw-ci-result" name="fixed?" default="f_ci_blocked">
244
+ <bpmn:exclusiveGateway id="gw-ci-result" name="fixed?" default="f_ci_reconcile">
245
245
  <bpmn:incoming>f_ci_done</bpmn:incoming>
246
246
  <bpmn:outgoing>f_ci_fixed</bpmn:outgoing>
247
247
  <bpmn:outgoing>f_ci_wait</bpmn:outgoing>
248
248
  <bpmn:outgoing>f_ci_blocked</bpmn:outgoing>
249
+ <bpmn:outgoing>f_ci_reconcile</bpmn:outgoing>
249
250
  </bpmn:exclusiveGateway>
250
251
  <bpmn:exclusiveGateway id="gw-rebase" name="auto-rebase?" default="f_reb_giveup">
251
252
  <bpmn:incoming>f_m_mRebase</bpmn:incoming>
@@ -270,11 +271,12 @@
270
271
  <bpmn:incoming>f_reb_go</bpmn:incoming>
271
272
  <bpmn:outgoing>f_reb_done</bpmn:outgoing>
272
273
  </bpmn:serviceTask>
273
- <bpmn:exclusiveGateway id="gw-rebase-result" name="rebased?" default="f_reb_blocked">
274
+ <bpmn:exclusiveGateway id="gw-rebase-result" name="rebased?" default="f_reb_reconcile">
274
275
  <bpmn:incoming>f_reb_done</bpmn:incoming>
275
276
  <bpmn:outgoing>f_reb_rebased</bpmn:outgoing>
276
277
  <bpmn:outgoing>f_reb_wait</bpmn:outgoing>
277
278
  <bpmn:outgoing>f_reb_blocked</bpmn:outgoing>
279
+ <bpmn:outgoing>f_reb_reconcile</bpmn:outgoing>
278
280
  </bpmn:exclusiveGateway>
279
281
  <bpmn:serviceTask id="record-merge-dep" name="Wait on another PR">
280
282
  <bpmn:extensionElements>
@@ -309,7 +311,10 @@
309
311
  <bpmn:sequenceFlow id="f_ci_fixed" name="fixed" sourceRef="gw-ci-result" targetRef="arm-merge">
310
312
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "fixed"</bpmn:conditionExpression>
311
313
  </bpmn:sequenceFlow>
312
- <bpmn:sequenceFlow id="f_ci_blocked" name="could not fix" sourceRef="gw-ci-result" targetRef="merge-esc-attempt" />
314
+ <bpmn:sequenceFlow id="f_ci_blocked" name="could not fix" sourceRef="gw-ci-result" targetRef="merge-esc-attempt">
315
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "blocked"</bpmn:conditionExpression>
316
+ </bpmn:sequenceFlow>
317
+ <bpmn:sequenceFlow id="f_ci_reconcile" name="no verdict — reconcile from ground truth" sourceRef="gw-ci-result" targetRef="arm-merge" />
313
318
  <bpmn:sequenceFlow id="f_ci_wait" name="waits on another PR" sourceRef="gw-ci-result" targetRef="record-merge-dep">
314
319
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
315
320
  </bpmn:sequenceFlow>
@@ -321,7 +326,10 @@
321
326
  <bpmn:sequenceFlow id="f_reb_rebased" name="rebased" sourceRef="gw-rebase-result" targetRef="arm-merge">
322
327
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "rebased"</bpmn:conditionExpression>
323
328
  </bpmn:sequenceFlow>
324
- <bpmn:sequenceFlow id="f_reb_blocked" name="could not resolve" sourceRef="gw-rebase-result" targetRef="merge-esc-attempt" />
329
+ <bpmn:sequenceFlow id="f_reb_blocked" name="could not resolve" sourceRef="gw-rebase-result" targetRef="merge-esc-attempt">
330
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "blocked"</bpmn:conditionExpression>
331
+ </bpmn:sequenceFlow>
332
+ <bpmn:sequenceFlow id="f_reb_reconcile" name="no verdict — reconcile from ground truth" sourceRef="gw-rebase-result" targetRef="arm-merge" />
325
333
  <bpmn:sequenceFlow id="f_reb_wait" name="waits on another PR" sourceRef="gw-rebase-result" targetRef="record-merge-dep">
326
334
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
327
335
  </bpmn:sequenceFlow>
@@ -390,7 +398,7 @@
390
398
  <bpmndi:BPMNShape id="BPMNShape_wait-landed" bpmnElement="wait-landed">
391
399
  <dc:Bounds x="1420" y="262" width="36" height="36" />
392
400
  <bpmndi:BPMNLabel>
393
- <dc:Bounds x="1396" y="303" width="85" height="28" />
401
+ <dc:Bounds x="1396" y="229" width="85" height="28" />
394
402
  </bpmndi:BPMNLabel>
395
403
  </bpmndi:BPMNShape>
396
404
  <bpmndi:BPMNShape id="BPMNShape_wait-evicted" bpmnElement="wait-evicted">
@@ -417,7 +425,7 @@
417
425
  <bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
418
426
  <dc:Bounds x="1620" y="582" width="36" height="36" />
419
427
  <bpmndi:BPMNLabel>
420
- <dc:Bounds x="1661" y="579" width="74" height="42" />
428
+ <dc:Bounds x="1541" y="683" width="74" height="42" />
421
429
  </bpmndi:BPMNLabel>
422
430
  </bpmndi:BPMNShape>
423
431
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
@@ -447,7 +455,7 @@
447
455
  <bpmndi:BPMNShape id="BPMNShape_gw-rebase-result" bpmnElement="gw-rebase-result" isMarkerVisible="true">
448
456
  <dc:Bounds x="1238" y="1215" width="50" height="50" />
449
457
  <bpmndi:BPMNLabel>
450
- <dc:Bounds x="1173" y="1233" width="60" height="14" />
458
+ <dc:Bounds x="1193" y="1270" width="60" height="14" />
451
459
  </bpmndi:BPMNLabel>
452
460
  </bpmndi:BPMNShape>
453
461
  <bpmndi:BPMNShape id="BPMNShape_record-merge-dep" bpmnElement="record-merge-dep">
@@ -498,12 +506,13 @@
498
506
  <dc:Bounds x="942" y="765" width="67" height="28" />
499
507
  </bpmndi:BPMNLabel>
500
508
  </bpmndi:BPMNEdge>
501
- <bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
502
- <di:waypoint x="1288" y="920" />
503
- <di:waypoint x="1438" y="920" />
504
- <di:waypoint x="1438" y="640" />
509
+ <bpmndi:BPMNEdge id="BPMNEdge_f_ci_reconcile" bpmnElement="f_ci_reconcile">
510
+ <di:waypoint x="1263" y="945" />
511
+ <di:waypoint x="1263" y="980" />
512
+ <di:waypoint x="402" y="980" />
513
+ <di:waypoint x="402" y="160" />
505
514
  <bpmndi:BPMNLabel>
506
- <dc:Bounds x="1319" y="928" width="89" height="14" />
515
+ <dc:Bounds x="792" y="919" width="82" height="56" />
507
516
  </bpmndi:BPMNLabel>
508
517
  </bpmndi:BPMNEdge>
509
518
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_giveup" bpmnElement="f_reb_giveup">
@@ -514,13 +523,13 @@
514
523
  <dc:Bounds x="977" y="247" width="67" height="28" />
515
524
  </bpmndi:BPMNLabel>
516
525
  </bpmndi:BPMNEdge>
517
- <bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
518
- <di:waypoint x="1263" y="1215" />
519
- <di:waypoint x="1263" y="1020" />
520
- <di:waypoint x="1438" y="1020" />
521
- <di:waypoint x="1438" y="640" />
526
+ <bpmndi:BPMNEdge id="BPMNEdge_f_reb_reconcile" bpmnElement="f_reb_reconcile">
527
+ <di:waypoint x="1263" y="1265" />
528
+ <di:waypoint x="1263" y="1300" />
529
+ <di:waypoint x="402" y="1300" />
530
+ <di:waypoint x="402" y="160" />
522
531
  <bpmndi:BPMNLabel>
523
- <dc:Bounds x="1319" y="987" width="64" height="28" />
532
+ <dc:Bounds x="792" y="1239" width="82" height="56" />
524
533
  </bpmndi:BPMNLabel>
525
534
  </bpmndi:BPMNEdge>
526
535
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
@@ -533,15 +542,9 @@
533
542
  <di:waypoint x="1638" y="160" />
534
543
  </bpmndi:BPMNEdge>
535
544
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
536
- <di:waypoint x="1138" y="740" />
537
- <di:waypoint x="1158" y="740" />
538
- <di:waypoint x="1158" y="540" />
539
- <di:waypoint x="1638" y="540" />
540
- <di:waypoint x="1638" y="582" />
541
- </bpmndi:BPMNEdge>
542
- <bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
543
- <di:waypoint x="1488" y="600" />
544
- <di:waypoint x="1620" y="600" />
545
+ <di:waypoint x="1138" y="760" />
546
+ <di:waypoint x="1638" y="760" />
547
+ <di:waypoint x="1638" y="618" />
545
548
  </bpmndi:BPMNEdge>
546
549
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
547
550
  <di:waypoint x="1638" y="618" />
@@ -583,31 +586,56 @@
583
586
  <dc:Bounds x="893" y="839" width="49" height="28" />
584
587
  </bpmndi:BPMNLabel>
585
588
  </bpmndi:BPMNEdge>
586
- <bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
589
+ <bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
587
590
  <di:waypoint x="1288" y="920" />
588
- <di:waypoint x="1438" y="920" />
589
- <di:waypoint x="1438" y="1040" />
591
+ <di:waypoint x="1308" y="920" />
592
+ <di:waypoint x="1308" y="895" />
593
+ <di:waypoint x="1676" y="895" />
594
+ <di:waypoint x="1676" y="562" />
595
+ <di:waypoint x="1488" y="562" />
590
596
  <bpmndi:BPMNLabel>
591
- <dc:Bounds x="1326" y="887" width="75" height="28" />
597
+ <dc:Bounds x="1448" y="873" width="89" height="14" />
598
+ </bpmndi:BPMNLabel>
599
+ </bpmndi:BPMNEdge>
600
+ <bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
601
+ <di:waypoint x="1263" y="945" />
602
+ <di:waypoint x="1263" y="1080" />
603
+ <di:waypoint x="1388" y="1080" />
604
+ <bpmndi:BPMNLabel>
605
+ <dc:Bounds x="1268" y="1016" width="75" height="28" />
592
606
  </bpmndi:BPMNLabel>
593
607
  </bpmndi:BPMNEdge>
594
608
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_go" bpmnElement="f_reb_go">
595
609
  <di:waypoint x="913" y="280" />
596
610
  <di:waypoint x="933" y="280" />
597
611
  <di:waypoint x="933" y="305" />
598
- <di:waypoint x="1158" y="305" />
599
- <di:waypoint x="1158" y="1240" />
600
- <di:waypoint x="1138" y="1240" />
612
+ <di:waypoint x="1708" y="305" />
613
+ <di:waypoint x="1708" y="1140" />
614
+ <di:waypoint x="1018" y="1140" />
615
+ <di:waypoint x="1018" y="1220" />
616
+ <di:waypoint x="1038" y="1220" />
601
617
  <bpmndi:BPMNLabel>
602
- <dc:Bounds x="1101" y="310" width="49" height="28" />
618
+ <dc:Bounds x="1713" y="709" width="49" height="28" />
619
+ </bpmndi:BPMNLabel>
620
+ </bpmndi:BPMNEdge>
621
+ <bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
622
+ <di:waypoint x="1288" y="1240" />
623
+ <di:waypoint x="1308" y="1240" />
624
+ <di:waypoint x="1308" y="1215" />
625
+ <di:waypoint x="1676" y="1215" />
626
+ <di:waypoint x="1676" y="562" />
627
+ <di:waypoint x="1488" y="562" />
628
+ <bpmndi:BPMNLabel>
629
+ <dc:Bounds x="1460" y="1182" width="64" height="28" />
603
630
  </bpmndi:BPMNLabel>
604
631
  </bpmndi:BPMNEdge>
605
632
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_wait" bpmnElement="f_reb_wait">
606
633
  <di:waypoint x="1263" y="1215" />
607
- <di:waypoint x="1263" y="1080" />
608
- <di:waypoint x="1388" y="1080" />
634
+ <di:waypoint x="1263" y="1140" />
635
+ <di:waypoint x="1438" y="1140" />
636
+ <di:waypoint x="1438" y="1120" />
609
637
  <bpmndi:BPMNLabel>
610
- <dc:Bounds x="1288" y="1047" width="75" height="28" />
638
+ <dc:Bounds x="1268" y="1164" width="75" height="28" />
611
639
  </bpmndi:BPMNLabel>
612
640
  </bpmndi:BPMNEdge>
613
641
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gQueued" bpmnElement="f_m_gQueued">
@@ -640,12 +668,12 @@
640
668
  <di:waypoint x="1238" y="920" />
641
669
  </bpmndi:BPMNEdge>
642
670
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_done" bpmnElement="f_reb_done">
643
- <di:waypoint x="1138" y="1260" />
644
- <di:waypoint x="1158" y="1260" />
645
- <di:waypoint x="1158" y="1285" />
646
- <di:waypoint x="1238" y="1285" />
647
- <di:waypoint x="1263" y="1285" />
648
- <di:waypoint x="1263" y="1265" />
671
+ <di:waypoint x="1138" y="1240" />
672
+ <di:waypoint x="1238" y="1240" />
673
+ </bpmndi:BPMNEdge>
674
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
675
+ <di:waypoint x="1488" y="600" />
676
+ <di:waypoint x="1620" y="600" />
649
677
  </bpmndi:BPMNEdge>
650
678
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
651
679
  <di:waypoint x="1263" y="945" />
@@ -653,7 +681,7 @@
653
681
  <di:waypoint x="402" y="980" />
654
682
  <di:waypoint x="402" y="160" />
655
683
  <bpmndi:BPMNLabel>
656
- <dc:Bounds x="813" y="958" width="39" height="14" />
684
+ <dc:Bounds x="813" y="988" width="39" height="14" />
657
685
  </bpmndi:BPMNLabel>
658
686
  </bpmndi:BPMNEdge>
659
687
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_rebased" bpmnElement="f_reb_rebased">
@@ -662,13 +690,13 @@
662
690
  <di:waypoint x="402" y="1300" />
663
691
  <di:waypoint x="402" y="160" />
664
692
  <bpmndi:BPMNLabel>
665
- <dc:Bounds x="806" y="1278" width="53" height="14" />
693
+ <dc:Bounds x="806" y="1308" width="53" height="14" />
666
694
  </bpmndi:BPMNLabel>
667
695
  </bpmndi:BPMNEdge>
668
696
  <bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
669
697
  <di:waypoint x="1438" y="1120" />
670
- <di:waypoint x="1438" y="1320" />
671
- <di:waypoint x="234" y="1320" />
698
+ <di:waypoint x="1438" y="1140" />
699
+ <di:waypoint x="234" y="1140" />
672
700
  <di:waypoint x="234" y="138" />
673
701
  </bpmndi:BPMNEdge>
674
702
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
@@ -207,12 +207,15 @@ test("every page datasource column exists on its table", async () => {
207
207
  }
208
208
  });
209
209
 
210
- test("issue #87: plan_reviews is surfaced on the epic and home pages", async () => {
210
+ test("issue #87: plan_reviews is surfaced on the per-epic detail page", async () => {
211
211
  const refs = await loadRefs();
212
- const onEpic = refs.some((r) => r.page === "epic.page.json" && r.table === "plan_reviews");
213
- const onHome = refs.some((r) => r.page === "home.page.json" && r.table === "plan_reviews");
214
- assert(onEpic, "epic.page.json must bind a grid to plan_reviews (plan-review trace)");
215
- assert(onHome, "home.page.json plan detail must include a plan_reviews child grid");
212
+ // The operator-visibility redesign (issue #137) moved per-plan detail grids off the flat
213
+ // epic index onto the param-scoped per-epic page (#/epic-detail/<plan_key>), where the
214
+ // plan-review trace is filtered to a single epic. Assert the trace lives there.
215
+ const onEpicDetail = refs.some(
216
+ (r) => r.page === "epic-detail.page.json" && r.table === "plan_reviews",
217
+ );
218
+ assert(onEpicDetail, "epic-detail.page.json must bind a grid to plan_reviews (plan-review trace)");
216
219
 
217
220
  // The trace is only useful with the verdict + critique columns, so pin them. Assert against the
218
221
  // visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field) so
@@ -0,0 +1,89 @@
1
+ // Regression coverage for record-plan's operator-visibility wave progress projection (issue #137).
2
+ //
3
+ // record-plan initializes plans.wave_count/current_wave/wave_label when a plan is dispatched. The
4
+ // three fields must stay consistent: a taskful plan gets wave_count N, current_wave 0, "1/N"; a
5
+ // taskless plan gets all three NULL (never wave_count 0 against NULL current_wave/wave_label, which
6
+ // would leak a misleading value to the epics-index — the documented contract is "NULL until
7
+ // dispatched with tasks").
8
+ import { test } from "node:test";
9
+ import { assertEquals } from "#test-assert";
10
+ import handler from "./worker.ts";
11
+
12
+ interface Row extends Record<string, unknown> {
13
+ id?: number;
14
+ plan_key: string;
15
+ }
16
+
17
+ function fakeApp() {
18
+ const planTasks: Row[] = [];
19
+ const planTaskDeps: Row[] = [];
20
+ const plans: Row[] = [{ plan_key: "owner/repo#137" }];
21
+ let nextId = 1;
22
+ const app = {
23
+ log: { error() {}, info() {}, warn() {} },
24
+ data: {
25
+ table(name: string, key: string) {
26
+ const store = name === "plan_tasks"
27
+ ? planTasks
28
+ : name === "plan_task_deps"
29
+ ? planTaskDeps
30
+ : plans;
31
+ return {
32
+ find: (q: Record<string, unknown>) =>
33
+ Promise.resolve(
34
+ store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
35
+ ),
36
+ insert: (row: Row) => {
37
+ store.push({ ...row, id: row.id ?? nextId++ });
38
+ return Promise.resolve();
39
+ },
40
+ delete: (k: unknown) => {
41
+ for (let i = store.length - 1; i >= 0; i--) {
42
+ if (store[i][key] === k) store.splice(i, 1);
43
+ }
44
+ return Promise.resolve();
45
+ },
46
+ update: (k: unknown, patch: Record<string, unknown>) => {
47
+ const row = store.find((r) => r[key] === k);
48
+ if (row) Object.assign(row, patch);
49
+ return Promise.resolve(row);
50
+ },
51
+ };
52
+ },
53
+ },
54
+ } as any;
55
+ return { app, plans, planTasks };
56
+ }
57
+
58
+ test("record-plan initializes wave progress fields for a taskful plan", async () => {
59
+ const { app, plans } = fakeApp();
60
+ await handler(
61
+ {
62
+ variables: {
63
+ planKey: "owner/repo#137",
64
+ tasks: [
65
+ { id: "a", prompt: "do A" },
66
+ { id: "b", prompt: "do B", dependsOn: ["a"] },
67
+ ],
68
+ },
69
+ } as any,
70
+ app,
71
+ );
72
+ assertEquals(plans[0].status, "dispatched");
73
+ assertEquals(plans[0].wave_count, 2);
74
+ assertEquals(plans[0].current_wave, 0);
75
+ assertEquals(plans[0].wave_label, "1/2");
76
+ });
77
+
78
+ test("record-plan leaves all three wave progress fields NULL for a taskless plan", async () => {
79
+ const { app, plans } = fakeApp();
80
+ await handler(
81
+ { variables: { planKey: "owner/repo#137", tasks: [], note: "planner emitted no tasks" } } as any,
82
+ app,
83
+ );
84
+ assertEquals(plans[0].status, "done");
85
+ // No wave to implement => no misleading wave_count: 0 while current_wave/wave_label are NULL.
86
+ assertEquals(plans[0].wave_count, null);
87
+ assertEquals(plans[0].current_wave, null);
88
+ assertEquals(plans[0].wave_label, null);
89
+ });
@@ -123,6 +123,14 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
123
123
  const patch: Record<string, unknown> = {
124
124
  status: tasks.length > 0 ? "dispatched" : "done",
125
125
  task_count: tasks.length,
126
+ // Operator-visibility progress projection (issue #137): total waves (N), and the wave the
127
+ // fleet is actively implementing (0 at dispatch). `select-wave` advances current_wave per
128
+ // wave; a taskless plan gets no wave (NULL) since there is nothing to implement. Display-only.
129
+ // All three fields stay NULL until dispatched with tasks — a taskless plan must not leak a
130
+ // misleading wave_count: 0 while current_wave/wave_label are NULL (inconsistent projection).
131
+ wave_count: tasks.length > 0 ? waveCount : null,
132
+ current_wave: tasks.length > 0 ? 0 : null,
133
+ wave_label: tasks.length > 0 ? `1/${waveCount}` : null,
126
134
  updated_at: ts,
127
135
  };
128
136
  if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
@@ -142,6 +142,62 @@ test("record-wave retries the same wave when a task is still pending", async ()
142
142
  trialMergeSkipReason: "wave-still-pending",
143
143
  });
144
144
  assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, 1);
145
+ // Retry keeps the projection on the same (still-pending) wave.
146
+ assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 1);
147
+ });
148
+
149
+ test("record-wave pins current_wave to the last index and clears gate_wave on the final wave", async () => {
150
+ const rows: Row[] = [{
151
+ id: 9,
152
+ plan_key: "owner/repo#63",
153
+ task_id: "z",
154
+ status: "opened",
155
+ wave: 2,
156
+ }];
157
+ const { app, planUpdates } = fakeApp(rows);
158
+
159
+ await handler(
160
+ {
161
+ variables: {
162
+ planKey: "owner/repo#63",
163
+ currentWave: 2,
164
+ waveCount: 3,
165
+ waveTasks: [],
166
+ waveResults: [],
167
+ },
168
+ } as any,
169
+ app,
170
+ );
171
+
172
+ // Final wave (2 of 3): no successor wave — gate cleared, projection pinned to N-1 so the
173
+ // epics-index reads 3/3 rather than the one-past-the-end nextWave (3).
174
+ assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, null);
175
+ assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 2);
176
+ assertEquals((planUpdates[0].patch as Record<string, unknown>).wave_label, "3/3");
177
+ });
178
+
179
+ test("record-wave keeps all wave-progress fields NULL for a taskless plan (waveCount 0)", async () => {
180
+ // A taskless plan runs record-wave with waveCount 0 (the MI `implement` step completed
181
+ // immediately). All three progress fields must stay NULL together — never current_wave=0 against
182
+ // a NULL wave_label, which would clobber record-plan/select-wave's NULL projection.
183
+ const { app, planUpdates } = fakeApp([]);
184
+
185
+ await handler(
186
+ {
187
+ variables: {
188
+ planKey: "owner/repo#70",
189
+ currentWave: 0,
190
+ waveCount: 0,
191
+ waveTasks: [],
192
+ waveResults: [],
193
+ },
194
+ } as any,
195
+ app,
196
+ );
197
+
198
+ const patch = planUpdates[0].patch as Record<string, unknown>;
199
+ assertEquals(patch.current_wave, null);
200
+ assertEquals(patch.wave_label, null);
145
201
  });
146
202
 
147
203
  test("record-wave skips trial merge for mergify-queue repos with 2+ heads", async () => {
@@ -278,6 +278,18 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
278
278
  const nextWave = stillPendingCurrentWave ? currentWave : currentWave + 1;
279
279
  const hasMoreWaves = stillPendingCurrentWave || nextWave < waveCount;
280
280
 
281
+ // Operator-visibility projection (issue #137): keep plans.current_wave tracking the wave the
282
+ // fleet is on. While more waves remain, point it at the wave about to run (select-wave re-writes
283
+ // the same value when it dispatches); on the final wave, pin it to the last index so a finished
284
+ // epic reads N/N (nextWave would be waveCount, one past the last band). Display-only.
285
+ const projectedCurrentWave = hasMoreWaves ? nextWave : Math.max(0, waveCount - 1);
286
+ // Keep the three progress fields consistent: a taskless plan (waveCount 0 — the MI `implement`
287
+ // step completed immediately with no waves) has no wave to be on, so current_wave and wave_label
288
+ // both stay NULL rather than writing current_wave=0 against a NULL label (and clobbering the NULL
289
+ // projection record-plan/select-wave already recorded).
290
+ const currentWaveProjection = waveCount > 0 ? projectedCurrentWave : null;
291
+ const waveLabel = waveCount > 0 ? `${projectedCurrentWave + 1}/${waveCount}` : null;
292
+
281
293
  // Wave-merge barrier: when another wave follows, park the plan-fanout instance at the
282
294
  // `wait-wave-merged` catch event until THIS wave's opened PRs have MERGED (not merely opened).
283
295
  // `gate_wave` is that durable marker; the poller (`pollWaveGates`) clears it and publishes
@@ -288,6 +300,8 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
288
300
  try {
289
301
  await plans(app.data).update(planKey, {
290
302
  gate_wave: hasMoreWaves ? currentWave : null,
303
+ current_wave: currentWaveProjection,
304
+ wave_label: waveLabel,
291
305
  updated_at: ts,
292
306
  });
293
307
  } catch (err) {
@@ -25,11 +25,12 @@ interface DepRow {
25
25
  depends_on_task_id: string;
26
26
  }
27
27
 
28
- function fakeApp(rows: Row[], deps: DepRow[]) {
28
+ function fakeApp(rows: Row[], deps: DepRow[], plans: Record<string, unknown>[] = []) {
29
29
  return {
30
+ log: { error() {}, info() {}, warn() {} },
30
31
  data: {
31
32
  table(name: string, key: string) {
32
- const store = name === "plan_tasks" ? rows : deps;
33
+ const store = name === "plan_tasks" ? rows : name === "plans" ? plans : deps;
33
34
  return {
34
35
  find: (q: any) =>
35
36
  Promise.resolve(
@@ -60,6 +61,36 @@ async function selectWave(rows: Row[], deps: DepRow[]) {
60
61
  return out as { waveTasks: unknown[] };
61
62
  }
62
63
 
64
+ test("select-wave projects the active wave onto plans.current_wave", async () => {
65
+ const rows: Row[] = [
66
+ { id: 1, plan_key: "owner/repo#63", task_id: "a", title: "A", prompt: "do A", status: "pending", wave: 1 },
67
+ ];
68
+ const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63", current_wave: 0 }];
69
+ const out = await handler(
70
+ { variables: { planKey: "owner/repo#63", currentWave: 1 } } as any,
71
+ fakeApp(rows, [], plans),
72
+ );
73
+ assertEquals((out as { waveTasks: unknown[] }).waveTasks.length, 1);
74
+ assertEquals(plans[0].current_wave, 1);
75
+ // wave_count is derived from the levelized rows (max wave + 1) and the 1-based "X/N" label
76
+ // is pre-formatted for the epics-index at-a-glance column.
77
+ assertEquals(plans[0].wave_count, 2);
78
+ assertEquals(plans[0].wave_label, "2/2");
79
+ });
80
+
81
+ test("select-wave nulls all three progress fields when there are no levelized rows", async () => {
82
+ // No plan_tasks rows => waveCount 0. current_wave must be NULL too (not a stray index against a
83
+ // NULL wave_count/wave_label), matching the documented "NULL until dispatched with tasks".
84
+ const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63", current_wave: 5 }];
85
+ await handler(
86
+ { variables: { planKey: "owner/repo#63", currentWave: 0 } } as any,
87
+ fakeApp([], [], plans),
88
+ );
89
+ assertEquals(plans[0].current_wave, null);
90
+ assertEquals(plans[0].wave_count, null);
91
+ assertEquals(plans[0].wave_label, null);
92
+ });
93
+
63
94
  test("select-wave leaves dependents pending behind a waiting-for-lane dependency", async () => {
64
95
  const rows: Row[] = [
65
96
  {
@@ -15,7 +15,7 @@
15
15
  // Emitting an empty `waveTasks` is fine: the MI activity over an empty collection completes
16
16
  // immediately (the same 0-task path the flat fan-out already relied on).
17
17
  import type { AppJobHandler } from "@nanobpm/urban";
18
- import { planTaskDeps, planTasks } from "../../app/plan.ts";
18
+ import { plans, planTaskDeps, planTasks } from "../../app/plan.ts";
19
19
 
20
20
  interface In extends Record<string, unknown> {
21
21
  planKey: string;
@@ -47,6 +47,28 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
47
47
  const statusById = new Map<string, string>();
48
48
  for (const r of rows) statusById.set(r.task_id, r.status);
49
49
 
50
+ // Operator-visibility projection (issue #137): mark this as the wave the fleet is now
51
+ // implementing, so the epics-index can show wave X/N at a glance. wave_count is derivable from
52
+ // the levelized rows (max wave + 1), so the "X/N" label stays correct even if a re-levelize
53
+ // changed the total. Best-effort + idempotent (a retry re-writes the same value) and
54
+ // display-only — it must never gate control flow, which stays driven by the process
55
+ // `currentWave`/`waveCount`/`gate_wave` state.
56
+ const waveCount = rows.reduce((m, r) => Math.max(m, r.wave ?? 0), -1) + 1;
57
+ try {
58
+ await plans(app.data).update(planKey, {
59
+ // Keep the three progress fields consistent: with no levelized rows (waveCount 0) there is
60
+ // no wave to implement, so current_wave is NULL too — never a stray index against NULL N.
61
+ current_wave: waveCount > 0 ? currentWave : null,
62
+ wave_count: waveCount > 0 ? waveCount : null,
63
+ wave_label: waveCount > 0 ? `${currentWave + 1}/${waveCount}` : null,
64
+ updated_at: ts,
65
+ });
66
+ } catch (err) {
67
+ app.log.error(`select-wave: projecting current_wave failed for ${planKey}`, {
68
+ err: String(err),
69
+ });
70
+ }
71
+
50
72
  const deps = await planTaskDeps(app.data).find({ plan_key: planKey });
51
73
  const depsByTask = new Map<string, string[]>();
52
74
  for (const d of deps) {