@nanobpm/nano-workforce 0.56.0 → 0.58.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 (90) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +10 -2
  5. package/SPEC.md +27 -24
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/agentGuide.ts +1 -1
  9. package/app/answer-escalation.test.ts +106 -0
  10. package/app/answerEscalation.test.ts +67 -0
  11. package/app/baseGuard.test.ts +9 -1
  12. package/app/baseGuard.ts +11 -0
  13. package/app/escalationSla.test.ts +39 -0
  14. package/app/escalationSla.ts +28 -0
  15. package/app/escalationTaxonomy.test.ts +115 -0
  16. package/app/escalationTaxonomy.ts +115 -0
  17. package/app/feature.test.ts +161 -0
  18. package/app/feature.ts +173 -0
  19. package/app/github.test.ts +179 -1
  20. package/app/github.ts +132 -0
  21. package/app/mergeProtocol.test.ts +15 -0
  22. package/app/mergeProtocol.ts +10 -0
  23. package/app/persist-escalation.test.ts +34 -36
  24. package/app/plan.test.ts +267 -313
  25. package/app/plan.ts +173 -231
  26. package/app/reviewWait.ts +12 -4
  27. package/app/roundResultDefault.test.ts +111 -2
  28. package/app/roundResultDefault.ts +35 -0
  29. package/app/service.test.ts +6 -7
  30. package/app/service.ts +52 -35
  31. package/db/migrations/026_agent_completion.sql +36 -0
  32. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  33. package/db/migrations/028_feature_runs.sql +28 -0
  34. package/e2e/agent-answerable.e2e.ts +185 -0
  35. package/e2e/convergence-escalation.e2e.ts +180 -0
  36. package/e2e/convergence-loop.e2e.ts +1 -1
  37. package/e2e/feature-run.e2e.ts +231 -0
  38. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  39. package/e2e/plan-fanout.e2e.ts +303 -0
  40. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  41. package/e2e/support/github-admit.ts +99 -0
  42. package/e2e/user-task-spine.e2e.ts +155 -0
  43. package/nano.app.json +41 -11
  44. package/openapi.yaml +237 -84
  45. package/operations/agentCompleteEscalation.ts +53 -0
  46. package/operations/listActivePrs.test.ts +39 -6
  47. package/operations/postMessage.ts +10 -41
  48. package/operations/revertEscalationCompletion.ts +44 -0
  49. package/operations/startAndMessage.test.ts +62 -60
  50. package/operations/startFeature.ts +127 -0
  51. package/operations/startPlanFanout.admission.integration.test.ts +263 -0
  52. package/operations/startPlanFanout.ts +70 -11
  53. package/package.json +4 -1
  54. package/pages/cockpit.page.json +1 -0
  55. package/pages/epic-detail.page.json +11 -37
  56. package/pages/epic.page.json +5 -2
  57. package/pages/feature.page.json +82 -0
  58. package/pages/home.page.json +6 -18
  59. package/resources/agent-guide.md +90 -26
  60. package/resources/forms/feature-escalation.form +27 -0
  61. package/resources/forms/plan-review-decision.form +27 -0
  62. package/resources/forms/pr-escalation.form +23 -0
  63. package/resources/forms/spine-demo.form +15 -0
  64. package/resources/forms/trial-merge-decision.form +25 -0
  65. package/resources/processes/convergence-loop.bpmn +127 -75
  66. package/resources/processes/feature.bpmn +240 -0
  67. package/resources/processes/plan-fanout.bpmn +322 -222
  68. package/resources/processes/spine-demo.bpmn +72 -0
  69. package/scripts/check-migrations.ts +68 -0
  70. package/workers/answer-escalation/worker.ts +78 -0
  71. package/workers/converge-feature/worker.ts +51 -0
  72. package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
  73. package/workers/ensure-base-branch/worker.test.ts +104 -0
  74. package/workers/ensure-base-branch/worker.ts +31 -0
  75. package/workers/finalize/worker.ts +0 -2
  76. package/workers/mark-merged/worker.ts +0 -2
  77. package/workers/merge/worker.ts +6 -5
  78. package/workers/persist-escalation/worker.ts +28 -32
  79. package/workers/record-feature/worker.ts +61 -0
  80. package/workers/record-plan-review/worker.test.ts +9 -10
  81. package/workers/record-plan-review/worker.ts +15 -5
  82. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  83. package/workers/resolve-trial-attention/worker.ts +43 -0
  84. package/operations/answerFeatureEscalation.test.ts +0 -112
  85. package/operations/answerFeatureEscalation.ts +0 -58
  86. package/operations/answerPlanEscalation.test.ts +0 -115
  87. package/operations/answerPlanEscalation.ts +0 -41
  88. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  89. package/workers/persist-plan-escalation/worker.ts +0 -73
  90. package/workers/persist-task-escalation/worker.ts +0 -120
