@nanobpm/nano-workforce 0.42.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/SPEC.md +14 -4
  3. package/app/retro.ts +6 -6
  4. package/nano.app.json +4 -0
  5. package/openapi.yaml +85 -14
  6. package/operations/answerFeatureEscalation.test.ts +12 -0
  7. package/operations/answerFeatureEscalation.ts +25 -7
  8. package/operations/appendBlackboard.ts +10 -1
  9. package/operations/blackboard.test.ts +2 -1
  10. package/operations/checkAbandon.test.ts +2 -1
  11. package/operations/checkAbandon.ts +4 -1
  12. package/operations/getAgentInstructions.test.ts +2 -1
  13. package/operations/getAgentInstructions.ts +2 -1
  14. package/operations/getVersion.test.ts +2 -1
  15. package/operations/getVersion.ts +2 -1
  16. package/operations/listActivePrs.test.ts +2 -1
  17. package/operations/listActivePrs.ts +1 -0
  18. package/operations/postMessage.ts +9 -1
  19. package/operations/readBlackboard.ts +4 -1
  20. package/operations/startAndMessage.test.ts +39 -7
  21. package/operations/startConvergenceLoop.ts +25 -13
  22. package/operations/startPlanFanout.ts +17 -4
  23. package/package.json +1 -1
  24. package/prompts/fix-ci.md +14 -3
  25. package/prompts/rebase.md +10 -0
  26. package/resources/processes/merge-loop.bpmn +73 -20
  27. package/test/log.ts +12 -0
  28. package/workers/finalize/worker.test.ts +2 -1
  29. package/workers/finalize/worker.ts +2 -2
  30. package/workers/merge/worker.test.ts +2 -1
  31. package/workers/record-dependency/worker.test.ts +116 -0
  32. package/workers/record-dependency/worker.ts +109 -0
  33. package/workers/record-plan/worker.ts +1 -1
  34. package/workers/record-plan-review/worker.test.ts +2 -1
  35. package/workers/record-plan-review/worker.ts +2 -2
  36. package/workers/record-results/worker.test.ts +2 -1
  37. package/workers/record-results/worker.ts +1 -1
  38. package/workers/record-trial-merge/worker.test.ts +2 -1
  39. package/workers/record-trial-merge/worker.ts +1 -1
  40. package/workers/record-wave/worker.test.ts +2 -1
  41. package/workers/record-wave/worker.ts +7 -7
  42. package/workers/retro-gather/worker.test.ts +3 -2
  43. package/workers/retro-gather/worker.ts +1 -1
  44. package/workers/retro-record/worker.test.ts +2 -1
  45. package/workers/retro-record/worker.ts +1 -1
@@ -5,28 +5,40 @@
5
5
  //
6
6
  // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, convergeOnly? }`), not wrapped in a
7
7
  // `variables` envelope: this is a purpose-built operation, not a generic engine "start process" call,
8
- // so it does not leak the engine's variable-map concept to callers. The runtime validates the body
9
- // against openapi.yaml; this delegate keeps the PR-parse guard because the reference format
10
- // (owner/repo#123 or a URL) is app logic, not something the JSON schema can express an unparseable
11
- // reference is a 400.
8
+ // so it does not leak the engine's variable-map concept to callers. The body is a `oneOf` — EXACTLY
9
+ // ONE of `pr` or `url` — so the runtime rejects an empty or ambiguous target at the edge (a 400 that
10
+ // names the allowed shapes); this delegate no longer coalesces `pr ?? url`, it just narrows the
11
+ // validated variant. It keeps the PR-parse guard because the reference FORMAT (owner/repo#123 or a
12
+ // URL) is app logic the JSON schema can't express — an unparseable reference is a 400.
12
13
 
13
14
  import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
14
15
  import { defineOperation } from "../nano-generated/operations.ts";
15
16
 
16
17
  export default defineOperation("startConvergenceLoop", async ({ body }, app) => {
17
- const b = body ?? {};
18
- const raw = String(b.pr ?? b.url ?? "").trim();
18
+ // The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
19
+ // (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500 from `in`.
20
+ if (!body || typeof body !== "object") {
21
+ app.log.warn("start-convergence rejected: missing request body");
22
+ return { status: 400, body: { error: "request body is required (owner/repo#123 or a PR URL)" } };
23
+ }
24
+ const raw = ("pr" in body ? body.pr : body.url).trim();
19
25
  const parsed = parsePr(raw);
20
26
  if (!parsed) {
27
+ app.log.warn("start-convergence rejected: unparseable PR reference", { raw });
21
28
  return { status: 400, body: { error: "could not parse PR (use owner/repo#123 or a PR URL)" } };
22
29
  }
23
- const dependsOn = Array.isArray(b.dependsOn) ? b.dependsOn.map((d) => String(d)) : [];
24
- const maxRounds = clampRounds(b.maxRounds, MAX_ROUNDS);
30
+ const dependsOn = body.dependsOn ?? [];
31
+ const maxRounds = clampRounds(body.maxRounds, MAX_ROUNDS);
25
32
  // Per-request review-only override: when true the PR stops at `converged` and is never
26
33
  // handed to the merge-loop, regardless of the global NANO_PR_AUTO_MERGE default.
27
- const convergeOnly = b.convergeOnly === true;
28
- return {
29
- status: 202,
30
- body: await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly),
31
- };
34
+ const convergeOnly = body.convergeOnly === true;
35
+ const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly);
36
+ app.log.info("convergence loop started", {
37
+ prKey: parsed.prKey,
38
+ alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
39
+ dependsOn: dependsOn.length,
40
+ maxRounds,
41
+ convergeOnly,
42
+ });
43
+ return { status: 202, body: result };
32
44
  });
