@nanobpm/nano-workforce 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.44.0](https://github.com/nanobpm/nano-workforce/compare/v0.43.0...v0.44.0) (2026-08-11)
2
+
3
+
4
+ ### Features
5
+
6
+ * **merge-loop:** wait on a blocking PR instead of escalating to a human ([#122](https://github.com/nanobpm/nano-workforce/issues/122)) ([aec738f](https://github.com/nanobpm/nano-workforce/commit/aec738f165df5a216b9094b4eb9b5b6d4a7ac419))
7
+
1
8
  # [0.43.0](https://github.com/nanobpm/nano-workforce/compare/v0.42.0...v0.43.0) (2026-08-11)
2
9
 
3
10
 
package/SPEC.md CHANGED
@@ -368,11 +368,21 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
368
368
  exhausted, the agent reports `blocked`, or the branch is in `conflict` does it fall
369
369
  through to the human escalation path.
370
370
 
371
+ - **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
372
+ the PR cannot land because **another PR must merge first** (a required linked-issue
373
+ gate a sibling PR will close, a stacked base PR, or a `Depends-on:` the agent read
374
+ from the PR/issue text). That is an ordering **wait, not a human decision**: the
375
+ agent returns `status: "waiting-on-pr"` with a `dependsOn` list of `owner/repo#N`
376
+ refs. `pr.record-dependency` appends those edges to `pr_dependencies` and parks the
377
+ PR back at *wait: deps merged*, so the same poller pass lands it automatically once
378
+ the named PRs merge — no escalation is opened.
379
+
371
380
  - **Dependencies** — `pr_dependencies(pr_key, depends_on_key)` (migration 004).
372
- Declared two ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
373
- submit) and/or a `dependsOn` array on the submit request. `merge-loop` parks at
374
- *wait: deps merged*; the poller checks each dependency (own tracked row first,
375
- else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
381
+ Declared three ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
382
+ submit), a `dependsOn` array on the submit request, and/or **discovered at merge
383
+ time** by a `fix-ci`/`rebase` agent (`status: "waiting-on-pr"`, above). `merge-loop`
384
+ parks at *wait: deps merged*; the poller checks each dependency (own tracked row
385
+ first, else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
376
386
  - **Mergeability** — the poller classifies GitHub's `mergeStateStatus`:
377
387
  `CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND` → `ready`; `DIRTY` → `conflict`;
378
388
  `BLOCKED` → `blocked` if a required check is failing, else keep waiting;
package/nano.app.json CHANGED
@@ -87,6 +87,10 @@
87
87
  "taskType": "pr.mark-merged",
88
88
  "handler": "workers/mark-merged/worker.ts"
89
89
  },
90
+ {
91
+ "taskType": "pr.record-dependency",
92
+ "handler": "workers/record-dependency/worker.ts"
93
+ },
90
94
  {
91
95
  "taskType": "pr.record-plan",
92
96
  "handler": "workers/record-plan/worker.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
package/prompts/fix-ci.md CHANGED
@@ -56,9 +56,20 @@ the PR's checks yourself (`gh pr checks`, `gh run view`).
56
56
  Return a structured result:
57
57
 
58
58
  - `status: "fixed"` — you pushed a fix you believe makes the failing checks pass.
59
- - `status: "blocked"` — you could **not** fix it (e.g. the failure needs a human
60
- decision, a secret, or an upstream change). Set `question` to a concise,
61
- specific description of what is blocking and what a human must decide.
59
+ - `status: "waiting-on-pr"` — the PR cannot merge yet because **another PR must land
60
+ first**, and this is an ordering constraint, not a defect: e.g. the failing check
61
+ is a required linked-issue / "closes #N" gate that a sibling PR will satisfy, the
62
+ PR is stacked on a base PR that has not merged, or the PR body / an issue it
63
+ references says it depends on another PR. This is a **wait, not an escalation** — do
64
+ **not** ask a human to babysit it. Set `dependsOn` to the PR(s) that must merge
65
+ first, as `owner/repo#N` refs (or PR URLs), separated by commas or spaces. The
66
+ process records the dependency and automatically re-attempts the merge once every
67
+ named PR has landed.
68
+ - `status: "blocked"` — you could **not** fix it and it genuinely needs a human
69
+ **decision** (a secret, an upstream change, or a judgement call). Set `question` to a
70
+ concise, specific description of what is blocking and what a human must decide.
71
+ Reserve this for a real decision — if the PR is merely waiting on another PR, use
72
+ `waiting-on-pr` instead so no human is pulled in.
62
73
 
63
74
  Never report `fixed` unless you actually pushed a change. If nothing was wrong on
64
75
  the branch (the failure was transient infrastructure), say so in `summary` and
package/prompts/rebase.md CHANGED
@@ -67,10 +67,20 @@ Return a structured result:
67
67
  - `status: "rebased"` — the branch tip now contains the latest base: you resolved
68
68
  any conflicts mechanically and pushed, **or** it was already up to date. The
69
69
  process will re-attempt the merge.
70
+ - `status: "waiting-on-pr"` — the PR cannot merge yet because **another PR must land
71
+ first**, and this is an ordering constraint, not a conflict you can resolve: e.g.
72
+ the branch is stacked on a base PR that has not merged, or the PR body / an issue
73
+ it references says it depends on another PR that must close a blocking issue first.
74
+ This is a **wait, not an escalation** — do **not** ask a human to babysit it. Set
75
+ `dependsOn` to the PR(s) that must merge first, as `owner/repo#N` refs (or PR URLs),
76
+ separated by commas or spaces. The process records the dependency and automatically
77
+ re-attempts the merge once every named PR has landed.
70
78
  - `status: "blocked"` — you could **not** resolve it mechanically (a genuine
71
79
  semantic conflict where two changes contradict and a human must decide which
72
80
  behaviour wins, or the branch is un-rebaseable). Set `question` to a concise,
73
81
  specific description of the conflicting intent and the decision a human must make.
82
+ Reserve this for a real decision — if the PR is merely waiting on another PR to land
83
+ first, use `waiting-on-pr` instead so no human is pulled in.
74
84
 
75
85
  Report `rebased` when the branch tip now contains the latest base — either
76
86
  because you pushed a resolved update, or because it was **already up to date**
@@ -76,6 +76,7 @@
76
76
  <nano:extend name="status" type="string" />
77
77
  <nano:extend name="summary" type="string" optional="true" />
78
78
  <nano:extend name="question" type="string" optional="true" />
79
+ <nano:extend name="dependsOn" type="string" optional="true" />
79
80
  </nano:shape>
80
81
  <nano:shape id="RebaseIn" name="Rebase — input">
81
82
  <nano:extend name="prKey" type="string" />
@@ -88,6 +89,11 @@
88
89
  <nano:extend name="status" type="string" />
89
90
  <nano:extend name="summary" type="string" optional="true" />
90
91
  <nano:extend name="question" type="string" optional="true" />
92
+ <nano:extend name="dependsOn" type="string" optional="true" />
93
+ </nano:shape>
94
+ <nano:shape id="RecordDepIn" name="Record discovered dependency — input">
95
+ <nano:extend name="prKey" type="string" />
96
+ <nano:extend name="dependsOn" type="string" optional="true" />
91
97
  </nano:shape>
92
98
  <nano:shape id="MergeEscalationAnswered" name="escalation-answered message payload">
93
99
  <nano:extend name="answer" type="string" />
@@ -100,6 +106,7 @@
100
106
  </bpmn:startEvent>
101
107
  <bpmn:intermediateCatchEvent id="wait-deps" name="Wait: dependencies merged">
102
108
  <bpmn:incoming>f_m_start</bpmn:incoming>
109
+ <bpmn:incoming>f_dep_rewait</bpmn:incoming>
103
110
  <bpmn:outgoing>f_m_deps</bpmn:outgoing>
104
111
  <bpmn:messageEventDefinition id="med_depsCleared" messageRef="Message_depsCleared" />
105
112
  </bpmn:intermediateCatchEvent>
@@ -237,6 +244,7 @@
237
244
  <bpmn:exclusiveGateway id="gw-ci-result" name="fixed?" default="f_ci_blocked">
238
245
  <bpmn:incoming>f_ci_done</bpmn:incoming>
239
246
  <bpmn:outgoing>f_ci_fixed</bpmn:outgoing>
247
+ <bpmn:outgoing>f_ci_wait</bpmn:outgoing>
240
248
  <bpmn:outgoing>f_ci_blocked</bpmn:outgoing>
241
249
  </bpmn:exclusiveGateway>
242
250
  <bpmn:exclusiveGateway id="gw-rebase" name="auto-rebase?" default="f_reb_giveup">
@@ -265,8 +273,20 @@
265
273
  <bpmn:exclusiveGateway id="gw-rebase-result" name="rebased?" default="f_reb_blocked">
266
274
  <bpmn:incoming>f_reb_done</bpmn:incoming>
267
275
  <bpmn:outgoing>f_reb_rebased</bpmn:outgoing>
276
+ <bpmn:outgoing>f_reb_wait</bpmn:outgoing>
268
277
  <bpmn:outgoing>f_reb_blocked</bpmn:outgoing>
269
278
  </bpmn:exclusiveGateway>
279
+ <bpmn:serviceTask id="record-merge-dep" name="Wait on another PR">
280
+ <bpmn:extensionElements>
281
+ <zeebe:taskDefinition type="pr.record-dependency" />
282
+ <zeebe:properties>
283
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordDepIn" />
284
+ </zeebe:properties>
285
+ </bpmn:extensionElements>
286
+ <bpmn:incoming>f_ci_wait</bpmn:incoming>
287
+ <bpmn:incoming>f_reb_wait</bpmn:incoming>
288
+ <bpmn:outgoing>f_dep_rewait</bpmn:outgoing>
289
+ </bpmn:serviceTask>
270
290
  <bpmn:sequenceFlow id="f_m_start" sourceRef="MergeStart" targetRef="wait-deps" />
271
291
  <bpmn:sequenceFlow id="f_m_deps" sourceRef="wait-deps" targetRef="arm-merge" />
272
292
  <bpmn:sequenceFlow id="f_m_arm" sourceRef="arm-merge" targetRef="wait-mergeable" />
@@ -290,6 +310,9 @@
290
310
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "fixed"</bpmn:conditionExpression>
291
311
  </bpmn:sequenceFlow>
292
312
  <bpmn:sequenceFlow id="f_ci_blocked" name="could not fix" sourceRef="gw-ci-result" targetRef="merge-esc-attempt" />
313
+ <bpmn:sequenceFlow id="f_ci_wait" name="waits on another PR" sourceRef="gw-ci-result" targetRef="record-merge-dep">
314
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
315
+ </bpmn:sequenceFlow>
293
316
  <bpmn:sequenceFlow id="f_reb_go" name="within budget" sourceRef="gw-rebase" targetRef="rebase">
294
317
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=rebaseRound &lt; rebaseMax</bpmn:conditionExpression>
295
318
  </bpmn:sequenceFlow>
@@ -299,6 +322,10 @@
299
322
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "rebased"</bpmn:conditionExpression>
300
323
  </bpmn:sequenceFlow>
301
324
  <bpmn:sequenceFlow id="f_reb_blocked" name="could not resolve" sourceRef="gw-rebase-result" targetRef="merge-esc-attempt" />
325
+ <bpmn:sequenceFlow id="f_reb_wait" name="waits on another PR" sourceRef="gw-rebase-result" targetRef="record-merge-dep">
326
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
327
+ </bpmn:sequenceFlow>
328
+ <bpmn:sequenceFlow id="f_dep_rewait" sourceRef="record-merge-dep" targetRef="wait-deps" />
302
329
  <bpmn:sequenceFlow id="f_m_attempt" sourceRef="attempt-merge" targetRef="gw-merge" />
303
330
  <bpmn:sequenceFlow id="f_m_gMerged" name="merged" sourceRef="gw-merge" targetRef="mark-merged">
304
331
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "merged"</bpmn:conditionExpression>
@@ -327,7 +354,7 @@
327
354
  <bpmndi:BPMNShape id="BPMNShape_wait-deps" bpmnElement="wait-deps">
328
355
  <dc:Bounds x="216" y="102" width="36" height="36" />
329
356
  <bpmndi:BPMNLabel>
330
- <dc:Bounds x="190" y="143" width="88" height="42" />
357
+ <dc:Bounds x="190" y="55" width="88" height="42" />
331
358
  </bpmndi:BPMNLabel>
332
359
  </bpmndi:BPMNShape>
333
360
  <bpmndi:BPMNShape id="BPMNShape_arm-merge" bpmnElement="arm-merge">
@@ -415,14 +442,17 @@
415
442
  </bpmndi:BPMNLabel>
416
443
  </bpmndi:BPMNShape>
417
444
  <bpmndi:BPMNShape id="BPMNShape_rebase" bpmnElement="rebase">
418
- <dc:Bounds x="1038" y="1040" width="100" height="80" />
445
+ <dc:Bounds x="1038" y="1200" width="100" height="80" />
419
446
  </bpmndi:BPMNShape>
420
447
  <bpmndi:BPMNShape id="BPMNShape_gw-rebase-result" bpmnElement="gw-rebase-result" isMarkerVisible="true">
421
- <dc:Bounds x="1238" y="1055" width="50" height="50" />
448
+ <dc:Bounds x="1238" y="1215" width="50" height="50" />
422
449
  <bpmndi:BPMNLabel>
423
- <dc:Bounds x="1233" y="1036" width="60" height="14" />
450
+ <dc:Bounds x="1173" y="1233" width="60" height="14" />
424
451
  </bpmndi:BPMNLabel>
425
452
  </bpmndi:BPMNShape>
453
+ <bpmndi:BPMNShape id="BPMNShape_record-merge-dep" bpmnElement="record-merge-dep">
454
+ <dc:Bounds x="1388" y="1040" width="100" height="80" />
455
+ </bpmndi:BPMNShape>
426
456
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_start" bpmnElement="f_m_start">
427
457
  <di:waypoint x="116" y="120" />
428
458
  <di:waypoint x="216" y="120" />
@@ -473,7 +503,7 @@
473
503
  <di:waypoint x="1438" y="920" />
474
504
  <di:waypoint x="1438" y="640" />
475
505
  <bpmndi:BPMNLabel>
476
- <dc:Bounds x="1319" y="898" width="89" height="14" />
506
+ <dc:Bounds x="1319" y="928" width="89" height="14" />
477
507
  </bpmndi:BPMNLabel>
478
508
  </bpmndi:BPMNEdge>
479
509
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_giveup" bpmnElement="f_reb_giveup">
@@ -485,11 +515,12 @@
485
515
  </bpmndi:BPMNLabel>
486
516
  </bpmndi:BPMNEdge>
487
517
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
488
- <di:waypoint x="1288" y="1080" />
489
- <di:waypoint x="1438" y="1080" />
518
+ <di:waypoint x="1263" y="1215" />
519
+ <di:waypoint x="1263" y="1020" />
520
+ <di:waypoint x="1438" y="1020" />
490
521
  <di:waypoint x="1438" y="640" />
491
522
  <bpmndi:BPMNLabel>
492
- <dc:Bounds x="1331" y="1047" width="64" height="28" />
523
+ <dc:Bounds x="1319" y="987" width="64" height="28" />
493
524
  </bpmndi:BPMNLabel>
494
525
  </bpmndi:BPMNEdge>
495
526
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
@@ -552,17 +583,33 @@
552
583
  <dc:Bounds x="893" y="839" width="49" height="28" />
553
584
  </bpmndi:BPMNLabel>
554
585
  </bpmndi:BPMNEdge>
586
+ <bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
587
+ <di:waypoint x="1288" y="920" />
588
+ <di:waypoint x="1438" y="920" />
589
+ <di:waypoint x="1438" y="1040" />
590
+ <bpmndi:BPMNLabel>
591
+ <dc:Bounds x="1326" y="887" width="75" height="28" />
592
+ </bpmndi:BPMNLabel>
593
+ </bpmndi:BPMNEdge>
555
594
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_go" bpmnElement="f_reb_go">
556
595
  <di:waypoint x="913" y="280" />
557
596
  <di:waypoint x="933" y="280" />
558
597
  <di:waypoint x="933" y="305" />
559
598
  <di:waypoint x="1158" y="305" />
560
- <di:waypoint x="1158" y="1080" />
561
- <di:waypoint x="1138" y="1080" />
599
+ <di:waypoint x="1158" y="1240" />
600
+ <di:waypoint x="1138" y="1240" />
562
601
  <bpmndi:BPMNLabel>
563
602
  <dc:Bounds x="1101" y="310" width="49" height="28" />
564
603
  </bpmndi:BPMNLabel>
565
604
  </bpmndi:BPMNEdge>
605
+ <bpmndi:BPMNEdge id="BPMNEdge_f_reb_wait" bpmnElement="f_reb_wait">
606
+ <di:waypoint x="1263" y="1215" />
607
+ <di:waypoint x="1263" y="1080" />
608
+ <di:waypoint x="1388" y="1080" />
609
+ <bpmndi:BPMNLabel>
610
+ <dc:Bounds x="1288" y="1047" width="75" height="28" />
611
+ </bpmndi:BPMNLabel>
612
+ </bpmndi:BPMNEdge>
566
613
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gQueued" bpmnElement="f_m_gQueued">
567
614
  <di:waypoint x="1088" y="145" />
568
615
  <di:waypoint x="1088" y="280" />
@@ -593,12 +640,12 @@
593
640
  <di:waypoint x="1238" y="920" />
594
641
  </bpmndi:BPMNEdge>
595
642
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_done" bpmnElement="f_reb_done">
596
- <di:waypoint x="1138" y="1100" />
597
- <di:waypoint x="1158" y="1100" />
598
- <di:waypoint x="1158" y="1125" />
599
- <di:waypoint x="1238" y="1125" />
600
- <di:waypoint x="1263" y="1125" />
601
- <di:waypoint x="1263" y="1105" />
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" />
602
649
  </bpmndi:BPMNEdge>
603
650
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
604
651
  <di:waypoint x="1263" y="945" />
@@ -610,14 +657,20 @@
610
657
  </bpmndi:BPMNLabel>
611
658
  </bpmndi:BPMNEdge>
612
659
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_rebased" bpmnElement="f_reb_rebased">
613
- <di:waypoint x="1263" y="1105" />
614
- <di:waypoint x="1263" y="1140" />
615
- <di:waypoint x="402" y="1140" />
660
+ <di:waypoint x="1263" y="1265" />
661
+ <di:waypoint x="1263" y="1300" />
662
+ <di:waypoint x="402" y="1300" />
616
663
  <di:waypoint x="402" y="160" />
617
664
  <bpmndi:BPMNLabel>
618
- <dc:Bounds x="806" y="1118" width="53" height="14" />
665
+ <dc:Bounds x="806" y="1278" width="53" height="14" />
619
666
  </bpmndi:BPMNLabel>
620
667
  </bpmndi:BPMNEdge>
668
+ <bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
669
+ <di:waypoint x="1438" y="1120" />
670
+ <di:waypoint x="1438" y="1320" />
671
+ <di:waypoint x="234" y="1320" />
672
+ <di:waypoint x="234" y="138" />
673
+ </bpmndi:BPMNEdge>
621
674
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
622
675
  <di:waypoint x="1438" y="458" />
623
676
  <di:waypoint x="1438" y="478" />
@@ -0,0 +1,116 @@
1
+ // pr.record-dependency: a merge-stage agent that discovers "this PR must wait for another PR to
2
+ // merge first" turns that into a durable dependency wait (parking the PR back in `waiting_deps`)
3
+ // instead of a human escalation — the wait, not the escalation, is the correct outcome.
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
6
+ import handler from "./worker.ts";
7
+
8
+ interface DepRow {
9
+ pr_key: string;
10
+ depends_on_key: string;
11
+ created_at: string;
12
+ }
13
+
14
+ function fakeApp(seedDeps: DepRow[] = []) {
15
+ const logs: { level: string; msg: string }[] = [];
16
+ const stores: Record<string, Record<string, unknown>[]> = {
17
+ pr_dependencies: seedDeps as unknown as Record<string, unknown>[],
18
+ pull_requests: [{ pr_key: "o/r#1", status: "waiting_merge" }],
19
+ };
20
+ return {
21
+ app: {
22
+ data: {
23
+ table(name: string, key: string) {
24
+ const store = (stores[name] ??= []);
25
+ return {
26
+ get: (k: unknown) => Promise.resolve(store.find((r) => r[key] === k)),
27
+ find: (q: Record<string, unknown>) =>
28
+ Promise.resolve(
29
+ store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
30
+ ),
31
+ insert: (row: Record<string, unknown>) => {
32
+ store.push(row);
33
+ return Promise.resolve(store.length);
34
+ },
35
+ update: (k: unknown, patch: Record<string, unknown>) => {
36
+ const row = store.find((r) => r[key] === k);
37
+ if (row) Object.assign(row, patch);
38
+ return Promise.resolve(row);
39
+ },
40
+ delete: (k: unknown) => {
41
+ for (let i = store.length - 1; i >= 0; i--) if (store[i][key] === k) store.splice(i, 1);
42
+ return Promise.resolve(undefined);
43
+ },
44
+ };
45
+ },
46
+ },
47
+ log: (level: string, msg: string) => logs.push({ level, msg }),
48
+ engine: {},
49
+ // biome-ignore lint/suspicious/noExplicitAny: minimal test double for AppContext
50
+ } as any,
51
+ stores,
52
+ logs,
53
+ };
54
+ }
55
+
56
+ const job = (variables: Record<string, unknown>) => ({ variables }) as never;
57
+
58
+ test("records a discovered dependency from a string ref and parks the PR in waiting_deps", async () => {
59
+ const { app, stores } = fakeApp();
60
+ await handler(job({ prKey: "o/r#1", dependsOn: "o/r#2" }), app);
61
+
62
+ assertEquals(stores.pr_dependencies.length, 1);
63
+ assertEquals(stores.pr_dependencies[0].pr_key, "o/r#1");
64
+ assertEquals(stores.pr_dependencies[0].depends_on_key, "o/r#2");
65
+ assertEquals(stores.pull_requests[0].status, "waiting_deps");
66
+ });
67
+
68
+ test("parses several refs (commas/spaces/URLs), dedupes, and never waits on itself", async () => {
69
+ const { app, stores } = fakeApp();
70
+ await handler(
71
+ job({
72
+ prKey: "o/r#1",
73
+ dependsOn: "o/r#2, o/r#2 https://github.com/o/r/pull/3 o/r#1",
74
+ }),
75
+ app,
76
+ );
77
+
78
+ const keys = stores.pr_dependencies.map((d) => d.depends_on_key).sort();
79
+ assertEquals(keys, ["o/r#2", "o/r#3"]); // #2 deduped, self #1 dropped
80
+ assertEquals(stores.pull_requests[0].status, "waiting_deps");
81
+ });
82
+
83
+ test("appends to existing edges without wiping them and skips already-recorded ones", async () => {
84
+ const { app, stores } = fakeApp([
85
+ { pr_key: "o/r#1", depends_on_key: "o/r#9", created_at: "t0" },
86
+ ]);
87
+ await handler(job({ prKey: "o/r#1", dependsOn: ["o/r#9", "o/r#2"] }), app);
88
+
89
+ const keys = stores.pr_dependencies.map((d) => d.depends_on_key).sort();
90
+ assertEquals(keys, ["o/r#2", "o/r#9"]); // pre-existing #9 preserved, only #2 added
91
+ });
92
+
93
+ test("no parseable ref still parks in waiting_deps and logs the miswiring loudly", async () => {
94
+ const { app, stores, logs } = fakeApp();
95
+ await handler(job({ prKey: "o/r#1", dependsOn: "not-a-pr" }), app);
96
+
97
+ assertEquals(stores.pr_dependencies.length, 0);
98
+ assertEquals(stores.pull_requests[0].status, "waiting_deps");
99
+ assertEquals(logs.some((l) => l.level === "error"), true);
100
+ });
101
+
102
+ test("heals a missing pull_requests parent row before parking so the merge poller can watch it", async () => {
103
+ const { app, stores } = fakeApp();
104
+ stores.pull_requests.length = 0; // engine/app.db desync: no parent row for o/r#1
105
+
106
+ await handler(job({ prKey: "o/r#1", dependsOn: "o/r#2" }), app);
107
+
108
+ // ensurePr reconstructed the row, and the subsequent update landed on it (not a silent no-op).
109
+ assertEquals(stores.pull_requests.length, 1);
110
+ const healed = stores.pull_requests[0];
111
+ assertEquals(healed.pr_key, "o/r#1");
112
+ assertEquals(healed.repo, "o/r");
113
+ assertEquals(healed.number, 1);
114
+ assertEquals(healed.status, "waiting_deps");
115
+ assertEquals(stores.pr_dependencies.length, 1);
116
+ });
@@ -0,0 +1,109 @@
1
+ // pr.record-dependency — a merge-stage agent discovered that this PR cannot land until ANOTHER
2
+ // PR merges first (e.g. a stacked base PR must land, or a sibling PR closes an issue this one
3
+ // requires). That is a WAIT, not a human escalation: record the discovered edge(s) in the
4
+ // `pr_dependencies` DAG and park the PR back in `waiting_deps` so the merge poller's dependency
5
+ // pass (`pollMerges` block 1) advances it — publishing `deps-cleared` — once every named PR has
6
+ // merged. The process re-enters its existing `wait-deps` catch, so no human has to babysit an
7
+ // ordering constraint the machinery already knows how to satisfy.
8
+ //
9
+ // `dependsOn` is whatever the agent returned (see prompts/fix-ci.md, prompts/rebase.md): a
10
+ // string of one or more `owner/repo#N` refs (or PR URLs) separated by commas/whitespace/newlines,
11
+ // or an array of such tokens. We parse each robustly (reusing `parsePr`), drop self-references and
12
+ // duplicates, and insert missing edges idempotently — a worker retry never double-inserts, and an
13
+ // already-recorded edge is a no-op.
14
+ import type { AppJobHandler } from "@nanobpm/urban";
15
+ import { ensurePr, parsePr } from "../../app/service.ts";
16
+
17
+ interface In extends Record<string, unknown> {
18
+ prKey: string;
19
+ dependsOn?: unknown;
20
+ }
21
+
22
+ interface DependencyRow {
23
+ pr_key: string;
24
+ depends_on_key: string;
25
+ created_at: string;
26
+ }
27
+
28
+ /** Normalize the agent's `dependsOn` into a de-duplicated list of `owner/repo#N` keys.
29
+ * Accepts a string (split on commas/whitespace/newlines) or an array of such tokens; unparseable
30
+ * tokens are ignored, mirroring `parseDependsOn`'s tolerance for the `Depends-on:` PR-body line. */
31
+ function parseDependsOn(raw: unknown): string[] {
32
+ const tokens: string[] = [];
33
+ if (typeof raw === "string") {
34
+ tokens.push(...raw.split(/[,\s]+/));
35
+ } else if (Array.isArray(raw)) {
36
+ for (const item of raw) {
37
+ if (typeof item === "string") tokens.push(...item.split(/[,\s]+/));
38
+ }
39
+ }
40
+ const out = new Set<string>();
41
+ for (const tok of tokens) {
42
+ const parsed = parsePr(tok);
43
+ if (parsed) out.add(parsed.prKey);
44
+ }
45
+ return [...out];
46
+ }
47
+
48
+ const handler: AppJobHandler<In> = async (job, app) => {
49
+ const prKey = job.variables.prKey;
50
+ const depKeys = parseDependsOn(job.variables.dependsOn);
51
+ const ts = new Date().toISOString();
52
+
53
+ const depTable = app.data.table<DependencyRow>("pr_dependencies", "pr_key");
54
+
55
+ // Append the discovered edges to whatever the plan DAG already declared — never wipe the set
56
+ // (a `registerDependencies`-style replace would drop still-relevant sibling ordering). Dedupe
57
+ // against existing rows so this is safe to retry.
58
+ const existing = await depTable.find({ pr_key: prKey });
59
+ const have = new Set(existing.map((d) => d.depends_on_key));
60
+ let recorded = 0;
61
+ for (const depKey of depKeys) {
62
+ if (depKey === prKey || have.has(depKey)) continue; // never wait on self; skip known edges
63
+ await depTable.insert({ pr_key: prKey, depends_on_key: depKey, created_at: ts });
64
+ have.add(depKey);
65
+ recorded += 1;
66
+ }
67
+
68
+ if (depKeys.length === 0) {
69
+ // The agent signalled "waiting-on-pr" but named no parseable PR. Whether this actually strands
70
+ // the PR depends on the existing DAG: with no other edges the poller clears it immediately (an
71
+ // empty dep set trivially "all merged"); if prior edges already exist it will keep waiting on
72
+ // those. Either way the miswiring — a "waiting-on-pr" signal with no parseable ref — is a
73
+ // defect worth logging loudly.
74
+ const clause =
75
+ have.size === 0
76
+ ? "it will clear immediately (no other dependencies recorded)"
77
+ : `it still has ${have.size} previously-recorded dependency edge(s) to wait on`;
78
+ app.log(
79
+ "error",
80
+ `record-dependency: ${prKey} reported waiting-on-pr but no parseable dependsOn ref; ` +
81
+ `${clause}. Raw: ${JSON.stringify(job.variables.dependsOn)}`,
82
+ );
83
+ }
84
+
85
+ // Heal a missing FK parent (engine/app.db desync) before updating `pull_requests`; otherwise the
86
+ // update silently no-ops and the instance re-enters `wait-deps` with no row for the merge poller
87
+ // to watch — wedging the merge loop. Mirrors persist-round's heal; repo/number are derived from
88
+ // the canonical `owner/repo#N` prKey since the RecordDepIn envelope carries only prKey/dependsOn.
89
+ const parsed = parsePr(prKey);
90
+ if (parsed) {
91
+ await ensurePr(app.data, { prKey, repo: parsed.repo, number: parsed.number, url: parsed.url });
92
+ }
93
+
94
+ // Park the PR back in the merge-stage dependency wait so `pollMerges` block 1 watches it.
95
+ await app.data.table("pull_requests", "pr_key").update(prKey, {
96
+ status: "waiting_deps",
97
+ updated_at: ts,
98
+ });
99
+
100
+ app.log("info", `record-dependency: ${prKey} waiting on ${depKeys.length} PR(s)`, {
101
+ prKey,
102
+ dependsOn: depKeys,
103
+ recorded,
104
+ });
105
+
106
+ return {};
107
+ };
108
+
109
+ export default handler;