@nanobpm/nano-workforce 0.31.0 → 0.32.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 CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.32.1](https://github.com/nanobpm/nano-workforce/compare/v0.32.0...v0.32.1) (2026-08-09)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **cancel:** reconcile terminated Epic/plan runs via instanceTracking ([#96](https://github.com/nanobpm/nano-workforce/issues/96)) ([3d0c5ba](https://github.com/nanobpm/nano-workforce/commit/3d0c5ba1b8bb9bd0c28432323b1867a7c86d1835))
7
+
8
+ # [0.32.0](https://github.com/nanobpm/nano-workforce/compare/v0.31.0...v0.32.0) (2026-08-09)
9
+
10
+
11
+ ### Features
12
+
13
+ * **poller:** surface technical incidents on the PR row ([#95](https://github.com/nanobpm/nano-workforce/issues/95)) ([596153c](https://github.com/nanobpm/nano-workforce/commit/596153c0f58a0d511f4a7bac66896dab98a6a5c8)), closes [#94](https://github.com/nanobpm/nano-workforce/issues/94)
14
+
1
15
  # [0.31.0](https://github.com/nanobpm/nano-workforce/compare/v0.30.0...v0.31.0) (2026-08-09)
2
16
 
3
17
 
@@ -0,0 +1,73 @@
1
+ // Guard for the `instanceTracking` manifest bindings (nano.app.json). The reconciler flips a row
2
+ // whose engine instance is TERMINATED only when the row is in one of `activeStatuses`. A status
3
+ // that is genuinely in-flight but missing from that list would leave an operator-terminated (or
4
+ // crashed) run stuck "active" in the UI — the exact drift Copilot flagged on #96. This ties the
5
+ // manifest to the code's single source of truth for "done" (TERMINAL_STATUSES / PLAN_TERMINAL_
6
+ // STATUSES) so the two can't diverge silently.
7
+ import { assert, assertEquals } from "jsr:@std/assert@1";
8
+ import { TERMINAL_STATUSES } from "./service.ts";
9
+ import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
10
+
11
+ interface Binding {
12
+ table: string;
13
+ statusField?: string;
14
+ activeStatuses?: string[];
15
+ onTerminated: { set: Record<string, unknown> };
16
+ }
17
+
18
+ async function bindings(): Promise<Binding[]> {
19
+ const manifest = JSON.parse(await Deno.readTextFile(new URL("../nano.app.json", import.meta.url)));
20
+ return manifest.instanceTracking as Binding[];
21
+ }
22
+
23
+ function bindingFor(all: Binding[], table: string): Binding {
24
+ const b = all.find((x) => x.table === table);
25
+ assert(b, `no instanceTracking binding for ${table}`);
26
+ return b;
27
+ }
28
+
29
+ Deno.test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
30
+ const b = bindingFor(await bindings(), "pull_requests");
31
+ for (const terminal of TERMINAL_STATUSES) {
32
+ assert(
33
+ !b.activeStatuses?.includes(terminal),
34
+ `terminal status "${terminal}" must not be listed active (it would let the reconciler clobber a settled row)`,
35
+ );
36
+ }
37
+ });
38
+
39
+ // Every in-flight status the merge train keys off must be reconcilable. These are the states a
40
+ // pull_requests row can hold while a live engine instance still backs it (see app/service.ts merge
41
+ // poller: converging/waiting_review/escalated + the merge-stage waiting_deps/waiting_merge/
42
+ // waiting_lane/queued/merging). If a new one is added to the flow, add it here AND to the manifest.
43
+ Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
44
+ const inFlight = [
45
+ "converging",
46
+ "waiting_review",
47
+ "escalated",
48
+ "waiting_deps",
49
+ "waiting_merge",
50
+ "waiting_lane",
51
+ "queued",
52
+ "merging",
53
+ ];
54
+ const b = bindingFor(await bindings(), "pull_requests");
55
+ for (const s of inFlight) {
56
+ assert(b.activeStatuses?.includes(s), `in-flight status "${s}" missing from activeStatuses`);
57
+ }
58
+ // No terminal status leaks into the in-flight universe we assert on.
59
+ for (const s of inFlight) assert(!TERMINAL_STATUSES.includes(s));
60
+ });
61
+
62
+ Deno.test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
63
+ const b = bindingFor(await bindings(), "plans");
64
+ for (const terminal of PLAN_TERMINAL_STATUSES) {
65
+ assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
66
+ }
67
+ });
68
+
69
+ Deno.test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
70
+ const inFlight = ["planning", "dispatched"];
71
+ const b = bindingFor(await bindings(), "plans");
72
+ assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
73
+ });
@@ -6,13 +6,14 @@
6
6
  // loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { assertEquals } from "jsr:@std/assert@1";