package/openapi.yaml CHANGED
@@ -306,6 +306,19 @@ components:
306
306
  alreadyRunning:
307
307
  type: boolean
308
308
  description: True when a non-terminal plan for this issue already exists; no new instance was started.
309
+ StartFeatureResult:
310
+ type: object
311
+ required:
312
+ - featureKey
313
+ properties:
314
+ featureKey:
315
+ type: string
316
+ processKey:
317
+ type: string
318
+ nullable: true
319
+ alreadyRunning:
320
+ type: boolean
321
+ description: True when a non-terminal feature run for this issue already exists; no new instance was started.
309
322
  ConvergenceStart:
310
323
  description: The start-convergence request body. Names the target PR by EXACTLY ONE of `pr`
311
324
  (an `owner/repo#123` reference) or `url` (a bare PR URL) — never both, never neither — with
@@ -361,9 +374,10 @@ components:
361
374
  per-request review-only override; defaults to false (the global auto-merge default applies).
362
375
  PlanStart:
363
376
  description: The start-plan-fanout request body. Names the target issue by EXACTLY ONE of
364
- `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL). Modeled as `oneOf`
365
- named variants (Camunda REST v2 pattern) so an ambiguous or empty target is a 400 at the
366
- edge, not a silent `issue ?? url` coalesce in the delegate.
377
+ `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a REQUIRED
378
+ `baseBranch` (ADR 0003) the fleet branches off a blank/absent base is a 400. Modeled as
379
+ `oneOf` named variants (Camunda REST v2 pattern) so an ambiguous or empty target is a 400 at
380
+ the edge, not a silent `issue ?? url` coalesce in the delegate.
367
381
  oneOf:
368
382
  - $ref: "#/components/schemas/PlanStartByIssue"
369
383
  - $ref: "#/components/schemas/PlanStartByUrl"
@@ -372,105 +386,215 @@ components:
372
386
  additionalProperties: false
373
387
  required:
374
388
  - issue
389
+ - baseBranch
375
390
  properties:
376
391
  issue:
377
392
  type: string
378
393
  description: "Issue reference: owner/repo#123."
379
394
  baseBranch:
380
395
  type: string
396
+ minLength: 1
397
+ maxLength: 255
398
+ pattern: '\S'
399
+ description: >-
400
+ REQUIRED target branch the fleet branches off and opens every PR against. Every epic
401
+ launch must name its base explicitly (ADR 0003): a blank/absent value is rejected with a
402
+ 400, not silently coalesced to the repository default branch. Use it to land an entire
403
+ epic on a long-lived integration branch (e.g. `epic/agent-protocol`) so nothing reaches
404
+ the default branch — and any merge-to-default side effect, such as auto-publishing a
405
+ package — until you deliberately merge the integration branch. NOTE: this slice (B0)
406
+ only enforces that `baseBranch` is present and a plausible branch name; branch-existence
407
+ admission (auto-creating a missing `epic/*` base off the default branch HEAD, and
408
+ rejecting a missing non-`epic/*` base with a 400) is specified by ADR 0003 but NOT yet
409
+ enforced here — it lands in a later admission slice.
410
+ allowSharedBase:
411
+ type: boolean
381
412
  description: >-
382
- Optional target branch the fleet branches off and opens every PR against, instead of the
383
- repository's default branch. Use this to land an entire epic on a long-lived integration
384
- branch (e.g. `epic/agent-protocol`) so nothing reaches the default branch and any
385
- merge-to-default side effect, such as auto-publishing a package until you deliberately
386
- merge the integration branch. Blank/omitted keeps the current behaviour (the repo
387
- default branch).
413
+ Reserved for a later ADR 0003 admission slice NOT yet enforced in this slice (B0). When
414
+ implemented it will opt in to sharing a custom integration base branch with another
415
+ already-active plan: admission will otherwise reject (409) when another active plan already
416
+ targets the same repo + same custom base branch, to stop two epics interleaving commits on
417
+ one integration branch (the repository default branch is exempt from that guard). Accepted
418
+ by the schema today but currently has no runtime effect.
419
+ confirmDefaultBase:
420
+ type: boolean
421
+ description: >-
422
+ Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0). When
423
+ implemented it will be the required acknowledgement when `baseBranch` names the repository
424
+ default branch: targeting the default lands every task directly on it with no integration
425
+ buffer — and fires any merge-to-default side effect per task — so admission will reject
426
+ (400) unless confirmed with true (no effect for a non-default base). Accepted by the schema
427
+ today but currently has no runtime effect.
388
428
  PlanStartByUrl:
