@nanobpm/nano-workforce 0.69.1 → 0.70.1

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 (39) hide show
  1. package/AGENTS.md +18 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +10 -8
  4. package/app/agentic/cockpit/index.ts +18 -0
  5. package/app/agentic/cockpit/supply-boot-past.test.ts +519 -0
  6. package/app/agentic/cockpit/supply-boot.test.ts +34 -0
  7. package/app/agentic/cockpit/supply-boot.ts +256 -21
  8. package/app/agentic/cockpit/transcript-render.test.ts +110 -0
  9. package/app/agentic/cockpit/transcript-render.ts +136 -0
  10. package/app/agentic/cockpit/transcript-view.test.ts +61 -0
  11. package/app/agentic/cockpit/transcript-view.ts +131 -0
  12. package/app/agentic/families/relay.family.test.ts +103 -0
  13. package/app/agentic/families/relay.family.ts +74 -0
  14. package/app/agentic/transcript-read.test.ts +72 -0
  15. package/app/agentic/transcript-read.ts +161 -0
  16. package/app/blackboard.test.ts +15 -7
  17. package/app/blackboard.ts +4 -4
  18. package/app/convergeGate.test.ts +406 -0
  19. package/app/convergeGate.ts +48 -0
  20. package/app/github.ts +225 -0
  21. package/app/roundProgress.test.ts +229 -0
  22. package/app/roundProgress.ts +70 -0
  23. package/app/service.test.ts +18 -1
  24. package/app/service.ts +5 -1
  25. package/db/migrations/033_pr_round_head.sql +16 -0
  26. package/nano.app.json +8 -0
  27. package/openapi.yaml +267 -0
  28. package/operations/getAgenticTranscript.test.ts +165 -0
  29. package/operations/getAgenticTranscript.ts +42 -0
  30. package/operations/listAgenticTranscripts.test.ts +169 -0
  31. package/operations/listAgenticTranscripts.ts +61 -0
  32. package/package.json +1 -1
  33. package/pages/cockpit/cockpit.css +70 -0
  34. package/pages/cockpit/mount.js +254 -13
  35. package/pages/cockpit.page.json +1 -1
  36. package/prompts/review-round.md +55 -9
  37. package/resources/processes/convergence-loop.bpmn +236 -65
  38. package/workers/converge-gate/worker.ts +101 -0
  39. package/workers/progress-check/worker.ts +77 -0
@@ -33,6 +33,24 @@
33
33
  <nano:extend name="prUrl" type="string" optional="true" />
34
34
  <nano:extend name="abandonUrl" type="string" optional="true" />
35
35
  </nano:shape>
36
+ <nano:shape id="PrProgressCheckIn" name="Check progress — input">
37
+ <nano:extend name="prKey" type="string" />
38
+ <nano:extend name="status" type="string" optional="true" />
39
+ <nano:extend name="repo" type="string" optional="true" />
40
+ <nano:extend name="prNumber" type="integer" optional="true" />
41
+ </nano:shape>
42
+ <nano:shape id="PrProgressCheckOut" name="Check progress — result">
43
+ <nano:extend name="progressed" type="boolean" />
44
+ </nano:shape>
45
+ <nano:shape id="PrConvergeGateIn" name="Converge gate — input">
46
+ <nano:extend name="prKey" type="string" />
47
+ <nano:extend name="repo" type="string" optional="true" />
48
+ <nano:extend name="prNumber" type="integer" optional="true" />
49
+ </nano:shape>
50
+ <nano:shape id="PrConvergeGateOut" name="Converge gate — result">
51
+ <nano:extend name="convergeBlocked" type="boolean" />
52
+ <nano:extend name="convergeBlockReason" type="string" optional="true" />
53
+ </nano:shape>
36
54
  <nano:shape id="EscalationIn" name="Record escalation — input">
37
55
  <nano:extend name="prKey" type="string" />
38
56
  <nano:extend name="round" type="integer" />
@@ -118,8 +136,40 @@
118
136
  <bpmn:incoming>f_guardOk</bpmn:incoming>
119
137
  <bpmn:outgoing>f_roundWait</bpmn:outgoing>
120
138
  </bpmn:serviceTask>
121
- <bpmn:eventBasedGateway id="gw-review-wait" name="review ready or timeout?">
139
+ <bpmn:serviceTask id="check-progress" name="Check progress">
140
+ <bpmn:extensionElements>
141
+ <zeebe:taskDefinition type="pr.progress-check" />
142
+ <zeebe:properties>
143
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrProgressCheckIn" />
144
+ <zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrProgressCheckOut" />
145
+ </zeebe:properties>
146
+ </bpmn:extensionElements>
122
147
  <bpmn:incoming>f_roundWait</bpmn:incoming>
