@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 +7 -0
- package/SPEC.md +14 -4
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/prompts/fix-ci.md +14 -3
- package/prompts/rebase.md +10 -0
- package/resources/processes/merge-loop.bpmn +73 -20
- package/workers/record-dependency/worker.test.ts +116 -0
- package/workers/record-dependency/worker.ts +109 -0
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
|
|
373
|
-
submit)
|
|
374
|
-
|
|
375
|
-
|
|
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.
|
|
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: "
|
|
60
|
-
|
|
61
|
-
|
|
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 < 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="
|
|
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="
|
|
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="
|
|
448
|
+
<dc:Bounds x="1238" y="1215" width="50" height="50" />
|
|
422
449
|
<bpmndi:BPMNLabel>
|
|
423
|
-
<dc:Bounds x="
|
|
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="
|
|
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="
|
|
489
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
561
|
-
<di:waypoint x="1138" y="
|
|
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="
|
|
597
|
-
<di:waypoint x="1158" y="
|
|
598
|
-
<di:waypoint x="1158" y="
|
|
599
|
-
<di:waypoint x="1238" y="
|
|
600
|
-
<di:waypoint x="1263" y="
|
|
601
|
-
<di:waypoint x="1263" y="
|
|
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="
|
|
614
|
-
<di:waypoint x="1263" y="
|
|
615
|
-
<di:waypoint x="402" y="
|
|
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="
|
|
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;
|