@nanobpm/nano-workforce 0.57.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 (82) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +1 -1
  5. package/SPEC.md +21 -22
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/answer-escalation.test.ts +106 -0
  9. package/app/answerEscalation.test.ts +67 -0
  10. package/app/baseGuard.test.ts +9 -1
  11. package/app/baseGuard.ts +11 -0
  12. package/app/escalationSla.test.ts +39 -0
  13. package/app/escalationSla.ts +28 -0
  14. package/app/escalationTaxonomy.test.ts +115 -0
  15. package/app/escalationTaxonomy.ts +115 -0
  16. package/app/feature.test.ts +161 -0
  17. package/app/feature.ts +173 -0
  18. package/app/mergeProtocol.test.ts +15 -0
  19. package/app/mergeProtocol.ts +10 -0
  20. package/app/persist-escalation.test.ts +34 -36
  21. package/app/plan.test.ts +0 -294
  22. package/app/plan.ts +26 -216
  23. package/app/reviewWait.ts +12 -4
  24. package/app/roundResultDefault.test.ts +111 -2
  25. package/app/roundResultDefault.ts +35 -0
  26. package/app/service.test.ts +6 -7
  27. package/app/service.ts +52 -35
  28. package/db/migrations/026_agent_completion.sql +36 -0
  29. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  30. package/db/migrations/028_feature_runs.sql +28 -0
  31. package/e2e/agent-answerable.e2e.ts +185 -0
  32. package/e2e/convergence-escalation.e2e.ts +180 -0
  33. package/e2e/convergence-loop.e2e.ts +1 -1
  34. package/e2e/feature-run.e2e.ts +231 -0
  35. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  36. package/e2e/plan-fanout.e2e.ts +303 -0
  37. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  38. package/e2e/support/github-admit.ts +99 -0
  39. package/e2e/user-task-spine.e2e.ts +155 -0
  40. package/nano.app.json +37 -11
  41. package/openapi.yaml +181 -73
  42. package/operations/agentCompleteEscalation.ts +53 -0
  43. package/operations/listActivePrs.test.ts +39 -6
  44. package/operations/postMessage.ts +10 -41
  45. package/operations/revertEscalationCompletion.ts +44 -0
  46. package/operations/startAndMessage.test.ts +0 -58
  47. package/operations/startFeature.ts +127 -0
  48. package/package.json +4 -1
  49. package/pages/cockpit.page.json +1 -0
  50. package/pages/epic-detail.page.json +11 -37
  51. package/pages/epic.page.json +1 -1
  52. package/pages/feature.page.json +82 -0
  53. package/pages/home.page.json +6 -18
  54. package/resources/agent-guide.md +52 -24
  55. package/resources/forms/feature-escalation.form +27 -0
  56. package/resources/forms/plan-review-decision.form +27 -0
  57. package/resources/forms/pr-escalation.form +23 -0
  58. package/resources/forms/spine-demo.form +15 -0
  59. package/resources/forms/trial-merge-decision.form +25 -0
  60. package/resources/processes/convergence-loop.bpmn +127 -75
  61. package/resources/processes/feature.bpmn +240 -0
  62. package/resources/processes/plan-fanout.bpmn +304 -223
  63. package/resources/processes/spine-demo.bpmn +72 -0
  64. package/scripts/check-migrations.ts +68 -0
  65. package/workers/answer-escalation/worker.ts +78 -0
  66. package/workers/converge-feature/worker.ts +51 -0
  67. package/workers/finalize/worker.ts +0 -2
  68. package/workers/mark-merged/worker.ts +0 -2
  69. package/workers/merge/worker.ts +6 -5
  70. package/workers/persist-escalation/worker.ts +28 -32
  71. package/workers/record-feature/worker.ts +61 -0
  72. package/workers/record-plan-review/worker.test.ts +9 -10
  73. package/workers/record-plan-review/worker.ts +15 -5
  74. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  75. package/workers/resolve-trial-attention/worker.ts +43 -0
  76. package/operations/answerFeatureEscalation.test.ts +0 -112
  77. package/operations/answerFeatureEscalation.ts +0 -58
  78. package/operations/answerPlanEscalation.test.ts +0 -115
  79. package/operations/answerPlanEscalation.ts +0 -41
  80. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  81. package/workers/persist-plan-escalation/worker.ts +0 -73
  82. package/workers/persist-task-escalation/worker.ts +0 -120