148
+ <bpmn:outgoing>f_checkGate</bpmn:outgoing>
149
+ </bpmn:serviceTask>
150
+ <bpmn:exclusiveGateway id="gw-progress" name="progress made?" default="f_progressOk">
151
+ <bpmn:incoming>f_checkGate</bpmn:incoming>
152
+ <bpmn:outgoing>f_noProgress</bpmn:outgoing>
153
+ <bpmn:outgoing>f_progressOk</bpmn:outgoing>
154
+ </bpmn:exclusiveGateway>
155
+ <bpmn:serviceTask id="persist-escalation-noprogress" name="Escalate: no progress">
156
+ <bpmn:extensionElements>
157
+ <zeebe:taskDefinition type="pr.persist-escalation" />
158
+ <zeebe:properties>
159
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
160
+ <zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
161
+ </zeebe:properties>
162
+ <zeebe:ioMapping>
163
+ <zeebe:input source="=&#34;blocked&#34;" target="status" />
164
+ <zeebe:input source="=false" target="recordRound" />
165
+ <zeebe:input source="=&#34;Round &#34; + string(round) + &#34; reported the review comments were addressed, but the PR head did not advance (no commit was pushed), so another review round would loop on identical code. A human must decide how to proceed (reply to resume the loop).&#34;" target="question" />
166
+ </zeebe:ioMapping>
167
+ </bpmn:extensionElements>
168
+ <bpmn:incoming>f_noProgress</bpmn:incoming>
169
+ <bpmn:outgoing>f_noprogressWait</bpmn:outgoing>
170
+ </bpmn:serviceTask>
171
+ <bpmn:eventBasedGateway id="gw-review-wait" name="review ready or timeout?">
172
+ <bpmn:incoming>f_progressOk</bpmn:incoming>
123
173
  <bpmn:outgoing>f_toReviewWait</bpmn:outgoing>
124
174
  <bpmn:outgoing>f_toReviewTimeout</bpmn:outgoing>
125
175
  </bpmn:eventBasedGateway>
@@ -199,6 +249,8 @@
199
249
  <bpmn:incoming>f_escWait</bpmn:incoming>
200
250
  <bpmn:incoming>f_maxWait</bpmn:incoming>
201
251
  <bpmn:incoming>f_stalledWait</bpmn:incoming>
252
+ <bpmn:incoming>f_noprogressWait</bpmn:incoming>
253
+ <bpmn:incoming>f_blockedWait</bpmn:incoming>
202
254
  <bpmn:outgoing>f_answerRecord</bpmn:outgoing>
203
255
  </bpmn:userTask>
204
256
  <bpmn:serviceTask id="record-answer" name="Record answer">
@@ -222,17 +274,55 @@
222
274
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrFinalizeIn" />
223
275
  </zeebe:properties>
224
276
  </bpmn:extensionElements>
225
- <bpmn:incoming>f_converged</bpmn:incoming>
277
+ <bpmn:incoming>f_convergeOk</bpmn:incoming>
226
278
  <bpmn:outgoing>f_finalize</bpmn:outgoing>
227
279
  </bpmn:serviceTask>
280
+ <bpmn:serviceTask id="check-converge" name="Check review comments">
281
+ <bpmn:extensionElements>
282
+ <zeebe:taskDefinition type="pr.converge-gate" />
283
+ <zeebe:properties>
284
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrConvergeGateIn" />
285
+ <zeebe:property name="io.nanobpm.dataEnvelope.out" value="PrConvergeGateOut" />
286
+ </zeebe:properties>
287
+ </bpmn:extensionElements>
288
+ <bpmn:incoming>f_converged</bpmn:incoming>
289
+ <bpmn:outgoing>f_toConvergeGate</bpmn:outgoing>
290
+ </bpmn:serviceTask>
291
+ <bpmn:exclusiveGateway id="gw-converge-gate" name="comments addressed?" default="f_convergeOk">
292
+ <bpmn:incoming>f_toConvergeGate</bpmn:incoming>
293
+ <bpmn:outgoing>f_convergeBlocked</bpmn:outgoing>
294
+ <bpmn:outgoing>f_convergeOk</bpmn:outgoing>
295
+ </bpmn:exclusiveGateway>
296
+ <bpmn:serviceTask id="persist-escalation-blockedcomments" name="Escalate: unaddressed comments">
297
+ <bpmn:extensionElements>
298
+ <zeebe:taskDefinition type="pr.persist-escalation" />
299
+ <zeebe:properties>
300
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
301
+ <zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
302
+ </zeebe:properties>
303
+ <zeebe:ioMapping>
304
+ <zeebe:input source="=&#34;blocked&#34;" target="status" />
305
+ <zeebe:input source="=false" target="recordRound" />
306
+ <zeebe:input source="=convergeBlockReason" target="question" />
307
+ </zeebe:ioMapping>
308
+ </bpmn:extensionElements>
309
+ <bpmn:incoming>f_convergeBlocked</bpmn:incoming>
310
+ <bpmn:outgoing>f_blockedWait</bpmn:outgoing>
311
+ </bpmn:serviceTask>
228
312
  <bpmn:endEvent id="End" name="Converged">
229
313
  <bpmn:incoming>f_finalize</bpmn:incoming>
230
314
  </bpmn:endEvent>
231
315
  <bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="review-round" />
232
316
  <bpmn:sequenceFlow id="f_toStatus" sourceRef="review-round" targetRef="gw-status" />
233
- <bpmn:sequenceFlow id="f_converged" name="converged" sourceRef="gw-status" targetRef="persist-converged">
317
+ <bpmn:sequenceFlow id="f_converged" name="converged" sourceRef="gw-status" targetRef="check-converge">
234
318
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "converged"</bpmn:conditionExpression>
235
319
  </bpmn:sequenceFlow>
