@nanobpm/nano-workforce 0.57.0 → 0.58.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 (82) 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 +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 +5 -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
@@ -47,6 +47,13 @@ jobs:
47
47
  - name: Check agent prompt templates
48
48
  run: npm run check:prompts
49
49
 
50
+ # Merge-safety gate: migrations are applied in numeric-prefix order, but each fan-out branch
51
+ # picks "the next" prefix independently, so parallel siblings silently collide on one slot
52
+ # (the historical 004/005/006/007 pairs; recurred on epic #142). This fails the build on any
53
+ # new duplicate prefix so the collision can't merge unnoticed.
54
+ - name: Check migration prefixes (no collisions)
55
+ run: npm run check:migrations
56
+
50
57
  # Runs the full *.test.ts suite under Node's built-in test runner (node:test), which strips
51
58
  # TypeScript types on the fly (Node >= 22.6) — no build step.
52
59
  - name: Test (Node)
package/AGENTS.md CHANGED
@@ -79,6 +79,20 @@ GENERATED, never hand-edited.**
79
79
  stale. It runs in `.github/workflows/ci.yml`, so a flow change that forgets the
80
80
  relayout **cannot merge**. Run `npm run layout` and commit the result whenever
81
81
  the check flags a model.
82
+ - **`cancelActivity="true"` is the BPMN default and `bpmn-auto-layout`
83
+ canonicalises it away.** Don't hand-add it back to an interrupting boundary
84
+ event — the serializer strips it, so a hand-written `cancelActivity="true"`
85
+ makes `layout:check` drift on the next relayout. Interrupting is the default;
86
+ only `cancelActivity="false"` (non-interrupting) is emitted.
87
+ - **One task owns each `.bpmn` file — never fan two parallel tasks onto the same
88
+ process diagram.** `layoutBpmn` regenerates the *entire* `<bpmndi:BPMNDiagram>`
89
+ block, so two independently relaid-out copies of one process diverge across
90
+ every shape and edge. Each PR is green alone, but the second to land collides
91
+ in the DI and — even after a text-merge — leaves the committed diagram stale
92
+ vs. the merged semantic model, so `layout:check` fails on a combined state that
93
+ no single PR's CI ever exercised. When decomposing a fleet, coarsen the tasks
94
+ that touch a shared process file into one; do **not** paper the collision over
95
+ with a `dependsOn` edge added purely to serialise otherwise-parallel work.
82
96
 
83
97
  ## Engine capabilities (Zeebe parity — use them, don't work around them)
84
98
 
@@ -105,6 +119,29 @@ cannot yet express event-based gateways or boundary events (its `FlowNode` union
105
119
  has no such node). This app authors BPMN as XML, so author those constructs in
106
120
  the XML directly; `layoutBpmn` lays them out correctly.
107
121
 
122
+ ## Testing flows against the testkit (WASM) engine
123
+
124
+ Unit tests boot the app against an in-process WASM build of the engine. Two
125
+ non-obvious behaviours bite user-task / escalation tests — budget for them
126
+ instead of rediscovering them:
127
+
128
+ - **A COMPLETED instance's variables are folded away.** `snapshot()` reports
129
+ `instance.variables = {}` once an instance completes, and a completed
130
+ `userTask` carries no vars — so you cannot assert a typed completion variable
131
+ from a finished instance. Either read variables while the instance is still
132
+ ACTIVE (parked on the next wait), or route the resume through a FEEL gateway
133
+ condition and assert `app.snapshot().takenSequenceFlows`. `takenSequenceFlows`
134
+ is engine-**global** and cumulative, so assert it with a single instance per
135
+ booted app.
136
+ - **A `zeebe:input source="=null"` seed shadows a job-completion value at an
137
+ immediately-following gateway inside a multi-instance subProcess.** The gate
138
+ reads the stale `null` and takes its default. Don't seed a var that an
139
+ in-subprocess gateway reads; instead hoist it into the MI-child scope with a
140
+ `zeebe:output source="=var" target="var"` mapping on the service task (a no-op
141
+ on the production nano engine, which updates the nearest scope, but it keeps
142
+ per-child isolation in the testkit). Only vars read by an in-subprocess gateway
143
+ need this — output-only vars (e.g. `summary`, `pr`) are fine.
144
+
108
145
  ## Data envelopes are scalar-only
109
146
 
110
147
  `nano:dataEnvelope` shapes support only **scalar** `nano:extend` types
