@nanobpm/nano-workforce 0.169.0 → 0.170.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.
- package/CHANGELOG.md +12 -0
- package/app/agentGuide.ts +1 -0
- package/app/mcpExclusions.test.ts +1 -0
- package/app/prParse.ts +34 -0
- package/app/service.ts +31 -47
- package/app/userTasks.test.ts +36 -0
- package/app/userTasks.ts +75 -0
- package/docs/agent-guide.md +198 -42
- package/docs/mcp-runbook.md +21 -4
- package/e2e/convergence-escalation.e2e.ts +19 -7
- package/e2e/retire-escalation-subsystem.e2e.ts +18 -5
- package/openapi.yaml +134 -2
- package/operations/listActivePrs.test.ts +31 -13
- package/operations/listEscalations.test.ts +215 -0
- package/operations/listEscalations.ts +32 -0
- package/package.json +1 -1
- package/pages/overview.page.json +2 -2
package/docs/agent-guide.md
CHANGED
|
@@ -25,18 +25,28 @@ in full so you can tell which are under the control-API base and which are not.
|
|
|
25
25
|
|
|
26
26
|
## 0. Orient yourself first
|
|
27
27
|
|
|
28
|
-
Before acting, confirm what is running and what is in flight
|
|
28
|
+
Before acting, confirm what is running and what is in flight. **Prefer the projected
|
|
29
|
+
tools** (`getVersion`, `listActivePrs`); curl is the no-MCP fallback only. Every step in
|
|
30
|
+
this guide that has a tool is mapped in the **tool↔curl crosswalk (§10)** — reach for the
|
|
31
|
+
tool first and drop to curl only when you are not driving this instance over MCP.
|
|
29
32
|
|
|
30
|
-
```
|
|
33
|
+
```text
|
|
31
34
|
# Which code is live (app version, urban version, git sha/branch, uptime):
|
|
32
|
-
|
|
35
|
+
tool: getVersion # GET /version
|
|
33
36
|
|
|
34
37
|
# Every PR currently in flight (not converged/abandoned), with its engine
|
|
35
38
|
# process key, status, round, and any open escalation:
|
|
39
|
+
tool: listActivePrs # GET /status
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# No-MCP fallback (agent without the projected tools):
|
|
44
|
+
curl -sS __BASE__/version | jq
|
|
36
45
|
curl -sS __BASE__/status | jq
|
|
37
46
|
```
|
|
38
47
|
|
|
39
|
-
`/status` is your primary situational-awareness
|
|
48
|
+
`listActivePrs` (the `/status` endpoint) is your primary situational-awareness surface.
|
|
49
|
+
Each entry carries:
|
|
40
50
|
`prKey` (`owner/repo#123`), `status`, `round`, `processKey` (the **engine process
|
|
41
51
|
instance key** — the bridge to the engine REST API, §5), `openEscalation`,
|
|
42
52
|
`activeWorker`/`leaseUntil` (is an agent actually working the round, or is the job
|
|
@@ -152,24 +162,36 @@ A loop escalates only when an agent returns `needs_input`/`blocked`, or a safety
|
|
|
152
162
|
fires (round cap, a review that never arrives, a merge conflict, an unfixable CI
|
|
153
163
|
failure). The parked process waits for a human answer.
|
|
154
164
|
|
|
155
|
-
Find the open escalations, then answer them
|
|
165
|
+
Find the open escalations, then answer them. **Prefer the `listEscalations` tool**
|
|
166
|
+
(the projected read tool that lists every open escalation with the `userTaskKey` and form —
|
|
167
|
+
tracked by sibling #666; on a deployment that predates it, use the `/status` / task-inbox
|
|
168
|
+
curl fallback below —
|
|
169
|
+
you need to answer it); curl is the no-MCP fallback.
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
# Every open escalation waiting for a human, across all four kinds — each with its
|
|
173
|
+
# userTaskKey, prKey, kind (elementId), question/findings/task, and formVariables:
|
|
174
|
+
tool: listEscalations
|
|
175
|
+
```
|
|
156
176
|
|
|
157
177
|
```bash
|
|
158
|
-
#
|
|
178
|
+
# No-MCP fallback: read the open escalations off /status …
|
|
159
179
|
curl -sS __BASE__/status | jq '.prs[] | select(.openEscalation != null)
|
|
160
180
|
| { prKey, status, round, openEscalation }'
|
|
161
181
|
```
|
|
162
182
|
|
|
163
183
|
The four decision-required escalation kinds — **PR review-loop**, **implementation
|
|
164
184
|
(feature) task**, **plan-review**, and **trial-merge** — are now native BPMN
|
|
165
|
-
`userTask`s bearing a linked `.form`, all answered the same way through the
|
|
166
|
-
|
|
185
|
+
`userTask`s bearing a linked `.form`, all answered the same way through the
|
|
186
|
+
`completeUserTask` tool (a human uses the same **task inbox** surface underneath).
|
|
187
|
+
There is no bespoke per-kind webhook or answer page any more.
|
|
167
188
|
|
|
168
|
-
**List the open escalation tasks.**
|
|
169
|
-
`question` / `findings` / `task`)
|
|
189
|
+
**List the open escalation tasks.** `listEscalations` returns each open task with its
|
|
190
|
+
context (`prKey` / `question` / `findings` / `task`) and its kind in `elementId` /
|
|
191
|
+
`kind`, so you do **not** need to reach for the raw Tasks inbox. No-MCP fallback only:
|
|
170
192
|
|
|
171
193
|
```bash
|
|
172
|
-
#
|
|
194
|
+
# No-MCP fallback — the un-projected engine task inbox (prefer `listEscalations`):
|
|
173
195
|
curl -sS __BASE__/../../tasks/api/tasks | jq '.[] | { userTaskKey, elementId, variables }'
|
|
174
196
|
|
|
175
197
|
# Filter to one kind (e.g. plan-review decisions) by elementId:
|
|
@@ -187,34 +209,52 @@ answer (assignee/candidate-group and age surface on each task once assignment la
|
|
|
187
209
|
> node then runs against the unmet dependency. See §9.2 before clearing one.
|
|
188
210
|
|
|
189
211
|
**Answer a task** by completing it with the typed variables its form expects — the
|
|
190
|
-
completion resumes the parked process
|
|
212
|
+
completion resumes the parked process. **Prefer the `completeUserTask` tool** (or
|
|
213
|
+
`agentCompleteEscalation` when you answer as an agent assignee, ADR 0046); the
|
|
214
|
+
`curl … /actions/complete-user-task` app-owned door is the no-MCP fallback.
|
|
191
215
|
|
|
192
|
-
```
|
|
216
|
+
```text
|
|
193
217
|
# PR review-loop (elementId `wait-answer`, pr-escalation form):
|
|
194
|
-
|
|
195
|
-
|
|
218
|
+
tool: completeUserTask { "userTaskKey": "<key>",
|
|
219
|
+
"variables": { "answer": "Cap retries at 5 and proceed." } }
|
|
196
220
|
|
|
197
221
|
# Implementation (feature) task (elementId `feature-escalation`):
|
|
198
222
|
# { "resolution": "answer", "answer": "…" } to resume, or { "resolution": "abandon" }
|
|
199
|
-
|
|
200
|
-
|
|
223
|
+
tool: completeUserTask { "userTaskKey": "<key>",
|
|
224
|
+
"variables": { "resolution": "answer", "answer": "Use v2." } }
|
|
201
225
|
|
|
202
226
|
# Plan-review (elementId `plan-review-decision`):
|
|
203
227
|
# { "directive": "revise", "notes": "…" } (fresh review budget) or { "directive": "proceed" }
|
|
204
|
-
|
|
205
|
-
|
|
228
|
+
tool: completeUserTask { "userTaskKey": "<key>",
|
|
229
|
+
"variables": { "directive": "revise", "notes": "Make issue-7 the seam." } }
|
|
206
230
|
|
|
207
231
|
# Trial-merge (elementId `trial-merge-decision`):
|
|
208
232
|
# { "action": "proceed" | "rebase" | "abandon", "notes"?: "…" }
|
|
209
|
-
|
|
210
|
-
|
|
233
|
+
tool: completeUserTask { "userTaskKey": "<key>",
|
|
234
|
+
"variables": { "action": "rebase", "notes": "Re-run after the fix." } }
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
# No-MCP fallback — the app-owned complete-user-task door (prefer the tool).
|
|
239
|
+
# `variables` must carry the shape THIS task's escalation kind expects (see the per-kind
|
|
240
|
+
# tool forms above): `{ answer }` here is the PR review-loop form — a feature task wants
|
|
241
|
+
# `{ resolution, answer }`, plan-review `{ directive, notes }`, trial-merge `{ action, notes? }`.
|
|
242
|
+
curl -sS -X POST __BASE__/actions/complete-user-task -H 'content-type: application/json' \
|
|
243
|
+
-d '{ "userTaskKey": "<key>", "variables": { "answer": "Cap retries at 5 and proceed." } }'
|
|
211
244
|
```
|
|
212
245
|
|
|
213
246
|
**Answer a PR escalation** (both the review-loop `wait-answer` and the merge-loop
|
|
214
247
|
`wait-merge-answer` — both are now native user tasks answered the same way, #256).
|
|
215
|
-
Use the PR key's parked user task and submit the `pr-escalation` form's `{ answer }
|
|
248
|
+
Use the PR key's parked user task and submit the `pr-escalation` form's `{ answer }`
|
|
249
|
+
through the `completeUserTask` tool; curl is the no-MCP fallback:
|
|
250
|
+
|
|
251
|
+
```text
|
|
252
|
+
tool: completeUserTask { "userTaskKey": "<key>",
|
|
253
|
+
"variables": { "answer": "Yes — cap the retries at 5 and proceed." } }
|
|
254
|
+
```
|
|
216
255
|
|
|
217
256
|
```bash
|
|
257
|
+
# No-MCP fallback:
|
|
218
258
|
curl -sS -X POST __BASE__/actions/complete-user-task \
|
|
219
259
|
-H 'content-type: application/json' \
|
|
220
260
|
-d '{
|
|
@@ -223,9 +263,11 @@ curl -sS -X POST __BASE__/actions/complete-user-task \
|
|
|
223
263
|
}'
|
|
224
264
|
```
|
|
225
265
|
|
|
226
|
-
The `userTaskKey` comes from `GET /status
|
|
227
|
-
canonical answer door for every escalation kind
|
|
228
|
-
|
|
266
|
+
The `userTaskKey` comes from `listEscalations` (or `GET /status`/the Tasks inbox). This
|
|
267
|
+
is the ONE canonical answer door (`completeUserTask`) for every escalation kind — with
|
|
268
|
+
`agentCompleteEscalation` the agent-assignee variant that records identity for
|
|
269
|
+
attribution (ADR 0046); the merge loop no longer uses a durable `escalation-answered`
|
|
270
|
+
message catch.
|
|
229
271
|
|
|
230
272
|
If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
|
|
231
273
|
|
|
@@ -266,10 +308,35 @@ The app stores each PR/plan's engine **process instance key** in its `process_ke
|
|
|
266
308
|
column and surfaces it as `processKey` in `/status`. That key is the join between the
|
|
267
309
|
app's business view and the engine's execution view.
|
|
268
310
|
|
|
269
|
-
**Find the instance for a PR:** take `processKey` from `/status
|
|
270
|
-
engine
|
|
311
|
+
**Find the instance for a PR:** take `processKey` from `listActivePrs` (`/status`), then
|
|
312
|
+
query engine truth. **Prefer the framework `urban_debug_*` engine-debug tools** (owned by
|
|
313
|
+
the nano-ide urban runtime — see the crosswalk in §10); the `curl __ENGINE__/…` engine
|
|
314
|
+
REST calls are the no-MCP fallback:
|
|
315
|
+
|
|
316
|
+
```text
|
|
317
|
+
PK = <processKey from listActivePrs>
|
|
318
|
+
|
|
319
|
+
# The instance itself (state, the BPMN process it is running, start time):
|
|
320
|
+
tool: urban_debug_search_process_instances { "filter": { "processInstanceKey": PK } }
|
|
321
|
+
|
|
322
|
+
# Where is it parked? — active jobs on the instance (a CREATED senior:pr-review job
|
|
323
|
+
# with a `worker` set means an agent has leased the round; none means it is queued).
|
|
324
|
+
# `urban_debug_search_jobs`/`_search_variables` are projected only where the framework
|
|
325
|
+
# offers them (sibling nano-ide epic); else drop to the curl door (see §10 crosswalk):
|
|
326
|
+
tool: urban_debug_search_jobs { "filter": { "processInstanceKey": PK, "state": "CREATED" } } # where projected
|
|
327
|
+
|
|
328
|
+
# Its variables (the aggregate/job payload the engine holds):
|
|
329
|
+
tool: urban_debug_search_variables { "filter": { "processInstanceKey": PK } } # where projected
|
|
330
|
+
|
|
331
|
+
# Is it dead-in-the-water on a technical fault? — active incidents:
|
|
332
|
+
tool: urban_debug_search_incidents { "filter": { "processInstanceKey": PK, "state": "ACTIVE" } }
|
|
333
|
+
|
|
334
|
+
# Which BPMN element/wait-state is it sitting on?
|
|
335
|
+
tool: urban_debug_search_element_instance_wait_states { "filter": { "processInstanceKey": PK } }
|
|
336
|
+
```
|
|
271
337
|
|
|
272
338
|
```bash
|
|
339
|
+
# No-MCP fallback — the raw engine Camunda-8 v2 REST API:
|
|
273
340
|
PK=<processKey-from-status>
|
|
274
341
|
|
|
275
342
|
# The instance itself (state, the BPMN process it is running, start time):
|
|
@@ -277,8 +344,7 @@ curl -sS -X POST __ENGINE__/process-instances/search \
|
|
|
277
344
|
-H 'content-type: application/json' \
|
|
278
345
|
-d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
|
|
279
346
|
|
|
280
|
-
# Where is it parked? — active jobs on the instance
|
|
281
|
-
# with a `worker` set means an agent has leased the round; none means it is queued):
|
|
347
|
+
# Where is it parked? — active jobs on the instance:
|
|
282
348
|
curl -sS -X POST __ENGINE__/jobs/search \
|
|
283
349
|
-H 'content-type: application/json' \
|
|
284
350
|
-d "{ \"filter\": { \"processInstanceKey\": \"$PK\", \"state\": \"CREATED\" } }" | jq
|
|
@@ -294,15 +360,23 @@ curl -sS -X POST __ENGINE__/element-instances/search \
|
|
|
294
360
|
-d "{ \"filter\": { \"processInstanceKey\": \"$PK\" } }" | jq
|
|
295
361
|
```
|
|
296
362
|
|
|
363
|
+
> The `urban_debug_search_jobs`, `urban_debug_search_variables` and
|
|
364
|
+
> `urban_debug_get_process_definition_xml` reads are owned by the sibling nano-ide urban
|
|
365
|
+
> epic; where a deployment's framework has not yet projected them, use the curl fallback
|
|
366
|
+
> for that row. The three always-present engine reads are
|
|
367
|
+
> `urban_debug_search_process_instances`, `urban_debug_search_element_instance_wait_states`
|
|
368
|
+
> and `urban_debug_search_incidents`.
|
|
369
|
+
|
|
297
370
|
The app already mirrors an ACTIVE incident onto the PR row (`incident`/incident
|
|
298
371
|
message), so a PR that shows an incident in the UI is parked on an engine fault —
|
|
299
|
-
inspect it with `incidents/search` above. If the
|
|
300
|
-
deployment's engine base is `__ENGINE__` (set via
|
|
301
|
-
`CAMUNDA_REST_ADDRESS`).
|
|
372
|
+
inspect it with `urban_debug_search_incidents` (or `incidents/search`) above. If the
|
|
373
|
+
engine is not at the default, the deployment's engine base is `__ENGINE__` (set via
|
|
374
|
+
`NANOBPMN_BASE_URL` or `CAMUNDA_REST_ADDRESS`).
|
|
302
375
|
|
|
303
376
|
**Relate an instance back to a PR:** if you have a `processKey` but not the PR, match
|
|
304
|
-
it against
|
|
305
|
-
longer in `/status`; its instance has already completed or been
|
|
377
|
+
it against `listActivePrs`/`/status` (`.prs[] | select(.processKey == "<PK>")`). A
|
|
378
|
+
terminal PR is no longer in `/status`; its instance has already completed or been
|
|
379
|
+
cancelled.
|
|
306
380
|
|
|
307
381
|
---
|
|
308
382
|
|
|
@@ -311,9 +385,15 @@ longer in `/status`; its instance has already completed or been cancelled.
|
|
|
311
385
|
The behaviour is defined by durable BPMN processes and model-authored agent prompts —
|
|
312
386
|
both live in the source repo, not in the job payload.
|
|
313
387
|
|
|
314
|
-
- **Processes:**
|
|
315
|
-
|
|
316
|
-
|
|
388
|
+
- **Processes:** to inspect the model an instance is **actually running**, prefer the
|
|
389
|
+
framework tool **`urban_debug_get_process_definition_xml`** (owned by the sibling
|
|
390
|
+
nano-ide urban epic — see §10), which returns the deployed BPMN XML for a process
|
|
391
|
+
definition; that is engine truth and cannot drift from what is live. The repo copies
|
|
392
|
+
`resources/processes/*.bpmn` — `convergence-loop.bpmn` (review), `merge-loop.bpmn`
|
|
393
|
+
(merge/CI-fix/rebase), `plan-fanout.bpmn` (planning), `retro.bpmn` — are the
|
|
394
|
+
**source of truth in git** for routing, read them from the checkout when you want the
|
|
395
|
+
authored form (or have no MCP). They can lag a running deployment, so confirm against
|
|
396
|
+
the deployed XML when *why did this instance route here* matters. To understand *why* an
|
|
317
397
|
instance went where it did, read the gateway conditions (FEEL expressions on the
|
|
318
398
|
sequence flows) for the element it is parked on (§5).
|
|
319
399
|
- **Prompts (agent base instructions):** `resources/prompts/*.md` — `review-round.md`,
|
|
@@ -369,11 +449,24 @@ Work through this order:
|
|
|
369
449
|
`NANO_PR_REVIEW_WAIT_TIMEOUT` (default `PT20M`); the poller also re-nudges the
|
|
370
450
|
reviewer periodically. If the reviewer bot is not provisioned on the repo, no
|
|
371
451
|
review will ever land — that is a repo-config problem, not an app bug.
|
|
372
|
-
5. **Cancel + resubmit** as a last resort. Cancel
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
452
|
+
5. **Cancel + resubmit** as a last resort. Cancel through the **app-owned** door —
|
|
453
|
+
**prefer the `cancelInstance` tool** (projected by sibling #667; the same
|
|
454
|
+
record-consistent path as the UI's per-row Cancel, `POST /app/actions/cancel
|
|
455
|
+
{ "processInstanceKey": "<PK>" }`), which cancels
|
|
456
|
+
the engine instance **and** marks the PR `abandoned` so it drops out of
|
|
457
|
+
`listActivePrs`. Then re-submit the PR (§1) to start a fresh loop. **Do not** cancel a
|
|
458
|
+
raw engine instance out from under the app with the engine-level
|
|
459
|
+
`urban_debug_cancel_instance` tool (or a direct engine REST cancel) — that cancels the
|
|
460
|
+
token but leaves the PR row inconsistent. Always go through `cancelInstance` /
|
|
461
|
+
`POST /app/actions/cancel` so the app's record state stays consistent. Note the door
|
|
462
|
+
sits at `/app/actions/cancel`, **outside** the `__BASE__` control-API base (ADR 0059) —
|
|
463
|
+
until #667 projects `cancelInstance`, that curl door is the only cancel path. No-MCP
|
|
464
|
+
fallback:
|
|
465
|
+
|
|
466
|
+
```bash
|
|
467
|
+
curl -sS -X POST __BASE__/../actions/cancel -H 'content-type: application/json' \
|
|
468
|
+
-d '{ "processInstanceKey": "<PK>" }'
|
|
469
|
+
```
|
|
377
470
|
|
|
378
471
|
---
|
|
379
472
|
|
|
@@ -767,3 +860,66 @@ Semantics:
|
|
|
767
860
|
`stage:"merged"` once its PR merges. So `wait[epic]` targeting a feature/epic **root issue**
|
|
768
861
|
observes that thread's aggregate frontier and releases on `stage:"merged" && active:false` either
|
|
769
862
|
way — see `getDeliveryGraphVocabulary` (the `epic` probe entry) for the structured contract.
|
|
863
|
+
|
|
864
|
+
---
|
|
865
|
+
|
|
866
|
+
## 10. Tool ↔ curl crosswalk (MCP-first, curl is the no-MCP fallback)
|
|
867
|
+
|
|
868
|
+
When you drive this instance over MCP, **call the projected tool** — every recipe above
|
|
869
|
+
leads with it. `curl __BASE__/…`/`curl __ENGINE__/…` is only the **no-MCP fallback** for an
|
|
870
|
+
agent that has no tools (see the runbook, `docs/mcp-runbook.md` §2/§3, and [§5
|
|
871
|
+
Fallback](mcp-runbook.md#5-fallback)). This table maps every guide action to its projected
|
|
872
|
+
tool name and its no-MCP fallback underneath (usually a curl door; a few rows fall back to reading a repo file instead).
|
|
873
|
+
|
|
874
|
+
App-owned operations (projected from this app's `openapi.yaml`; the `__BASE__` control API).
|
|
875
|
+
Rows tagged *(projected by sibling #NNN)* name a tool that lands with that sibling task —
|
|
876
|
+
until it does, only the curl door in the third column exists on the deployment:
|
|
877
|
+
|
|
878
|
+
| Guide action | Projected tool | No-MCP fallback |
|
|
879
|
+
|---|---|---|
|
|
880
|
+
| Which code is live (§0) | `getVersion` | `curl __BASE__/version` |
|
|
881
|
+
| Every PR in flight (§0/§5) | `listActivePrs` | `curl __BASE__/status` |
|
|
882
|
+
| List open escalations (§3) | `listEscalations` *(projected by sibling #666; curl door until then)* | `curl __BASE__/../../tasks/api/tasks` (the answerable list — carries each `userTaskKey`); `/status` filtered with the jq recipe just below this table is only a quick *indicator* (it surfaces `openEscalation` as a question string, **no `userTaskKey`**, so you can't answer from it) |
|
|
883
|
+
| Answer an escalation (§3) | `completeUserTask` (agent-assignee: `agentCompleteEscalation`) | `curl -X POST __BASE__/actions/complete-user-task` |
|
|
884
|
+
| Cancel an instance (record-consistent) (§7) | `cancelInstance` *(projected by sibling #667; curl door until then)* | `curl -X POST __BASE__/../actions/cancel` *(the `/app/actions/cancel` door, outside `__BASE__`)* |
|
|
885
|
+
| Publish a BPMN message (§7) | `postMessage` | `curl -X POST __BASE__/actions/message` |
|
|
886
|
+
| The operator guide itself | `getAgentInstructions` (full) / `getAgentGuide(section?)` (addressable) | `curl __BASE__/agent` / `curl __BASE__/agent/guide` |
|
|
887
|
+
|
|
888
|
+
The `listEscalations` no-MCP fallback that filters `/status` with `jq` lives here, not inside the
|
|
889
|
+
table cell above: a literal `|` can't be written in a GFM table cell without escaping it as `\|`,
|
|
890
|
+
and this guide is served **raw** (byte-for-byte, `__BASE__`/`__ENGINE__` aside) to no-MCP agents, so
|
|
891
|
+
the escaped form would be copied verbatim and the shell would treat `\|` as a literal argument
|
|
892
|
+
rather than a pipe. It's only a **quick indicator** — `/status` surfaces `openEscalation` as a
|
|
893
|
+
question string with **no `userTaskKey`**, so to actually *answer* one you still need the tasks
|
|
894
|
+
inbox (`curl __BASE__/../../tasks/api/tasks`) or the `listEscalations` tool for its `userTaskKey`
|
|
895
|
+
(§3). It selects the same `{ prKey, status, round, openEscalation }` fields as the §3 fallback for
|
|
896
|
+
consistency. Copy it from here:
|
|
897
|
+
|
|
898
|
+
```sh
|
|
899
|
+
curl __BASE__/status | jq '.prs[] | select(.openEscalation != null)
|
|
900
|
+
| { prKey, status, round, openEscalation }'
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
Framework engine-debug tools (owned by the nano-ide urban runtime, not this app; they front
|
|
905
|
+
the engine's Camunda-8 v2 REST API at `__ENGINE__`). The first three are always projected;
|
|
906
|
+
`urban_debug_search_jobs` / `urban_debug_search_variables` /
|
|
907
|
+
`urban_debug_get_process_definition_xml` are owned by the sibling nano-ide urban epic — use
|
|
908
|
+
them by name where the framework projects them, else drop to the curl door:
|
|
909
|
+
|
|
910
|
+
| Guide action | Projected tool | No-MCP fallback |
|
|
911
|
+
|---|---|---|
|
|
912
|
+
| Search process instances (§5) | `urban_debug_search_process_instances` | `curl -X POST __ENGINE__/process-instances/search` |
|
|
913
|
+
| Inspect element/wait states (§5) | `urban_debug_search_element_instance_wait_states` | `curl -X POST __ENGINE__/element-instances/search` |
|
|
914
|
+
| Active incidents (§5) | `urban_debug_search_incidents` | `curl -X POST __ENGINE__/incidents/search` |
|
|
915
|
+
| Jobs on an instance (§5) | `urban_debug_search_jobs` | `curl -X POST __ENGINE__/jobs/search` |
|
|
916
|
+
| Instance variables (§5) | `urban_debug_search_variables` | `curl -X POST __ENGINE__/variables/search` |
|
|
917
|
+
| Deployed BPMN model XML (§6) | `urban_debug_get_process_definition_xml` | read the source copy `resources/processes/*.bpmn` from the checkout |
|
|
918
|
+
| Engine-level cancel — **avoid** (§7) | `urban_debug_cancel_instance` | *(use the app-owned `cancelInstance` instead — engine cancel leaves the PR row inconsistent)* |
|
|
919
|
+
|
|
920
|
+
**Rule of thumb (agent-guide §5 fallback discipline):** if a row has a projected tool, use
|
|
921
|
+
it; reach for its curl door only when you are genuinely not on MCP (or the tool's sibling
|
|
922
|
+
task has not yet landed — see the *(projected by sibling #NNN)* tags above). The app-owned
|
|
923
|
+
cancel door (`cancelInstance`, #667) is always preferred over the engine-level
|
|
924
|
+
`urban_debug_cancel_instance`,
|
|
925
|
+
because only the app door transitions the PR/plan record to `abandoned`.
|
package/docs/mcp-runbook.md
CHANGED
|
@@ -91,7 +91,14 @@ family). Ask:
|
|
|
91
91
|
|
|
92
92
|
> *"Using workforce-local, show what's in flight and any open escalations."*
|
|
93
93
|
|
|
94
|
-
The agent should call the status operation tool, not curl.
|
|
94
|
+
The agent should call the status operation tool (`listActivePrs`), not curl. The operator
|
|
95
|
+
guide's **tool↔curl crosswalk** (agent-guide §10, via `getAgentGuide(section="tool-crosswalk")`)
|
|
96
|
+
maps every guide action to its projected tool name and the curl door underneath — so a
|
|
97
|
+
tool-aware agent leads with the tool (`listActivePrs`, `getVersion`, `completeUserTask`,
|
|
98
|
+
the `urban_debug_*` engine reads — plus `listEscalations` and `cancelInstance` *where the
|
|
99
|
+
deployment projects them*; those two are sibling tasks #666/#667 and are not on a
|
|
100
|
+
deployment that predates them, so fall back to the curl door there) and treats curl as
|
|
101
|
+
the no-MCP fallback only.
|
|
95
102
|
|
|
96
103
|
## 3. Debug a wedged instance
|
|
97
104
|
|
|
@@ -99,10 +106,19 @@ The agent should call the status operation tool, not curl.
|
|
|
99
106
|
> instance, compare engine truth against the app's projections, and tell me where
|
|
100
107
|
> it's stuck."*
|
|
101
108
|
|
|
102
|
-
The agent has: instance search, wait states,
|
|
109
|
+
The agent has: instance search, wait states, incidents (engine truth) — plus
|
|
110
|
+
`variables` where the framework projects the `urban_debug_search_variables` read
|
|
111
|
+
(otherwise via the engine curl fallback) — plus
|
|
103
112
|
the `urban_*` projection reads (app belief) + the operator guide (the
|
|
104
113
|
`getAgentInstructions` tool) for the convergence-loop-specific meaning of each wedge
|
|
105
|
-
shape. A wedge is frequently exactly a disagreement between the two planes.
|
|
114
|
+
shape. A wedge is frequently exactly a disagreement between the two planes. The guide's
|
|
115
|
+
**tool↔curl crosswalk** (agent-guide §10) names each engine-truth tool
|
|
116
|
+
(`urban_debug_search_process_instances` / `urban_debug_search_element_instance_wait_states`
|
|
117
|
+
/ `urban_debug_search_incidents`, and where projected `urban_debug_search_jobs` /
|
|
118
|
+
`urban_debug_search_variables` / `urban_debug_get_process_definition_xml`) so the agent
|
|
119
|
+
reaches for the tool, not the raw `__ENGINE__` curl — and steers cancels to the app-owned
|
|
120
|
+
`cancelInstance` (projected by sibling #667; until it lands, the `/app/actions/cancel`
|
|
121
|
+
door the UI's Cancel uses), never the record-desyncing engine-level `urban_debug_cancel_instance`.
|
|
106
122
|
|
|
107
123
|
## 4. Guard posture
|
|
108
124
|
|
|
@@ -166,7 +182,8 @@ limit. Over MCP, prefer the **addressable** companion tool `getAgentGuide(sectio
|
|
|
166
182
|
|
|
167
183
|
- **No argument** → a compact **table of contents**: every stable section id
|
|
168
184
|
(`orient`, `submit-pr`, `submit-epic`, `escalations`, `lifecycle`, `debug`,
|
|
169
|
-
`debug-models`, `unstick`, `raise-issue`, `delivery-graphs`) with a
|
|
185
|
+
`debug-models`, `unstick`, `raise-issue`, `delivery-graphs`, `tool-crosswalk`) with a
|
|
186
|
+
one-line summary.
|
|
170
187
|
- **`section=<id>`** → **only** that section's markdown, small enough to fit a typical
|
|
171
188
|
limit. An unknown id is rejected with `issues[{path,message}]` listing the valid ids.
|
|
172
189
|
|
|
@@ -28,6 +28,8 @@ import { dirname, join, resolve } from "node:path";
|
|
|
28
28
|
import { after, before, describe, test } from "node:test";
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
31
|
+
import { pollUserTasks } from "../app/service.ts";
|
|
32
|
+
import { asEngineClient } from "./support/engine-client.ts";
|
|
31
33
|
|
|
32
34
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
33
35
|
const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u4-"));
|
|
@@ -49,7 +51,11 @@ interface InboxTask {
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
interface StatusBody {
|
|
52
|
-
prs: Array<{
|
|
54
|
+
prs: Array<{
|
|
55
|
+
prKey: string;
|
|
56
|
+
status: string;
|
|
57
|
+
openEscalation: { userTaskKey: string; kind: string; summary: string | null } | null;
|
|
58
|
+
}>;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
interface TakenFlow {
|
|
@@ -149,17 +155,20 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
|
|
|
149
155
|
assert.equal(task.elementId, "wait-answer", "the open task is the review-loop escalation userTask");
|
|
150
156
|
assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
|
|
151
157
|
|
|
152
|
-
// The open escalation is DERIVED from the
|
|
153
|
-
//
|
|
158
|
+
// The open escalation is DERIVED from the `user_tasks` read model on the status endpoint — no
|
|
159
|
+
// denormalised `open_escalation_*` pointer is written or read. That read model is projected by
|
|
160
|
+
// `pollUserTasks` (part of the imperative main.ts poll loop the testkit does not run), so drive it
|
|
161
|
+
// explicitly (mirrors feature-run.e2e.ts) to project the parked `wait-answer` task first.
|
|
162
|
+
await pollUserTasks(app.db, asEngineClient(app.engine));
|
|
154
163
|
const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
155
164
|
assert.equal(status.status, 200, "the status endpoint responds");
|
|
156
165
|
const statusRow = status.body.prs.find((p) => p.prKey === prKey);
|
|
157
166
|
assert.ok(statusRow, "the escalated PR is listed as active");
|
|
158
167
|
assert.equal(statusRow?.status, "escalated", "the PR reads as escalated");
|
|
159
168
|
assert.equal(
|
|
160
|
-
statusRow?.openEscalation,
|
|
169
|
+
statusRow?.openEscalation?.summary,
|
|
161
170
|
"Which retry cap?",
|
|
162
|
-
"the open escalation question is derived from the
|
|
171
|
+
"the open escalation question is derived from the user_tasks read model",
|
|
163
172
|
);
|
|
164
173
|
|
|
165
174
|
// Complete the escalation through the taskInbox completion route with the typed `answer`.
|
|
@@ -185,8 +194,11 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
|
|
|
185
194
|
assert.equal(reviewCalls, 2, "the review agent ran a second round after the answer");
|
|
186
195
|
assert.equal(capturedAnswer, answer, "the typed answer reached the resumed review round");
|
|
187
196
|
|
|
188
|
-
// The escalations row was retired to `answered`
|
|
189
|
-
//
|
|
197
|
+
// The escalations row was retired to `answered` and the completed task's `user_tasks` row
|
|
198
|
+
// reconciles away, so no open escalation lingers on the status endpoint once answered + resumed.
|
|
199
|
+
// Re-run the projection (as the durable poller would) so the closed task's read-model row is
|
|
200
|
+
// deleted before re-reading /status.
|
|
201
|
+
await pollUserTasks(app.db, asEngineClient(app.engine));
|
|
190
202
|
const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
191
203
|
const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
|
|
192
204
|
if (afterRow) {
|
|
@@ -25,6 +25,8 @@ import { dirname, join, resolve } from "node:path";
|
|
|
25
25
|
import { after, before, describe, test } from "node:test";
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
27
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
28
|
+
import { pollUserTasks } from "../app/service.ts";
|
|
29
|
+
import { asEngineClient } from "./support/engine-client.ts";
|
|
28
30
|
|
|
29
31
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
32
|
const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u7-"));
|
|
@@ -46,7 +48,11 @@ interface InboxTask {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
interface StatusBody {
|
|
49
|
-
prs: Array<{
|
|
51
|
+
prs: Array<{
|
|
52
|
+
prKey: string;
|
|
53
|
+
status: string;
|
|
54
|
+
openEscalation: { userTaskKey: string; kind: string; summary: string | null } | null;
|
|
55
|
+
}>;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
interface TableInfoRow {
|
|
@@ -227,14 +233,18 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
|
|
|
227
233
|
assert.equal(task.elementId, "wait-answer", "the open task is the review-loop escalation userTask");
|
|
228
234
|
assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
|
|
229
235
|
|
|
230
|
-
// The open escalation is DERIVED from the
|
|
236
|
+
// The open escalation is DERIVED from the `user_tasks` read model — no denormalised pointer is
|
|
237
|
+
// written. That read model is projected by `pollUserTasks` (part of the imperative main.ts poll
|
|
238
|
+
// loop the testkit does not run), so drive it explicitly (mirrors feature-run.e2e.ts) to project
|
|
239
|
+
// the parked `wait-answer` task before reading /status.
|
|
240
|
+
await pollUserTasks(app.db, asEngineClient(app.engine));
|
|
231
241
|
const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
232
242
|
const statusRow = status.body.prs.find((p) => p.prKey === prKey);
|
|
233
243
|
assert.equal(statusRow?.status, "escalated", "the PR reads as escalated");
|
|
234
244
|
assert.equal(
|
|
235
|
-
statusRow?.openEscalation,
|
|
245
|
+
statusRow?.openEscalation?.summary,
|
|
236
246
|
"Which retry cap?",
|
|
237
|
-
"the open escalation question is derived from the
|
|
247
|
+
"the open escalation question is derived from the user_tasks read model",
|
|
238
248
|
);
|
|
239
249
|
|
|
240
250
|
// Answer through the inbox completion route with the typed `answer` (the re-issued-new path).
|
|
@@ -252,7 +262,10 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
|
|
|
252
262
|
assert.equal(reviewCalls, 2, "the review agent ran a second round after the answer");
|
|
253
263
|
assert.equal(capturedAnswer, answer, "the typed answer reached the resumed review round");
|
|
254
264
|
|
|
255
|
-
// No addressed escalation lingers — the
|
|
265
|
+
// No addressed escalation lingers — the completed task's `user_tasks` row reconciles away. Re-run
|
|
266
|
+
// the projection (as the durable poller would) so the closed task's read-model row is deleted
|
|
267
|
+
// before re-reading /status.
|
|
268
|
+
await pollUserTasks(app.db, asEngineClient(app.engine));
|
|
256
269
|
const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
257
270
|
const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
|
|
258
271
|
if (afterRow) {
|