389
429
  type: object
390
430
  additionalProperties: false
391
431
  required:
392
432
  - url
433
+ - baseBranch
393
434
  properties:
394
435
  url:
395
436
  type: string
396
437
  description: A bare issue URL, when no `owner/repo#123` reference is supplied.
397
438
  baseBranch:
398
439
  type: string
440
+ minLength: 1
441
+ maxLength: 255
442
+ pattern: '\S'
399
443
  description: >-
400
- Optional target branch the fleet branches off and opens every PR against, instead of the
401
- repository's default branch. See `PlanStartByIssue.baseBranch`.
402
- MessageResult:
403
- type: object
404
- description: The result of publishing a message / answering an escalation. Shape varies by message
405
- name; `ok` is always present.
406
- additionalProperties: true
407
- required:
408
- - ok
409
- properties:
410
- ok:
444
+ REQUIRED target branch the fleet branches off and opens every PR against. See
445
+ `PlanStartByIssue.baseBranch`.
446
+ allowSharedBase:
447
+ type: boolean
448
+ description: >-
449
+ Reserved for a later ADR 0003 admission slice — NOT yet enforced in this slice (B0).
450
+ Accepted by the schema today but currently has no runtime effect. When implemented it
451
+ will opt in to sharing a custom integration base branch with another already-active plan.
452
+ See `PlanStartByIssue.allowSharedBase`.
453
+ confirmDefaultBase:
411
454
  type: boolean
