@nanobpm/nano-workforce 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.32.0](https://github.com/nanobpm/nano-workforce/compare/v0.31.0...v0.32.0) (2026-08-09)
2
+
3
+
4
+ ### Features
5
+
6
+ * **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)
7
+
1
8
  # [0.31.0](https://github.com/nanobpm/nano-workforce/compare/v0.30.0...v0.31.0) (2026-08-09)
2
9
 
3
10
 
@@ -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 { 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,154 @@ 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
+ });
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 {
@@ -836,6 +843,111 @@ async function pollJobActivation(
836
843
  }
837
844
  }
838
845
 
846
+ /** The subset of a Camunda-8 `/v2/incidents/search` result item this app reads. `incidentKey` is
847
+ * the unique incident id; `errorMessage` is the human-readable fault; `state` is the incident
848
+ * lifecycle (`ACTIVE` while it parks the token, `RESOLVED` once cleared); `creationTime` orders
849
+ * concurrent incidents. */
850
+ interface IncidentSearchItem {
851
+ incidentKey?: string;
852
+ errorMessage?: string | null;
853
+ state?: string;
854
+ creationTime?: string | null;
855
+ }
856
+
857
+ /** Incident-surfacing poll pass (issue #94). A convergence or merge process instance can hit a
858
+ * *technical* incident — an unhandled engine error that parks the token — and nothing on the PR
859
+ * row reflected it: the grid kept showing the last workflow status (`converging`, `merging`, …)
860
+ * while the run was actually dead in the water (a PR sat "converging" all day on an incident).
861
+ *
862
+ * This pass reads the engine's Camunda-8 `/v2/incidents/search` for each PR that still has a live
863
+ * instance (has a `process_key`, non-terminal status) and mirrors an ACTIVE incident onto two
864
+ * orthogonal columns — `incident_key` + `incident_message` — leaving `status` untouched. An
865
+ * incident is a cross-cutting liveness fault, not a workflow stage, so it must not overload the
866
+ * status machine. Clearing is idempotent: when the instance has no active incident (resolved, or
867
+ * never had one) the columns are nulled, so an incident raised or resolved out-of-band converges
868
+ * to the truth on the next pass. Best-effort transport: a failed query leaves the last-known
869
+ * values untouched and the next pass retries. Updates (and bumps `updated_at`) only on an actual
870
+ * change so a steady state doesn't churn the grid. */
871
+ async function pollIncidents(
872
+ data: DataLayer,
873
+ restAddress: string,
874
+ engineToken: string | undefined,
875
+ ) {
876
+ const base = restAddress.replace(/\/+$/, "");
877
+ const headers: Record<string, string> = { "content-type": "application/json" };
878
+ if (engineToken) headers.authorization = `Bearer ${engineToken}`;
879
+ await pollIncidentsImpl(data, base, headers);
880
+ }
881
+
882
+ /** Testable core of {@link pollIncidents}: given the normalised `base` URL and prepared auth
883
+ * `headers`, reconcile every PR row against the engine's active incidents. Split out so tests can
884
+ * exercise the reconciliation with a stubbed `fetch` without re-deriving transport wiring. */
885
+ export async function pollIncidentsImpl(
886
+ data: DataLayer,
887
+ base: string,
888
+ headers: Record<string, string>,
889
+ ) {
890
+ const all = await prs(data).all();
891
+ for (const pr of all) {
892
+ // No live instance to inspect (never created, mid-transition, or terminal — the run has
893
+ // finished or was given up, so its instance is gone) → make sure no stale incident lingers on
894
+ // the row, then move on. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift
895
+ // from the rest of the status machine.
896
+ if (!pr.process_key || TERMINAL_STATUSES.includes(pr.status)) {
897
+ if (pr.incident_key || pr.incident_message) {
898
+ await prs(data).update(pr.pr_key, {
899
+ incident_key: null,
900
+ incident_message: null,
901
+ updated_at: now(),
902
+ });
903
+ }
904
+ continue;
905
+ }
906
+
907
+ let incidentKey: string | null = null;
908
+ let incidentMessage: string | null = null;
909
+ try {
910
+ const res = await fetch(`${base}/incidents/search`, {
911
+ method: "POST",
912
+ headers,
913
+ body: JSON.stringify({
914
+ filter: { processInstanceKey: pr.process_key, state: "ACTIVE" },
915
+ page: { limit: 20 },
916
+ }),
917
+ });
918
+ if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
919
+ const body = (await res.json()) as { items?: IncidentSearchItem[] };
920
+ // Surface the oldest ACTIVE incident (the first thing that broke — a stable choice if the
921
+ // instance somehow parks more than one). Re-filter on state defensively in case the wire
922
+ // filter is ignored. An incident with no `creationTime` sorts *last*, so a missing timestamp
923
+ // can never masquerade as the oldest.
924
+ const active = (body.items ?? [])
925
+ .filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
926
+ .sort((a, b) =>
927
+ (a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff")
928
+ )[0];
929
+ if (active) {
930
+ incidentKey = active.incidentKey ?? null;
931
+ incidentMessage = active.errorMessage ?? null;
932
+ }
933
+ } catch (err) {
934
+ console.error(`[poller] incidents ${pr.pr_key}: ${err}`);
935
+ continue;
936
+ }
937
+
938
+ if (
939
+ incidentKey !== (pr.incident_key ?? null) ||
940
+ incidentMessage !== (pr.incident_message ?? null)
941
+ ) {
942
+ await prs(data).update(pr.pr_key, {
943
+ incident_key: incidentKey,
944
+ incident_message: incidentMessage,
945
+ updated_at: now(),
946
+ });
947
+ }
948
+ }
949
+ }
950
+
839
951
  /** Wave-merge barrier poll pass. After `record-wave` hands off a wave that has a successor, the
840
952
  * plan-fanout instance parks at the `wait-wave-merged` catch event and `plans.gate_wave` records
841
953
  * that wave's index. Here we check whether every OPENED PR in that wave has MERGED and, if so,
@@ -879,9 +991,9 @@ async function pollWaveGates(data: DataLayer, engine: EngineClient, token: strin
879
991
  }
880
992
  }
881
993
 
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`. */
994
+ /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
995
+ * (when the engine REST endpoint is supplied) the job-activation visibility pass and the
996
+ * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
885
997
  export async function pollOnce(
886
998
  data: DataLayer,
887
999
  engine: EngineClient,
@@ -891,5 +1003,8 @@ export async function pollOnce(
891
1003
  await pollReviews(data, engine, token);
892
1004
  await pollMerges(data, engine, token);
893
1005
  await pollWaveGates(data, engine, token);
894
- if (engineRest) await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1006
+ if (engineRest) {
1007
+ await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1008
+ await pollIncidents(data, engineRest.restAddress, engineRest.token);
1009
+ }
895
1010
  }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.31.0",
3
+ "version": "0.32.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",
@@ -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
  ],