9
- import { submitPr } from "./service.ts";
9
+ import { cancelRun, pollIncidentsImpl, submitPr } from "./service.ts";
10
10
 
11
11
  // deno-lint-ignore no-explicit-any
12
12
  function memTable(rows: any[], key: string) {
13
13
  return {
14
14
  // deno-lint-ignore no-explicit-any
15
15
  get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
16
+ all: () => Promise.resolve([...rows]),
16
17
  // deno-lint-ignore no-explicit-any
17
18
  find: (q: any) =>
18
19
  Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
@@ -99,3 +100,228 @@ Deno.test("re-submit of a cancelled PR clears stale open escalations + the denor
99
100
  assertEquals(pr.process_key, "PI-9");
100
101
  });
101
102
  });
103
+
104
+ // Red/green regression for technical-incident surfacing (issue #94). A convergence/merge instance
105
+ // can hit an engine incident that parks the token; until `pollIncidents` nothing on the PR row
106
+ // reflected it, so the grid kept showing "converging" while the run was dead in the water. This
107
+ // drives the pass's reconciliation core against a stubbed `/v2/incidents/search`:
108
+ // 1. an ACTIVE incident is mirrored onto `incident_key` + `incident_message` (status untouched),
109
+ // 2. once the engine reports no active incident, the columns are cleared idempotently,
110
+ // 3. a PR with no live instance (no process_key / terminal status) is never queried and any
111
+ // stale incident on it is cleared.
112
+ function incidentFetch(byInstance: Record<string, unknown[]>) {
113
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
114
+ const u = typeof url === "string" ? url : url.toString();
115
+ if (!u.endsWith("/incidents/search")) {
116
+ throw new Error(`unexpected fetch: ${u}`);
117
+ }
118
+ const body = JSON.parse(String(init?.body ?? "{}")) as {
119
+ filter?: { processInstanceKey?: string };
120
+ };
121
+ const items = byInstance[body.filter?.processInstanceKey ?? ""] ?? [];
122
+ return Promise.resolve(
123
+ new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
124
+ );
125
+ };
126
+ }
127
+
128
+ Deno.test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it, leaving status untouched", async () => {
129
+ const row = {
130
+ pr_key: "owner/repo#7",
131
+ repo: "owner/repo",
132
+ number: 7,
133
+ status: "converging",
134
+ process_key: "PI-7",
135
+ incident_key: null as string | null,
136
+ incident_message: null as string | null,
137
+ updated_at: "t0",
138
+ };
139
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
140
+ pull_requests: { rows: [row], key: "pr_key" },
141
+ };
142
+ const data = {
143
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
144
+ // deno-lint-ignore no-explicit-any
145
+ } as any;
146
+ const headers = { "content-type": "application/json" };
147
+
148
+ const prevFetch = globalThis.fetch;
149
+
150
+ // Red-ish: with an ACTIVE incident on the instance, the pass must surface it (before this
151
+ // feature the columns stayed null and the incident was invisible).
152
+ globalThis.fetch = incidentFetch({
153
+ "PI-7": [{ incidentKey: "INC-1", errorMessage: "boom: unhandled error", state: "ACTIVE", creationTime: "2024-01-01T00:00:00Z" }],
154
+ }) as typeof fetch;
155
+ try {
156
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
157
+ } finally {
158
+ globalThis.fetch = prevFetch;
159
+ }
160
+ assertEquals(row.incident_key, "INC-1");
161
+ assertEquals(row.incident_message, "boom: unhandled error");
162
+ assertEquals(row.status, "converging"); // orthogonal: status is never touched
163
+
164
+ // Green: once the engine reports no active incident, the columns clear idempotently.
165
+ globalThis.fetch = incidentFetch({ "PI-7": [] }) as typeof fetch;
166
+ try {
167
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
168
+ } finally {
169
+ globalThis.fetch = prevFetch;
170
+ }
171
+ assertEquals(row.incident_key, null);
172
+ assertEquals(row.incident_message, null);
173
+ assertEquals(row.status, "converging");
174
+ });
175
+
176
+ Deno.test("pollIncidents never queries a PR with no live instance and clears any stale incident", async () => {
177
+ const noKey = {
178
+ pr_key: "owner/repo#8",
179
+ status: "converging",
180
+ process_key: null as string | null,
181
+ incident_key: "STALE-A",
182
+ incident_message: "left over",
183
+ updated_at: "t0",
184
+ };
185
+ const terminal = {
186
+ pr_key: "owner/repo#9",
187
+ status: "merged",
188
+ process_key: "PI-9",
189
+ incident_key: "STALE-B",
190
+ incident_message: "left over",
191
+ updated_at: "t0",
192
+ };
193
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
194
+ pull_requests: { rows: [noKey, terminal], key: "pr_key" },
195
+ };
196
+ const data = {
197
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
198
+ // deno-lint-ignore no-explicit-any
199
+ } as any;
200
+ const headers = { "content-type": "application/json" };
201
+
202
+ const prevFetch = globalThis.fetch;
203
+ // Any fetch here is a bug — neither PR has a live instance to inspect.
204
+ globalThis.fetch = (() => {
205
+ throw new Error("pollIncidents must not query a PR with no live instance");
206
+ }) as typeof fetch;
207
+ try {
208
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
209
+ } finally {
210
+ globalThis.fetch = prevFetch;
211
+ }
212
+ assertEquals(noKey.incident_key, null);
213
+ assertEquals(noKey.incident_message, null);
214
+ assertEquals(terminal.incident_key, null);
215
+ assertEquals(terminal.incident_message, null);
216
+ });
217
+
218
+ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a missing timestamp last", async () => {
219
+ const row = {
220
+ pr_key: "owner/repo#11",
221
+ status: "converging",
222
+ process_key: "PI-11",
223
+ incident_key: null as string | null,
224
+ incident_message: null as string | null,
225
+ updated_at: "t0",
226
+ };
227
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
228
+ pull_requests: { rows: [row], key: "pr_key" },
229
+ };
230
+ const data = {
231
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
232
+ // deno-lint-ignore no-explicit-any
233
+ } as any;
234
+ const headers = { "content-type": "application/json" };
235
+
236
+ const prevFetch = globalThis.fetch;
237
+ // A no-`creationTime` incident must not masquerade as the oldest (empty-string sort bug): the
238
+ // real earliest ISO timestamp wins even when a timestamp-less incident is returned first.
239
+ globalThis.fetch = incidentFetch({
240
+ "PI-11": [
241
+ { incidentKey: "INC-NOTS", errorMessage: "no timestamp", state: "ACTIVE" },
242
+ { incidentKey: "INC-OLD", errorMessage: "the first fault", state: "ACTIVE", creationTime: "2024-01-01T00:00:00Z" },
243
+ { incidentKey: "INC-NEW", errorMessage: "a later fault", state: "ACTIVE", creationTime: "2024-06-01T00:00:00Z" },
244
+ ],
245
+ }) as typeof fetch;
246
+ try {
247
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
248
+ } finally {
249
+ globalThis.fetch = prevFetch;
250
+ }
251
+ assertEquals(row.incident_key, "INC-OLD");
252
+ assertEquals(row.incident_message, "the first fault");
253
+ });
254
+
255
+
256
+ // Bug: the Epic cancel button (Nano Workforce UI) POSTs the plan row's `process_key` to
257
+ // /app/actions/cancel → cancelRun. cancelRun only knows the `pull_requests` table, so for a plan
258
+ // instance it terminated the engine instance but returned `not_found` (a 404 the UI surfaces as an
259
+ // error), and never reconciled the `plans` row — so "cancel didn't cancel the epic". The instance
260
+ // IS torn down; the declarative instanceTracking reconciler flips the plans row. cancelRun must
261
+ // therefore report success for a raw instance key it terminated.
262
+ Deno.test("cancelRun terminates a non-PR (Epic/plan) instance and reports success", async () => {
263
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
264
+ pull_requests: { rows: [], key: "pr_key" }, // no PR tracks this key — it's a plan instance
265
+ };
266
+ const data = {
267
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
268
+ // deno-lint-ignore no-explicit-any
269
+ } as any;
270
+ const cancelled: string[] = [];
271
+ const engine = {
272
+ // deno-lint-ignore no-explicit-any
273
+ cancelInstance: (input: any) => {
274
+ cancelled.push(String(input.processInstanceKey));
275
+ return Promise.resolve();
276
+ },
277
+ // deno-lint-ignore no-explicit-any
278
+ } as any;
279
+
280
+ const r = await cancelRun(data, engine, { processInstanceKey: "PI-EPIC-1" });
281
+
282
+ assertEquals(cancelled, ["PI-EPIC-1"]); // the engine instance was terminated …
283
+ assertEquals(r.ok, true); // … and cancel is reported successful (no misleading 404).
284
+ });
285
+
286
+ // The tracked-PR path must still flip the row abandoned SYNCHRONOUSLY: app/abandon.ts derives the
287
+ // agent-abort signal straight off pull_requests.status, so a deferred (reconciler-only) write would
288
+ // widen the check-then-push window a side-effecting agent races against.
289
+ Deno.test("cancelRun flips a tracked PR to abandoned immediately and clears the escalation pointer", async () => {
290
+ const PR_KEY = "owner/repo#7";
291
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
292
+ pull_requests: {
293
+ rows: [{
294
+ pr_key: PR_KEY,
295
+ repo: "owner/repo",
296
+ number: 7,
297
+ status: "escalated",
298
+ process_key: "PI-PR-7",
299
+ open_escalation_id: 3,
300
+ open_escalation_question: "why?",
301
+ }],
302
+ key: "pr_key",
303
+ },
304
+ };
305
+ const data = {
306
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
307
+ // deno-lint-ignore no-explicit-any
308
+ } as any;
309
+ const cancelled: string[] = [];
310
+ const engine = {
311
+ // deno-lint-ignore no-explicit-any
312
+ cancelInstance: (input: any) => {
313
+ cancelled.push(String(input.processInstanceKey));
314
+ return Promise.resolve();
315
+ },
316
+ // deno-lint-ignore no-explicit-any
317
+ } as any;
318
+
319
+ const r = await cancelRun(data, engine, { prKey: PR_KEY });
320
+
321
+ assertEquals(r.ok, true);
322
+ assertEquals(cancelled, ["PI-PR-7"]);
323
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
324
+ assertEquals(pr.status, "abandoned");
325
+ assertEquals(pr.open_escalation_id, null);
326
+ assertEquals(pr.open_escalation_question, null);
327
+ });
package/app/service.ts CHANGED
@@ -130,6 +130,13 @@ interface PullRequest {
130
130
  // running agent curls (GET /hooks/abandon?token=…) to learn whether this run was cancelled before
131
131
  // it performs a side effect. Minted at submit, reused across the convergence + merge instances.
132
132
  abandon_token: string | null;
133
+ // Technical-incident surfacing (017_pr_incident.sql, issue #94), written by the poller's
134
+ // `pollIncidents` pass. `incident_key` is the engine incidentKey of the ACTIVE incident parking
135
+ // this PR's instance and `incident_message` its errorMessage; both NULL when the instance has no
136
+ // active incident. Orthogonal to `status` — an incident is a cross-cutting liveness fault, not a
137
+ // workflow stage.
138
+ incident_key: string | null;
139
+ incident_message: string | null;
133
140
  }