412
- FeatureAnswerRequest:
413
- description: "Answer an implementation-phase task escalation (issue #25). Supply the target by
414
- EXACTLY ONE of `corrKey`, or the `plan`+`task` pair the delegate derives it from; `answer`
415
- is always required. Modeled as `oneOf` named variants (Camunda REST v2 pattern) so a body
416
- that supplies neither addressing form — or mixes them — is a 400 at the edge that names the
417
- allowed shapes, instead of the delegate re-deriving the precedence by hand."
455
+ description: >-
456
+ Reserved for a later ADR 0003 admission slice NOT yet enforced in this slice (B0).
457
+ Accepted by the schema today but currently has no runtime effect. When implemented it
458
+ will be the required acknowledgement when `baseBranch` names the repository default
459
+ branch. See `PlanStartByIssue.confirmDefaultBase`.
460
+ FeatureStart:
461
+ description: The start-feature request body — a SINGLE-issue feature run. Names the target issue
462
+ by EXACTLY ONE of `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a
463
+ REQUIRED `baseBranch` (ADR 0003, same admission as the epic path), and the two optional
464
+ follow-on knobs `converge` / `autoMerge`. Modeled as `oneOf` named variants (Camunda REST v2
465
+ pattern) so an ambiguous or empty target is a 400 at the edge.
418
466
  oneOf:
419
- - $ref: "#/components/schemas/FeatureAnswerByCorrKey"
420
- - $ref: "#/components/schemas/FeatureAnswerByPlanTask"
421
- FeatureAnswerByCorrKey:
467
+ - $ref: "#/components/schemas/FeatureStartByIssue"
468
+ - $ref: "#/components/schemas/FeatureStartByUrl"
469
+ FeatureStartByIssue:
422
470
  type: object
423
471
  additionalProperties: false
424
472
  required:
425
- - corrKey
426
- - answer
473
+ - issue
474
+ - baseBranch
427
475
  properties:
428
- corrKey:
476
+ issue:
429
477
  type: string
430
- description: The task correlation key, `<plan_key>:<task_id>` (e.g. owner/repo#12:task-3).
431
- answer:
478
+ description: "Issue reference: owner/repo#123."
479
+ baseBranch:
432
480
  type: string
433
481
  minLength: 1
434
- description: The operator's answer that resumes the parked implementation agent.
435
- FeatureAnswerByPlanTask:
482
+ maxLength: 255
483
+ pattern: '\S'
484
+ description: >-
485
+ REQUIRED target branch the agent branches off and opens its PR against. Admitted through
486
+ the same ADR 0003 policy as the epic path (`admitPlan`): a missing `epic/*` base is
487
+ auto-created off default HEAD; a non-`epic/*` base must already exist; naming the default
488
+ branch requires `confirmDefaultBase`.
489
+ converge:
490
+ type: boolean
491
+ description: >-
492
+ When true, hand the opened PR to the convergence loop (review rounds) as a follow-on step.
493
+ When false (default) the run ends at "PR raised, not reviewed".
494
+ autoMerge:
495
+ type: boolean
496
+ description: >-
497
+ When true (and `converge` is true), drive the merge-loop after convergence — mirrors
498
+ `convergeOnly`/`NANO_PR_AUTO_MERGE` inverted. When false the run stops at `converged`.
499
+ Moot when `converge` is false.
500
+ confirmDefaultBase:
501
+ type: boolean
502
+ description: >-
503
+ Acknowledge that `baseBranch` is the repository default branch (the PR would target it
504
+ directly). See `PlanStartByIssue.confirmDefaultBase`.
505
+ allowSharedBase:
506
+ type: boolean
507
+ description: >-
508
+ Opt in to sharing a custom integration base branch with another already-active epic. See
509
+ `PlanStartByIssue.allowSharedBase`.
510
+ FeatureStartByUrl:
436
511
  type: object
437
512
  additionalProperties: false
438
513
  required:
439
- - plan
440
- - task
441
- - answer
514
+ - url
515
+ - baseBranch
442
516
  properties:
443
- plan:
444
- type: string
445
- description: Plan reference (owner/repo#N); combined with `task` to derive the corrKey.
446
- task:
517
+ url:
447
518
  type: string
448
- description: Task id within the plan; combined with `plan` to derive the corrKey.
449
- answer:
519
+ description: A bare issue URL, when no `owner/repo#123` reference is supplied.
520
+ baseBranch:
450
521
  type: string
451
522
  minLength: 1
452
- description: The operator's answer that resumes the parked implementation agent.
453
- PlanAnswerRequest:
523
+ maxLength: 255
524
+ pattern: '\S'
525
+ description: >-
526
+ REQUIRED target branch the agent branches off and opens its PR against. See
527
+ `FeatureStartByIssue.baseBranch`.
528
+ converge:
529
+ type: boolean
530
+ description: When true, hand the opened PR to the convergence loop. See `FeatureStartByIssue.converge`.
531
+ autoMerge:
532
+ type: boolean
533
+ description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
534
+ confirmDefaultBase:
535
+ type: boolean
536
+ description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
537
+ allowSharedBase:
538
+ type: boolean
539
+ description: Share a custom integration base with another active epic. See `PlanStartByIssue.allowSharedBase`.
540
+ MessageResult:
541
+ type: object
542
+ description: The result of publishing a message / answering an escalation. Shape varies by message
543
+ name; `ok` is always present.
544
+ additionalProperties: true
545
+ required:
546
+ - ok
547
+ properties:
548
+ ok:
549
+ type: boolean
550
+ AgentCompleteRequest:
454
551
  type: object
455
552
  additionalProperties: false
553
+ description: >-
554
+ Complete an escalation user task AS AN AGENT (epic #156 / ADR 0046). The agent submits the
555
+ SAME typed form variables a human would through the task inbox; the host routes them through
556
+ the one canonical attributed completer, records the agent's identity for the audit trail, and
557
+ resumes the process. Only the migrated escalation user tasks may be completed this way.
456
558
  required:
457
- - plan
458
- - directive
559
+ - userTaskKey
560
+ - agentId
561
+ - variables
459
562
  properties:
460
- plan:
563
+ userTaskKey:
461
564
  type: string
462
- description: Plan reference (owner/repo#N), also the message correlation key.
463
- directive:
565
+ minLength: 1
566
+ description: The engine user-task key of the parked escalation task (from GET /tasks/api/tasks).
567
+ agentId:
464
568
  type: string
569
+ minLength: 1
570
+ description: The completing agent's identity (ADR 0046), recorded for attribution.
571
+ variables:
572
+ type: object
573
+ additionalProperties: true
465
574
  description: >-
466
- One of `proceed` or `revise` (case-insensitive; normalized to lowercase and trimmed
467
- server-side, see `parsePlanEscalationDirective`).
468
- `proceed` dispatches the current unapproved plan as an explicit human override;
469
- `revise` loops back to the planner with the note folded into planFindings and a fresh
470
- review budget.
575
+ The typed form completion variables the SAME shape a human submits (e.g.
576
+ `{ resolution, answer }`, `{ directive, notes }`, `{ action, notes }`, or `{ answer }`).
577
+ RevertCompletionRequest:
578
+ type: object
579
+ additionalProperties: false
580
+ description: >-
581
+ Revert/override an agent escalation completion (the reversibility guarantee of ADR 0046). A
582
+ human marks a reversible agent completion reverted so the agent's answer is no longer treated
583
+ as authoritative; the reverter's identity + timestamp are recorded.
584
+ required:
585
+ - completionId
586
+ - reverterId
587
+ properties:
588
+ completionId:
589
+ type: integer
590
+ description: The `task_completions` id returned when the agent completion was recorded.
591
+ reverterId:
592
+ type: string
593
+ minLength: 1
594
+ description: The human identity overriding the agent completion, recorded for the audit trail.
471
595
  note:
472
596
  type: string
473
- description: Human guidance for the planner (used for `revise`; optional for `proceed`).
597
+ description: Optional corrective guidance from the human that overrides the agent's answer.
474
598
  BlackboardEntry:
475
599
  type: object
476
600
  additionalProperties: false
@@ -722,12 +846,43 @@ paths:
722
846
  application/json:
723
847
  schema:
724
848
  $ref: "#/components/schemas/ErrorBody"
849
+ /actions/start/feature:
850
+ post:
851
+ operationId: startFeature
852
+ summary: Register/refresh a feature-run aggregate (idempotent on featureKey) and start the single-issue run.
853
+ requestBody:
854
+ required: true
855
+ content:
856
+ application/json:
857
+ schema:
858
+ $ref: "#/components/schemas/FeatureStart"
859
+ responses:
860
+ "202":
861
+ description: The feature run was started (or was already running).
862
+ content:
863
+ application/json:
864
+ schema:
865
+ $ref: "#/components/schemas/StartFeatureResult"
866
+ "400":
867
+ description: The issue reference could not be parsed, or the base branch was rejected.
868
+ content:
869
+ application/json:
870
+ schema:
871
+ $ref: "#/components/schemas/ErrorBody"
872
+ "409":
873
+ description: The base branch is already in use by another active epic.
874
+ content:
875
+ application/json:
876
+ schema:
877
+ $ref: "#/components/schemas/ErrorBody"
725
878
  /actions/message:
726
879
  post:
727
880
  operationId: postMessage
728
- summary: Publish a message / answer an escalation. For escalation-answered,
729
- feature-escalation-answered, and plan-escalation-answered names, runs the corresponding
730
- answer flow; otherwise a plain publishMessage.
881
+ summary: "Publish a message / answer an escalation. For the merge-loop `escalation-answered`
882
+ name, runs the merge-loop escalation answer flow; otherwise a plain publishMessage. The
883
+ epic #156 escalation
884
+ kinds (task, plan-review, trial-merge, PR review-loop) are native user tasks answered via
885
+ the task inbox (POST /tasks/api/complete), not this route."
731
886
  requestBody:
732
887
  required: true
733
888
  content:
@@ -750,13 +905,7 @@ paths:
750
905
  properties:
751
906
  answer:
752
907
  type: string
753
- directive:
754
- type: string
755
- description: >-
756
- One of `proceed` or `revise` (case-insensitive; normalized to lowercase and
757
- trimmed server-side, see `parsePlanEscalationDirective`).
758
- note:
759
- type: string
908
+ description: The operator's answer that resumes a parked merge-loop escalation.
760
909
  responses:
761
910
  "200":
762
911
  description: The message was published (or the escalation answered).
@@ -776,11 +925,13 @@ paths:
776
925
  application/json:
777
926
  schema:
778
927
  $ref: "#/components/schemas/MessageResult"
779
- /hooks/feature-answer:
928
+ /hooks/agent-complete:
780
929
  post:
781
- operationId: answerFeatureEscalation
782
- summary: "Answer an implementation-phase task escalation out of band (issue #25). Optional
783
- shared-secret guard (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET is set."
930
+ operationId: agentCompleteEscalation
931
+ summary: "Complete an escalation user task as an agent assignee (ADR 0046). Reuses the same
932
+ `.form` contract + engine resume path a human uses; records the agent identity for
933
+ attribution. Optional shared-secret guard (x-hook-secret), enforced only when
934
+ NANO_PR_WEBHOOK_SECRET is set."
784
935
  security:
785
936
  - hookSecret: []
786
937
  - {}
@@ -789,16 +940,16 @@ paths:
789
940
  content:
790
941
  application/json:
791
942
  schema:
792
- $ref: "#/components/schemas/FeatureAnswerRequest"
943
+ $ref: "#/components/schemas/AgentCompleteRequest"
793
944
  responses:
794
945
  "200":
795
- description: The escalation was answered and the parked agent resumed.
946
+ description: The agent completed the escalation task and the process resumed.
796
947
  content:
797
948
  application/json:
798
949
  schema:
799
950
  $ref: "#/components/schemas/MessageResult"
800
951
  "400":
801
- description: A required field was missing (answer, or a resolvable correlation key).
952
+ description: A required field was missing (userTaskKey, agentId, variables), or the target is not an escalation task.
802
953
  content:
803
954
  application/json:
804
955
  schema:
@@ -810,16 +961,18 @@ paths:
810
961
  schema:
811
962
  $ref: "#/components/schemas/MessageResult"
812
963
  "404":
813
- description: No matching open task escalation for the correlation key.
964
+ description: No open escalation user task matches the userTaskKey.
814
965
  content:
815
966
  application/json:
816
967
  schema:
817
968
  $ref: "#/components/schemas/MessageResult"
818
- /hooks/plan-answer:
969
+ /hooks/revert-completion:
819
970
  post:
820
- operationId: answerPlanEscalation
821
- summary: "Answer a plan-review cap escalation out of band. Optional shared-secret guard
822
- (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET is set."
971
+ operationId: revertEscalationCompletion
972
+ summary: "Revert/override an agent escalation completion (ADR 0046 reversibility). A human marks
973
+ a reversible agent completion reverted so the agent's answer is no longer authoritative.
974
+ Optional shared-secret guard (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET is
975
+ set."
823
976
  security:
824
977
  - hookSecret: []
825
978
  - {}
@@ -828,16 +981,16 @@ paths:
828
981
  content:
829
982
  application/json:
830
983
  schema:
831
- $ref: "#/components/schemas/PlanAnswerRequest"
984
+ $ref: "#/components/schemas/RevertCompletionRequest"
832
985
  responses:
833
986
  "200":
834
- description: The escalation was answered and the parked plan resumed.
987
+ description: The agent completion was reverted.
835
988
  content:
836
989
  application/json:
837
990
  schema:
838
991
  $ref: "#/components/schemas/MessageResult"
839
992
  "400":
840
- description: A required field was missing (plan or directive).
993
+ description: A required field was missing, or the completion is not reversible / already reverted.
841
994
  content:
842
995
  application/json:
843
996
  schema:
@@ -849,7 +1002,7 @@ paths:
849
1002
  schema:
850
1003
  $ref: "#/components/schemas/MessageResult"
851
1004
  "404":
852
- description: No matching open plan escalation for the plan key.
1005
+ description: No completion matches the completionId.
853
1006
  content:
854
1007
  application/json:
855
1008
  schema:
@@ -0,0 +1,53 @@
1
+ // POST /app/api/hooks/agent-complete → operationId `agentCompleteEscalation` (epic #156, slice U6;
2
+ // ADR 0046). Lets an AGENT assignee complete an escalation user task by submitting the SAME typed
3
+ // form variables a human would through the task inbox. The completion routes through the one
4
+ // canonical attributed completer (`completeEscalationAsAgent` → `completeUserTaskAttributed`), which
5
+ // records the agent's identity in the `task_completions` ledger (for attribution) and then resumes
6
+ // the process via `engine.completeUserTask` — the exact same resume path a human drives, no parallel
7
+ // lane. Optional shared-secret guard (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET is
8
+ // set, mirroring the other operator/webhook control surfaces.
9
+ //
10
+ // The runtime validates the body against openapi.yaml (`userTaskKey`, `agentId`, `variables` all
11
+ // required); this delegate narrows the validated shape and applies the shared-secret guard.
12
+
13
+ import { completeEscalationAsAgent } from "../app/agentCompletion.ts";
14
+ import { envVar } from "../app/version.ts";
15
+ import { defineOperation } from "../nano-generated/operations.ts";
16
+
17
+ const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
18
+
19
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
20
+
21
+ export default defineOperation("agentCompleteEscalation", async ({ req, body }, app) => {
22
+ if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
23
+ app.log.warn("agent-complete rejected: missing/invalid shared secret");
24
+ return { status: 401, body: { ok: false, error: "unauthorized" } };
25
+ }
26
+ if (!body || typeof body !== "object") {
27
+ app.log.warn("agent-complete rejected: missing request body");
28
+ return { status: 400, body: { ok: false, error: "userTaskKey, agentId and variables are required" } };
29
+ }
30
+
31
+ const userTaskKey = str(body.userTaskKey);
32
+ const agentId = str(body.agentId);
33
+ const variables = body.variables;
34
+ if (!userTaskKey) return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
35
+ if (!agentId) return { status: 400, body: { ok: false, error: "agentId is required" } };
36
+ if (!variables || typeof variables !== "object" || Array.isArray(variables)) {
37
+ return { status: 400, body: { ok: false, error: "variables must be an object" } };
38
+ }
39
+
40
+ const r = await completeEscalationAsAgent(app.data, app.engine, {
41
+ userTaskKey,
42
+ agentId,
43
+ variables,
44
+ });
45
+ if (r.ok) {
46
+ app.log.info("agent completed escalation", { userTaskKey, agentId, elementId: r.elementId });
47
+ return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
48
+ }
49
+ // A non-escalation / missing-target is a client error; an unknown key is a 404.
50
+ const status = r.reason === "no open escalation task" ? 404 : 400;
51
+ app.log.warn("agent-complete: not completed", { userTaskKey, reason: r.reason });
52
+ return { status, body: { ok: false, error: r.reason } };
53
+ });
@@ -7,13 +7,24 @@ import type { AppApi } from "@nanobpm/urban";
7
7
  import { noopLog } from "../test/log.ts";
8
8
  import handler from "./listActivePrs.ts";
9
9
 
10
- function memApp(rows: any[]): AppApi {
11
- const tbl = {
12
- async all() {
13
- return rows;
14
- },
10
+ function memApp(rows: any[], escalations: any[] = []): AppApi {
11
+ const table = (name: string) => {
12
+ if (name === "escalations") {
13
+ return {
14
+ async find(where: Record<string, unknown>) {
15
+ return escalations.filter((e) =>
16
+ Object.entries(where).every(([k, v]) => e[k] === v)
17
+ );
18
+ },
19
+ };
20
+ }
21
+ return {
22
+ async all() {
23
+ return rows;
24
+ },
25
+ };
15
26
  };
16
- return { data: { table: () => tbl }, log: noopLog() } as any as AppApi;
27
+ return { data: { table }, log: noopLog() } as any as AppApi;
17
28
  }
18
29
 
19
30
  function input(headers: Record<string, string> = {}) {
@@ -46,6 +57,28 @@ test("returns 200 with a count + projected active PRs", async () => {
46
57
  assertEquals(r.body.prs[0].processKey, "9");
47
58
  });
48
59
 
60
+ test("surfaces openEscalation for an escalated PR from its open escalations row (both loops)", async () => {
61
+ // Regression: a merge-loop escalation parks on a message catch (no user task), so deriving
62
+ // openEscalation from a user-task probe hid it. Deriving from the canonical `escalations` row
63
+ // surfaces it. Two escalated PRs — one with an open row (visible), one already answered (null).
64
+ const app = memApp(
65
+ [
66
+ { pr_key: "o/r#10", repo: "o/r", number: 10, url: "u10", title: "merge blocked", status: "escalated", current_round: 3, process_key: "m1", updated_at: "2026-02-02" },
67
+ { pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "answered", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
68
+ ],
69
+ [
70
+ { id: 1, pr_key: "o/r#10", status: "open", question: "Resolve the conflict on the branch, then retry?" },
71
+ { id: 2, pr_key: "o/r#11", status: "answered", question: "old question" },
72
+ ],
73
+ );
74
+ const res = (await handler(input(), app)) as any;
75
+ assertEquals(res.status, 200);
76
+ const p10 = res.body.prs.find((p: any) => p.prKey === "o/r#10");
77
+ const p11 = res.body.prs.find((p: any) => p.prKey === "o/r#11");
78
+ assertEquals(p10.openEscalation, "Resolve the conflict on the branch, then retry?");
79
+ assertEquals(p11.openEscalation, null);
80
+ });
81
+
49
82
  test("shared-secret guard rejects a missing secret when configured", async () => {
50
83
  const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
51
84
  process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
@@ -1,21 +1,18 @@
1
1
  // POST /app/api/actions/message → operationId `postMessage` (ADR 0058, base /app/api).
2
2
  // Replaces the hand-rolled action that overrode the generic publishMessage action. For the
3
- // `escalation-answered` message we run the review answer flow, for `feature-escalation-answered`
4
- // (issue #25) the implementation-phase (per-task) answer flow, and for
5
- // `plan-escalation-answered` the plan-review cap answer flow. Any other message falls back to a
6
- // plain publishMessage.
3
+ // merge-loop `escalation-answered` message we run the merge-loop escalation answer flow; any other
4
+ // message falls back to a plain publishMessage.
5
+ //
6
+ // The four #156 escalation kinds (task, plan-review, trial-merge, PR review-loop) are now native
7
+ // `userTask`s answered directly through the task inbox (`POST /tasks/api/complete`), so this
8
+ // delegate no longer carries their bespoke `feature-escalation-answered` / `plan-escalation-answered`
9
+ // discriminators. The merge-loop escalation is still a durable message catch (out of scope for
10
+ // #156), so its `escalation-answered` branch is kept.
7
11
  //
8
12
  // The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
9
13
  // for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
10
14
  // is app logic, not something the JSON schema can express.
11
15
 
12
- import {
13
- answerPlanEscalation,
14
- answerTaskEscalation,
15
- FEATURE_ESCALATION_MESSAGE,
16
- PLAN_ESCALATION_MESSAGE,
17
- parsePlanEscalationDirective,
18
- } from "../app/plan.ts";
19
16
  import { answerEscalation } from "../app/service.ts";
20
17
  import { defineOperation } from "../nano-generated/operations.ts";
21
18
 
@@ -33,36 +30,8 @@ export default defineOperation("postMessage", async ({ body }, app) => {
33
30
  if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
34
31
  if (!answer) return { status: 400, body: { error: "answer is required" } };
35
32
  const r = await answerEscalation(app.data, app.engine, prKey, answer);
36
- if (r.ok) app.log.info("review escalation answered", { name, prKey });
37
- else app.log.warn("postMessage: no open review escalation to answer", { name, prKey });
38
- return { status: r.ok ? 200 : 404, body: r };
39
- }
40
-
41
- if (name === FEATURE_ESCALATION_MESSAGE) {
42
- // Implementation-phase task escalation (issue #25): correlationKey is the task's
43
- // `<plan_key>:<task_id>`; record the answer, resume the parked child, and re-surface the next
44
- // open escalation.
45
- const corrKey = String(b.correlationKey ?? "");
46
- const answer = String(b.variables?.answer ?? "").trim();
47
- if (!corrKey) return { status: 400, body: { error: "correlationKey is required" } };
48
- if (!answer) return { status: 400, body: { error: "answer is required" } };
49
- const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
50
- if (r.ok) app.log.info("feature escalation answered", { name, corrKey });
51
- else app.log.warn("postMessage: no open feature escalation to answer", { name, corrKey });
52
- return { status: r.ok ? 200 : 404, body: r };
53
- }
54
-
55
- if (name === PLAN_ESCALATION_MESSAGE) {
56
- const planKey = String(b.correlationKey ?? "");
57
- const directive = parsePlanEscalationDirective(b.variables?.directive ?? "revise");
58
- const note = String(b.variables?.note ?? b.variables?.answer ?? "").trim();
59
- if (!planKey) return { status: 400, body: { error: "correlationKey is required" } };
60
- if (!directive) {
61
- return { status: 400, body: { error: "directive must be proceed or revise" } };
62
- }
63
- const r = await answerPlanEscalation(app.data, app.engine, planKey, directive, note);
64
- if (r.ok) app.log.info("plan escalation answered", { name, planKey, directive });
65
- else app.log.warn("postMessage: no open plan escalation to answer", { name, planKey });
33
+ if (r.ok) app.log.info("merge-loop escalation answered", { name, prKey });
34
+ else app.log.warn("postMessage: no open merge-loop escalation to answer", { name, prKey });
66
35
  return { status: r.ok ? 200 : 404, body: r };
67
36
  }
68
37