@nanobpm/nano-workforce 0.206.4 → 0.208.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +18 -0
  3. package/app/deliveryGraphReadModel.test.ts +4 -2
  4. package/app/deliveryGraphRun.ts +12 -0
  5. package/app/github.ts +29 -0
  6. package/app/incidentStatus.test.ts +175 -0
  7. package/app/mcpExclusions.test.ts +4 -3
  8. package/app/mcpToolSurface.ts +11 -2
  9. package/app/mergeLoopBehaviour.test.ts +136 -1
  10. package/app/pullRequestReadModel.test.ts +1 -1
  11. package/app/service.test.ts +266 -0
  12. package/app/service.ts +311 -96
  13. package/build-info.json +4 -4
  14. package/db/migrations/126_pull_requests_incident_type_since.sql +8 -0
  15. package/db/migrations/127_delivery_graph_run_incidents.sql +60 -0
  16. package/db/migrations/128_pull_requests_read_model_incidents.sql +58 -0
  17. package/db/migrations/129_delivery_graph_read_model_incidents.sql +58 -0
  18. package/docs/mcp-runbook.md +2 -2
  19. package/nano.app.json +8 -0
  20. package/openapi.yaml +238 -0
  21. package/operations/listIncidents.test.ts +70 -0
  22. package/operations/listIncidents.ts +78 -0
  23. package/operations/retryIncident.test.ts +280 -0
  24. package/operations/retryIncident.ts +201 -0
  25. package/package.json +1 -1
  26. package/pages/delivery-graphs.page.json +16 -3
  27. package/pages/home.page.json +19 -0
  28. package/pages/overview.page.json +9 -0
  29. package/resources/processes/merge-loop.bpmn +401 -213
  30. package/workers/merge/worker.test.ts +30 -9
  31. package/workers/merge/worker.ts +23 -12
  32. package/workers/reconverge/worker.test.ts +110 -0
  33. package/workers/reconverge/worker.ts +81 -0
  34. package/workers/request-review/worker.test.ts +54 -0
  35. package/workers/request-review/worker.ts +68 -0