@@ -0,0 +1,155 @@
1
+ // End-to-end proof for the user-task + form spine (epic #156, slice U0 — the keystone).
2
+ //
3
+ // Boots this whole Urban app in-process against the WASM engine and a virtual clock via
4
+ // `bootTestApp`, deploys the throwaway `spine-demo` process (a native `userTask` linked to
5
+ // `spine-demo.form`, whose completion drives a data-based decision gateway), and proves the
6
+ // round-trip every later escalation slice builds on:
7
+ //
8
+ // start instance → the task is listed via the `taskInbox` surface route
9
+ // (GET /tasks/api/tasks) → complete it with the typed form field via POST /tasks/api/complete
10
+ // → the process resumes, and the TYPED variable routes the decision gateway before the instance
11
+ // COMPLETES.
12
+ //
13
+ // The gateway is deliberate: a bare "the token advanced" assertion cannot tell a real typed
14
+ // submission apart from an empty one (the WASM engine folds a completed instance's variables away,
15
+ // so they are not observable post-completion). Routing the resume through a FEEL condition on the
16
+ // form's `decision` field makes "resumes WITH those variables" falsifiable — an empty/wrong value
17
+ // would take the gateway's default (reject) flow instead of the approve flow this test asserts. This
18
+ // mirrors the answer/abandon gateway the escalation slices (U2/U3) build on the same spine.
19
+ //
20
+ // Network isolation mirrors the sibling convergence e2e: the app's GitHub transport is forced to
21
+ // `token` mode with no token, so any best-effort GitHub read short-circuits instead of reaching out.
22
+ //
23
+ // Run with `npm run e2e` (a dedicated node:test invocation, kept out of the fast unit `npm test`).
24
+
25
+ import assert from "node:assert/strict";
26
+ import { mkdtempSync, rmSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { dirname, join, resolve } from "node:path";
29
+ import { after, before, describe, test } from "node:test";
30
+ import { fileURLToPath } from "node:url";
31
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
32
+
33
+ // The app root is this repo's root (one level up from `e2e/`) — where nano.app.json + the
34
+ // resources/processes + resources/forms it deploys live.
35
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
36
+
37
+ // Provision the app's SQLite in a throwaway temp dir so the test never touches (or leaks into) the
38
+ // repo's real ./app.db, and every run starts from a freshly-migrated, empty schema.
39
+ const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u0-"));
40
+
41
+ // Force the app fully offline (github.ts reads process.env directly, not the harness env overlay):
42
+ // `token` mode with no GITHUB_TOKEN means every best-effort GitHub read short-circuits to null.
43
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
44
+ NANO_PR_GITHUB_TRANSPORT: "token",
45
+ GITHUB_TOKEN: "",
46
+ };
47
+ const savedEnv = new Map<string, string | undefined>();
48
+
49
+ // The harness `env` overlay drives the runtime's `${NANO_APP_DB_URL}` resolution.
50
+ const HARNESS_ENV = {
51
+ NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`,
52
+ } as const;
53
+
54
+ interface InboxTask {
55
+ userTaskKey: string;
56
+ elementId?: string;
57
+ variables?: Record<string, unknown>;
58
+ }
59
+
60
+ interface TakenFlow {
61
+ from: string;
62
+ to: string;
63
+ }
64
+
65
+ /** The engine snapshot's cumulative taken sequence flows, as `from->to` strings. With a single
66
+ * instance in play this is exactly that instance's routing history. */
67
+ function takenFlows(app: TestApp): string[] {
68
+ const snapshot = app.snapshot();
69
+ const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
70
+ return flows
71
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
72
+ .map((f) => `${f.from}->${f.to}`);
73
+ }
74
+
75
+ describe("nano-workforce user-task spine (U0 keystone)", () => {
76
+ let app: TestApp;
77
+
78
+ before(async () => {
79
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
80
+ savedEnv.set(k, process.env[k]);
81
+ process.env[k] = v;
82
+ }
83
+ app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV });
84
+ });
85
+
86
+ after(async () => {
87
+ await app?.stop();
88
+ for (const [k, v] of savedEnv) {
89
+ if (v === undefined) delete process.env[k];
90
+ else process.env[k] = v;
91
+ }
92
+ rmSync(DB_DIR, { recursive: true, force: true });
93
+ });
94
+
95
+ test("a userTask bearing a linked .form round-trips list → complete → resume with typed vars", async () => {
96
+ // Boot deploys every model under `models` — including the throwaway spine-demo.bpmn and its
97
+ // linked spine-demo.form. Start one instance of the spine process.
98
+ const created = await app.engine.createInstance({ processDefinitionId: "spine-demo" });
99
+ const processInstanceKey = created.processInstanceKey;
100
+ assert.ok(processInstanceKey, "starting spine-demo returns a process instance key");
101
+
102
+ // The instance parks on the native userTask. It is visible through the `taskInbox` surface's
103
+ // JSON route (GET /tasks/api/tasks) — the manifest-enabled surface this slice lands.
104
+ const listed = await app.callRoute<InboxTask[]>({
105
+ method: "GET",
106
+ path: "/tasks/api/tasks",
107
+ query: { processInstanceKey },
108
+ });
109
+ assert.equal(listed.status, 200, "the taskInbox surface serves the task list");
110
+ assert.equal(listed.body.length, 1, "exactly the one spine userTask is open");
111
+ const task = listed.body[0];
112
+ assert.equal(task.elementId, "decide", "the open task is the spine's `decide` userTask");
113
+ assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
114
+
115
+ // Complete it through the surface's completion route (POST /tasks/api/complete) with exactly the
116
+ // typed field the linked .form declares (`decision`). This is the list → render → complete path
117
+ // an operator drives through the inbox.
118
+ const completed = await app.callRoute<{ ok: boolean }>({
119
+ method: "POST",
120
+ path: "/tasks/api/complete",
121
+ body: JSON.stringify({ userTaskKey: task.userTaskKey, variables: { decision: "approve" } }),
122
+ });
123
+ assert.equal(completed.status, 200, "the completion route accepts the typed form submission");
124
+ assert.equal(completed.body.ok, true, "the userTask was completed");
125
+
126
+ // The typed variable resumed the token through the decision gateway: `decision = "approve"`
127
+ // satisfied the FEEL condition, so the token took the approve flow (NOT the default reject flow).
128
+ // This is the falsifiable core of "resumes WITH those variables" — an empty/wrong value would
129
+ // have fallen through to `end-rejected`.
130
+ const flows = takenFlows(app);
131
+ assert.ok(
132
+ flows.includes("gw-decision->end-approved"),
133
+ `the typed decision routed to the approve end (flows: ${flows.join(", ")})`,
134
+ );
135
+ assert.ok(
136
+ !flows.includes("gw-decision->end-rejected"),
137
+ `the default reject flow was NOT taken (flows: ${flows.join(", ")})`,
138
+ );
139
+
140
+ // The token advanced start → userTask → gateway → end: the process resumes and reaches COMPLETED,
141
+ // with no open task left behind.
142
+ const instances = await app.engine.searchProcessInstances({
143
+ processInstanceKeys: [processInstanceKey],
144
+ });
145
+ assert.equal(instances.length, 1, "the spine instance is still resolvable");
146
+ assert.equal(instances[0].state, "COMPLETED", "the process resumed and completed");
147
+
148
+ const remaining = await app.callRoute<InboxTask[]>({
149
+ method: "GET",
150
+ path: "/tasks/api/tasks",
151
+ query: { processInstanceKey },
152
+ });
153
+ assert.equal(remaining.body.length, 0, "the completed task is no longer open");
154
+ });
155
+ });
package/nano.app.json CHANGED
@@ -8,6 +8,9 @@
8
8
  "processes": [
9
9
  "resources/processes/*.bpmn"
10
10
  ],
11
+ "forms": [
12
+ "resources/forms/*.form"
13
+ ],
11
14
  "templates": [
12
15
  "prompts/*.md"
13
16
  ]
@@ -39,9 +42,7 @@
39
42
  ],
40
43
  "onTerminated": {
41
44
  "set": {
42
- "status": "abandoned",
43
- "open_escalation_id": null,
44
- "open_escalation_question": null
45
+ "status": "abandoned"
45
46
  }
46
47
  },
47
48
  "pollMs": 5000
@@ -60,6 +61,20 @@
60
61
  }
61
62
  },
62
63
  "pollMs": 5000
64
+ },
65
+ {
66
+ "table": "feature_runs",
67
+ "keyField": "process_key",
68
+ "statusField": "status",
69
+ "activeStatuses": [
70
+ "running"
71
+ ],
72
+ "onTerminated": {
73
+ "set": {
74
+ "status": "abandoned"
75
+ }
76
+ },
77
+ "pollMs": 5000
63
78
  }
64
79
  ],
65
80
  "workers": [
@@ -75,6 +90,10 @@
75
90
  "taskType": "pr.persist-escalation",
76
91
  "handler": "workers/persist-escalation/worker.ts"
77
92
  },
93
+ {
94
+ "taskType": "pr.answer-escalation",
95
+ "handler": "workers/answer-escalation/worker.ts"
96
+ },
78
97
  {
79
98
  "taskType": "pr.finalize",
80
99
  "handler": "workers/finalize/worker.ts"
@@ -112,20 +131,24 @@
112
131
  "handler": "workers/record-wave/worker.ts"
113
132
  },
114
133
  {
115
- "taskType": "pr.record-trial-merge",
116
- "handler": "workers/record-trial-merge/worker.ts"
134
+ "taskType": "pr.record-feature",
135
+ "handler": "workers/record-feature/worker.ts"
117
136
  },
118
137
  {
119
- "taskType": "pr.record-results",
120
- "handler": "workers/record-results/worker.ts"
138
+ "taskType": "pr.converge-feature",
139
+ "handler": "workers/converge-feature/worker.ts"
121
140
  },
122
141
  {
123
- "taskType": "pr.persist-task-escalation",
124
- "handler": "workers/persist-task-escalation/worker.ts"
142
+ "taskType": "pr.record-trial-merge",
143
+ "handler": "workers/record-trial-merge/worker.ts"
144
+ },
145
+ {
146
+ "taskType": "pr.resolve-trial-attention",
147
+ "handler": "workers/resolve-trial-attention/worker.ts"
125
148
  },
126
149
  {
127
- "taskType": "pr.persist-plan-escalation",
128
- "handler": "workers/persist-plan-escalation/worker.ts"
150
+ "taskType": "pr.record-results",
151
+ "handler": "workers/record-results/worker.ts"
129
152
  },
130
153
  {
131
154
  "taskType": "pr.retro-gather",
@@ -147,6 +170,9 @@
147
170
  "senior:retro"
148
171
  ],
149
172
  "surfaces": {
173
+ "taskInbox": {
174
+ "enabled": true
175
+ },
150
176
  "pages": {
151
177
  "enabled": true,
152
178
  "pagesDir": "pages",
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
@@ -444,78 +457,144 @@ components:
444
457
  Accepted by the schema today but currently has no runtime effect. When implemented it
445
458
  will be the required acknowledgement when `baseBranch` names the repository default
446
459
  branch. See `PlanStartByIssue.confirmDefaultBase`.
447
- MessageResult:
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.
466
+ oneOf:
467
+ - $ref: "#/components/schemas/FeatureStartByIssue"
468
+ - $ref: "#/components/schemas/FeatureStartByUrl"
469
+ FeatureStartByIssue:
448
470
  type: object
449
- description: The result of publishing a message / answering an escalation. Shape varies by message
450
- name; `ok` is always present.
451
- additionalProperties: true
471
+ additionalProperties: false
452
472
  required:
453
- - ok
473
+ - issue
474
+ - baseBranch
454
475
  properties:
455
- ok:
476
+ issue:
477
+ type: string
478
+ description: "Issue reference: owner/repo#123."
479
+ baseBranch:
480
+ type: string
481
+ minLength: 1
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:
456
490
  type: boolean
457
- FeatureAnswerRequest:
458
- description: "Answer an implementation-phase task escalation (issue #25). Supply the target by
459
- EXACTLY ONE of `corrKey`, or the `plan`+`task` pair the delegate derives it from; `answer`
460
- is always required. Modeled as `oneOf` named variants (Camunda REST v2 pattern) so a body
461
- that supplies neither addressing form — or mixes them — is a 400 at the edge that names the
462
- allowed shapes, instead of the delegate re-deriving the precedence by hand."
463
- oneOf:
464
- - $ref: "#/components/schemas/FeatureAnswerByCorrKey"
465
- - $ref: "#/components/schemas/FeatureAnswerByPlanTask"
466
- FeatureAnswerByCorrKey:
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:
467
511
  type: object
468
512
  additionalProperties: false
469
513
  required:
470
- - corrKey
471
- - answer
514
+ - url
515
+ - baseBranch
472
516
  properties:
473
- corrKey:
517
+ url:
474
518
  type: string
475
- description: The task correlation key, `<plan_key>:<task_id>` (e.g. owner/repo#12:task-3).
476
- answer:
519
+ description: A bare issue URL, when no `owner/repo#123` reference is supplied.
520
+ baseBranch:
477
521
  type: string
478
522
  minLength: 1
479
- description: The operator's answer that resumes the parked implementation agent.
480
- FeatureAnswerByPlanTask:
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:
481
551
  type: object
482
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.
483
558
  required:
484
- - plan
485
- - task
486
- - answer
559
+ - userTaskKey
560
+ - agentId
561
+ - variables
487
562
  properties:
488
- plan:
489
- type: string
490
- description: Plan reference (owner/repo#N); combined with `task` to derive the corrKey.
491
- task:
563
+ userTaskKey:
492
564
  type: string
493
- description: Task id within the plan; combined with `plan` to derive the corrKey.
494
- answer:
565
+ minLength: 1
566
+ description: The engine user-task key of the parked escalation task (from GET /tasks/api/tasks).
567
+ agentId:
495
568
  type: string
496
569
  minLength: 1
497
- description: The operator's answer that resumes the parked implementation agent.
498
- PlanAnswerRequest:
570
+ description: The completing agent's identity (ADR 0046), recorded for attribution.
571
+ variables:
572
+ type: object
573
+ additionalProperties: true
574
+ description: >-
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:
499
578
  type: object
500
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.
501
584
  required:
502
- - plan
503
- - directive
585
+ - completionId
586
+ - reverterId
504
587
  properties:
505
- plan:
506
- type: string
507
- description: Plan reference (owner/repo#N), also the message correlation key.
508
- directive:
588
+ completionId:
589
+ type: integer
590
+ description: The `task_completions` id returned when the agent completion was recorded.
591
+ reverterId:
509
592
  type: string
510
- description: >-
511
- One of `proceed` or `revise` (case-insensitive; normalized to lowercase and trimmed
512
- server-side, see `parsePlanEscalationDirective`).
513
- `proceed` dispatches the current unapproved plan as an explicit human override;
514
- `revise` loops back to the planner with the note folded into planFindings and a fresh
515
- review budget.
593
+ minLength: 1
594
+ description: The human identity overriding the agent completion, recorded for the audit trail.
516
595
  note:
517
596
  type: string
518
- 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.
519
598
  BlackboardEntry:
520
599
  type: object
521
600
  additionalProperties: false
@@ -767,12 +846,43 @@ paths:
767
846
  application/json:
768
847
  schema:
769
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"
770
878
  /actions/message:
771
879
  post:
772
880
  operationId: postMessage
773
- summary: Publish a message / answer an escalation. For escalation-answered,
774
- feature-escalation-answered, and plan-escalation-answered names, runs the corresponding
775
- 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."
776
886
  requestBody:
777
887
  required: true
778
888
  content:
@@ -795,13 +905,7 @@ paths:
795
905
  properties:
796
906
  answer:
797
907
  type: string
798
- directive:
799
- type: string
800
- description: >-
801
- One of `proceed` or `revise` (case-insensitive; normalized to lowercase and
802
- trimmed server-side, see `parsePlanEscalationDirective`).
803
- note:
804
- type: string
908
+ description: The operator's answer that resumes a parked merge-loop escalation.
805
909
  responses:
806
910
  "200":
807
911
  description: The message was published (or the escalation answered).
@@ -821,11 +925,13 @@ paths:
821
925
  application/json:
822
926
  schema:
823
927
  $ref: "#/components/schemas/MessageResult"
824
- /hooks/feature-answer:
928
+ /hooks/agent-complete:
825
929
  post:
826
- operationId: answerFeatureEscalation
827
- summary: "Answer an implementation-phase task escalation out of band (issue #25). Optional
828
- 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."
829
935
  security:
830
936
  - hookSecret: []
831
937
  - {}
@@ -834,16 +940,16 @@ paths:
834
940
  content:
835
941
  application/json:
836
942
  schema:
837
- $ref: "#/components/schemas/FeatureAnswerRequest"
943
+ $ref: "#/components/schemas/AgentCompleteRequest"
838
944
  responses:
839
945
  "200":
840
- description: The escalation was answered and the parked agent resumed.
946
+ description: The agent completed the escalation task and the process resumed.
841
947
  content:
842
948
  application/json:
843
949
  schema:
844
950
  $ref: "#/components/schemas/MessageResult"
845
951
  "400":
846
- 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.
847
953
  content:
848
954
  application/json:
849
955
  schema:
@@ -855,16 +961,18 @@ paths:
855
961
  schema:
856
962
  $ref: "#/components/schemas/MessageResult"
857
963
  "404":
858
- description: No matching open task escalation for the correlation key.
964
+ description: No open escalation user task matches the userTaskKey.
859
965
  content:
860
966
  application/json:
861
967
  schema:
862
968
  $ref: "#/components/schemas/MessageResult"
863
- /hooks/plan-answer:
969
+ /hooks/revert-completion:
864
970
  post:
865
- operationId: answerPlanEscalation
866
- summary: "Answer a plan-review cap escalation out of band. Optional shared-secret guard
867
- (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."
868
976
  security:
869
977
  - hookSecret: []
870
978
  - {}
@@ -873,16 +981,16 @@ paths:
873
981
  content:
874
982
  application/json:
875
983
  schema:
876
- $ref: "#/components/schemas/PlanAnswerRequest"
984
+ $ref: "#/components/schemas/RevertCompletionRequest"
877
985
  responses:
878
986
  "200":
879
- description: The escalation was answered and the parked plan resumed.
987
+ description: The agent completion was reverted.
880
988
  content:
881
989
  application/json:
882
990
  schema:
883
991
  $ref: "#/components/schemas/MessageResult"
884
992
  "400":
885
- description: A required field was missing (plan or directive).
993
+ description: A required field was missing, or the completion is not reversible / already reverted.
886
994
  content:
887
995
  application/json:
888
996
  schema:
@@ -894,7 +1002,7 @@ paths:
894
1002
  schema:
895
1003
  $ref: "#/components/schemas/MessageResult"
896
1004
  "404":
897
- description: No matching open plan escalation for the plan key.
1005
+ description: No completion matches the completionId.
898
1006
  content:
899
1007
  application/json:
900
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
+ });