320
+ <bpmn:sequenceFlow id="f_toConvergeGate" sourceRef="check-converge" targetRef="gw-converge-gate" />
321
+ <bpmn:sequenceFlow id="f_convergeBlocked" name="unaddressed comments" sourceRef="gw-converge-gate" targetRef="persist-escalation-blockedcomments">
322
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=convergeBlocked = true</bpmn:conditionExpression>
323
+ </bpmn:sequenceFlow>
324
+ <bpmn:sequenceFlow id="f_convergeOk" name="addressed / default" sourceRef="gw-converge-gate" targetRef="persist-converged" />
325
+ <bpmn:sequenceFlow id="f_blockedWait" sourceRef="persist-escalation-blockedcomments" targetRef="wait-answer" />
236
326
  <bpmn:sequenceFlow id="f_addressed" name="addressed / default" sourceRef="gw-status" targetRef="gw-guard" />
237
327
  <bpmn:sequenceFlow id="f_waiting" name="waiting" sourceRef="gw-status" targetRef="gw-guard">
238
328
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting"</bpmn:conditionExpression>
@@ -244,7 +334,13 @@
244
334
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=round &gt;= maxRounds</bpmn:conditionExpression>
245
335
  </bpmn:sequenceFlow>
246
336
  <bpmn:sequenceFlow id="f_guardOk" name="continue" sourceRef="gw-guard" targetRef="persist-round" />
247
- <bpmn:sequenceFlow id="f_roundWait" sourceRef="persist-round" targetRef="gw-review-wait" />
337
+ <bpmn:sequenceFlow id="f_roundWait" sourceRef="persist-round" targetRef="check-progress" />
338
+ <bpmn:sequenceFlow id="f_checkGate" sourceRef="check-progress" targetRef="gw-progress" />
339
+ <bpmn:sequenceFlow id="f_noProgress" name="no progress" sourceRef="gw-progress" targetRef="persist-escalation-noprogress">
340
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=progressed = false</bpmn:conditionExpression>
341
+ </bpmn:sequenceFlow>
342
+ <bpmn:sequenceFlow id="f_progressOk" name="progress / default" sourceRef="gw-progress" targetRef="gw-review-wait" />
343
+ <bpmn:sequenceFlow id="f_noprogressWait" sourceRef="persist-escalation-noprogress" targetRef="wait-answer" />
248
344
  <bpmn:sequenceFlow id="f_toReviewWait" sourceRef="gw-review-wait" targetRef="wait-review" />
249
345
  <bpmn:sequenceFlow id="f_toReviewTimeout" sourceRef="gw-review-wait" targetRef="wait-review-timeout" />
250
346
  <bpmn:sequenceFlow id="f_reviewLoop" sourceRef="wait-review" targetRef="review-round" />
@@ -278,34 +374,46 @@
278
374
  </bpmndi:BPMNLabel>
279
375
  </bpmndi:BPMNShape>
280
376
  <bpmndi:BPMNShape id="BPMNShape_gw-guard" bpmnElement="gw-guard" isMarkerVisible="true">
281
- <dc:Bounds x="916" y="255" width="50" height="50" />
377
+ <dc:Bounds x="941" y="415" width="50" height="50" />
282
378
  <bpmndi:BPMNLabel>
283
- <dc:Bounds x="899" y="310" width="85" height="14" />
379
+ <dc:Bounds x="924" y="470" width="85" height="14" />
284
380
  </bpmndi:BPMNLabel>
285
381
  </bpmndi:BPMNShape>
286
382
  <bpmndi:BPMNShape id="BPMNShape_persist-round" bpmnElement="persist-round">
287
- <dc:Bounds x="1066" y="240" width="100" height="80" />
383
+ <dc:Bounds x="1116" y="400" width="100" height="80" />
384
+ </bpmndi:BPMNShape>
385
+ <bpmndi:BPMNShape id="BPMNShape_check-progress" bpmnElement="check-progress">
386
+ <dc:Bounds x="1316" y="400" width="100" height="80" />
387
+ </bpmndi:BPMNShape>
388
+ <bpmndi:BPMNShape id="BPMNShape_gw-progress" bpmnElement="gw-progress" isMarkerVisible="true">
389
+ <dc:Bounds x="1516" y="415" width="50" height="50" />
390
+ <bpmndi:BPMNLabel>
391
+ <dc:Bounds x="1511" y="382" width="60" height="28" />
392
+ </bpmndi:BPMNLabel>
393
+ </bpmndi:BPMNShape>
394
+ <bpmndi:BPMNShape id="BPMNShape_persist-escalation-noprogress" bpmnElement="persist-escalation-noprogress">
395
+ <dc:Bounds x="1666" y="720" width="100" height="80" />
288
396
  </bpmndi:BPMNShape>
289
397
  <bpmndi:BPMNShape id="BPMNShape_gw-review-wait" bpmnElement="gw-review-wait" isMarkerVisible="true">
290
- <dc:Bounds x="1266" y="255" width="50" height="50" />
398
+ <dc:Bounds x="1691" y="415" width="50" height="50" />
291
399
  <bpmndi:BPMNLabel>
292
- <dc:Bounds x="1247" y="222" width="88" height="28" />
400
+ <dc:Bounds x="1672" y="382" width="88" height="28" />
293
401
  </bpmndi:BPMNLabel>
294
402
  </bpmndi:BPMNShape>
295
403
  <bpmndi:BPMNShape id="BPMNShape_wait-review" bpmnElement="wait-review">
296
- <dc:Bounds x="1416" y="262" width="36" height="36" />
404
+ <dc:Bounds x="1866" y="422" width="36" height="36" />
297
405
  <bpmndi:BPMNLabel>
298
- <dc:Bounds x="1390" y="229" width="88" height="28" />
406
+ <dc:Bounds x="1840" y="389" width="88" height="28" />
299
407
  </bpmndi:BPMNLabel>