@@ -113,6 +150,30 @@ must carry a list (e.g. failing check names), join it to a scalar (e.g.
113
150
  `\n`-separated) in the publisher before it crosses the envelope, and split it on
114
151
  the far side if needed.
115
152
 
153
+ ## Urban page runtime: rendering primitives are not JS-truthy
154
+
155
+ The `@nanobpm/urban` page runtime (`pages.ts`, ~0.46) renders `pages/*.page.json`
156
+ with primitive-specific gating and linking rules that do **not** match ordinary JS
157
+ truthiness. Getting these wrong renders stray badges on every row or drops a link
158
+ silently — cheap to avoid, annoying to debug after the fact.
159
+
160
+ - **`badge` columns gate on non-empty string, not truthiness.** A column badge
161
+ renders whenever `String(value).trim() !== ""`. So an `INTEGER NOT NULL DEFAULT
162
+ 0` flag renders the badge on *every* non-set row (value `0` → `"0"` → non-empty).
163
+ For a "show a badge only when set" flag, store **`NULL`** (not `0`) when
164
+ not-set, in the **canonical derivation** *and* everywhere else that clears it —
165
+ a single `0`-writer re-lights the badge.
166
+ - **`detail.fields` render as plain text — no per-field links.** The detail panel
167
+ emits `label + String(value)` per field; there is no per-field `linkField`/
168
+ `link`. Clickable links exist only on **grid columns** (`col.linkField`,
169
+ http(s)-gated — see `issue_url`/`issue_number` in `pages/epic.page.json`) and on
170
+ the single block-level `detail.linkField`. To make a value clickable, add it as
171
+ a grid column with `linkField`, not a `detail.fields` entry.
172
+ - **`showWhenField` *does* use JS truthiness.** Unlike `badge`, a control gated by
173
+ `showWhenField` is hidden for `0`/`null`/`""` alike — so a `0`-or-`NULL` flag
174
+ correctly hides it either way. (This is why the same flag can need `NULL` for a
175
+ badge yet work as `0` for a `showWhenField` button.)
176
+
116
177
  ## The poller owns liveness/reconciliation
117
178
 
118
179
  `main.ts` runs a **self-scheduling** poll loop (`pollOnce` in `app/service.ts`),
@@ -135,6 +196,10 @@ Migrations live in `db/migrations/*.sql` and are **auto-applied on boot** from
135
196
  - **Destructive drops are a separate, later contract phase**, shipped only after
136
197
  a release stopped reading the old shape.
137
198
  - Number a new migration after the current highest prefix (they apply in order).
199
+ Check `origin/main`, not your branch point — a fan-out epic branch forks at one
200
+ prefix while `main` keeps advancing, so the branch-local "next" number collides
201
+ on merge. Two files must never share a prefix; `npm run check:migrations`
202
+ (a CI gate) enforces this and fails the build on any new duplicate.
138
203
 
139
204
  ## Runtime & CI gates
140
205
 
@@ -148,14 +213,31 @@ npm run typecheck # tsc --noEmit (Node)
148
213
  npm run check # urban check (manifest validation)
149
214
  npm run layout:check # BPMN diagram freshness (no drift)
150
215
  npm run check:prompts # agent-prompt template resolution
216
+ npm run check:migrations # migration prefixes (no collisions)
151
217
  npm test # unit tests (node --test)