@@ -0,0 +1,280 @@
1
+ // Tests for POST /app/api/actions/incident/retry — operationId `retryIncident` (issue #931).
2
+ //
3
+ // The app-owned, RECORD-AWARE incident-retry door. Before it, unsticking an incident-parked loop
4
+ // meant hand-calling engine REST — `PATCH /v2/jobs/<k> {changeset:{retries:1}}` + `POST
5
+ // /v2/incidents/<k>/resolution` — with no app door at all (the two observed incidents on merlin sat
6
+ // dead for 1.3h / 5h partly because nothing surfaced them and nothing offered the retry). This
7
+ // delegate wraps exactly that two-call remedy in one operator action:
8
+ // 1. resolve the ACTIVE incident on the tracked record's instance (404 when no tracked record
9
+ // owns the key — we never retry an instance we don't track; 409 when the instance carries no
10
+ // active incident — nothing to retry),
11
+ // 2. bump the failed job's retries (a JOB_NO_RETRIES incident parks until the job has retries
12
+ // again) and resolve the incident,
13
+ // 3. clear the mirrored incident columns off the record row immediately, so the `/status` row
14
+ // leaves `incident` on the action's return instead of waiting out a poll tick.
15
+ import { test } from "node:test";
16
+ import { assert, assertEquals } from "#test-assert";
17
+ import type { AppApi } from "@nanobpm/urban";
18
+ import { noopLog } from "../test/log.ts";
19
+ import { withTrackingViews } from "../test/trackingViews.ts";
20
+
21
+ /** The engine base every test here targets — set BEFORE the delegate module is (lazily) imported,
22
+ * since the engine REST address is resolved at module load. */
23
+ const ENGINE = "http://engine.test/v2";
24
+ process.env["CAMUNDA_REST_ADDRESS"] = ENGINE;
25
+ /** A shared secret IS configured for every test here: the door is reached by the declarative
26
+ * "Retry incident" page action, which cannot attach `x-hook-secret`, so the door must stay
27
+ * reachable without it (PR #938 review) — every header-less call below proves it does. */
28
+ process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cret";
29
+
30
+ const { default: handler } = await import("./retryIncident.ts");
31
+
32
+ interface EngineCall {
33
+ method: string;
34
+ url: string;
35
+ body?: unknown;
36
+ }
37
+
38
+ /** A fetch stub serving the engine's Camunda-8 v2 REST surface for one instance: the ACTIVE
39
+ * incident search, the job retries PATCH, and the incident resolution POST. */
40
+ function engineFetch(
41
+ byInstance: Record<string, unknown[]>,
42
+ calls: EngineCall[],
43
+ opts: { failPatch?: boolean; failResolve?: boolean } = {},
44
+ ) {
45
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
46
+ const u = typeof url === "string" ? url : url.toString();
47
+ const json = (body: unknown, status = 200) =>
48
+ status === 204
49
+ ? new Response(null, { status: 204 })
50
+ : new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
51
+ if (u.endsWith("/incidents/search")) {
52
+ const body = JSON.parse(String(init?.body ?? "{}")) as { filter?: { processInstanceKey?: string } };
53
+ calls.push({ method: "POST", url: u, body });
54
+ return Promise.resolve(json({ items: byInstance[body.filter?.processInstanceKey ?? ""] ?? [] }));
55
+ }
56
+ const jobPatch = u.match(/\/jobs\/([^/]+)$/);
57
+ if (jobPatch && init?.method === "PATCH") {
58
+ const body = JSON.parse(String(init?.body ?? "{}"));
59
+ calls.push({ method: "PATCH", url: u, body });
60
+ return Promise.resolve(opts.failPatch ? json({ detail: "no such job" }, 404) : json({}, 204));
61
+ }
62
+ const resolution = u.match(/\/incidents\/([^/]+)\/resolution$/);
63
+ if (resolution) {
64
+ calls.push({ method: "POST", url: u });
65
+ return Promise.resolve(opts.failResolve ? json({ detail: "engine unhappy" }, 500) : json({}, 204));
66
+ }
67
+ throw new Error(`unexpected fetch: ${init?.method ?? "GET"} ${u}`);
68
+ };
69
+ }
70
+
71
+ function memTable(rows: any[], key: string) {
72
+ return {
73
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
74
+ all: () => Promise.resolve([...rows]),
75
+ find: (q: any) =>
76
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
77
+ findOne: (q: any) =>
78
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
79
+ insert: (r: any) => {
80
+ rows.push(r);
81
+ return Promise.resolve(r);
82
+ },
83
+ update: (k: any, patch: any) => {
84
+ const r = rows.find((x) => x[key] === k);
85
+ if (r) Object.assign(r, patch);
86
+ return Promise.resolve(r);
87
+ },
88
+ delete: (k: any) => {
89
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
90
+ return Promise.resolve();
91
+ },
92
+ };
93
+ }
94
+
95
+ function prRow(over: Record<string, unknown> = {}) {
96
+ return {
97
+ pr_key: "owner/repo#1",
98
+ repo: "owner/repo",
99
+ number: 1,
100
+ url: "https://github.com/owner/repo/pull/1",
101
+ title: "t",
102
+ status: "converging",
103
+ current_round: 1,
104
+ process_key: "PI-1",
105
+ incident_key: "598235",
106
+ incident_type: "JOB_NO_RETRIES",
107
+ incident_message: 'agent "nano-coder" failed: idle',
108
+ incident_since: "2026-11-09T01:00:00.000Z",
109
+ updated_at: "2024-01-01",
110
+ ...over,
111
+ };
112
+ }
113
+
114
+ function memApp(stores: Record<string, { rows: any[]; key: string }>): AppApi {
115
+ return {
116
+ data: {
117
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
118
+ hasDefaultSource: () => true,
119
+ },
120
+ log: noopLog(),
121
+ } as unknown as AppApi;
122
+ }
123
+
124
+ // biome-ignore lint/suspicious/noExplicitAny: test-only invocation shim for the delegate.
125
+ async function call(app: AppApi, body: unknown, headers: Record<string, string> = {}): Promise<any> {
126
+ const req = { method: "POST", path: "/app/api/actions/incident/retry", headers: new Headers(headers) };
127
+ // biome-ignore lint/suspicious/noExplicitAny: test-only invocation shim for the delegate.
128
+ return await handler({ req: req as any, params: {}, query: {}, body } as any, app);
129
+ }
130
+
131
+ const ACTIVE_INCIDENT = {
132
+ incidentKey: "598235",
133
+ errorType: "JOB_NO_RETRIES",
134
+ errorMessage: 'agent "nano-coder" failed: idle',
135
+ state: "ACTIVE",
136
+ creationTime: "2026-11-09T01:00:00.000Z",
137
+ jobKey: "595995",
138
+ };
139
+
140
+ test("a missing/non-string processInstanceKey → 400 and never touches the engine", async () => {
141
+ const calls: EngineCall[] = [];
142
+ const prevFetch = globalThis.fetch;
143
+ globalThis.fetch = engineFetch({ "PI-1": [ACTIVE_INCIDENT] }, calls) as typeof fetch;
144
+ const app = memApp({ pull_requests: { rows: [prRow()], key: "pr_key" } });
145
+ try {
146
+ assertEquals((await call(app, {})).status, 400);
147
+ assertEquals((await call(app, { processInstanceKey: " " })).status, 400);
148
+ assertEquals((await call(app, { processInstanceKey: 2985 })).status, 400);
149
+ assertEquals((await call(app, undefined)).status, 400);
150
+ } finally {
151
+ globalThis.fetch = prevFetch;
152
+ }
153
+ assertEquals(calls.length, 0, "no engine call without a valid key");
154
+ });
155
+
156
+ test("a key no tracked record owns → 404 and never touches the engine", async () => {
157
+ const calls: EngineCall[] = [];
158
+ const prevFetch = globalThis.fetch;
159
+ globalThis.fetch = engineFetch({}, calls) as typeof fetch;
160
+ const app = memApp({ pull_requests: { rows: [prRow()], key: "pr_key" } });
161
+ try {
162
+ const res = await call(app, { processInstanceKey: "PI-UNKNOWN" });
163
+ assertEquals(res.status, 404);
164
+ assertEquals(res.body.ok, false);
165
+ } finally {
166
+ globalThis.fetch = prevFetch;
167
+ }
168
+ assertEquals(calls.length, 0, "an untracked key never reaches the engine");
169
+ });
170
+
171
+ test("an instance with no ACTIVE incident → 409 (nothing to retry), no mutation", async () => {
172
+ const calls: EngineCall[] = [];
173
+ const prevFetch = globalThis.fetch;
174
+ globalThis.fetch = engineFetch({ "PI-1": [] }, calls) as typeof fetch;
175
+ const row = prRow({ incident_key: null, incident_type: null, incident_message: null, incident_since: null });
176
+ const app = memApp({ pull_requests: { rows: [row], key: "pr_key" } });
177
+ try {
178
+ const res = await call(app, { processInstanceKey: "PI-1" });
179
+ assertEquals(res.status, 409);
180
+ assertEquals(res.body.ok, false);
181
+ } finally {
182
+ globalThis.fetch = prevFetch;
183
+ }
184
+ assertEquals(calls.length, 1, "only the incident search ran");
185
+ assertEquals(calls[0].url.endsWith("/incidents/search"), true);
186
+ });
187
+
188
+ test("the happy path: bumps the failed job's retries, resolves the incident, and clears the mirrored row", async () => {
189
+ const calls: EngineCall[] = [];
190
+ const prevFetch = globalThis.fetch;
191
+ globalThis.fetch = engineFetch({ "PI-1": [ACTIVE_INCIDENT] }, calls) as typeof fetch;
192
+ const row = prRow();
193
+ const app = memApp({ pull_requests: { rows: [row], key: "pr_key" } });
194
+ try {
195
+ const res = await call(app, { processInstanceKey: "PI-1" });
196
+ assertEquals(res.status, 200);
197
+ assertEquals(res.body.ok, true);
198
+ assertEquals(res.body.processInstanceKey, "PI-1");
199
+ assertEquals(res.body.incidentKey, "598235");
200
+ assertEquals(res.body.jobKey, "595995");
201
+ } finally {
202
+ globalThis.fetch = prevFetch;
203
+ }
204
+ // Exactly the manual remedy, in order: search → PATCH retries → POST resolution.
205
+ assertEquals(calls.map((c) => `${c.method} ${c.url}`), [
206
+ "POST http://engine.test/v2/incidents/search",
207
+ "PATCH http://engine.test/v2/jobs/595995",
208
+ "POST http://engine.test/v2/incidents/598235/resolution",
209
+ ]);
210
+ assertEquals(calls[1].body, { changeset: { retries: 1 } });
211
+ // The mirrored row columns clear immediately — the /status row leaves `incident` on return.
212
+ assertEquals(row.incident_key, null);
213
+ assertEquals(row.incident_type, null);
214
+ assertEquals(row.incident_message, null);
215
+ assertEquals(row.incident_since, null);
216
+ });
217
+
218
+ test("a retries-bump failure → 502 and the incident is NOT resolved (no half-applied remedy)", async () => {
219
+ const calls: EngineCall[] = [];
220
+ const prevFetch = globalThis.fetch;
221
+ globalThis.fetch = engineFetch({ "PI-1": [ACTIVE_INCIDENT] }, calls, { failPatch: true }) as typeof fetch;
222
+ const row = prRow();
223
+ const app = memApp({ pull_requests: { rows: [row], key: "pr_key" } });
224
+ try {
225
+ const res = await call(app, { processInstanceKey: "PI-1" });
226
+ assertEquals(res.status, 502);
227
+ assertEquals(res.body.ok, false);
228
+ } finally {
229
+ globalThis.fetch = prevFetch;
230
+ }
231
+ assertEquals(
232
+ calls.some((c) => c.url.endsWith("/resolution")),
233
+ false,
234
+ "the incident is left ACTIVE when the job could not be given retries",
235
+ );
236
+ // The row keeps its incident mirror — the incident is still real.
237
+ assertEquals(row.incident_key, "598235");
238
+ });
239
+
240
+ test("a resolution failure after a successful retries bump → 502, row keeps the mirror (the next poll reconciles)", async () => {
241
+ const calls: EngineCall[] = [];
242
+ const prevFetch = globalThis.fetch;
243
+ globalThis.fetch = engineFetch({ "PI-1": [ACTIVE_INCIDENT] }, calls, { failResolve: true }) as typeof fetch;
244
+ const row = prRow();
245
+ const app = memApp({ pull_requests: { rows: [row], key: "pr_key" } });
246
+ try {
247
+ const res = await call(app, { processInstanceKey: "PI-1" });
248
+ assertEquals(res.status, 502);
249
+ assertEquals(res.body.ok, false);
250
+ } finally {
251
+ globalThis.fetch = prevFetch;
252
+ }
253
+ assertEquals(row.incident_key, "598235", "the mirror stays until the engine confirms resolution");
254
+ });
255
+
256
+ test("a delivery-graph run's incident retries through the same door", async () => {
257
+ const calls: EngineCall[] = [];
258
+ const prevFetch = globalThis.fetch;
259
+ globalThis.fetch = engineFetch({ "PI-DG-1": [ACTIVE_INCIDENT] }, calls) as typeof fetch;
260
+ const run = {
261
+ run_key: "dg-1",
262
+ status: "running",
263
+ process_key: "PI-DG-1",
264
+ incident_key: "598235",
265
+ incident_type: "JOB_NO_RETRIES",
266
+ incident_message: "delivery node job exhausted retries",
267
+ incident_since: "2026-11-09T03:00:00.000Z",
268
+ updated_at: "t0",
269
+ };
270
+ const app = memApp({ delivery_graph_runs: { rows: [run], key: "run_key" } });
271
+ try {
272
+ const res = await call(app, { processInstanceKey: "PI-DG-1" });
273
+ assertEquals(res.status, 200);
274
+ assertEquals(res.body.ok, true);
275
+ } finally {
276
+ globalThis.fetch = prevFetch;
277
+ }
278
+ assertEquals(run.incident_key, null);
279
+ assertEquals(run.incident_since, null);
280
+ });
@@ -0,0 +1,201 @@
1
+ // POST /app/api/actions/incident/retry → operationId `retryIncident` (issue #931).
2
+ //
3
+ // The app-owned, RECORD-AWARE incident-retry door. A convergence/merge/delivery instance parked on
4
+ // an engine incident used to need a hand-run engine-REST remedy — `PATCH /v2/jobs/<k>
5
+ // {changeset:{retries:1}}` to give the failed job a retry, then `POST /v2/incidents/<k>/resolution`
6
+ // — which is exactly what the two merlin incidents (nano-bpm#1452 loop 576515, nano-workforce#930
7
+ // loop 594789) sat waiting on for hours. This delegate wraps that two-call remedy in ONE operator
8
+ // action the UI's per-row "Retry incident" button and an operator/agent can call:
9
+ //
10
+ // 1. Resolve the tracked record owning `processInstanceKey` (the SAME `resolveTrackedInstance`
11
+ // the cancel door uses, issue #705) — a key no tracked record owns is a clean 404 no-op; we
12
+ // never retry an instance we don't track.
13
+ // 2. Read the instance's OLDEST ACTIVE incident off the engine (`fetchActiveIncident`, the SAME
14
+ // reader the poller's `pollIncidents` pass mirrors from, so the door and the mirror can never
15
+ // disagree about WHICH incident is being retried). No active incident → 409 (nothing to
16
+ // retry — it may already be resolved).
17
+ // 3. Bump the failed job's retries (a `JOB_NO_RETRIES` incident parks until the job can be
18
+ // retried) and resolve the incident. A failed bump aborts BEFORE the resolution — resolving
19
+ // without retries would re-incident instantly, a half-applied remedy.
20
+ // 4. Clear the mirrored `incident_*` columns off the record row immediately, so the `/status`
21
+ // row leaves `incident` on the action's return rather than waiting out a poll tick (the next
22
+ // `pollIncidents` pass re-mirrors if the engine still reports an active incident — the row
23
+ // can never lie for more than a tick).
24
+ //
25
+ // The runtime validates the body against openapi.yaml (`processInstanceKey` required).
26
+ //
27
+ // This door is INTENTIONALLY UNGUARDED by the optional NANO_PR_WEBHOOK_SECRET / x-hook-secret
28
+ // header (PR #938 review). Its primary caller is the declarative per-row "Retry incident" page
29
+ // action (home / overview / delivery-graphs), and the `@nanobpm/urban` page runtime structurally
30
+ // cannot attach a custom header — a guard here would 401 every button press on a secured
31
+ // deployment, making the one-click remedy dead exactly where it is needed (the same reasoning as
32
+ // `saveToLibrary`). The posture matches the runtime's built-in `/app/actions/cancel` page action the
33
+ // same rows already expose, which is a strictly MORE destructive, equally unguarded UI mutation.
34
+ // The blast radius is bounded by construction: only an instance a tracked record owns (404
35
+ // otherwise) with an ACTIVE engine incident (409 otherwise) is touched, and the only effect is the
36
+ // retry the incident is already waiting for. The door remains operator-only: it is excluded from
37
+ // the projected MCP tool surface (`x-mcp`, see app/mcpExclusions.test.ts).
38
+
39
+ import type { DataLayer } from "@nanobpm/urban";
40
+ import { resolveEngineAddress } from "../app/enginePreflight.ts";
41
+ import { resolveTrackedInstance } from "../app/instanceTracking.ts";
42
+ import { fetchActiveIncident, type IncidentSearchItem } from "../app/service.ts";
43
+ import { envVar } from "../app/version.ts";
44
+ import { defineOperation } from "../nano-generated/operations.ts";
45
+
46
+ // The engine REST base + auth the remedy calls run against, resolved ONCE at module load exactly as
47
+ // main.ts wires the poller (`resolveEngineAddress` — CAMUNDA_REST_ADDRESS wins, else
48
+ // NANOBPMN_BASE_URL + /v2 — and the CAMUNDA_TOKEN bearer).
49
+ const ENGINE_BASE = resolveEngineAddress().restAddress.replace(/\/+$/, "");
50
+ const ENGINE_HEADERS: Record<string, string> = { "content-type": "application/json" };
51
+ {
52
+ const token = envVar("CAMUNDA_TOKEN");
53
+ if (token) ENGINE_HEADERS.authorization = `Bearer ${token}`;
54
+ }
55
+
56
+ /** The mirrored incident columns cleared off the record row after a confirmed resolution — the
57
+ * same all-four null write `pollIncidents`' clearing path makes, so the two can never drift. */
58
+ const INCIDENT_CLEARED = {
59
+ incident_key: null,
60
+ incident_type: null,
61
+ incident_message: null,
62
+ incident_since: null,
63
+ } as const;
64
+
65
+ export default defineOperation("retryIncident", async ({ body }, app) => {
66
+ if (!app.data) {
67
+ app.log.warn("retryIncident: no data source configured — cannot resolve the record");
68
+ return { status: 503, body: { error: "no data source configured" } };
69
+ }
70
+
71
+ // Require a STRING key (mirrors cancelInstance): engine keys are 64-bit and a numeric JSON value
72
+ // would already have lost precision before it reached us — reject rather than coerce.
73
+ const raw = body && typeof body === "object" ? body.processInstanceKey : undefined;
74
+ const processInstanceKey = typeof raw === "string" ? raw.trim() : "";
75
+ if (!processInstanceKey) {
76
+ return { status: 400, body: { error: "processInstanceKey is required and must be a string" } };
77
+ }
78
+
79
+ // Resolve which tracked aggregate (PR / delivery run / …) this key belongs to BEFORE touching the
80
+ // engine. A key that maps to NO tracked record is a clean 404 no-op — we never retry an instance
81
+ // we don't track (mirrors cancelInstance, issue #705).
82
+ const record = await resolveTrackedInstance(app.data, processInstanceKey);
83
+ if (!record) {
84
+ app.log.warn("retryIncident: no tracked record for processInstanceKey — no-op", { processInstanceKey });
85
+ return {
86
+ status: 404,
87
+ body: { ok: false, processInstanceKey, error: "no tracked record for processInstanceKey" },
88
+ };
89
+ }
90
+
91
+ // Read the instance's OLDEST ACTIVE incident off the engine — the SAME reader the poller mirrors
92
+ // from, so the door retries exactly the incident the row shows. `undefined` = transport unhappy →
93
+ // 502 (unknown, retry the action); `null` = no active incident → 409 (nothing to retry).
94
+ let active: IncidentSearchItem | null | undefined;
95
+ try {
96
+ active = await fetchActiveIncident(ENGINE_BASE, ENGINE_HEADERS, processInstanceKey);
97
+ } catch (err) {
98
+ app.log.warn("retryIncident: incident search failed", { processInstanceKey, error: String(err) });
99
+ return { status: 502, body: { ok: false, processInstanceKey, error: `incident search failed: ${String(err)}` } };
100
+ }
101
+ if (active === undefined) {
102
+ return { status: 502, body: { ok: false, processInstanceKey, error: "engine incident search unavailable" } };
103
+ }
104
+ if (!active?.incidentKey) {
105
+ return { status: 409, body: { ok: false, processInstanceKey, error: "no active incident on this instance" } };
106
+ }
107
+ const incidentKey = active.incidentKey;
108
+ const jobKey = active.jobKey ?? null;
109
+
110
+ // The remedy, in order: give the failed job a retry, THEN resolve the incident. A failed bump
111
+ // aborts before the resolution — resolving a JOB_NO_RETRIES incident without retries would
112
+ // re-incident instantly, a half-applied remedy that reads as success.
113
+ if (jobKey) {
114
+ const patch = await fetch(`${ENGINE_BASE}/jobs/${jobKey}`, {
115
+ method: "PATCH",
116
+ headers: ENGINE_HEADERS,
117
+ body: JSON.stringify({ changeset: { retries: 1 } }),
118
+ });
119
+ if (!patch.ok) {
120
+ app.log.warn("retryIncident: job retries bump failed", { processInstanceKey, jobKey, status: patch.status });
121
+ return {
122
+ status: 502,
123
+ body: {
124
+ ok: false,
125
+ processInstanceKey,
126
+ incidentKey,
127
+ jobKey,
128
+ error: `job retries bump failed (engine ${patch.status}) — incident left ACTIVE`,
129
+ },
130
+ };
131
+ }
132
+ }
133
+ const resolution = await fetch(`${ENGINE_BASE}/incidents/${incidentKey}/resolution`, {
134
+ method: "POST",
135
+ headers: ENGINE_HEADERS,
136
+ });
137
+ if (!resolution.ok) {
138
+ app.log.warn("retryIncident: incident resolution failed", { processInstanceKey, incidentKey, status: resolution.status });
139
+ return {
140
+ status: 502,
141
+ body: {
142
+ ok: false,
143
+ processInstanceKey,
144
+ incidentKey,
145
+ jobKey,
146
+ error: `incident resolution failed (engine ${resolution.status}) — the mirror stays until the engine confirms`,
147
+ },
148
+ };
149
+ }
150
+
151
+ // The engine confirmed the resolution: clear the mirrored columns off the record row NOW so the
152
+ // `/status` row leaves `incident` on this action's return. The next pollIncidents pass re-mirrors
153
+ // if the engine still reports an active incident, so this can never paper over a real one. The
154
+ // record row is the one `resolveTrackedInstance` matched on the tracking VIEW — re-read it off
155
+ // the base table by the SAME instance key, then update by the table's primary key.
156
+ const cleared = await clearMirroredIncident(app.data, record.table, processInstanceKey);
157
+ if (!cleared) {
158
+ app.log.warn("retryIncident: resolved but the mirrored row could not be cleared (the next poll reconciles)", {
159
+ processInstanceKey,
160
+ table: record.table,
161
+ });
162
+ }
163
+
164
+ app.log.info("retryIncident: incident resolved and job retried", { processInstanceKey, incidentKey, jobKey });
165
+ return { status: 200, body: { ok: true, processInstanceKey, incidentKey, jobKey } };
166
+ });
167
+
168
+ /** Clear the four mirrored incident columns off the tracked record owning `processInstanceKey`.
169
+ * Only the incident-watched aggregates carry the columns (PRs since #94/#931, delivery-graph runs
170
+ * since #931); any other tracked table has no mirror to clear and is a no-op. Returns false when
171
+ * the row could not be found/updated (the next poll reconciles regardless). */
172
+ async function clearMirroredIncident(
173
+ data: DataLayer,
174
+ table: string,
175
+ processInstanceKey: string,
176
+ ): Promise<boolean> {
177
+ const pkField = incidentMirrorPrimaryKeyFor(table);
178
+ if (!pkField) return true; // not an incident-watched aggregate — nothing to clear
179
+ const rows = await data.table<Record<string, unknown>>(table, pkField).find({ process_key: processInstanceKey });
180
+ const row = rows[0];
181
+ if (!row) return false;
182
+ const pk = row[pkField];
183
+ if (typeof pk !== "string" && typeof pk !== "number") return false;
184
+ await data.table(table, pkField).update(pk, { ...INCIDENT_CLEARED, updated_at: new Date().toISOString() });
185
+ return true;
186
+ }
187
+
188
+ /** The primary-key field of an incident-watched aggregate, or null for a tracked table that carries
189
+ * no incident mirror (plans, feature_runs, plan_conformance, delivery_units — nothing to clear
190
+ * there). Keyed off the SAME table name `resolveTrackedInstance` resolved, so the write can never
191
+ * drift from the tracked aggregate's identity column. */
192
+ function incidentMirrorPrimaryKeyFor(table: string): string | null {
193
+ switch (table) {
194
+ case "pull_requests":
195
+ return "pr_key";
196
+ case "delivery_graph_runs":
197
+ return "run_key";
198
+ default:
199
+ return null;
200
+ }
201
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.206.4",
3
+ "version": "0.208.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -149,7 +149,7 @@
149
149
  { "label": "All", "filter": [] }
150
150
  ],