300
408
  </bpmndi:BPMNShape>
301
409
  <bpmndi:BPMNShape id="BPMNShape_wait-review-timeout" bpmnElement="wait-review-timeout">
302
- <dc:Bounds x="1416" y="422" width="36" height="36" />
410
+ <dc:Bounds x="1866" y="582" width="36" height="36" />
303
411
  <bpmndi:BPMNLabel>
304
- <dc:Bounds x="1406" y="463" width="56" height="42" />
412
+ <dc:Bounds x="1856" y="623" width="56" height="42" />
305
413
  </bpmndi:BPMNLabel>
306
414
  </bpmndi:BPMNShape>
307
415
  <bpmndi:BPMNShape id="BPMNShape_persist-review-stalled" bpmnElement="persist-review-stalled">
308
- <dc:Bounds x="1552" y="400" width="100" height="80" />
416
+ <dc:Bounds x="2002" y="560" width="100" height="80" />
309
417
  </bpmndi:BPMNShape>
310
418
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation" bpmnElement="persist-escalation">
311
419
  <dc:Bounds x="566" y="560" width="100" height="80" />
@@ -317,21 +425,33 @@
317
425
  </bpmndi:BPMNLabel>
318
426
  </bpmndi:BPMNShape>
319
427
  <bpmndi:BPMNShape id="BPMNShape_persist-escalation-maxrounds" bpmnElement="persist-escalation-maxrounds">
320
- <dc:Bounds x="1066" y="560" width="100" height="80" />
428
+ <dc:Bounds x="1116" y="880" width="100" height="80" />
321
429
  </bpmndi:BPMNShape>
322
430
  <bpmndi:BPMNShape id="BPMNShape_wait-answer" bpmnElement="wait-answer">
323
- <dc:Bounds x="1752" y="400" width="100" height="80" />
431
+ <dc:Bounds x="2202" y="240" width="100" height="80" />
324
432
  </bpmndi:BPMNShape>
325
433
  <bpmndi:BPMNShape id="BPMNShape_record-answer" bpmnElement="record-answer">
326
- <dc:Bounds x="1952" y="400" width="100" height="80" />
434
+ <dc:Bounds x="2402" y="240" width="100" height="80" />
327
435
  </bpmndi:BPMNShape>
328
436
  <bpmndi:BPMNShape id="BPMNShape_persist-converged" bpmnElement="persist-converged">
437
+ <dc:Bounds x="916" y="80" width="100" height="80" />
438
+ </bpmndi:BPMNShape>
439
+ <bpmndi:BPMNShape id="BPMNShape_check-converge" bpmnElement="check-converge">
329
440
  <dc:Bounds x="566" y="80" width="100" height="80" />
330
441
  </bpmndi:BPMNShape>
442
+ <bpmndi:BPMNShape id="BPMNShape_gw-converge-gate" bpmnElement="gw-converge-gate" isMarkerVisible="true">
443
+ <dc:Bounds x="766" y="95" width="50" height="50" />
444
+ <bpmndi:BPMNLabel>
445
+ <dc:Bounds x="754" y="62" width="74" height="28" />
446
+ </bpmndi:BPMNLabel>
447
+ </bpmndi:BPMNShape>
448
+ <bpmndi:BPMNShape id="BPMNShape_persist-escalation-blockedcomments" bpmnElement="persist-escalation-blockedcomments">
449
+ <dc:Bounds x="916" y="240" width="100" height="80" />
450
+ </bpmndi:BPMNShape>
331
451
  <bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
332
- <dc:Bounds x="773" y="102" width="36" height="36" />
452
+ <dc:Bounds x="1148" y="102" width="36" height="36" />
333
453
  <bpmndi:BPMNLabel>
334
- <dc:Bounds x="757" y="143" width="69" height="14" />
454
+ <dc:Bounds x="1132" y="143" width="69" height="14" />
335
455
  </bpmndi:BPMNLabel>
336
456
  </bpmndi:BPMNShape>
337
457
  <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
@@ -349,53 +469,83 @@
349
469
  <dc:Bounds x="483" y="98" width="67" height="14" />
350
470
  </bpmndi:BPMNLabel>
351
471
  </bpmndi:BPMNEdge>
352
- <bpmndi:BPMNEdge id="BPMNEdge_f_finalize" bpmnElement="f_finalize">
472
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toConvergeGate" bpmnElement="f_toConvergeGate">
353
473
  <di:waypoint x="666" y="120" />
354
- <di:waypoint x="773" y="120" />
474
+ <di:waypoint x="766" y="120" />
475
+ </bpmndi:BPMNEdge>
476
+ <bpmndi:BPMNEdge id="BPMNEdge_f_convergeOk" bpmnElement="f_convergeOk">
477
+ <di:waypoint x="816" y="120" />
478
+ <di:waypoint x="916" y="120" />
479
+ <bpmndi:BPMNLabel>
480
+ <dc:Bounds x="827" y="125" width="78" height="28" />
481
+ </bpmndi:BPMNLabel>
482
+ </bpmndi:BPMNEdge>
483
+ <bpmndi:BPMNEdge id="BPMNEdge_f_finalize" bpmnElement="f_finalize">
484
+ <di:waypoint x="1016" y="120" />
485
+ <di:waypoint x="1148" y="120" />
355
486
  </bpmndi:BPMNEdge>