152
218
  ```
153
219
 
154
220
  CI (`.github/workflows/ci.yml`) gates lint, typecheck, `urban check`, `layout:check`,
155
- the prompt check, and the Node test suite. Run `npm run layout <file.bpmn>` after
221
+ the prompt check, the migration-prefix check, and the Node test suite. Run `npm run layout <file.bpmn>` after
156
222
  any BPMN flow change and commit the regenerated diagram — the `layout:check`
157
223
  gate fails the build otherwise.
158
224
 
225
+ ### Generated facades are gitignored — regenerate before running a single test file
226
+
227
+ `nano-generated/*` (the materialised Nano SDK facades — `operations.ts`, etc.) is
228
+ **gitignored** and produced by `urban gen`. Two consequences that have bitten
229
+ agents:
230
+
231
+ - **Never `git add nano-generated/`** — there is nothing to commit; `gen:check`
232
+ and `typecheck` validate its freshness on disk. `npm run gen` (or the
233
+ `pretypecheck`/`pretest` hook) refreshes it.
234
+ - **Run `npm run gen` before running a single delegate test file directly**, e.g.
235
+ `node --test operations/foo.test.ts`. A raw single-file run does **not** fire an
236
+ `urban gen` hook, so the delegate import fails with a cryptic
237
+ `ERR_MODULE_NOT_FOUND` for `nano-generated/operations.ts`. `npm test`/`npm run
238
+ e2e`/`typecheck` regenerate it first via a pre-hook, so full-suite runs are
239
+ fine.
240
+
159
241
  ## Repo conventions
160
242
 
161
243
  - **DCO sign-off is enforced.** Every commit needs a `Signed-off-by` trailer —
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.58.1](https://github.com/nanobpm/nano-workforce/compare/v0.58.0...v0.58.1) (2026-08-13)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * regenerate nano-generated/ before start (prestart: urban gen) ([#195](https://github.com/nanobpm/nano-workforce/issues/195)) ([d45fe22](https://github.com/nanobpm/nano-workforce/commit/d45fe22de58766730721f51cbcb7e97d6f4f06c8)), closes [#192](https://github.com/nanobpm/nano-workforce/issues/192)
7
+
8
+ # [0.58.0](https://github.com/nanobpm/nano-workforce/compare/v0.57.0...v0.58.0) (2026-08-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * single-issue feature run — one issue → one PR (+ optional converge/merge) ([#194](https://github.com/nanobpm/nano-workforce/issues/194)) ([7557439](https://github.com/nanobpm/nano-workforce/commit/7557439e7f88383a5da62fda45994a37f6262007)), closes [post-#192](https://github.com/post-/issues/192) [#172](https://github.com/nanobpm/nano-workforce/issues/172) [#172](https://github.com/nanobpm/nano-workforce/issues/172) [owner/repo#N](https://github.com/owner/repo/issues/N)
14
+
1
15
  # [0.57.0](https://github.com/nanobpm/nano-workforce/compare/v0.56.0...v0.57.0) (2026-08-13)
2
16
 
3
17
 
package/README.md CHANGED
@@ -255,7 +255,7 @@ active epic already targets the same custom base. See
255
255
  | `NANO_PR_GITHUB_TRANSPORT` | `auto` | how the poller reads GitHub: `gh` (host CLI), `token` (`GITHUB_TOKEN` over HTTP), or `auto` |
256
256
  | `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
257
257
  | `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / the `maxRounds` field on `start/convergence-loop`; clamped 1–100) |
258
- | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret for the `POST /app/api/hooks/feature-answer` webhook operation (`X-Hook-Secret`); unset = open |
258
+ | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`X-Hook-Secret`) for guarded operations (e.g. `POST /app/api/agent`, `/app/api/version`, `/app/api/status`); unset = open |
259
259
  | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the `convergeOnly` field on `start/convergence-loop` (`true` forces review-only for that PR) |
260
260
  | `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
261
261
  | `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
package/SPEC.md CHANGED
@@ -31,10 +31,10 @@ surface are proposed and open for adjustment.
31
31
 
32
32
  ┌───────────────────┐ review-ready (msg) ┌──────────┐
33
33
  │ convergence-loop │◀───────────────────────────────│ poller │
34
- │ (BPMN) │ escalation-answered (msg)└────┬─────┘
34
+ │ (BPMN) │ └────┬─────┘
35
35
  └─────────┬─────────┘◀───────────────┐ │ polls
36
- │ senior:pr-review job │ answer POST │ GitHub
37
- ▼ │
36
+ │ senior:pr-review job │ userTask complete │ GitHub
37
+ ▼ │ (inbox)
38
38
  ┌───────────────────┐ ┌──────┴───────┐ ┌────────────┐
39
39
  │ decoupled agent │ │ web UI + │ │ SQLite │
40
40
  │ (c8ctl nano work) │ │ API routes │────▶│ (app.db) │
@@ -197,16 +197,16 @@ Consequences the prompt (`prompts/review-round.md`) encodes:
197
197
  |---|---|---|---|
198
198
  | `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
199
199
  | `review-ready` | `prKey` | **poller** | `{reviewId, reviewState, submittedAt}` |
200
- | `escalation-answered` | `prKey` | UI answer route | `{answer, escalationId}` |
200
+ | `escalation-answered` | `prKey` | merge-loop answer (`answerEscalation`) | `{answer, escalationId}` |
201
201
  | `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
202
202
  | `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
203
203
  | `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
204
204
 
205
- Note `escalation-answered` is reused by both processes (`convergence-loop` and
206
- `merge-loop`); only one is ever active for a given `prKey`, so correlation is
207
- unambiguous. Each `.bpmn` gives it a distinct message **id** (and distinct
208
- envelope shape ids) to avoid duplicate-id collisions when the manifest deploys
209
- both files.
205
+ Note `escalation-answered` is now used only by the `merge-loop` process (the
206
+ `convergence-loop` review escalation is a native `userTask` answered through the
207
+ task inbox, not this message). The merge-loop `.bpmn` gives it a distinct message
208
+ **id** (and distinct envelope shape ids) to avoid duplicate-id collisions when the
209
+ manifest deploys both files.
210
210
 
211
211
  ## 7. Domain model (SQLite — `db/migrations/001_init.sql`) — PROPOSED
212
212
 
@@ -262,9 +262,9 @@ The UI is authored declaratively as `pages/home.page.json` and served by the
262
262
  generic Urban **page runtime** (`@nanobpm/app`, ADR 0042) — no hand-written SPA.
263
263
  The page defines status-filtered tabs (active vs. history), a submit form, a
264
264
  per-row **Cancel** action, and an expandable detail with the round/escalation
265
- child grids, a lazily-loaded transcript, and a conditional **answer** form shown
266
- when the PR has an open escalation (`open_escalation_id`, denormalised onto the
267
- row by migration `003`).
265
+ child grids and a lazily-loaded transcript. The round/escalation grids are
266
+ read-only audit; escalations are answered through the **task inbox** surface at
267
+ `/tasks` (native `userTask`s), not an inline form on this page.
268
268
 
269
269
  The app-specific business-logic endpoints are **OpenAPI operations** mounted
270
270
  under `api.base` (`/app/api`), each implemented by a delegate module in
@@ -280,8 +280,7 @@ The full, authoritative contract is `openapi.yaml` (Swagger UI at
280
280
  | `GET` | `/app/api/version` | app + engine version |
281
281
  | `POST` | `/app/api/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process (the ONE submit door — page + external callers) |
282
282
  | `POST` | `/app/api/actions/start/plan-fanout` | parse the issue ref → start a plan fan-out run (the ONE plan door) |
283
- | `POST` | `/app/api/actions/message` (`escalation-answered`) | answer an open escalation → publish `escalation-answered` |
284
- | `POST` | `/app/api/hooks/feature-answer` | answer an implementation-phase task escalation out of band (optional shared-secret) |
283
+ | `POST` | `/app/api/actions/message` (`escalation-answered`) | answer an open merge-loop escalation → publish `escalation-answered` (the four #156 escalation kinds are native user tasks answered via the task inbox) |
285
284
  | `GET`/`POST` | `/app/api/hooks/blackboard` | per-plan coordination blackboard (capability-token side-channel) |
286
285
  | `GET` | `/app/api/hooks/abandon` | cooperative abandon check (per-PR capability token) |
287
286
 
@@ -397,8 +396,8 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
397
396
  waits for `merge-landed` (poller detects the landed PR). Every attempt is
398
397
  recorded in the `merges` audit table.
399
398
  - **Escalation** — a conflict or a failing gate raises the same
400
- `pr.persist-escalation` worker / UI answer form as the review stage (status
401
- `escalated`); answering re-arms and retries.
399
+ `pr.persist-escalation` worker as the review stage (status `escalated`), answered
400
+ via the merge-loop `escalation-answered` message; answering re-arms and retries.
402
401
  - **Terminal** — `merged` (with `merged_at`), or `converged` when
403
402
  `NANO_PR_AUTO_MERGE=0` (review-only), or `abandoned` on cancel.
404
403
 
@@ -415,7 +414,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
415
414
  | `GITHUB_TOKEN` | — | GitHub API (poller + agent) |
416
415
  | `NANO_PR_POLL_MS` | 60000 | poll interval |
417
416
  | `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
418
- | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret for the `/app/api/hooks/feature-answer` webhook operation (`X-Hook-Secret`) |
417
+ | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`X-Hook-Secret`) for guarded operations (e.g. `/app/api/agent`, `/app/api/version`, `/app/api/status`) |
419
418
  | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `convergeOnly: true` override) |
420
419
  | `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
421
420
  | `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
@@ -526,11 +525,11 @@ It runs only for `headCount >= 2` on non-mergify repos (`shouldRunTrialMerge`).
526
525
 
527
526
  Flow (`resources/processes/plan-fanout.bpmn`): `gw-trial-needed` → `trial-merge`
528
527
  (`senior:trial-merge`) → `record-trial-merge` (audit row in `plan_trial_merges`) →
529
- `gw-trial` (`trial red?`). On red it persists a plan-level escalation
530
- (`pr.persist-task-escalation`, task id `trial-merge-wave-<N>`, corrKey
531
- `<plan_key>:trial-merge-wave-<N>`) and parks at `wait-trial-answer`
532
- (`feature-escalation-answered`). The operator answers exactly `proceed` to override and
533
- continue, or anything else to **rerun** the trial after pushing a fix.
528
+ `gw-trial` (`trial red?`). On red it opens a plan-level **user task**
529
+ (`trial-merge-decision`, task id `trial-merge-wave-<N>`) and parks at
530
+ `wait-trial-answer` until it is completed through the task inbox. The operator
531
+ answers with `action: "proceed"` to override and continue, or `"rebase"`/`"abandon"`
532
+ to **rerun** the trial after pushing a fix (or give up).
534
533
 
535
534
  **Known gap — inherited vs emergent failures (issue #129, PLANNED).** As shipped, D3
536
535
  escalates on *any* red combined suite, including a failure that was **already red on
@@ -0,0 +1,337 @@
1
+ // Red/green unit coverage for the agent-answerable escalation completer (epic #156, slice U6;
2
+ // ADR 0046). Drives the canonical attributed completer + the agent/revert entry points against an
3
+ // in-memory data layer and a stub engine, asserting the three things U6 promises:
4
+ //
5
+ // 1. an AGENT assignee completes the SAME typed form a human would — the engine `completeUserTask`
6
+ // is called with the EXACT variables (no parallel path), and the process resume is driven;
7
+ // 2. attribution is recorded — a `task_completions` ledger row captures actor_kind=agent + the
8
+ // agent id + the submitted variables;
9
+ // 3. the completion is reversible — a human can revert an agent completion (recording who + when),
10
+ // while a human completion is NOT reversible and a completion can be reverted only once.
11
+ //
12
+ // The taxonomy/spine slices proved the form + resume round-trip end to end (the e2e does too, on the
13
+ // real process); this suite pins the host-side attribution + reversibility contract in isolation.
14
+
15
+ import { test } from "node:test";
16
+ import { assert, assertEquals, assertRejects } from "#test-assert";
17
+ import {
18
+ completeEscalationAsAgent,
19
+ completeUserTaskAttributed,
20
+ latestCompletion,
21
+ revertAgentCompletion,
22
+ type TaskCompletion,
23
+ } from "./agentCompletion.ts";
24
+
25
+ /** A minimal in-memory `Table<T>`: AUTOINCREMENT ids on insert, structural `find`, `get`, `update`. */
26
+ function memTable(rows: any[], key: string) {
27
+ let seq = rows.reduce((m, r) => Math.max(m, Number(r[key]) || 0), 0);
28
+ return {
29
+ insert: (row: any) => {
30
+ const id = ++seq;
31
+ const stored = key === "id" ? { ...row, id } : { ...row };
32
+ rows.push(stored);
33
+ return Promise.resolve(key === "id" ? id : stored[key]);
34
+ },
35
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k)),
36
+ find: (q: any = {}) =>
37
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
38
+ findOne: (q: any = {}) =>
39
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
40
+ update: (k: any, patch: any) => {
41
+ const r = rows.find((x) => x[key] === k);
42
+ if (r) Object.assign(r, patch);
43
+ return Promise.resolve(r ? 1 : 0);
44
+ },
45
+ delete: (k: any) => {
46
+ const i = rows.findIndex((r) => r[key] === k);
47
+ if (i >= 0) rows.splice(i, 1);
48
+ return Promise.resolve(i >= 0 ? 1 : 0);
49
+ },
50
+ count: (q: any = {}) =>
51
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
52
+ all: () => Promise.resolve(rows.slice()),
53
+ };
54
+ }
55
+
56
+ function memData(stores: Record<string, { rows: any[]; key: string }>) {
57
+ return {
58
+ table: (name: string, key: string) =>
59
+ memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
60
+ } as any;
61
+ }
62
+
63
+ /** A stub engine recording every `completeUserTask`, with a seeded set of open user tasks. */
64
+ function fakeEngine(openTasks: Array<{ userTaskKey: string; elementId?: string }>) {
65
+ const completed: Array<{ userTaskKey: string; variables?: Record<string, unknown> }> = [];
66
+ const engine = {
67
+ searchUserTasks: (_filter?: Record<string, unknown>) => Promise.resolve(openTasks),
68
+ completeUserTask: (userTaskKey: string, variables?: Record<string, unknown>) => {
69
+ completed.push({ userTaskKey, variables });
70
+ return Promise.resolve();
71
+ },
72
+ } as any;
73
+ return { engine, completed };
74
+ }
75
+
76
+ test("agent completion resumes with the exact typed vars a human submits AND records agent attribution", async () => {
77
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
78
+ const data = memData(stores);
79
+ const { engine, completed } = fakeEngine([
80
+ { userTaskKey: "ut-1", elementId: "feature-escalation" },
81
+ ]);
82
+
83
+ const r = await completeEscalationAsAgent(data, engine, {
84
+ userTaskKey: "ut-1",
85
+ agentId: "senior:answer-bot",
86
+ variables: { resolution: "answer", answer: "use v2" },
87
+ });
88
+
89
+ assertEquals(r.ok, true);
90
+ assertEquals(r.elementId, "feature-escalation");
91
+
92
+ // Same resume path a human drives: completeUserTask called with the identical typed variables.
93
+ assertEquals(completed.length, 1);
94
+ assertEquals(completed[0].userTaskKey, "ut-1");
95
+ assertEquals(completed[0].variables, { resolution: "answer", answer: "use v2" });
96
+
97
+ // Attribution recorded: an agent completion, its id, and the submitted variables.
98
+ const row = stores.task_completions.rows[0] as TaskCompletion;
99
+ assertEquals(row.actor_kind, "agent");
100
+ assertEquals(row.actor_id, "senior:answer-bot");
101
+ assertEquals(row.element_id, "feature-escalation");
102
+ assertEquals(JSON.parse(row.variables_json), { resolution: "answer", answer: "use v2" });
103
+ assertEquals(row.reversible, 1, "an agent completion is reversible");
104
+ assertEquals(row.reverted, 0);
105
+ });
106
+
107
+ test("agent completer refuses a non-escalation user task (scoped to the migrated escalation tasks)", async () => {
108
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
109
+ const data = memData(stores);
110
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-x", elementId: "decide" }]);
111
+
112
+ const r = await completeEscalationAsAgent(data, engine, {
113
+ userTaskKey: "ut-x",
114
+ agentId: "bot",
115
+ variables: { decision: "approve" },
116
+ });
117
+
118
+ assertEquals(r.ok, false);
119
+ assertEquals(r.reason, "not an escalation task");
120
+ assertEquals(completed.length, 0, "a non-escalation task is never completed");
121
+ assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
122
+ });
123
+
124
+ test("agent completer is a no-op for an unknown userTaskKey", async () => {
125
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
126
+ const data = memData(stores);
127
+ const { engine, completed } = fakeEngine([
128
+ { userTaskKey: "ut-1", elementId: "feature-escalation" },
129
+ ]);
130
+
131
+ const r = await completeEscalationAsAgent(data, engine, {
132
+ userTaskKey: "ut-missing",
133
+ agentId: "bot",
134
+ variables: { resolution: "abandon" },
135
+ });
136
+
137
+ assertEquals(r.ok, false);
138
+ assertEquals(r.reason, "no open escalation task");
139
+ assertEquals(completed.length, 0);
140
+ });
141
+
142
+ test("a human can revert/override an agent completion (recording who + when + corrective note)", async () => {
143
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
144
+ const data = memData(stores);
145
+ const { engine } = fakeEngine([{ userTaskKey: "ut-1", elementId: "feature-escalation" }]);
146
+
147
+ const { completionId } = await completeUserTaskAttributed(
148
+ data,
149
+ engine,
150
+ { userTaskKey: "ut-1", elementId: "feature-escalation", variables: { resolution: "answer", answer: "guess" } },
151
+ { kind: "agent", id: "bot" },
152
+ );
153
+
154
+ const r = await revertAgentCompletion(data, completionId, { kind: "human", id: "alice" }, "wrong — use v3");
155
+ assertEquals(r.ok, true);
156
+
157
+ const row = (await latestCompletion(data, "ut-1"))!;
158
+ assertEquals(row.reverted, 1);
159
+ assertEquals(row.reverted_by, "alice");
160
+ assertEquals(row.reverted_note, "wrong — use v3", "the human's correction is captured");
161
+ assert(typeof row.reverted_at === "string" && row.reverted_at.length > 0, "reverted_at is stamped");
162
+ });
163
+
164
+ test("the ledger rolls back when the engine completion fails (never claims a completion that did not happen)", async () => {
165
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
166
+ const data = memData(stores);
167
+ const engine = {
168
+ searchUserTasks: () => Promise.resolve([{ userTaskKey: "ut-1", elementId: "feature-escalation" }]),
169
+ completeUserTask: () => Promise.reject(new Error("engine rejected the completion")),
170
+ } as any;
171
+
172
+ let threw: unknown;
173
+ try {
174
+ await completeUserTaskAttributed(
175
+ data,
176
+ engine,
177
+ { userTaskKey: "ut-1", elementId: "feature-escalation", variables: { resolution: "answer", answer: "x" } },
178
+ { kind: "agent", id: "bot" },
179
+ );
180
+ } catch (err) {
181
+ threw = err;
182
+ }
183
+ assert(threw instanceof Error && /engine rejected/.test(threw.message), "the engine failure propagates");
184
+ assertEquals(stores.task_completions.rows.length, 0, "the attribution row was rolled back on failure");
185
+ });
186
+
187
+ test("a human completion is NOT reversible (it is already the authority)", async () => {
188
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
189
+ const data = memData(stores);
190
+ const { engine } = fakeEngine([{ userTaskKey: "ut-2", elementId: "plan-review-decision" }]);
191
+
192
+ const { completionId } = await completeUserTaskAttributed(
193
+ data,
194
+ engine,
195
+ { userTaskKey: "ut-2", elementId: "plan-review-decision", variables: { directive: "proceed", notes: "" } },
196
+ { kind: "human", id: "operator" },
197
+ );
198
+
199
+ const stored = (await latestCompletion(data, "ut-2"))!;
200
+ assertEquals(stored.reversible, 0);
201
+
202
+ const r = await revertAgentCompletion(data, completionId, { kind: "human", id: "bob" });
203
+ assertEquals(r.ok, false);
204
+ assertEquals(r.reason, "completion is not reversible");
205
+ });
206
+
207
+ test("an agent completion can be reverted only once", async () => {
208
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
209
+ const data = memData(stores);
210
+ const { engine } = fakeEngine([{ userTaskKey: "ut-3", elementId: "trial-merge-decision" }]);
211
+
212
+ const { completionId } = await completeUserTaskAttributed(
213
+ data,
214
+ engine,
215
+ { userTaskKey: "ut-3", elementId: "trial-merge-decision", variables: { action: "proceed" } },
216
+ { kind: "agent", id: "bot" },
217
+ );
218
+
219
+ assertEquals((await revertAgentCompletion(data, completionId, { kind: "human", id: "alice" })).ok, true);
220
+ const second = await revertAgentCompletion(data, completionId, { kind: "human", id: "bob" });
221
+ assertEquals(second.ok, false);
222
+ assertEquals(second.reason, "completion already reverted");
223
+ });
224
+
225
+ test("reverting an unknown completion id is a no-op", async () => {
226
+ const data = memData({ task_completions: { rows: [], key: "id" } });
227
+ const r = await revertAgentCompletion(data, 999, { kind: "human", id: "alice" });
228
+ assertEquals(r.ok, false);
229
+ assertEquals(r.reason, "no such completion");
230
+ });
231
+
232
+ test("only a human may revert a completion (an agent identity cannot weaken the audit trail)", async () => {
233
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
234
+ const data = memData(stores);
235
+ const { engine } = fakeEngine([{ userTaskKey: "ut-4", elementId: "feature-escalation" }]);
236
+
237
+ const { completionId } = await completeUserTaskAttributed(
238
+ data,
239
+ engine,
240
+ { userTaskKey: "ut-4", elementId: "feature-escalation", variables: { resolution: "answer", answer: "x" } },
241
+ { kind: "agent", id: "bot" },
242
+ );
243
+
244
+ const r = await revertAgentCompletion(data, completionId, { kind: "agent", id: "other-bot" });
245
+ assertEquals(r.ok, false);
246
+ assertEquals(r.reason, "only a human may revert a completion");
247
+
248
+ const row = (await latestCompletion(data, "ut-4"))!;
249
+ assertEquals(row.reverted, 0, "the completion is left un-reverted when a non-human attempts it");
250
+ });
251
+
252
+ test("the attributed completer normalizes keys and rejects blank attribution", async () => {
253
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
254
+ const data = memData(stores);
255
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-5", elementId: "feature-escalation" }]);
256
+
257
+ // Whitespace around the key + actor id is trimmed before it reaches the ledger and the engine.
258
+ const { completionId } = await completeUserTaskAttributed(
259
+ data,
260
+ engine,
261
+ { userTaskKey: " ut-5 ", elementId: "feature-escalation", variables: { resolution: "answer", answer: "x" } },
262
+ { kind: "agent", id: " bot " },
263
+ );
264
+ assert(completionId > 0);
265
+ assertEquals(completed[0].userTaskKey, "ut-5", "the engine is completed with the trimmed key");
266
+ const row = stores.task_completions.rows[0] as TaskCompletion;
267
+ assertEquals(row.user_task_key, "ut-5");
268
+ assertEquals(row.actor_id, "bot");
269
+
270
+ // A blank key / actor id is rejected upfront — the ledger never records blank attribution.
271
+ await assertRejects(() =>
272
+ completeUserTaskAttributed(
273
+ data,
274
+ engine,
275
+ { userTaskKey: " ", variables: {} },
276
+ { kind: "human", id: "operator" },
277
+ ),
278
+ );
279
+ await assertRejects(() =>
280
+ completeUserTaskAttributed(
281
+ data,
282
+ engine,
283
+ { userTaskKey: "ut-5", variables: {} },
284
+ { kind: "human", id: " " },
285
+ ),
286
+ );
287
+ assertEquals(stores.task_completions.rows.length, 1, "no ledger row is written for a rejected completion");
288
+ });
289
+
290
+ test("a rollback failure never masks the original engine error", async () => {
291
+ const engine = {
292
+ searchUserTasks: () => Promise.resolve([{ userTaskKey: "ut-6", elementId: "feature-escalation" }]),
293
+ completeUserTask: () => Promise.reject(new Error("engine rejected the completion")),
294
+ } as any;
295
+
296
+ // A data layer whose ledger delete (the rollback) also throws — the engine error must still win.
297
+ const data = {
298
+ table: () => ({
299
+ insert: () => Promise.resolve(1),
300
+ delete: () => Promise.reject(new Error("ledger delete failed")),
301
+ }),
302
+ } as any;
303
+
304
+ let threw: unknown;
305
+ try {
306
+ await completeUserTaskAttributed(
307
+ data,
308
+ engine,
309
+ { userTaskKey: "ut-6", elementId: "feature-escalation", variables: { resolution: "answer", answer: "x" } },
310
+ { kind: "agent", id: "bot" },
311
+ );
312
+ } catch (err) {
313
+ threw = err;
314
+ }
315
+ assert(
316
+ threw instanceof Error && /engine rejected/.test(threw.message),
317
+ "the engine failure propagates, not the rollback failure",
318
+ );
319
+ });
320
+
321
+ test("latestCompletion returns the newest row by id regardless of insertion order", async () => {
322
+ const stores = {
323
+ task_completions: {
324
+ rows: [
325
+ { id: 3, user_task_key: "ut-x", actor_kind: "agent", actor_id: "bot", variables_json: "{}", reversible: 1, reverted: 0, created_at: "t3" },
326
+ { id: 1, user_task_key: "ut-x", actor_kind: "human", actor_id: "alice", variables_json: "{}", reversible: 0, reverted: 0, created_at: "t1" },
327
+ { id: 2, user_task_key: "ut-other", actor_kind: "agent", actor_id: "bot", variables_json: "{}", reversible: 1, reverted: 0, created_at: "t2" },
328
+ ] as any[],
329
+ key: "id",
330
+ },
331
+ };
332
+ const data = memData(stores);
333
+
334
+ const newest = (await latestCompletion(data, "ut-x"))!;
335
+ assertEquals(newest.id, 3, "the highest-id row for the key wins, not the first found");
336
+ assertEquals(stores.task_completions.rows[0].id, 3, "the backing array is not reordered");
337
+ });