134
141
 
135
142
  interface PrDependency {
@@ -381,11 +388,21 @@ export interface CancelSelector {
381
388
  prKey?: string;
382
389
  }
383
390
 
384
- /** Cancel a PR's running convergence instance and mark it abandoned. Terminating the engine
385
- * instance emits no completion event (no worker runs), so the app-tier flips the PR's status
386
- * here the same place ADR 0040 puts app-owned rest state. Accepts either selector; a PR
387
- * already in a terminal state is left untouched so a stale cancel can't overwrite a `converged`
388
- * outcome with `abandoned`. */
391
+ /** Cancel a run and mark its read-model row abandoned. Two paths converge here:
392
+ *
393
+ * - **Tracked PR** (a `pull_requests` row): the engine instance is terminated (which emits no
394
+ * completion event no worker runs) and this function flips the PR row to `abandoned`
395
+ * *synchronously*. The immediacy matters: the agent-abort capability (`app/abandon.ts`) derives
396
+ * `abandoned` straight off `pull_requests.status`, so a deferred write would widen the
397
+ * check-then-push window a side-effecting agent races against.
398
+ * - **Any other instance** (e.g. the cancel button on an Epic/plan row, which POSTs the row's
399
+ * `process_key`): the instance is terminated here, and the declarative `instanceTracking`
400
+ * reconciler (`nano.app.json`) flips the owning row (`plans`) to abandoned on its next poll.
401
+ * That same reconciler is also the safety net for terminations that never reach this function
402
+ * at all — an operator terminating the instance directly, or a crash.
403
+ *
404
+ * Accepts either selector; a PR already in a terminal state is left untouched so a stale cancel
405
+ * can't overwrite a `converged` outcome with `abandoned`. */
389
406
  export async function cancelRun(data: DataLayer, engine: EngineClient, selector: CancelSelector) {
390
407
  const { processInstanceKey, prKey } = selector;
391
408
  const table = prs(data);
@@ -416,6 +433,14 @@ export async function cancelRun(data: DataLayer, engine: EngineClient, selector:
416
433
  });
417
434
  return { ok: true, prKey: pr.pr_key };
418
435
  }
