@nanobpm/nano-workforce 0.208.3 → 0.208.5

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/openapi.yaml CHANGED
@@ -177,23 +177,27 @@ components:
177
177
  additionalProperties: false
178
178
  description: >-
179
179
  The effective merge policy for this PR's run (issue #916). `requested` is the run's merge
180
- intent — the canonical positive `autoMerge` process variable on its convergence loop
181
- (falling back to the inverse of the legacy `convergeOnly` variable for a loop seeded
182
- before `autoMerge` existed, and to the historical default `true` when the instance is
183
- unreadable). `enabled` is the deployment's global `NANO_PR_AUTO_MERGE` switch.
180
+ intent — served from the durable `pull_requests` projection (issue #947) that
181
+ `submitPr`/`startMerge` stamp, falling back for a pre-projection (legacy) row to the
182
+ loop's `autoMerge` process variable (then the inverse of the legacy `convergeOnly`
183
+ variable, then the historical default `true` when the instance is unreadable).
184
+ `enabled` is the deployment's global `NANO_PR_AUTO_MERGE` switch.
184
185
  `effective` is `requested && enabled && !humanApproval` — the run merges with no further
185
186
  gate. `blockedBy` names WHY a requested merge will not proceed: `env` (the global switch
186
187
  is off) or `human-approval` (parked at the merge-approval task); null when the merge is
187
- effective or none was requested (converge-only).
188
+ effective or none was requested (converge-only). `stale` is the timing-guard marker
189
+ (issue #947): true when the policy could not be verified and the row was served from
190
+ the historical default rather than blocking the serve.
188
191
  required:
189
192
  - requested
190
193
  - enabled
191
194
  - effective
192
195
  - blockedBy
196
+ - stale
193
197
  properties:
194
198
  requested:
195
199
  type: boolean
196
- description: The run's merge intent (the loop's `autoMerge` variable, or the inverse of its legacy `convergeOnly`).
200
+ description: The run's merge intent (the durable row projection, or the loop's `autoMerge` variable / the inverse of its legacy `convergeOnly` for a pre-projection row).
197
201
  enabled:
198
202
  type: boolean
199
203
  description: The deployment's global NANO_PR_AUTO_MERGE switch.
@@ -205,6 +209,9 @@ components:
205
209
  nullable: true
206
210
  enum: ["env", "human-approval", null]
207
211
  description: Why a requested merge will not proceed; null when effective or converge-only.
212
+ stale:
213
+ type: boolean
214
+ description: True when the policy could not be verified (a legacy row whose fallback engine read failed) and the row was served from the historical default instead of blocking (issue #947).
208
215
  incident:
209
216
  type: object
210
217
  nullable: true
@@ -265,7 +265,7 @@ test("issue #936: N active rows cost a constant 3 engine variable reads, not 3N
265
265
  );
266
266
  // The batch still projects the per-row policy correctly.
267
267
  for (const pr of res.body.prs) {
268
- assertEquals(pr.merge, { requested: true, enabled: true, effective: true, blockedBy: null });
268
+ assertEquals(pr.merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
269
269
  }
270
270
  });
271
271
 
@@ -420,7 +420,7 @@ test("PR #937 r4: a failed probe after a partial batch drops the partial entry (
420
420
  } as any;
421
421
  const res = (await handler(input(), { ...memApp([prRow936(1)]), engine: failing } as AppApi)) as any;
422
422
  assertEquals(res.status, 200);
423
- assertEquals(res.body.prs[0].merge, { requested: true, enabled: true, effective: true, blockedBy: null });
423
+ assertEquals(res.body.prs[0].merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: true });
424
424
  });
425
425
 