@@ -6,17 +6,30 @@
6
6
  // short-circuits.
7
7
  //
8
8
  // The request body is FLAT (`{ issue | url }`), not wrapped in a `variables` envelope — this is a
9
- // purpose-built operation, not a generic engine "start process" call.
9
+ // purpose-built operation, not a generic engine "start process" call. The body is a `oneOf` — EXACTLY
10
+ // ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at the edge; this delegate just
11
+ // narrows the validated variant and keeps the issue-FORMAT parse guard (schema can't express it).
10
12
 
11
13
  import { parseIssue, startPlan } from "../app/plan.ts";
12
14
  import { defineOperation } from "../nano-generated/operations.ts";
13
15
 
14
16
  export default defineOperation("startPlanFanout", async ({ body }, app) => {
15
- const b = body ?? {};
16
- const raw = String(b.issue ?? b.url ?? "").trim();
17
+ // The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
18
+ // (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500 from `in`.
19
+ if (!body || typeof body !== "object") {
20
+ app.log.warn("start-plan rejected: missing request body");
21
+ return { status: 400, body: { error: "request body is required (owner/repo#123 or an issue URL)" } };
22
+ }
23
+ const raw = ("issue" in body ? body.issue : body.url).trim();
17
24
  const parsed = parseIssue(raw);
18
25
  if (!parsed) {
26
+ app.log.warn("start-plan rejected: unparseable issue reference", { raw });
19
27
  return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
20
28
  }
21
- return { status: 202, body: await startPlan(app.data, app.engine, parsed) };
29
+ const result = await startPlan(app.data, app.engine, parsed);
30
+ app.log.info("plan fan-out started", {
31
+ planKey: parsed.planKey,
32
+ alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
33
+ });
34
+ return { status: 202, body: result };
22
35
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.42.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" />
package/test/log.ts ADDED
@@ -0,0 +1,12 @@
1
+ // A no-op `Logger` for test doubles. The runtime injects `app.log` into every operation delegate
2
+ // and worker handler, but the in-memory `app` fakes the suite builds don't carry one — so a
3
+ // delegate that logs on a covered path would throw on `app.log.info(...)`. `createLogger` (exported
4
+ // from urban's runtime barrel since 0.42.0) builds a spec-correct Logger over a discarding sink, so
5
+ // tests exercise the logging code paths without asserting on them and stay correct if the Logger
6
+ // interface grows. Spread into the fake `app`: `{ ...data, log: noopLog() } as unknown as AppApi`.
7
+ import { createLogger, type Logger } from "@nanobpm/urban/runtime";
8
+
9
+ /** A `Logger` that silently discards every record (and whose `child()` does the same). */
10
+ export function noopLog(): Logger {
11
+ return createLogger(() => {});
12
+ }
@@ -6,6 +6,7 @@
6
6
  // AND the request did not force convergence-only.
7
7
  import { test } from "node:test";
8
8
  import { assertEquals } from "#test-assert";
9
+ import { noopLog } from "../../test/log.ts";
9
10
  import handler from "./worker.ts";
10
11
  import { MERGE_PROCESS_ID } from "../../app/service.ts";
11
12
 
@@ -48,7 +49,7 @@ function fakeApp() {
48
49
  return Promise.resolve({ processInstanceKey: "MERGE-1" });
49
50
  },
50
51
  },
51
- log: () => undefined,
52
+ log: noopLog(),
52
53
  },
53
54
  };
54
55
  }
@@ -83,10 +83,10 @@ const handler: AppJobHandler<In> = async (job, app) => {
83
83
  if (mergeProcessKey != null) {
84
84
  status = "waiting_deps";
85
85
  } else {
86
- app.log("error", `finalize: merge-loop start returned no process key for ${prKey}; leaving PR converged`);
86
+ app.log.error(`finalize: merge-loop start returned no process key for ${prKey}; leaving PR converged`);
87
87
  }
88
88
  } catch (err) {
89
- app.log("error", `finalize: could not start merge-loop for ${prKey}; leaving PR converged`, {
89
+ app.log.error(`finalize: could not start merge-loop for ${prKey}; leaving PR converged`, {
90
90
  err: String(err),
91
91
  });
92
92
  }
@@ -6,6 +6,7 @@
6
6
  // token transport and stubs `globalThis.fetch` so the single-PR GET reports `merged: true`.
7
7
  import { test } from "node:test";
8
8
  import { assertEquals } from "#test-assert";
9
+ import { noopLog } from "../../test/log.ts";
9
10
  import handler from "./worker.ts";
10
11
 
11
12
  function fakeApp() {
@@ -36,7 +37,7 @@ function fakeApp() {
36
37
  };
37
38
  },
38
39
  },
39
- log: () => undefined,
40
+ log: noopLog(),
40
41
  engine: {},
41
42
  } as any,
42
43
  stores,
@@ -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
+ });