436
+ // No tracked PR for this key. If we were handed a raw instance key (e.g. the cancel button
437
+ // on an Epic/plan row, which POSTs the row's `process_key`), the instance has still been
438
+ // terminated above — the declarative `instanceTracking` reconciler (nano.app.json) flips the
439
+ // owning row (`plans`) to abandoned on its next poll. Report success so the UI does not
440
+ // surface a misleading 404 for a cancel that actually took effect.
441
+ if (instanceKey) {
442
+ return { ok: true, processInstanceKey: instanceKey };
443
+ }
419
444
  return { ok: false, kind: "not_found", reason: "no PR for that selector" };
420
445
  }
421
446
 
@@ -836,6 +861,111 @@ async function pollJobActivation(
836
861
  }
837
862
  }
838
863
 
864
+ /** The subset of a Camunda-8 `/v2/incidents/search` result item this app reads. `incidentKey` is
865
+ * the unique incident id; `errorMessage` is the human-readable fault; `state` is the incident
866
+ * lifecycle (`ACTIVE` while it parks the token, `RESOLVED` once cleared); `creationTime` orders
867
+ * concurrent incidents. */
868
+ interface IncidentSearchItem {
869
+ incidentKey?: string;
870
+ errorMessage?: string | null;
871
+ state?: string;
872
+ creationTime?: string | null;
873
+ }
874
+
875
+ /** Incident-surfacing poll pass (issue #94). A convergence or merge process instance can hit a
876
+ * *technical* incident — an unhandled engine error that parks the token — and nothing on the PR
877
+ * row reflected it: the grid kept showing the last workflow status (`converging`, `merging`, …)
878
+ * while the run was actually dead in the water (a PR sat "converging" all day on an incident).
879
+ *
880
+ * This pass reads the engine's Camunda-8 `/v2/incidents/search` for each PR that still has a live
881
+ * instance (has a `process_key`, non-terminal status) and mirrors an ACTIVE incident onto two
882
+ * orthogonal columns — `incident_key` + `incident_message` — leaving `status` untouched. An
883
+ * incident is a cross-cutting liveness fault, not a workflow stage, so it must not overload the
884
+ * status machine. Clearing is idempotent: when the instance has no active incident (resolved, or
885
+ * never had one) the columns are nulled, so an incident raised or resolved out-of-band converges
886
+ * to the truth on the next pass. Best-effort transport: a failed query leaves the last-known
887
+ * values untouched and the next pass retries. Updates (and bumps `updated_at`) only on an actual
888
+ * change so a steady state doesn't churn the grid. */
889
+ async function pollIncidents(
890
+ data: DataLayer,
891
+ restAddress: string,
892
+ engineToken: string | undefined,
893
+ ) {
894
+ const base = restAddress.replace(/\/+$/, "");
895
+ const headers: Record<string, string> = { "content-type": "application/json" };
896
+ if (engineToken) headers.authorization = `Bearer ${engineToken}`;
897
+ await pollIncidentsImpl(data, base, headers);
898
+ }
899
+
900
+ /** Testable core of {@link pollIncidents}: given the normalised `base` URL and prepared auth
901
+ * `headers`, reconcile every PR row against the engine's active incidents. Split out so tests can
902
+ * exercise the reconciliation with a stubbed `fetch` without re-deriving transport wiring. */
903
+ export async function pollIncidentsImpl(
904
+ data: DataLayer,
905
+ base: string,
906
+ headers: Record<string, string>,
907
+ ) {
908
+ const all = await prs(data).all();
909
+ for (const pr of all) {
910
+ // No live instance to inspect (never created, mid-transition, or terminal — the run has
911
+ // finished or was given up, so its instance is gone) → make sure no stale incident lingers on
912
+ // the row, then move on. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift
913
+ // from the rest of the status machine.
914
+ if (!pr.process_key || TERMINAL_STATUSES.includes(pr.status)) {
915
+ if (pr.incident_key || pr.incident_message) {
916
+ await prs(data).update(pr.pr_key, {
917
+ incident_key: null,
918
+ incident_message: null,
919
+ updated_at: now(),
920
+ });
921
+ }
922
+ continue;
923
+ }
924
+
925
+ let incidentKey: string | null = null;
926
+ let incidentMessage: string | null = null;
927
+ try {
928
+ const res = await fetch(`${base}/incidents/search`, {
929
+ method: "POST",
930
+ headers,
931
+ body: JSON.stringify({
932
+ filter: { processInstanceKey: pr.process_key, state: "ACTIVE" },
933
+ page: { limit: 20 },
934
+ }),
935
+ });
936
+ if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
937
+ const body = (await res.json()) as { items?: IncidentSearchItem[] };
938
+ // Surface the oldest ACTIVE incident (the first thing that broke — a stable choice if the
939
+ // instance somehow parks more than one). Re-filter on state defensively in case the wire
940
+ // filter is ignored. An incident with no `creationTime` sorts *last*, so a missing timestamp
941
+ // can never masquerade as the oldest.
942
+ const active = (body.items ?? [])
943
+ .filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
944
+ .sort((a, b) =>
945
+ (a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff")
946
+ )[0];
947
+ if (active) {
948
+ incidentKey = active.incidentKey ?? null;
949
+ incidentMessage = active.errorMessage ?? null;
950
+ }
951
+ } catch (err) {
952
+ console.error(`[poller] incidents ${pr.pr_key}: ${err}`);
953
+ continue;
954
+ }
955
+
956
+ if (
957
+ incidentKey !== (pr.incident_key ?? null) ||
958
+ incidentMessage !== (pr.incident_message ?? null)
959
+ ) {
960
+ await prs(data).update(pr.pr_key, {
961
+ incident_key: incidentKey,
962
+ incident_message: incidentMessage,
963
+ updated_at: now(),
964
+ });
965
+ }
966
+ }
967
+ }
968
+
839
969
  /** Wave-merge barrier poll pass. After `record-wave` hands off a wave that has a successor, the
840
970
  * plan-fanout instance parks at the `wait-wave-merged` catch event and `plans.gate_wave` records
841
971
  * that wave's index. Here we check whether every OPENED PR in that wave has MERGED and, if so,
@@ -879,9 +1009,9 @@ async function pollWaveGates(data: DataLayer, engine: EngineClient, token: strin
879
1009
  }
880
1010
  }
881
1011
 
882
- /** One full poll pass: advance the review stage, the merge stage, and (when the engine REST
883
- * endpoint is supplied) the job-activation visibility pass. Called on the self-scheduling loop
884
- * in `main.ts`. */
1012
+ /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
1013
+ * (when the engine REST endpoint is supplied) the job-activation visibility pass and the
1014
+ * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
885
1015
  export async function pollOnce(
886
1016
  data: DataLayer,
887
1017
  engine: EngineClient,
@@ -891,5 +1021,8 @@ export async function pollOnce(
891
1021
  await pollReviews(data, engine, token);
892
1022
  await pollMerges(data, engine, token);
893
1023
  await pollWaveGates(data, engine, token);
894
- if (engineRest) await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1024
+ if (engineRest) {
1025
+ await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1026
+ await pollIncidents(data, engineRest.restAddress, engineRest.token);
1027
+ }
895
1028
  }
@@ -0,0 +1,14 @@
1
+ -- Technical-incident surfacing (issue #94). A convergence or merge process instance can hit a
2
+ -- *technical* incident — an unhandled engine error (an expression failure, a job that exhausted
3
+ -- its retries, …) that parks the token — and until now nothing on the PR row reflected it: the
4
+ -- grid kept showing the last workflow status (`converging`, `merging`, …) while the run was
5
+ -- actually stuck. A PR sat "converging" all day while its instance was dead on an incident.
6
+ --
7
+ -- These two orthogonal columns mirror an ACTIVE engine incident onto the PR row, written by the
8
+ -- poller's `pollIncidents` pass from a `/v2/incidents/search` filtered by the PR's `process_key`.
9
+ -- They are deliberately independent of `status`: an incident is a *cross-cutting* liveness fault,
10
+ -- not a workflow stage, so surfacing it must not overload the status machine. NULL means the
11
+ -- instance has no active incident (never had one, or it was resolved) — the poller clears the
12
+ -- columns idempotently, so an incident raised or resolved out-of-band converges on the next pass.
13
+ ALTER TABLE pull_requests ADD COLUMN incident_key TEXT; -- engine incidentKey of the active incident parking this PR's instance; NULL when none
14
+ ALTER TABLE pull_requests ADD COLUMN incident_message TEXT; -- the incident's errorMessage, surfaced on the grid; NULL when none
package/deno.json CHANGED
@@ -6,7 +6,7 @@
6
6
  ]
7
7
  },
8
8
  "imports": {
9
- "@nanobpm/urban": "npm:@nanobpm/urban@^0.28.0"
9
+ "@nanobpm/urban": "npm:@nanobpm/urban@^0.29.0"
10
10
  },
11
11
  "tasks": {
12
12
  "start": "deno run --allow-net --allow-read --allow-write --allow-run=gh --allow-env main.ts",
package/deno.lock CHANGED
@@ -3,7 +3,7 @@
3
3
  "specifiers": {
4
4
  "jsr:@std/assert@1": "1.0.19",
5
5
  "jsr:@std/internal@^1.0.12": "1.0.14",
6
- "npm:@nanobpm/urban@0.28": "0.28.0",
6
+ "npm:@nanobpm/urban@0.29": "0.29.0",
7
7
  "npm:@semantic-release/changelog@^6.0.3": "6.0.3_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
8
8
  "npm:@semantic-release/git@^10.0.1": "10.0.1_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
9
9
  "npm:@semantic-release/npm@^13.1.5": "13.1.5_semantic-release@24.2.9__typescript@5.9.3",
@@ -67,8 +67,8 @@
67
67
  "@colors/colors@1.5.0": {
68
68
  "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="
69
69
  },
70
- "@nanobpm/nano-app-schema@0.3.0": {
71
- "integrity": "sha512-bz7wqYsfetcSMYIMW2e1rmbE2k844TCtXWwBd+uVV/s3iRsi09Ff0WMaBEZWuvBPopwHhD2JATCNCXdEBULjfA==",
70
+ "@nanobpm/nano-app-schema@0.4.0": {
71
+ "integrity": "sha512-/1rXzXVDVglqjNoqrxJ0FjBUKqej3q8R+/3xXXU3cdwRO9QKwDkd3/2K+I0gS1bAiqNI8SWZiH9+pkKiT7I8wQ==",
72
72
  "dependencies": [
73
73
  "bpmn-moddle@9.0.4",
74
74
  "dmn-moddle",
@@ -82,8 +82,8 @@
82
82
  "ws"
83
83
  ]
84
84
  },
85
- "@nanobpm/urban@0.28.0": {
86
- "integrity": "sha512-rhzgOV+Vb1CPuNoVHVX3OtxjHqzSp2gJIV2pi3EBi245ZjDRN0gSoPGd/MEnJdufxgWwDKuvhyljRDHE8T957w==",
85
+ "@nanobpm/urban@0.29.0": {
86
+ "integrity": "sha512-79CMeYpfnPmKSF2x7zRJ6ZcmAimOknCxySFXvg2z0JtS/pTGlUvkzZy87xp5jHB88LZdKsUIuvVnN5hnZKdS+g==",
87
87
  "dependencies": [
88
88
  "@nanobpm/nano-app-schema",
89
89
  "@nanobpm/nano-sdk",
@@ -1757,11 +1757,11 @@
1757
1757
  },
1758
1758
  "workspace": {
1759
1759
  "dependencies": [
1760
- "npm:@nanobpm/urban@0.28"
1760
+ "npm:@nanobpm/urban@0.29"
1761
1761
  ],
1762
1762
  "packageJson": {
1763
1763
  "dependencies": [
1764
- "npm:@nanobpm/urban@0.28",
1764
+ "npm:@nanobpm/urban@0.29",
1765
1765
  "npm:@semantic-release/changelog@^6.0.3",
1766
1766
  "npm:@semantic-release/git@^10.0.1",
1767
1767
  "npm:@semantic-release/npm@^13.1.5",
package/nano.app.json CHANGED
@@ -22,6 +22,43 @@
22
22
  }
23
23
  }
24
24
  },
25
+ "instanceTracking": [
26
+ {
27
+ "table": "pull_requests",
28
+ "keyField": "process_key",
29
+ "statusField": "status",
30
+ "activeStatuses": [
31
+ "converging",
32
+ "waiting_review",
33
+ "escalated",
34
+ "waiting_deps",
35
+ "waiting_merge",
36
+ "waiting_lane",
37
+ "queued",
38
+ "merging"
39
+ ],
40
+ "onTerminated": {
41
+ "set": {
42
+ "status": "abandoned",
43
+ "open_escalation_id": null,
44
+ "open_escalation_question": null
45
+ }
46
+ },
47
+ "pollMs": 5000
48
+ },
49
+ {
50
+ "table": "plans",
51
+ "keyField": "process_key",
52
+ "statusField": "status",
53
+ "activeStatuses": ["planning", "dispatched"],
54
+ "onTerminated": {
55
+ "set": {
56
+ "status": "abandoned"
57
+ }
58
+ },
59
+ "pollMs": 5000
60
+ }
61
+ ],
25
62
  "workers": [
26
63
  {
27
64
  "taskType": "pr.persist-round",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
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",
@@ -40,7 +40,7 @@
40
40
  "test": "deno test -A"
41
41
  },
42
42
  "dependencies": {
43
- "@nanobpm/urban": "^0.28.0"
43
+ "@nanobpm/urban": "^0.29.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@semantic-release/changelog": "^6.0.3",
@@ -84,6 +84,7 @@
84
84
  "columns": [
85
85
  { "field": "pr_key", "header": "PR", "linkField": "url" },
86
86
  { "field": "status", "header": "Status" },
87
+ { "field": "incident_message", "header": "Incident" },
87
88
  { "field": "current_round", "header": "Round" },
88
89
  { "field": "active_worker", "header": "Agent" },
89
90
  { "field": "updated_at", "header": "Updated" }
@@ -103,6 +104,8 @@
103
104
  { "field": "number", "label": "PR number" },
104
105
  { "field": "active_worker", "label": "Agent (leasing worker)" },
105
106
  { "field": "lease_until", "label": "Activation lease until" },
107
+ { "field": "incident_message", "label": "Incident" },
108
+ { "field": "incident_key", "label": "Incident key" },
106
109
  { "field": "merged_at", "label": "Merged at" },
107
110
  { "field": "outcome", "label": "Outcome" }
108
111
  ],