426
426
  test("PR #937 r4: a cut-off instance is recovered with name-filtered (untruncatable) root probes", async () => {
@@ -476,5 +476,209 @@ test("PR #937 r3: a failed BATCH read still degrades every row to the default (n
476
476
  } as any;
477
477
  const res = (await handler(input(), { ...memApp([prRow936(1)]), engine: failing } as AppApi)) as any;
478
478
  assertEquals(res.status, 200);
479
- assertEquals(res.body.prs[0].merge, { requested: true, enabled: true, effective: true, blockedBy: null });
479
+ assertEquals(res.body.prs[0].merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: true });
480
+ });
481
+
482
+ // ── Issue #947: /status serves the merge policy from the app's OWN projection — ZERO engine reads ─
483
+ // The #936/#937 batching left /status at 3 engine-wide `searchVariables` pages plus a per-(instance,
484
+ // name) probe for any name cut from its page. On the loaded merlin host (v0.208.1, 9 PRs) the
485
+ // variables search itself takes ~1.4 s and the engine-wide pages silently truncate, so every active
486
+ // row paid probes — ~30 s per serve and a 502 behind the console proxy. The fix for the CLASS: the
487
+ // merge policy is written onto the `pull_requests` row by the writers that already know it
488
+ // (`submitPr` stamps `merge_requested` at submit/reopen and `merge_human_approval` when it gates a
489
+ // live loop; `startMerge` stamps the merge-stage policy), so the hot read path serves it from the
490
+ // app's own DB and never touches the engine. A NULL `merge_requested` marks a row written before
491
+ // this projection existed; only those legacy rows fall back to the #937 batched engine read.
492
+
493
+ /** A PR row carrying the durable merge-policy projection (issue #947): the shape every writer
494
+ * stamps from this fix on, so /status never pays an engine round-trip for it. */
495
+ function prRow947(n: number, over: Record<string, unknown> = {}) {
496
+ return { ...prRow936(n), merge_requested: 1, merge_human_approval: 0, ...over };
497
+ }
498
+
499
+ /** An engine double that counts calls and fails the test if it is read at all. */
500
+ function countingEngine(rows: { name: string; value: string; processInstanceKey: string; scopeKey: string }[] = []) {
501
+ const queries: { processInstanceKey?: string; scopeKey?: string; name?: string }[] = [];
502
+ return {
503
+ queries,
504
+ searchVariables: async (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
505
+ queries.push(q);
506
+ return rows
507
+ .filter(
508
+ (r) =>
509
+ (q.processInstanceKey == null || r.processInstanceKey === q.processInstanceKey) &&
510
+ (q.scopeKey == null || r.scopeKey === q.scopeKey) &&
511
+ (q.name == null || r.name === q.name),
512
+ )
513
+ .slice(0, 10);
514
+ },
515
+ } as any;
516
+ }
517
+
518
+ test("issue #947: N policy-stamped rows cost ZERO engine reads — /status serves its own projection", async () => {
519
+ // Red against the pre-fix shape: `activePrs` consulted the engine for every row, so this fails
520
+ // with 3N+ calls. Green when the merge policy is served from the `pull_requests` projection.
521
+ const N = 9; // the merlin fleet size that took 29.6 s
522
+ const rows = Array.from({ length: N }, (_, i) => prRow947(i + 1));
523
+ const engine = countingEngine();
524
+ const started = Date.now();
525
+ const res = (await handler(input(), { ...memApp(rows), engine } as AppApi)) as any;
526
+ const elapsed = Date.now() - started;
527
+ assertEquals(res.status, 200);
528
+ assertEquals(res.body.count, N);
529
+ assertEquals(
530
+ engine.queries.length,
531
+ 0,
532
+ "a row whose merge policy is projected on the pull_requests row never costs an engine round-trip",
533
+ );
534
+ assert(
535
+ elapsed < 1000,
536
+ `the projected read serves ${N} rows in well under the 2 s acceptance budget (took ${elapsed} ms)`,
537
+ );
538
+ for (const pr of res.body.prs) {
539
+ assertEquals(pr.merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
540
+ }
541
+ });
542
+
543
+ test("issue #947: the projected policy drives requested/blockedBy per row (review-only + approval gate)", async () => {
544
+ const rows = [
545
+ prRow947(1, { merge_requested: 0, merge_human_approval: 0 }), // review-only
546
+ prRow947(2, { merge_requested: 1, merge_human_approval: 1 }), // parked at merge-approval
547
+ prRow947(3, { merge_requested: 1, merge_human_approval: null }), // gated, approval outstanding
548
+ prRow947(4, { merge_requested: 1, merge_human_approval: 0 }), // approved / never gated
549
+ ];
550
+ const engine = countingEngine();
551
+ const res = (await handler(input(), { ...memApp(rows), engine } as AppApi)) as any;
552
+ assertEquals(engine.queries.length, 0);
553
+ const byKey = (k: string) => res.body.prs.find((p: any) => p.prKey === k).merge;
554
+ assertEquals(byKey("o/r#1"), { requested: false, enabled: true, effective: false, blockedBy: null, stale: false });
555
+ assertEquals(byKey("o/r#2"), { requested: true, enabled: true, effective: false, blockedBy: "human-approval", stale: false });
556
+ assertEquals(byKey("o/r#3"), { requested: true, enabled: true, effective: false, blockedBy: "human-approval", stale: false });
557
+ assertEquals(byKey("o/r#4"), { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
558
+ });
559
+
560
+ test("issue #947: ONLY legacy (unstamped) rows fall back to the batched engine read — stamped rows never do", async () => {
561
+ // Expand-and-contract back-compat: a row written before the projection existed carries
562
+ // `merge_requested: NULL`. It keeps the #937 batched engine read (3 constant calls for the whole
563
+ // legacy set, never per-row), while its stamped siblings are served from the projection.
564
+ const rows = [
565
+ prRow947(1, { merge_requested: 1, merge_human_approval: 0 }), // projected
566
+ prRow936(2), // legacy: no projection columns
567
+ ];
568
+ const engine = countingEngine([
569
+ v("PI-2", "autoMerge", false),
570
+ v("PI-2", "convergeOnly", true),
571
+ v("PI-2", "humanApproval", true),
572
+ ]);
573
+ const res = (await handler(input(), { ...memApp(rows), engine } as AppApi)) as any;
574
+ assertEquals(res.status, 200);
575
+ assertEquals(
576
+ engine.queries.length,
577
+ 3,
578
+ "the legacy fallback stays the constant 3 name-filtered reads — never a per-row fan-out",
579
+ );
580
+ const byKey = (k: string) => res.body.prs.find((p: any) => p.prKey === k).merge;
581
+ assertEquals(byKey("o/r#1"), { requested: true, enabled: true, effective: true, blockedBy: null, stale: false });
582
+ assertEquals(byKey("o/r#2"), { requested: false, enabled: true, effective: false, blockedBy: null, stale: false });
583
+ });
584
+
585
+ test("issue #947: a legacy row whose engine read fails degrades to the default, flagged stale", async () => {
586
+ // The timing guard's stale marker: when the policy cannot be verified (engine unreachable for a
587
+ // legacy row), the row is served immediately from the historical default and flagged `stale`
588
+ // rather than blocking the serve.
589
+ const failing = {
590
+ searchVariables: async () => {
591
+ throw new Error("engine unreachable");
592
+ },
593
+ } as any;
594
+ const res = (await handler(input(), { ...memApp([prRow936(1)]), engine: failing } as AppApi)) as any;
595
+ assertEquals(res.status, 200);
596
+ assertEquals(res.body.prs[0].merge, { requested: true, enabled: true, effective: true, blockedBy: null, stale: true });
597
+ });
598
+
599
+ // ── PR #950 review: the legacy fallback is BOUNDED and SELF-HEALING ───────────────────────────────
600
+ test("PR #950 review: truncated pages bound legacy per-instance probes, regardless of legacy row count", async () => {
601
+ const { MAX_PROBED_LEGACY_INSTANCES } = await import("../app/service.ts");
602
+ const N = 20; // an upgraded install: every row unstamped
603
+ const rows = Array.from({ length: N }, (_, i) => prRow936(i + 1));
604
+ // Every engine-wide page is truncated to nothing, so every legacy instance would need probes.
605
+ const queries: any[] = [];
606
+ const engine = {
607
+ searchVariables: async (q: any) => {
608
+ queries.push(q);
609
+ if (q.processInstanceKey == null) return [];
610
+ return [v(q.processInstanceKey, q.name, q.name === "autoMerge")];
611
+ },
612
+ } as any;
613
+ const res = (await handler(input(), { ...memApp(rows), engine } as AppApi)) as any;
614
+ assertEquals(res.status, 200);
615
+ assertEquals(res.body.count, N);
616
+ assert(
617
+ queries.length <= 3 + 3 * MAX_PROBED_LEGACY_INSTANCES,
618
+ `engine calls stay bounded (got ${queries.length} for ${N} legacy rows)`,
619
+ );
620
+ const stale = res.body.prs.filter((p: any) => p.merge.stale).length;
621
+ assertEquals(stale, N - MAX_PROBED_LEGACY_INSTANCES, "unprobed rows degrade to the stale default for this serve");
622
+ });
623
+
624
+ test("PR #950 review: a legacy row whose policy the engine proved is hydrated onto the projection", async () => {
625
+ const rows: any[] = [prRow936(1), prRow936(2)];
626
+ const engine = countingEngine([
627
+ v("PI-1", "autoMerge", false),
628
+ v("PI-1", "convergeOnly", true),
629
+ v("PI-1", "humanApproval", false),
630
+ v("PI-2", "autoMerge", true), // humanApproval PROVEN absent by its root probe (pre-humanApproval loop)
631
+ v("PI-2", "convergeOnly", false),
632
+ ]);
633
+ const app: any = memApp(rows);
634
+ const writes: unknown[][] = [];
635
+ app.data.open = () => ({
636
+ exec: async (sql: string, params: unknown[]) => {
637
+ assert(/WHERE "pr_key" = \? AND "process_key" = \? AND "merge_requested" IS NULL$/.test(sql), "guarded write-back");
638
+ writes.push(params);
639
+ return { changed: 1 };
640
+ },
641
+ });
642
+ const res = (await handler(input(), { ...app, engine } as AppApi)) as any;
643
+ assertEquals(res.status, 200);
644
+ assertEquals(
645
+ [...writes].sort((a, b) => String(a[2]).localeCompare(String(b[2]))),
646
+ [
647
+ [0, 0, "o/r#1", "PI-1"],
648
+ [1, 0, "o/r#2", "PI-2"],
649
+ ],
650
+ "complete reads (incl. proven-absent humanApproval) are written back",
651
+ );
652
+ });
653
+
654
+ test("PR #950 review: hydration never overwrites a concurrently stamped approval decision", async () => {
655
+ const rows: any[] = [prRow936(1)];
656
+ const engine = countingEngine([v("PI-1", "autoMerge", true), v("PI-1", "humanApproval", true)]);
657
+ const app: any = memApp(rows);
658
+ const sqls: string[] = [];
659
+ app.data.open = () => ({
660
+ exec: async (sql: string) => {
661
+ sqls.push(sql);
662
+ return { changed: 1 };
663
+ },
664
+ });
665
+ await handler(input(), { ...app, engine } as AppApi);
666
+ assertEquals(sqls.length, 1);
667
+ assert(/"merge_human_approval" = COALESCE\("merge_human_approval", \?\)/.test(sqls[0]), "approval stamp preserved");
668
+ });
669
+
670
+ test("PR #950 review: the bounded probe window rotates so unhydratable leading rows cannot starve later rows", async () => {
671
+ const { MAX_PROBED_LEGACY_INSTANCES } = await import("../app/service.ts");
672
+ const N = MAX_PROBED_LEGACY_INSTANCES * 3;
673
+ const rows = Array.from({ length: N }, (_, i) => prRow936(i + 1));
674
+ const probed = new Set<string>();
675
+ const engine = {
676
+ searchVariables: async (q: any) => {
677
+ if (q.processInstanceKey == null) return [];
678
+ probed.add(q.processInstanceKey);
679
+ return []; // nothing provable -> never hydrated, stays in the queue
680
+ },
681
+ } as any;
682
+ for (let i = 0; i < 3; i++) await handler(input(), { ...memApp(rows), engine } as AppApi);
683
+ assertEquals(probed.size, N, "every legacy row is eventually probed");
480
684
  });
@@ -20,11 +20,13 @@ export default defineOperation("listActivePrs", async ({ req }, app) => {
20
20
  app.log.warn("listActivePrs rejected: missing/invalid shared secret");
21
21
  return { status: 401, body: { error: "unauthorized" } };
22
22
  }
23
- // Per-phase timing (issue #936): /status regressed to ~13 s (and serialised concurrent callers
24
- // into proxy 502s) on an unprofiled per-row engine fan-out. The merge-policy engine read is now
25
- // ONE batched call inside `activePrs`, and these phase timings make any future regression
26
- // attributable from the logs instead of rediscovered by hand. Debug level: /status is a hot
27
- // polling endpoint, so this must not spam info.
23
+ // Per-phase timing (issue #936, budget raised for #947): /status regressed to ~13 s (and
24
+ // serialised concurrent callers into proxy 502s) on an unprofiled per-row engine fan-out, then
25
+ // to ~30 s on merlin when the batched fallback's page-cut probes still scaled with the fleet.
26
+ // The merge-policy engine read is now served from the app's OWN projection (zero engine reads
27
+ // for every projected row; the #937 batched read only for legacy rows), and these phase timings
28
+ // make any future regression attributable from the logs instead of rediscovered by hand. Debug
29
+ // level: /status is a hot polling endpoint, so this must not spam info.
28
30
  const t0 = Date.now();
29
31
  const [prs, deliveries] = await Promise.all([activePrs(app.data, app.engine), activeDeliveries(app.data)]);
30
32
  const readMs = Date.now() - t0;
@@ -42,7 +44,9 @@ export default defineOperation("listActivePrs", async ({ req }, app) => {
42
44
  return { status: 200, body: { count: prs.length, prs, deliveries, version: { version, gitSha } } };
43
45
  });
44
46
 
45
- /** The wall-clock budget over which a /status serve is logged as slow (issue #936). The endpoint
46
- * targets well under 1 s for a handful of rows; anything past this is a regression signal an
47
- * operator can grep for. */
48
- const STATUS_SLOW_MS = 1_000;
47
+ /** The wall-clock budget over which a /status serve is logged as slow (issue #947: raised from the
48
+ * #936 1 s to 5 s — the console-proxy timeout is the hard ceiling this must never approach; the
49
+ * acceptance target is 2 s at fleet scale, so 5 s leaves headroom that is still an unmistakable
50
+ * regression signal an operator can grep for). The endpoint targets well under 1 s for a handful
51
+ * of rows now that the merge policy is served from the app's own projection. */
52
+ const STATUS_SLOW_MS = 5_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.208.3",
3
+ "version": "0.208.5",
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",
@@ -17,8 +17,15 @@
17
17
  </nano:shapes>
18
18
  </bpmn:extensionElements>
19
19
  <bpmn:startEvent id="Start" name="Task">
20
- <bpmn:outgoing>ic_toImplement</bpmn:outgoing>
20
+ <bpmn:outgoing>ic_toSliceBranch</bpmn:outgoing>
21
21
  </bpmn:startEvent>
22
+ <bpmn:serviceTask id="slice-branch" name="Stamp slice branch">
23
+ <bpmn:extensionElements>
24
+ <zeebe:taskDefinition type="pr.slice-branch" />
25
+ </bpmn:extensionElements>
26
+ <bpmn:incoming>ic_toSliceBranch</bpmn:incoming>
27
+ <bpmn:outgoing>ic_toImplement</bpmn:outgoing>
28
+ </bpmn:serviceTask>
22
29
  <bpmn:serviceTask id="implement-task" name="Implement (agent)">
23
30
  <bpmn:extensionElements>
24
31
  <zeebe:taskDefinition type="senior:feature" />
@@ -143,7 +150,8 @@
143
150
  <bpmn:incoming>ic_reconciled</bpmn:incoming>
144
151
  <bpmn:incoming>ic_abandon</bpmn:incoming>
145
152
  </bpmn:endEvent>
146
- <bpmn:sequenceFlow id="ic_toImplement" sourceRef="Start" targetRef="implement-task" />
153
+ <bpmn:sequenceFlow id="ic_toSliceBranch" sourceRef="Start" targetRef="slice-branch" />
154
+ <bpmn:sequenceFlow id="ic_toImplement" sourceRef="slice-branch" targetRef="implement-task" />
147
155
  <bpmn:sequenceFlow id="ic_toGw" sourceRef="implement-task" targetRef="ic_gw" />
148
156
  <bpmn:sequenceFlow id="ic_done" name="done" sourceRef="ic_gw" targetRef="ic_end">
149
157
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "opened" or status = "blocked" or status = "skipped"</bpmn:conditionExpression>
@@ -175,149 +183,156 @@
175
183
  <dc:Bounds x="81" y="143" width="34" height="14" />
176
184
  </bpmndi:BPMNLabel>
177
185
  </bpmndi:BPMNShape>
178
- <bpmndi:BPMNShape id="BPMNShape_implement-task" bpmnElement="implement-task">
186
+ <bpmndi:BPMNShape id="BPMNShape_slice-branch" bpmnElement="slice-branch">
179
187
  <dc:Bounds x="216" y="80" width="100" height="80" />
180
188
  </bpmndi:BPMNShape>
189
+ <bpmndi:BPMNShape id="BPMNShape_implement-task" bpmnElement="implement-task">
190
+ <dc:Bounds x="416" y="80" width="100" height="80" />
191
+ </bpmndi:BPMNShape>
181
192
  <bpmndi:BPMNShape id="BPMNShape_ic_gw" bpmnElement="ic_gw" isMarkerVisible="true">
182
- <dc:Bounds x="441" y="95" width="50" height="50" />
193
+ <dc:Bounds x="641" y="95" width="50" height="50" />
183
194
  <bpmndi:BPMNLabel>
184
- <dc:Bounds x="431" y="62" width="70" height="28" />
195
+ <dc:Bounds x="631" y="62" width="70" height="28" />
185
196
  </bpmndi:BPMNLabel>
186
197
  </bpmndi:BPMNShape>
187
198
  <bpmndi:BPMNShape id="BPMNShape_reconcile-implement" bpmnElement="reconcile-implement">
188
- <dc:Bounds x="616" y="80" width="100" height="80" />
199
+ <dc:Bounds x="816" y="80" width="100" height="80" />
189
200
  </bpmndi:BPMNShape>
190
201
  <bpmndi:BPMNShape id="BPMNShape_ic_reconcile_gw" bpmnElement="ic_reconcile_gw" isMarkerVisible="true">
191
- <dc:Bounds x="816" y="95" width="50" height="50" />
202
+ <dc:Bounds x="1016" y="95" width="50" height="50" />
192
203
  <bpmndi:BPMNLabel>
193
- <dc:Bounds x="801" y="76" width="81" height="14" />
204
+ <dc:Bounds x="1001" y="76" width="81" height="14" />
194
205
  </bpmndi:BPMNLabel>
195
206
  </bpmndi:BPMNShape>
196
207
  <bpmndi:BPMNShape id="BPMNShape_escalate" bpmnElement="escalate">
197
- <dc:Bounds x="1166" y="80" width="100" height="80" />
208
+ <dc:Bounds x="1366" y="80" width="100" height="80" />
198
209
  </bpmndi:BPMNShape>
199
210
  <bpmndi:BPMNShape id="BPMNShape_record-escalation" bpmnElement="record-escalation">
200
- <dc:Bounds x="966" y="80" width="100" height="80" />
211
+ <dc:Bounds x="1166" y="80" width="100" height="80" />
201
212
  </bpmndi:BPMNShape>
202
213
  <bpmndi:BPMNShape id="BPMNShape_record-escalation-sla" bpmnElement="record-escalation-sla">
203
- <dc:Bounds x="416" y="400" width="100" height="80" />
214
+ <dc:Bounds x="616" y="400" width="100" height="80" />
204
215
  </bpmndi:BPMNShape>
205
216
  <bpmndi:BPMNShape id="BPMNShape_ic_gw_answer" bpmnElement="ic_gw_answer" isMarkerVisible="true">
206
- <dc:Bounds x="1366" y="95" width="50" height="50" />
217
+ <dc:Bounds x="1566" y="95" width="50" height="50" />
207
218
  <bpmndi:BPMNLabel>
208
- <dc:Bounds x="1323" y="150" width="56" height="14" />
219
+ <dc:Bounds x="1523" y="150" width="56" height="14" />
209
220
  </bpmndi:BPMNLabel>
210
221
  </bpmndi:BPMNShape>
211
222
  <bpmndi:BPMNShape id="BPMNShape_record-implementing" bpmnElement="record-implementing">
212
- <dc:Bounds x="1516" y="240" width="100" height="80" />
223
+ <dc:Bounds x="1716" y="240" width="100" height="80" />
213
224
  </bpmndi:BPMNShape>
214
225
  <bpmndi:BPMNShape id="BPMNShape_ic_end" bpmnElement="ic_end">
215
- <dc:Bounds x="1548" y="102" width="36" height="36" />
226
+ <dc:Bounds x="1748" y="102" width="36" height="36" />
216
227
  <bpmndi:BPMNLabel>
217
- <dc:Bounds x="1493" y="143" width="66" height="14" />
228
+ <dc:Bounds x="1693" y="143" width="66" height="14" />
218
229
  </bpmndi:BPMNLabel>
219
230
  </bpmndi:BPMNShape>
220
231
  <bpmndi:BPMNShape id="BPMNShape_be_implement_sla" bpmnElement="be_implement_sla">
221
- <dc:Bounds x="248" y="142" width="36" height="36" />
232
+ <dc:Bounds x="448" y="142" width="36" height="36" />
222
233
  <bpmndi:BPMNLabel>
223
- <dc:Bounds x="170" y="183" width="72" height="28" />
234
+ <dc:Bounds x="370" y="183" width="72" height="28" />
224
235
  </bpmndi:BPMNLabel>
225
236
  </bpmndi:BPMNShape>
226
- <bpmndi:BPMNEdge id="BPMNEdge_ic_toImplement" bpmnElement="ic_toImplement">
237
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toSliceBranch" bpmnElement="ic_toSliceBranch">
227
238
  <di:waypoint x="116" y="120" />
228
239
  <di:waypoint x="216" y="120" />
229
240
  </bpmndi:BPMNEdge>
230
- <bpmndi:BPMNEdge id="BPMNEdge_ic_toGw" bpmnElement="ic_toGw">
241
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toImplement" bpmnElement="ic_toImplement">
231
242
  <di:waypoint x="316" y="120" />
232
- <di:waypoint x="441" y="120" />
243
+ <di:waypoint x="416" y="120" />
244
+ </bpmndi:BPMNEdge>
245
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toGw" bpmnElement="ic_toGw">
246
+ <di:waypoint x="516" y="120" />
247
+ <di:waypoint x="641" y="120" />
233
248
  </bpmndi:BPMNEdge>
234
249
  <bpmndi:BPMNEdge id="BPMNEdge_ic_escalate" bpmnElement="ic_escalate">
235
- <di:waypoint x="491" y="120" />
236
- <di:waypoint x="616" y="120" />
250
+ <di:waypoint x="691" y="120" />
251
+ <di:waypoint x="816" y="120" />
237
252
  <bpmndi:BPMNLabel>
238
- <dc:Bounds x="520" y="98" width="67" height="14" />
253
+ <dc:Bounds x="720" y="98" width="67" height="14" />
239
254
  </bpmndi:BPMNLabel>
240
255
  </bpmndi:BPMNEdge>
241
256
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toReconcileGw" bpmnElement="ic_toReconcileGw">
242
- <di:waypoint x="716" y="120" />
243
- <di:waypoint x="816" y="120" />
257
+ <di:waypoint x="916" y="120" />
258
+ <di:waypoint x="1016" y="120" />
244
259
  </bpmndi:BPMNEdge>
245
260
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toRecordEscalation" bpmnElement="ic_toRecordEscalation">
246
- <di:waypoint x="866" y="120" />
247
- <di:waypoint x="966" y="120" />
261
+ <di:waypoint x="1066" y="120" />
262
+ <di:waypoint x="1166" y="120" />
248
263
  <bpmndi:BPMNLabel>
249
- <dc:Bounds x="883" y="128" width="67" height="14" />
264
+ <dc:Bounds x="1083" y="128" width="67" height="14" />
250
265
  </bpmndi:BPMNLabel>
251
266
  </bpmndi:BPMNEdge>
252
267
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toEscalate" bpmnElement="ic_toEscalate">
253
- <di:waypoint x="1066" y="120" />
254
- <di:waypoint x="1166" y="120" />
255
- </bpmndi:BPMNEdge>
256
- <bpmndi:BPMNEdge id="BPMNEdge_ic_toAnswerGw" bpmnElement="ic_toAnswerGw">
257
268
  <di:waypoint x="1266" y="120" />
258
269
  <di:waypoint x="1366" y="120" />
259
270
  </bpmndi:BPMNEdge>
271
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toAnswerGw" bpmnElement="ic_toAnswerGw">
272
+ <di:waypoint x="1466" y="120" />
273
+ <di:waypoint x="1566" y="120" />
274
+ </bpmndi:BPMNEdge>
260
275
  <bpmndi:BPMNEdge id="BPMNEdge_ic_abandon" bpmnElement="ic_abandon">
261
- <di:waypoint x="1416" y="120" />
262
- <di:waypoint x="1548" y="120" />
276
+ <di:waypoint x="1616" y="120" />
277
+ <di:waypoint x="1748" y="120" />
263
278
  <bpmndi:BPMNLabel>
264
- <dc:Bounds x="1456" y="98" width="53" height="14" />
279
+ <dc:Bounds x="1656" y="98" width="53" height="14" />
265
280
  </bpmndi:BPMNLabel>
266
281
  </bpmndi:BPMNEdge>
267
282
  <bpmndi:BPMNEdge id="BPMNEdge_ic_retry" bpmnElement="ic_retry">
268
- <di:waypoint x="841" y="145" />
269
- <di:waypoint x="841" y="280" />
270
- <di:waypoint x="1516" y="280" />
283
+ <di:waypoint x="1041" y="145" />
284
+ <di:waypoint x="1041" y="280" />
285
+ <di:waypoint x="1716" y="280" />
271
286
  <bpmndi:BPMNLabel>
272
- <dc:Bounds x="846" y="223" width="39" height="14" />
287
+ <dc:Bounds x="1046" y="223" width="39" height="14" />
273
288
  </bpmndi:BPMNLabel>
274
289
  </bpmndi:BPMNEdge>
275
290
  <bpmndi:BPMNEdge id="BPMNEdge_ic_answerLoop" bpmnElement="ic_answerLoop">
276
- <di:waypoint x="1391" y="145" />
277
- <di:waypoint x="1391" y="280" />
278
- <di:waypoint x="1516" y="280" />
291
+ <di:waypoint x="1591" y="145" />
292
+ <di:waypoint x="1591" y="280" />
293
+ <di:waypoint x="1716" y="280" />
279
294
  <bpmndi:BPMNLabel>
280
- <dc:Bounds x="1396" y="206" width="49" height="14" />
295
+ <dc:Bounds x="1596" y="206" width="49" height="14" />
281
296
  </bpmndi:BPMNLabel>
282
297
  </bpmndi:BPMNEdge>
283
298
  <bpmndi:BPMNEdge id="BPMNEdge_ic_done" bpmnElement="ic_done">
284
- <di:waypoint x="466" y="145" />
285
- <di:waypoint x="466" y="200" />
286
- <di:waypoint x="1566" y="200" />
287
- <di:waypoint x="1566" y="138" />
299
+ <di:waypoint x="666" y="145" />
300
+ <di:waypoint x="666" y="200" />
301
+ <di:waypoint x="1766" y="200" />
302
+ <di:waypoint x="1766" y="138" />
288
303
  <bpmndi:BPMNLabel>
289
- <dc:Bounds x="1000" y="208" width="32" height="14" />
304
+ <dc:Bounds x="1200" y="208" width="32" height="14" />
290
305
  </bpmndi:BPMNLabel>
291
306
  </bpmndi:BPMNEdge>
292
307
  <bpmndi:BPMNEdge id="BPMNEdge_ic_reconciled" bpmnElement="ic_reconciled">
293
- <di:waypoint x="841" y="145" />
294
- <di:waypoint x="841" y="180" />
295
- <di:waypoint x="1566" y="180" />
296
- <di:waypoint x="1566" y="138" />
308
+ <di:waypoint x="1041" y="145" />
309
+ <di:waypoint x="1041" y="180" />
310
+ <di:waypoint x="1766" y="180" />
311
+ <di:waypoint x="1766" y="138" />
297
312
  <bpmndi:BPMNLabel>
298
- <dc:Bounds x="1097" y="158" width="53" height="14" />
313
+ <dc:Bounds x="1297" y="158" width="53" height="14" />
299
314
  </bpmndi:BPMNLabel>
300
315
  </bpmndi:BPMNEdge>
301
316
  <bpmndi:BPMNEdge id="BPMNEdge_ic_slaToEscalate" bpmnElement="ic_slaToEscalate">
302
- <di:waypoint x="516" y="440" />
303
- <di:waypoint x="1636" y="440" />
304
- <di:waypoint x="1636" y="82" />
305
- <di:waypoint x="1266" y="82" />
317
+ <di:waypoint x="716" y="440" />
318
+ <di:waypoint x="1836" y="440" />
319
+ <di:waypoint x="1836" y="82" />
320
+ <di:waypoint x="1466" y="82" />
306
321
  </bpmndi:BPMNEdge>
307
322
  <bpmndi:BPMNEdge id="BPMNEdge_ic_sla" bpmnElement="ic_sla">
308
- <di:waypoint x="266" y="178" />
309
- <di:waypoint x="266" y="440" />
310
- <di:waypoint x="416" y="440" />
323
+ <di:waypoint x="466" y="178" />
324
+ <di:waypoint x="466" y="440" />
325
+ <di:waypoint x="616" y="440" />
311
326
  <bpmndi:BPMNLabel>
312
- <dc:Bounds x="271" y="295" width="70" height="28" />
327
+ <dc:Bounds x="471" y="295" width="70" height="28" />
313
328
  </bpmndi:BPMNLabel>
314
329
  </bpmndi:BPMNEdge>
315
330
  <bpmndi:BPMNEdge id="BPMNEdge_ic_reImplement" bpmnElement="ic_reImplement">
316
- <di:waypoint x="1516" y="280" />
331
+ <di:waypoint x="1716" y="280" />
317
332
  <di:waypoint x="60" y="280" />
318
333
  <di:waypoint x="60" y="60" />
319
- <di:waypoint x="266" y="60" />
320
- <di:waypoint x="266" y="80" />
334
+ <di:waypoint x="466" y="60" />
335
+ <di:waypoint x="466" y="80" />
321
336
  </bpmndi:BPMNEdge>
322
337
  </bpmndi:BPMNPlane>
323
338
  </bpmndi:BPMNDiagram>
@@ -60,3 +60,14 @@ test("caps-prepare: a malformed need is dropped — only a valid remainder gates
60
60
  );
61
61
  assertEquals(mixed.hasNeeds, true, "a valid need still gates even alongside a malformed one");
62
62
  });
63
+
64
+ // Issue #945: caps-prepare runs in the plan-fanout MI body, whose un-declared result keys land on the
65
+ // SHARED epic root — so it must NOT stamp the per-slice branch.create (a sibling would overwrite it).
66
+ // The emitter lives in each implement-cell instance (`pr.slice-branch`) instead.
67
+ test("caps-prepare: does NOT emit the per-slice branch.create (it would leak across MI siblings) (#945)", async () => {
68
+ const out = await handler(
69
+ { variables: { planKey: "owner/repo#7", taskId: "t1", needs: [] } } as any,
70
+ {} as any,
71
+ );
72
+ assertEquals(Object.keys(out).filter((k) => k.startsWith("io.nanobpm.agentTask")), []);
73
+ });
@@ -0,0 +1,31 @@
1
+ // Unit coverage for `pr.slice-branch` (issue #945): the per-slice deterministic `branch.create`,
2
+ // emitted as the FLAT dotted variable the c8ctl harness reads, derived from the ONE canonical
3
+ // `sliceBranchCreate`.
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
6
+ import { AGENT_TASK_BRANCH_CREATE_VAR, sliceBranchCreate } from "../../app/repoEnvelope.ts";
7
+ import handler from "./worker.ts";
8
+
9
+ async function run(task: unknown): Promise<Record<string, unknown>> {
10
+ return (await handler({ variables: { task } } as any, {} as any)) ?? {};
11
+ }
12
+
13
+ test("slice-branch: stamps feat/<task.id> as the flat dotted branch.create (#945)", async () => {
14
+ const out = await run({ id: "t1", prompt: "x" });
15
+ assertEquals(out[AGENT_TASK_BRANCH_CREATE_VAR], "feat/t1");
16
+ assertEquals(out[AGENT_TASK_BRANCH_CREATE_VAR], sliceBranchCreate("t1"));
17
+ });
18
+
19
+ test("slice-branch: distinct slices derive distinct branches (sibling isolation is per-instance) (#945)", async () => {
20
+ const a = await run({ id: "t1" });
21
+ const b = await run({ id: "t2" });
22
+ assertEquals(a[AGENT_TASK_BRANCH_CREATE_VAR], "feat/t1");
23
+ assertEquals(b[AGENT_TASK_BRANCH_CREATE_VAR], "feat/t2");
24
+ });
25
+
26
+ test("slice-branch: a missing/blank/implausible/non-string id emits nothing (#945)", async () => {
27
+ for (const task of [undefined, null, "t1", [], {}, { id: "" }, { id: " " }, { id: "bad..id" }, { id: 7 }]) {
28
+ const out = await run(task);
29
+ assertEquals(AGENT_TASK_BRANCH_CREATE_VAR in out, false, `no branch.create for ${JSON.stringify(task)}`);
30
+ }
31
+ });