356
487
  <bpmndi:BPMNEdge id="BPMNEdge_f_guardOk" bpmnElement="f_guardOk">
357
- <di:waypoint x="966" y="280" />
358
- <di:waypoint x="1066" y="280" />
488
+ <di:waypoint x="991" y="440" />
489
+ <di:waypoint x="1116" y="440" />
359
490
  <bpmndi:BPMNLabel>
360
- <dc:Bounds x="976" y="258" width="60" height="14" />
491
+ <dc:Bounds x="1014" y="418" width="60" height="14" />
361
492
  </bpmndi:BPMNLabel>
362
493
  </bpmndi:BPMNEdge>
363
494
  <bpmndi:BPMNEdge id="BPMNEdge_f_roundWait" bpmnElement="f_roundWait">
364
- <di:waypoint x="1166" y="280" />
365
- <di:waypoint x="1266" y="280" />
495
+ <di:waypoint x="1216" y="440" />
496
+ <di:waypoint x="1316" y="440" />
497
+ </bpmndi:BPMNEdge>
498
+ <bpmndi:BPMNEdge id="BPMNEdge_f_checkGate" bpmnElement="f_checkGate">
499
+ <di:waypoint x="1416" y="440" />
500
+ <di:waypoint x="1516" y="440" />
501
+ </bpmndi:BPMNEdge>
502
+ <bpmndi:BPMNEdge id="BPMNEdge_f_progressOk" bpmnElement="f_progressOk">
503
+ <di:waypoint x="1566" y="440" />
504
+ <di:waypoint x="1691" y="440" />
505
+ <bpmndi:BPMNLabel>
506
+ <dc:Bounds x="1591" y="407" width="71" height="28" />
507
+ </bpmndi:BPMNLabel>
366
508
  </bpmndi:BPMNEdge>
367
509
  <bpmndi:BPMNEdge id="BPMNEdge_f_toReviewWait" bpmnElement="f_toReviewWait">
368
- <di:waypoint x="1316" y="280" />
369
- <di:waypoint x="1416" y="280" />
510
+ <di:waypoint x="1741" y="440" />
511
+ <di:waypoint x="1866" y="440" />
370
512
  </bpmndi:BPMNEdge>
371
513
  <bpmndi:BPMNEdge id="BPMNEdge_f_reviewLoop" bpmnElement="f_reviewLoop">
372
- <di:waypoint x="1434" y="298" />
373
- <di:waypoint x="1434" y="340" />
374
- <di:waypoint x="266" y="340" />
514
+ <di:waypoint x="1884" y="458" />
515
+ <di:waypoint x="1884" y="500" />
516
+ <di:waypoint x="266" y="500" />
375
517
  <di:waypoint x="266" y="160" />
376
518
  </bpmndi:BPMNEdge>
377
519
  <bpmndi:BPMNEdge id="BPMNEdge_f_escReenter" bpmnElement="f_escReenter">
378
520
  <di:waypoint x="791" y="575" />
521
+ <di:waypoint x="791" y="440" />
522
+ <di:waypoint x="941" y="440" />
523
+ <bpmndi:BPMNLabel>
524
+ <dc:Bounds x="796" y="507" width="74" height="42" />
525
+ </bpmndi:BPMNLabel>
526
+ </bpmndi:BPMNEdge>
527
+ <bpmndi:BPMNEdge id="BPMNEdge_f_convergeBlocked" bpmnElement="f_convergeBlocked">
528
+ <di:waypoint x="791" y="145" />
379
529
  <di:waypoint x="791" y="280" />
380
530
  <di:waypoint x="916" y="280" />
381
531
  <bpmndi:BPMNLabel>
382
- <dc:Bounds x="796" y="407" width="74" height="42" />
532
+ <dc:Bounds x="796" y="199" width="81" height="28" />
383
533
  </bpmndi:BPMNLabel>
384
534
  </bpmndi:BPMNEdge>
385
535
  <bpmndi:BPMNEdge id="BPMNEdge_f_addressed" bpmnElement="f_addressed">
386
536
  <di:waypoint x="441" y="145" />
387
- <di:waypoint x="441" y="280" />
388
- <di:waypoint x="916" y="280" />
537
+ <di:waypoint x="441" y="440" />
538
+ <di:waypoint x="941" y="440" />
389
539
  <bpmndi:BPMNLabel>
390
- <dc:Bounds x="446" y="199" width="78" height="28" />
540
+ <dc:Bounds x="446" y="279" width="78" height="28" />
391
541
  </bpmndi:BPMNLabel>
392
542
  </bpmndi:BPMNEdge>
393
543
  <bpmndi:BPMNEdge id="BPMNEdge_f_waiting" bpmnElement="f_waiting">
394
544
  <di:waypoint x="441" y="145" />
395
- <di:waypoint x="441" y="280" />
396
- <di:waypoint x="916" y="280" />
545
+ <di:waypoint x="441" y="440" />
546
+ <di:waypoint x="941" y="440" />
397
547
  <bpmndi:BPMNLabel>
398
- <dc:Bounds x="380" y="206" width="56" height="14" />
548
+ <dc:Bounds x="380" y="286" width="56" height="14" />
399
549
  </bpmndi:BPMNLabel>
400
550
  </bpmndi:BPMNEdge>
401
551
  <bpmndi:BPMNEdge id="BPMNEdge_f_escalate" bpmnElement="f_escalate">
@@ -403,57 +553,78 @@
403
553
  <di:waypoint x="441" y="600" />
404
554
  <di:waypoint x="566" y="600" />
