@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 +14 -0
- package/app/instance-tracking.test.ts +73 -0
- package/app/service.test.ts +227 -1
- package/app/service.ts +142 -9
- package/db/migrations/017_pr_incident.sql +14 -0
- package/deno.json +1 -1
- package/deno.lock +7 -7
- package/nano.app.json +37 -0
- package/package.json +2 -2
- package/pages/home.page.json +3 -0
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
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -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
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
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,
|
|
883
|
-
* endpoint is supplied) the job-activation visibility pass
|
|
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)
|
|
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
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.
|
|
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.
|
|
71
|
-
"integrity": "sha512
|
|
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.
|
|
86
|
-
"integrity": "sha512-
|
|
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.
|
|
1760
|
+
"npm:@nanobpm/urban@0.29"
|
|
1761
1761
|
],
|
|
1762
1762
|
"packageJson": {
|
|
1763
1763
|
"dependencies": [
|
|
1764
|
-
"npm:@nanobpm/urban@0.
|
|
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.
|
|
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.
|
|
43
|
+
"@nanobpm/urban": "^0.29.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@semantic-release/changelog": "^6.0.3",
|
package/pages/home.page.json
CHANGED
|
@@ -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
|
],
|