151
151
  "columns": [
152
- { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "16%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
152
+ { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "13%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
153
153
  { "field": "status", "header": "Status", "truncate": true, "width": "10%", "link": { "kind": "processExplorer", "keyField": "process_key" } },
154
154
  { "field": "process_key", "header": "Agent", "template": "Transcript", "truncate": true, "width": "5%", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
155
155
  { "field": "process_key", "header": "Instance", "width": "8%", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
@@ -170,10 +170,11 @@
170
170
  "stateField": "stage_state",
171
171
  "locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
172
172
  },
173
- { "field": "park_label", "header": "Parked", "truncate": true, "width": "12%" },
173
+ { "field": "park_label", "header": "Parked", "truncate": true, "width": "9%" },
174
174
  { "field": "node_count", "header": "Nodes", "width": "5%" },
175
175
  { "field": "human_node_count", "header": "Human", "width": "5%" },
176
176
  { "field": "side_effect_count", "header": "Effects", "width": "6%" },
177
+ { "field": "incident_message", "header": "Incident", "truncate": true, "width": "6%", "badge": { "tone": "danger", "label": "1" } },
177
178
  { "field": "created_at", "header": "Dispatched", "width": "7%", "truncate": true, "format": "datetime" },
178
179
  { "field": "updated_at", "header": "Updated", "width": "6%", "truncate": true, "format": "datetime" }
179
180
  ],
@@ -181,10 +182,22 @@
181
182
  "fields": [
182
183
  { "field": "process_definition_id", "label": "Definition" },
183
184
  { "field": "digest", "label": "Digest" },
184
- { "field": "phase_node_id", "label": "Parked node" }
185
+ { "field": "phase_node_id", "label": "Parked node" },
186
+ { "field": "incident_message", "label": "Incident" },
187
+ { "field": "incident_type", "label": "Incident type" },
188
+ { "field": "incident_since", "label": "Incident since" }
185
189
  ]
186
190
  },
187
191
  "rowActions": [
192
+ {
193
+ "label": "Retry incident",
194
+ "confirm": "Retry the engine incident parking this delivery run? This gives the failed job a retry and resolves the incident.",
195
+ "showWhenField": "incident_key",
196
+ "action": {
197
+ "path": "/app/api/actions/incident/retry",
198
+ "body": { "processInstanceKey": "{{row.process_key}}" }
199
+ }
200
+ },
188
201
  {
189
202
  "label": "Save to library",
190
203
  "confirm": "Save this dispatched delivery graph to the reusable library? Its stored graph is copied into the Library (source: from-dispatched) so it can be reused later.",
@@ -231,6 +231,17 @@
231
231
  }
232
232
  ],
233
233
  "rowActions": [
234
+ {
235
+ "label": "Retry incident",
236
+ "confirm": "Retry the engine incident parking this PR's run? This gives the failed job a retry and resolves the incident.",
237
+ "showWhenField": "incident_key",
238
+ "action": {
239
+ "path": "/app/api/actions/incident/retry",
240
+ "body": {
241
+ "processInstanceKey": "{{row.process_key}}"
242
+ }
243
+ }
244
+ },
234
245
  {
235
246
  "label": "Cancel",
236
247
  "confirm": "Cancel this PR's review run?",
@@ -281,6 +292,14 @@
281
292
  "field": "incident_key",
282
293
  "label": "Incident key"
283
294
  },
295
+ {
296
+ "field": "incident_type",
297
+ "label": "Incident type"
298
+ },
299
+ {
300
+ "field": "incident_since",
301
+ "label": "Incident since"
302
+ },
284
303
  {
285
304
  "field": "merged_at",
286
305
  "label": "Merged at"
@@ -135,6 +135,15 @@
135
135
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
136
136
  ],
137
137
  "rowActions": [
138
+ {
139
+ "label": "Retry incident",
140
+ "confirm": "Retry the engine incident parking this PR's run? This gives the failed job a retry and resolves the incident.",
141
+ "showWhenField": "incident_key",
142
+ "action": {
143
+ "path": "/app/api/actions/incident/retry",
144
+ "body": { "processInstanceKey": "{{row.process_key}}" }
145
+ }
146
+ },
138
147
  {
139
148
  "label": "Dismiss",
140
149
  "confirm": "Dismiss this finished PR? It acknowledges the terminal pull request and files it under History.",