405
555
  <bpmndi:BPMNLabel>
406
- <dc:Bounds x="446" y="426" width="81" height="28" />
556
+ <dc:Bounds x="446" y="506" width="81" height="28" />
407
557
  </bpmndi:BPMNLabel>
408
558
  </bpmndi:BPMNEdge>
409
559
  <bpmndi:BPMNEdge id="BPMNEdge_f_guardMax" bpmnElement="f_guardMax">
410
- <di:waypoint x="966" y="280" />
411
- <di:waypoint x="1046" y="280" />
412
- <di:waypoint x="1046" y="600" />
413
- <di:waypoint x="1066" y="600" />
560
+ <di:waypoint x="991" y="440" />
561
+ <di:waypoint x="1096" y="440" />
562
+ <di:waypoint x="1096" y="920" />
563
+ <di:waypoint x="1116" y="920" />
564
+ <bpmndi:BPMNLabel>
565
+ <dc:Bounds x="1101" y="673" width="74" height="14" />
566
+ </bpmndi:BPMNLabel>
567
+ </bpmndi:BPMNEdge>
568
+ <bpmndi:BPMNEdge id="BPMNEdge_f_noProgress" bpmnElement="f_noProgress">
569
+ <di:waypoint x="1566" y="440" />
570
+ <di:waypoint x="1646" y="440" />
571
+ <di:waypoint x="1646" y="760" />
572
+ <di:waypoint x="1666" y="760" />
414
573
  <bpmndi:BPMNLabel>
415
- <dc:Bounds x="1051" y="433" width="74" height="14" />
574
+ <dc:Bounds x="1563" y="593" width="78" height="14" />
416
575
  </bpmndi:BPMNLabel>
417
576
  </bpmndi:BPMNEdge>
418
577
  <bpmndi:BPMNEdge id="BPMNEdge_f_toReviewTimeout" bpmnElement="f_toReviewTimeout">
419
- <di:waypoint x="1291" y="305" />
420
- <di:waypoint x="1291" y="440" />
421
- <di:waypoint x="1416" y="440" />
578
+ <di:waypoint x="1716" y="465" />
579
+ <di:waypoint x="1716" y="600" />
580
+ <di:waypoint x="1866" y="600" />
422
581
  </bpmndi:BPMNEdge>
423
582
  <bpmndi:BPMNEdge id="BPMNEdge_f_escWait" bpmnElement="f_escWait">
424
583
  <di:waypoint x="791" y="625" />
425
- <di:waypoint x="791" y="645" />
426
- <di:waypoint x="1732" y="645" />
427
- <di:waypoint x="1732" y="460" />
428
- <di:waypoint x="1752" y="460" />
584
+ <di:waypoint x="791" y="980" />
585
+ <di:waypoint x="2182" y="980" />
586
+ <di:waypoint x="2182" y="300" />
587
+ <di:waypoint x="2202" y="300" />
588
+ </bpmndi:BPMNEdge>
589
+ <bpmndi:BPMNEdge id="BPMNEdge_f_blockedWait" bpmnElement="f_blockedWait">
590
+ <di:waypoint x="1016" y="280" />
591
+ <di:waypoint x="2202" y="280" />
592
+ </bpmndi:BPMNEdge>
593
+ <bpmndi:BPMNEdge id="BPMNEdge_f_noprogressWait" bpmnElement="f_noprogressWait">
594
+ <di:waypoint x="1766" y="780" />
595
+ <di:waypoint x="1786" y="780" />
596
+ <di:waypoint x="1786" y="980" />
597
+ <di:waypoint x="2252" y="980" />
598
+ <di:waypoint x="2252" y="320" />
429
599
  </bpmndi:BPMNEdge>
430
600
  <bpmndi:BPMNEdge id="BPMNEdge_f_reviewStalled" bpmnElement="f_reviewStalled">
431
- <di:waypoint x="1452" y="440" />
432
- <di:waypoint x="1552" y="440" />
601
+ <di:waypoint x="1902" y="600" />
602
+ <di:waypoint x="2002" y="600" />
433
603
  </bpmndi:BPMNEdge>
434
604
  <bpmndi:BPMNEdge id="BPMNEdge_f_stalledWait" bpmnElement="f_stalledWait">
435
- <di:waypoint x="1652" y="440" />
436
- <di:waypoint x="1752" y="440" />
605
+ <di:waypoint x="2052" y="560" />
606
+ <di:waypoint x="2052" y="280" />
607
+ <di:waypoint x="2202" y="280" />
437
608
  </bpmndi:BPMNEdge>
438
609
  <bpmndi:BPMNEdge id="BPMNEdge_f_escGate" bpmnElement="f_escGate">
439
610
  <di:waypoint x="666" y="600" />
440
611
  <di:waypoint x="766" y="600" />
441
612
  </bpmndi:BPMNEdge>
442
613
  <bpmndi:BPMNEdge id="BPMNEdge_f_maxWait" bpmnElement="f_maxWait">
443
- <di:waypoint x="1166" y="620" />
444
- <di:waypoint x="1186" y="620" />
445
- <di:waypoint x="1186" y="645" />
446
- <di:waypoint x="1802" y="645" />
447
- <di:waypoint x="1802" y="480" />
614
+ <di:waypoint x="1216" y="940" />
615
+ <di:waypoint x="1236" y="940" />
616
+ <di:waypoint x="1236" y="980" />
617
+ <di:waypoint x="2252" y="980" />
618
+ <di:waypoint x="2252" y="320" />
448
619
  </bpmndi:BPMNEdge>
