@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.
- package/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +14 -0
- package/README.md +10 -2
- package/SPEC.md +27 -24
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/agentGuide.ts +1 -1
- package/app/answer-escalation.test.ts +106 -0
- package/app/answerEscalation.test.ts +67 -0
- package/app/baseGuard.test.ts +9 -1
- package/app/baseGuard.ts +11 -0
- package/app/escalationSla.test.ts +39 -0
- package/app/escalationSla.ts +28 -0
- package/app/escalationTaxonomy.test.ts +115 -0
- package/app/escalationTaxonomy.ts +115 -0
- package/app/feature.test.ts +161 -0
- package/app/feature.ts +173 -0
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +267 -313
- package/app/plan.ts +173 -231
- package/app/reviewWait.ts +12 -4
- package/app/roundResultDefault.test.ts +111 -2
- package/app/roundResultDefault.ts +35 -0
- package/app/service.test.ts +6 -7
- package/app/service.ts +52 -35
- package/db/migrations/026_agent_completion.sql +36 -0
- package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
- package/db/migrations/028_feature_runs.sql +28 -0
- package/e2e/agent-answerable.e2e.ts +185 -0
- package/e2e/convergence-escalation.e2e.ts +180 -0
- package/e2e/convergence-loop.e2e.ts +1 -1
- package/e2e/feature-run.e2e.ts +231 -0
- package/e2e/plan-fanout-sla.e2e.ts +238 -0
- package/e2e/plan-fanout.e2e.ts +303 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
- package/e2e/support/github-admit.ts +99 -0
- package/e2e/user-task-spine.e2e.ts +155 -0
- package/nano.app.json +41 -11
- package/openapi.yaml +237 -84
- package/operations/agentCompleteEscalation.ts +53 -0
- package/operations/listActivePrs.test.ts +39 -6
- package/operations/postMessage.ts +10 -41
- package/operations/revertEscalationCompletion.ts +44 -0
- package/operations/startAndMessage.test.ts +62 -60
- package/operations/startFeature.ts +127 -0
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +4 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +5 -2
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +90 -26
- package/resources/forms/feature-escalation.form +27 -0
- package/resources/forms/plan-review-decision.form +27 -0
- package/resources/forms/pr-escalation.form +23 -0
- package/resources/forms/spine-demo.form +15 -0
- package/resources/forms/trial-merge-decision.form +25 -0
- package/resources/processes/convergence-loop.bpmn +127 -75
- package/resources/processes/feature.bpmn +240 -0
- package/resources/processes/plan-fanout.bpmn +322 -222
- package/resources/processes/spine-demo.bpmn +72 -0
- package/scripts/check-migrations.ts +68 -0
- package/workers/answer-escalation/worker.ts +78 -0
- package/workers/converge-feature/worker.ts +51 -0
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -0
- package/workers/finalize/worker.ts +0 -2
- package/workers/mark-merged/worker.ts +0 -2
- package/workers/merge/worker.ts +6 -5
- package/workers/persist-escalation/worker.ts +28 -32
- package/workers/record-feature/worker.ts +61 -0
- package/workers/record-plan-review/worker.test.ts +9 -10
- package/workers/record-plan-review/worker.ts +15 -5
- package/workers/resolve-trial-attention/worker.test.ts +77 -0
- package/workers/resolve-trial-attention/worker.ts +43 -0
- package/operations/answerFeatureEscalation.test.ts +0 -112
- package/operations/answerFeatureEscalation.ts +0 -58
- package/operations/answerPlanEscalation.test.ts +0 -115
- package/operations/answerPlanEscalation.ts +0 -41
- package/workers/persist-plan-escalation/worker.test.ts +0 -80
- package/workers/persist-plan-escalation/worker.ts +0 -73
- package/workers/persist-task-escalation/worker.ts +0 -120
package/.github/workflows/ci.yml
CHANGED
|
@@ -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.0](https://github.com/nanobpm/nano-workforce/compare/v0.57.0...v0.58.0) (2026-08-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* 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)
|
|
7
|
+
|
|
8
|
+
# [0.57.0](https://github.com/nanobpm/nano-workforce/compare/v0.56.0...v0.57.0) (2026-08-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **app-view:** show epic base branch in epics overview grid ([#159](https://github.com/nanobpm/nano-workforce/issues/159)) ([#164](https://github.com/nanobpm/nano-workforce/issues/164)) ([216cb0f](https://github.com/nanobpm/nano-workforce/commit/216cb0f515561fbaa646725593707ae6a2418246))
|
|
14
|
+
|
|
1
15
|
# [0.56.0](https://github.com/nanobpm/nano-workforce/compare/v0.55.0...v0.56.0) (2026-08-13)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -231,9 +231,17 @@ fleet"** form, or POST the same operation the form does:
|
|
|
231
231
|
```bash
|
|
232
232
|
curl -sS -X POST http://localhost:3000/app/api/actions/start/plan-fanout \
|
|
233
233
|
-H 'content-type: application/json' \
|
|
234
|
-
-d '{ "issue": "owner/repo#123" }'
|
|
234
|
+
-d '{ "issue": "owner/repo#123", "baseBranch": "epic/agent-protocol" }'
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
+
`baseBranch` is **required** (ADR 0003) — it's the integration branch the whole fleet
|
|
238
|
+
branches off and opens every PR against. A missing `epic/*` base is auto-created off the
|
|
239
|
+
default branch's HEAD; a missing non-`epic/*` base is a `400` (must already exist). Two
|
|
240
|
+
optional flags gate the dangerous cases: `confirmDefaultBase: true` is required to name the
|
|
241
|
+
repository default branch as the base, and `allowSharedBase: true` is required when another
|
|
242
|
+
active epic already targets the same custom base. See
|
|
243
|
+
[ADR 0003](docs/adr/0003-epic-base-branch-admission.md) for the full admission model.
|
|
244
|
+
|
|
237
245
|
---
|
|
238
246
|
|
|
239
247
|
## Configuration
|
|
@@ -247,7 +255,7 @@ curl -sS -X POST http://localhost:3000/app/api/actions/start/plan-fanout \
|
|
|
247
255
|
| `NANO_PR_GITHUB_TRANSPORT` | `auto` | how the poller reads GitHub: `gh` (host CLI), `token` (`GITHUB_TOKEN` over HTTP), or `auto` |
|
|
248
256
|
| `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
|
|
249
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) |
|
|
250
|
-
| `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret for
|
|
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 |
|
|
251
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) |
|
|
252
260
|
| `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
|
|
253
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) │
|
|
34
|
+
│ (BPMN) │ └────┬─────┘
|
|
35
35
|
└─────────┬─────────┘◀───────────────┐ │ polls
|
|
36
|
-
│ senior:pr-review job │
|
|
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` |
|
|
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
|
|
206
|
-
`
|
|
207
|
-
|
|
208
|
-
envelope shape ids) to avoid duplicate-id collisions when the
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
401
|
-
`
|
|
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
|
|
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 |
|
|
@@ -466,8 +465,12 @@ scalar-only and cannot express the `tasks`/`results` lists, so the workers self-
|
|
|
466
465
|
`plan_tasks` (one row per slice, tracking its `status`/`pr_key`/`summary`).
|
|
467
466
|
|
|
468
467
|
**Entry points**: the epic page's "Hand an issue to the fleet" form or
|
|
469
|
-
`POST /app/api/actions/start/plan-fanout` (`{ issue
|
|
470
|
-
|
|
468
|
+
`POST /app/api/actions/start/plan-fanout` (either `{ issue, baseBranch }` or
|
|
469
|
+
`{ url, baseBranch }` — a `oneOf` naming the target by **exactly one** of `issue`
|
|
470
|
+
(`owner/repo#123`) or `url`, plus optional `confirmDefaultBase`/`allowSharedBase`) —
|
|
471
|
+
the same flat operation the form posts. `baseBranch`
|
|
472
|
+
is required and admitted through the ADR 0003 gate (auto-create `epic/*`, confirm-default,
|
|
473
|
+
shared-base guard).
|
|
471
474
|
|
|
472
475
|
**Visibility**: the home page adds a **Plans** grid (Active: planning/dispatched;
|
|
473
476
|
History: done/failed/abandoned) with a `plan_tasks` child grid showing each task's
|
|
@@ -522,11 +525,11 @@ It runs only for `headCount >= 2` on non-mergify repos (`shouldRunTrialMerge`).
|
|
|
522
525
|
|
|
523
526
|
Flow (`resources/processes/plan-fanout.bpmn`): `gw-trial-needed` → `trial-merge`
|
|
524
527
|
(`senior:trial-merge`) → `record-trial-merge` (audit row in `plan_trial_merges`) →
|
|
525
|
-
`gw-trial` (`trial red?`). On red it
|
|
526
|
-
(`
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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).
|
|
530
533
|
|
|
531
534
|
**Known gap — inherited vs emergent failures (issue #129, PLANNED).** As shipped, D3
|
|
532
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
|
+
});
|