449
620
  <bpmndi:BPMNEdge id="BPMNEdge_f_answerRecord" bpmnElement="f_answerRecord">
450
- <di:waypoint x="1852" y="440" />
451
- <di:waypoint x="1952" y="440" />
621
+ <di:waypoint x="2302" y="280" />
622
+ <di:waypoint x="2402" y="280" />
452
623
  </bpmndi:BPMNEdge>
453
624
  <bpmndi:BPMNEdge id="BPMNEdge_f_answerLoop" bpmnElement="f_answerLoop">
454
- <di:waypoint x="2002" y="480" />
455
- <di:waypoint x="2002" y="660" />
456
- <di:waypoint x="266" y="660" />
625
+ <di:waypoint x="2452" y="320" />
626
+ <di:waypoint x="2452" y="340" />
627
+ <di:waypoint x="266" y="340" />
457
628
  <di:waypoint x="266" y="160" />
458
629
  </bpmndi:BPMNEdge>
459
630
  </bpmndi:BPMNPlane>
@@ -0,0 +1,101 @@
1
+ // pr.converge-gate — the deterministic review-comment gate for the convergence loop.
2
+ //
3
+ // The loop declares convergence on the review-round AGENT's self-reported `status = "converged"`.
4
+ // That trusts the agent to only converge once every Copilot comment is addressed — which failed on
5
+ // Magikcraft/nano-bpm#770 (20 rounds, a suppressed advisory never applied, then auto-merged with
6
+ // the comment unaddressed). This step runs on the converged path, BEFORE pr.finalize hands off to
7
+ // the merge loop, and blocks handoff while GitHub still shows unaddressed comments:
8
+ // • any review THREAD is still unresolved (GraphQL `isResolved = false`), or
9
+ // • any SUPPRESSED advisory in the latest Copilot review body lacks a matching RESOLVED ack
10
+ // thread (a `nano-ack: <path>:<line>` marker copied from Copilot's `**path:line**` header).
11
+ // A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
12
+ // the human `wait-answer` escalation (recoverable), never a hard wedge.
13
+ //
14
+ // It FAILS CLOSED: if the live GitHub state cannot be read, it blocks (escalates) rather than
15
+ // letting an unverifiable "converged" through — the opposite of the no-progress guard, because a
16
+ // merge-gating check must escalate-on-uncertainty so #770 cannot recur.
17
+ import type { AppJobHandler } from "@nanobpm/urban";
18
+ import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
19
+ import {
20
+ fetchLatestCopilotReviewBody,
21
+ fetchReviewThreads,
22
+ parseAckedAdvisories,
23
+ parseSuppressedAdvisories,
24
+ type ReviewThread,
25
+ } from "../../app/github.ts";
26
+ import { parsePr } from "../../app/service.ts";
27
+ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
28
+
29
+ // Input/output typed off the model data envelopes (`PrConvergeGateIn` / `PrConvergeGateOut` in
30
+ // convergence-loop.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
31
+ type In = WorkerInputs["pr.converge-gate"];
32
+ type Out = WorkerOutputs["pr.converge-gate"];
33
+
34
+ // Reads a PR's review threads. `null` = no usable transport (treated as an unverifiable read →
35
+ // fail closed). Throws propagate to the fail-closed catch below.
36
+ export type ThreadsReader = (repo: string, prNumber: number) => Promise<ReviewThread[] | null>;
37
+ // Reads the latest Copilot review body. `null` = no usable transport (unverifiable → fail closed);
38
+ // `""` = transport usable but no Copilot review yet (verified: no suppressed advisories).
39
+ export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
40
+
41
+ const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
42
+ fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
43
+ const defaultReadReviewBody: ReviewBodyReader = (repo, prNumber) =>
44
+ fetchLatestCopilotReviewBody(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
45
+
46
+ const BLOCK_UNVERIFIABLE =
47
+ "Convergence blocked: could not verify the PR's review comments against GitHub. A human must confirm every Copilot review thread is resolved and every suppressed advisory acknowledged before this PR converges (reply to resume the loop).";
48
+
49
+ /** Build the handler with injectable GitHub readers. The default export binds the real readers;
50
+ * tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
51
+ export function makeHandler(deps: {
52
+ readThreads: ThreadsReader;
53
+ readReviewBody: ReviewBodyReader;
54
+ }): AppJobHandler<In, Out> {
55
+ return async (job) => {
56
+ const { prKey, repo, prNumber } = job.variables;
57
+ // `parsePr` is total on any input (fails closed to `null` on a missing/non-string prKey), so
58
+ // pass it straight through — a malformed prKey degrades to the fail-closed target check below.
59
+ const parsed = parsePr(prKey);
60
+ const ghRepo = repo ?? parsed?.repo;
61
+ const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
62
+ if (!ghRepo || typeof ghNumber !== "number") {
63
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
64
+ }
65
+
66
+ let result: ConvergeGateResult;
67
+ try {
68
+ const threads = await deps.readThreads(ghRepo, ghNumber);
69
+ // A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
70
+ // "no threads" and is fine.)
71
+ if (threads === null) {
72
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
73
+ }
74
+ const reviewBody = await deps.readReviewBody(ghRepo, ghNumber);
75
+ // A null review body is an unverifiable read (no usable transport) — fail closed, same as a
76
+ // null threads read. (An empty STRING is a verified "no Copilot review / no advisories".)
77
+ if (reviewBody === null) {
78
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
79
+ }
80
+ const unresolvedThreadCount = threads.filter((t) => !t.isResolved).length;
81
+ result = evaluateConvergeGate({
82
+ unresolvedThreadCount,
83
+ suppressedKeys: parseSuppressedAdvisories(reviewBody),
84
+ acknowledgedKeys: parseAckedAdvisories(threads),
85
+ });
86
+ } catch {
87
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
88
+ }
89
+
90
+ return {
91
+ convergeBlocked: result.convergeBlocked,
92
+ convergeBlockReason: result.convergeBlockReason,
93
+ };
94
+ };
95
+ }
96
+
97
+ const handler = makeHandler({
98
+ readThreads: defaultReadThreads,
99
+ readReviewBody: defaultReadReviewBody,
100
+ });
101
+ export default handler;
@@ -0,0 +1,77 @@
1
+ // pr.progress-check — the deterministic no-progress guard for the convergence loop.
2
+ //
3
+ // After a round is recorded (pr.persist-round), this step reads the PR's current head SHA and
4
+ // compares it to the head observed at the previous recorded round. An `addressed` round whose head
5
+ // did NOT advance pushed no commit, so requesting another Copilot review would loop on
6
+ // byte-identical code — the same comments, round after round, until the round cap escalates. It
7
+ // returns `progressed:false`, and the model's `gw-progress` gateway escalates to the human
8
+ // `wait-answer` task instead of soliciting another review.
9
+ //
10
+ // Every other case returns `progressed:true` (continue): a `waiting` round (round 1, awaiting the
11
+ // first review) legitimately has no push; an advanced head means real work landed; and a head we
12
+ // could not read fails OPEN — the round cap and the review-wait timeout stay the safety nets so a
13
+ // transient GitHub hiccup can never fabricate a no-progress escalation. The routing decision lives
14
+ // in app/roundProgress.ts, the single source of truth this worker and `gw-progress` both mirror.
15
+ import type { AppJobHandler } from "@nanobpm/urban";
16
+ import { fetchPrHead } from "../../app/github.ts";
17
+ import { isAddressedStatus, routeProgress } from "../../app/roundProgress.ts";
18
+ import { parsePr } from "../../app/service.ts";
19
+ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
20
+
21
+ // Input/output typed off the model data envelopes (`PrProgressCheckIn` / `PrProgressCheckOut` in
22
+ // convergence-loop.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
23
+ type In = WorkerInputs["pr.progress-check"];
24
+ type Out = WorkerOutputs["pr.progress-check"];
25
+
26
+ // Reads a PR's current head SHA. Injectable so unit tests never touch git/network; the default
27
+ // binds the real GitHub reader (the shared gh | token transport) and swallows any failure to
28
+ // `null` so the guard fails OPEN.
29
+ export type HeadReader = (repo: string, prNumber: number) => Promise<string | null>;
30
+
31
+ const defaultReadHead: HeadReader = async (repo, prNumber) => {
32
+ const token = process.env.GITHUB_TOKEN ?? "";
33
+ const head = await fetchPrHead(repo, prNumber, token).catch(() => null);
34
+ return head?.headSha ?? null;
35
+ };
36
+
37
+ /** Build the handler with an injectable head reader (see {@link HeadReader}). The default export
38
+ * binds the real GitHub reader; tests inject a stub. */
39
+ export function makeHandler(deps: { readHead: HeadReader }): AppJobHandler<In, Out> {
40
+ return async (job, app) => {
41
+ const { prKey, status, repo, prNumber } = job.variables;
42
+
43
+ // Only an `addressed` round claims a push, so only it can be a no-progress round — and a
44
+ // blank/unknown status counts as `addressed` here (gw-status defaults it down the addressed
45
+ // arm and pr.persist-round records a missing status as `addressed`), so it is the safe-default
46
+ // trap this guard exists for. Skip the GitHub read entirely only for an explicitly recognized
47
+ // non-addressed status — a `waiting` round costs nothing and continues.
48
+ if (!isAddressedStatus(status)) return { progressed: true };
49
+
50
+ // Prefer the carried repo/prNumber; fall back to parsing the canonical `owner/repo#N` prKey so
51
+ // an older in-flight instance (or a process-variable regression) still resolves a target. If
52
+ // neither yields one, fail open rather than guess.
53
+ const parsed = parsePr(prKey);
54
+ const ghRepo = repo ?? parsed?.repo;
55
+ const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
56
+ if (!ghRepo || typeof ghNumber !== "number") return { progressed: true };
57
+
58
+ const currentHead = await deps.readHead(ghRepo, ghNumber).catch(() => null);
59
+ const prs = app.data.table<{ pr_key: string; last_round_head: string | null }>(
60
+ "pull_requests",
61
+ "pr_key",
62
+ );
63
+ const row = await prs.get(prKey);
64
+ const previousHead = row?.last_round_head ?? null;
65
+
66
+ // Record the observed head as the baseline for the next round's comparison — but only when we
67
+ // actually read one, so a null (unreadable) head never clobbers a good baseline.
68
+ if (currentHead) {
69
+ await prs.update(prKey, { last_round_head: currentHead });
70
+ }
71
+
72
+ return { progressed: routeProgress(status, previousHead, currentHead) === "continue" };
73
+ };
74
+ }
75
+
76
+ const handler = makeHandler({ readHead: defaultReadHead });
77
+ export default handler;