@nanobpm/nano-workforce 0.206.3 → 0.207.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/app/service.ts CHANGED
@@ -388,13 +388,16 @@ export interface PullRequest {
388
388
  // running agent curls (GET /app/api/hooks/abandon?token=…) to learn whether this run was cancelled before
389
389
  // it performs a side effect. Minted at submit, reused across the convergence + merge instances.
390
390
  abandon_token: string | null;
391
- // Technical-incident surfacing (017_pr_incident.sql, issue #94), written by the poller's
392
- // `pollIncidents` pass. `incident_key` is the engine incidentKey of the ACTIVE incident parking
393
- // this PR's instance and `incident_message` its errorMessage; both NULL when the instance has no
394
- // active incident. Orthogonal to `status` — an incident is a cross-cutting liveness fault, not a
395
- // workflow stage.
391
+ // Technical-incident surfacing (017_pr_incident.sql, issue #94; enriched 126, issue #931), written
392
+ // by the poller's `pollIncidents` pass. `incident_key` is the engine incidentKey of the ACTIVE
393
+ // incident parking this PR's instance, `incident_type` its errorType, `incident_message` its
394
+ // errorMessage and `incident_since` its creationTime; all NULL when the instance has no active
395
+ // incident. Orthogonal to `status` — an incident is a cross-cutting liveness fault, not a
396
+ // workflow stage. `/status` derives the `incident` overlay from these columns (see `activePrs`).
396
397
  incident_key: string | null;
398
+ incident_type: string | null;
397
399
  incident_message: string | null;
400
+ incident_since: string | null;
398
401
  // Lineage projection (037_lineage.sql, issue #245): the stable ORIGIN identity (the issue =
399
402
  // feature_key / plan_key) threaded onto this PR by `submitPr`, and passed as a `createInstance`
400
403
  // variable onto the convergence + merge instances so every descendant carries the root. For a
@@ -1057,6 +1060,40 @@ export interface MergePolicy {
1057
1060
  blockedBy: "env" | "human-approval" | null;
1058
1061
  }
1059
1062
 
1063
+ /** The status `/status` reports for a run whose instance is parked on an ACTIVE engine incident
1064
+ * (issue #931). Derived at READ time from the mirrored `incident_*` columns — the base `status`
1065
+ * workflow transient is never overwritten, so the overlay can never wedge the status machine and
1066
+ * lifts the moment the incident clears. */
1067
+ export const INCIDENT_STATUS = "incident";
1068
+
1069
+ /** The incident block every `/status` row carries (issue #931): the ACTIVE engine incident the
1070
+ * poller mirrored onto the record, else null. `key` is the engine incidentKey (the handle the
1071
+ * `retryIncident` action resolves), `type` the errorType (e.g. `JOB_NO_RETRIES`), `message` the
1072
+ * errorMessage, `since` the incident's creationTime. */
1073
+ export interface StatusIncident {
1074
+ key: string;
1075
+ type: string | null;
1076
+ message: string | null;
1077
+ since: string | null;
1078
+ }
1079
+
1080
+ /** Derive the `/status` incident block from a row's mirrored incident columns (issue #931). Null
1081
+ * when the row carries no active incident. */
1082
+ function statusIncidentFor(row: {
1083
+ incident_key: string | null;
1084
+ incident_type: string | null;
1085
+ incident_message: string | null;
1086
+ incident_since: string | null;
1087
+ }): StatusIncident | null {
1088
+ if (!row.incident_key) return null;
1089
+ return {
1090
+ key: row.incident_key,
1091
+ type: row.incident_type ?? null,
1092
+ message: row.incident_message ?? null,
1093
+ since: row.incident_since ?? null,
1094
+ };
1095
+ }
1096
+
1060
1097
  /** A PR currently in flight, as reported by the status endpoint. */
1061
1098
  export interface ActivePr {
1062
1099
  prKey: string;
@@ -1081,6 +1118,9 @@ export interface ActivePr {
1081
1118
  /** The effective merge policy (issue #916): what the run intends (`requested`), what the
1082
1119
  * deployment allows (`enabled`), and what will actually happen (`effective`/`blockedBy`). */
1083
1120
  merge: MergePolicy;
1121
+ /** The ACTIVE engine incident parking this PR's instance (issue #931), mirrored by the poller's
1122
+ * `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
1123
+ incident: StatusIncident | null;
1084
1124
  }
1085
1125
 
1086
1126
  /** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
@@ -1135,23 +1175,30 @@ export async function activePrs(
1135
1175
  mergeByPr.set(p.pr_key, await mergePolicyFor(p.process_key ?? null, engine));
1136
1176
  }),
1137
1177
  );
1138
- return active.map((p) => ({
1139
- prKey: p.pr_key,
1140
- repo: p.repo,
1141
- number: p.number,
1142
- url: p.url,
1143
- title: p.title ?? null,
1144
- status: p.status,
1145
- round: p.current_round,
1146
- processKey: p.process_key ?? null,
1147
- waitingSince: p.waiting_since ?? null,
1148
- openEscalation: openEscByPr.get(p.pr_key) ?? null,
1149
- updatedAt: p.updated_at,
1150
- activeWorker: p.active_worker ?? null,
1151
- leaseUntil: p.lease_until ?? null,
1152
- autoResolveEscalations: p.auto_resolve_escalations === 1,
1153
- merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
1154
- }));
1178
+ return active.map((p) => {
1179
+ // Issue #931: an ACTIVE engine incident parked on the loop's instance must be VISIBLE — the row
1180
+ // kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
1181
+ // (the base `status` column is never touched) and carry the mirrored incident block.
1182
+ const incident = statusIncidentFor(p);
1183
+ return {
1184
+ prKey: p.pr_key,
1185
+ repo: p.repo,
1186
+ number: p.number,
1187
+ url: p.url,
1188
+ title: p.title ?? null,
1189
+ status: incident ? INCIDENT_STATUS : p.status,
1190
+ round: p.current_round,
1191
+ processKey: p.process_key ?? null,
1192
+ waitingSince: p.waiting_since ?? null,
1193
+ openEscalation: openEscByPr.get(p.pr_key) ?? null,
1194
+ updatedAt: p.updated_at,
1195
+ activeWorker: p.active_worker ?? null,
1196
+ leaseUntil: p.lease_until ?? null,
1197
+ autoResolveEscalations: p.auto_resolve_escalations === 1,
1198
+ merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
1199
+ incident,
1200
+ };
1201
+ });
1155
1202
  }
1156
1203
 
1157
1204
  /** The default merge policy when no instance variables are readable (mid-handoff row, engine
@@ -1225,6 +1272,9 @@ export interface ActiveDelivery {
1225
1272
  * eligible for steward auto-resolution. Derived from the durable `auto_resolve_escalations` flag,
1226
1273
  * never a clobbered process variable. Shown in `/status` per delivery. */
1227
1274
  autoResolveEscalations: boolean;
1275
+ /** The ACTIVE engine incident parking this run's instance (issue #931), mirrored by the poller's
1276
+ * `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
1277
+ incident: StatusIncident | null;
1228
1278
  }
1229
1279
 
1230
1280
  /** Every delivery-graph run not in a terminal state (done/failed/abandoned), newest-updated first.
@@ -1248,16 +1298,22 @@ export async function activeDeliveries(data: DataLayer): Promise<ActiveDelivery[
1248
1298
  if (openEscByRun.has(t.subject_key)) continue;
1249
1299
  openEscByRun.set(t.subject_key, toOpenEscalation(t));
1250
1300
  }
1251
- return active.map((r) => ({
1252
- runKey: r.run_key,
1253
- title: r.title ?? null,
1254
- status: r.status,
1255
- phase: r.phase ?? null,
1256
- processKey: r.process_key ?? null,
1257
- openEscalation: openEscByRun.get(r.run_key) ?? null,
1258
- updatedAt: r.updated_at,
1259
- autoResolveEscalations: r.auto_resolve_escalations === 1,
1260
- }));
1301
+ return active.map((r) => {
1302
+ // Issue #931: same incident overlay as the PR rows — a delivery run parked on an engine
1303
+ // incident must not keep reading `running`.
1304
+ const incident = statusIncidentFor(r);
1305
+ return {
1306
+ runKey: r.run_key,
1307
+ title: r.title ?? null,
1308
+ status: incident ? INCIDENT_STATUS : r.status,
1309
+ phase: r.phase ?? null,
1310
+ processKey: r.process_key ?? null,
1311
+ openEscalation: openEscByRun.get(r.run_key) ?? null,
1312
+ updatedAt: r.updated_at,
1313
+ autoResolveEscalations: r.auto_resolve_escalations === 1,
1314
+ incident,
1315
+ };
1316
+ });
1261
1317
  }
1262
1318
 
1263
1319
  /** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
@@ -2351,30 +2407,88 @@ export async function pollJobActivationImpl(
2351
2407
  }
2352
2408
 
2353
2409
  /** The subset of a Camunda-8 `/v2/incidents/search` result item this app reads. `incidentKey` is
2354
- * the unique incident id; `errorMessage` is the human-readable fault; `state` is the incident
2355
- * lifecycle (`ACTIVE` while it parks the token, `RESOLVED` once cleared); `creationTime` orders
2356
- * concurrent incidents. */
2357
- interface IncidentSearchItem {
2410
+ * the unique incident id; `errorType` the machine fault class (e.g. `JOB_NO_RETRIES`);
2411
+ * `errorMessage` is the human-readable fault; `state` is the incident lifecycle (`ACTIVE` while it
2412
+ * parks the token, `RESOLVED` once cleared); `creationTime` orders concurrent incidents and is
2413
+ * surfaced as the incident's `since`. */
2414
+ export interface IncidentSearchItem {
2358
2415
  incidentKey?: string;
2416
+ errorType?: string | null;
2359
2417
  errorMessage?: string | null;
2360
2418
  state?: string;
2361
2419
  creationTime?: string | null;
2420
+ /** The job the incident sits on (a `JOB_NO_RETRIES` incident parks until that job carries
2421
+ * retries again) — the handle the `retryIncident` action bumps. */
2422
+ jobKey?: string;
2423
+ }
2424
+
2425
+ /** The four mirrored incident columns every incident-watched aggregate carries (PRs since #94/#931,
2426
+ * delivery-graph runs since #931). All-or-nothing: a row with no ACTIVE incident reads NULL on all
2427
+ * four. */
2428
+ interface IncidentMirror {
2429
+ incident_key: string | null;
2430
+ incident_type: string | null;
2431
+ incident_message: string | null;
2432
+ incident_since: string | null;
2362
2433
  }
2363
2434
 
2364
- /** Incident-surfacing poll pass (issue #94). A convergence or merge process instance can hit a
2365
- * *technical* incident — an unhandled engine error that parks the token — and nothing on the PR
2366
- * row reflected it: the grid kept showing the last workflow status (`converging`, `merging`, …)
2367
- * while the run was actually dead in the water (a PR sat "converging" all day on an incident).
2435
+ /** Pick the ACTIVE incident to mirror for one instance: the OLDEST active one (the first thing that
2436
+ * broke — a stable choice if the instance somehow parks more than one). Re-filters on state
2437
+ * defensively in case the wire filter is ignored. An incident with no `creationTime` sorts *last*,
2438
+ * so a missing timestamp can never masquerade as the oldest. Returns null when none is active. */
2439
+ export function oldestActiveIncident(items: IncidentSearchItem[] | undefined): IncidentSearchItem | null {
2440
+ return (
2441
+ (items ?? [])
2442
+ .filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
2443
+ .sort((a, b) => (a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff"))[0] ?? null
2444
+ );
2445
+ }
2446
+
2447
+ /** Query the engine for the OLDEST ACTIVE incident on one process instance. Returns the incident,
2448
+ * `null` when the instance has no active incident, or `undefined` when the transport failed (the
2449
+ * caller keeps the last-known mirror and retries next pass — a failed query must never clear a real
2450
+ * incident nor invent one). */
2451
+ export async function fetchActiveIncident(
2452
+ base: string,
2453
+ headers: Record<string, string>,
2454
+ processInstanceKey: string,
2455
+ ): Promise<IncidentSearchItem | null | undefined> {
2456
+ try {
2457
+ const res = await fetch(`${base}/incidents/search`, {
2458
+ method: "POST",
2459
+ headers,
2460
+ body: JSON.stringify({
2461
+ filter: { processInstanceKey, state: "ACTIVE" },
2462
+ page: { limit: 20 },
2463
+ }),
2464
+ });
2465
+ if (!res.ok) return undefined; // engine unhappy → unknown, retry next pass
2466
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
2467
+ const body = (await res.json()) as { items?: IncidentSearchItem[] };
2468
+ return oldestActiveIncident(body.items);
2469
+ } catch {
2470
+ return undefined;
2471
+ }
2472
+ }
2473
+
2474
+ /** Incident-surfacing poll pass (issue #94; enriched and extended to delivery-graph runs in issue
2475
+ * #931). A convergence, merge, or delivery-graph process instance can hit a *technical* incident —
2476
+ * an unhandled engine error that parks the token — and nothing on the record row reflected it: the
2477
+ * grid kept showing the last workflow status (`converging`, `merging`, `running`, …) while the run
2478
+ * was actually dead in the water (a PR sat "converging" all day on an incident; on merlin two loops
2479
+ * sat dead for 1.3h/5h before anyone found them via a hand-run engine query).
2368
2480
  *
2369
- * This pass reads the engine's Camunda-8 `/v2/incidents/search` for each PR that still has a live
2370
- * instance (has a `process_key`, non-terminal status) and mirrors an ACTIVE incident onto two
2371
- * orthogonal columns — `incident_key` + `incident_message` — leaving `status` untouched. An
2372
- * incident is a cross-cutting liveness fault, not a workflow stage, so it must not overload the
2373
- * status machine. Clearing is idempotent: when the instance has no active incident (resolved, or
2374
- * never had one) the columns are nulled, so an incident raised or resolved out-of-band converges
2375
- * to the truth on the next pass. Best-effort transport: a failed query leaves the last-known
2376
- * values untouched and the next pass retries. Updates (and bumps `updated_at`) only on an actual
2377
- * change so a steady state doesn't churn the grid. */
2481
+ * This pass reads the engine's Camunda-8 `/v2/incidents/search` for each tracked record that still
2482
+ * has a live instance (has a `process_key`, non-terminal derived status) and mirrors an ACTIVE
2483
+ * incident onto four orthogonal columns — `incident_key`, `incident_type`, `incident_message`,
2484
+ * `incident_since` — leaving `status` untouched. An incident is a cross-cutting liveness fault, not
2485
+ * a workflow stage, so it must not overload the status machine (`/status` derives the `incident`
2486
+ * overlay at READ time — see `activePrs`/`activeDeliveries`). Clearing is idempotent: when the
2487
+ * instance has no active incident (resolved, or never had one) the columns are nulled, so an
2488
+ * incident raised or resolved out-of-band converges to the truth on the next pass. Best-effort
2489
+ * transport: a failed query leaves the last-known values untouched and the next pass retries.
2490
+ * Updates (and bumps `updated_at`) only on an actual change so a steady state doesn't churn the
2491
+ * grid. */
2378
2492
  async function pollIncidents(
2379
2493
  data: DataLayer,
2380
2494
  restAddress: string,
@@ -2387,12 +2501,24 @@ async function pollIncidents(
2387
2501
  }
2388
2502
 
2389
2503
  /** Testable core of {@link pollIncidents}: given the normalised `base` URL and prepared auth
2390
- * `headers`, reconcile every PR row against the engine's active incidents. Split out so tests can
2391
- * exercise the reconciliation with a stubbed `fetch` without re-deriving transport wiring. */
2504
+ * `headers`, reconcile every incident-watched record row (PRs and delivery-graph runs) against the
2505
+ * engine's active incidents. Split out so tests can exercise the reconciliation with a stubbed
2506
+ * `fetch` without re-deriving transport wiring. */
2392
2507
  export async function pollIncidentsImpl(
2393
2508
  data: DataLayer,
2394
2509
  base: string,
2395
2510
  headers: Record<string, string>,
2511
+ ) {
2512
+ await reconcilePrIncidents(data, base, headers);
2513
+ await reconcileDeliveryIncidents(data, base, headers);
2514
+ }
2515
+
2516
+ /** The PR half of {@link pollIncidentsImpl}: mirror the oldest ACTIVE incident of each live PR
2517
+ * instance onto the PR row's four incident columns. */
2518
+ async function reconcilePrIncidents(
2519
+ data: DataLayer,
2520
+ base: string,
2521
+ headers: Record<string, string>,
2396
2522
  ) {
2397
2523
  const all = await prsTracking(data).all();
2398
2524
  for (const pr of all) {
@@ -2404,58 +2530,83 @@ export async function pollIncidentsImpl(
2404
2530
  // instance. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift from the rest
2405
2531
  // of the status machine. Writes stay on the base `prs`.
2406
2532
  if (!pr.process_key || TERMINAL_STATUSES.includes(pr.derived_status)) {
2407
- if (pr.incident_key || pr.incident_message) {
2408
- await prs(data).update(pr.pr_key, {
2409
- incident_key: null,
2410
- incident_message: null,
2411
- updated_at: now(),
2412
- });
2413
- }
2533
+ await clearIncidentMirror(pr, (patch) => prs(data).update(pr.pr_key, patch));
2414
2534
  continue;
2415
2535
  }
2416
2536
 
2417
- let incidentKey: string | null = null;
2418
- let incidentMessage: string | null = null;
2537
+ let active: IncidentSearchItem | null | undefined;
2419
2538
  try {
2420
- const res = await fetch(`${base}/incidents/search`, {
2421
- method: "POST",
2422
- headers,
2423
- body: JSON.stringify({
2424
- filter: { processInstanceKey: pr.process_key, state: "ACTIVE" },
2425
- page: { limit: 20 },
2426
- }),
2427
- });
2428
- if (!res.ok) continue; // engine unhappy → keep last-known, retry next pass
2429
- // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
2430
- const body = (await res.json()) as { items?: IncidentSearchItem[] };
2431
- // Surface the oldest ACTIVE incident (the first thing that broke — a stable choice if the
2432
- // instance somehow parks more than one). Re-filter on state defensively in case the wire
2433
- // filter is ignored. An incident with no `creationTime` sorts *last*, so a missing timestamp
2434
- // can never masquerade as the oldest.
2435
- const active = (body.items ?? [])
2436
- .filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
2437
- .sort((a, b) =>
2438
- (a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff")
2439
- )[0];
2440
- if (active) {
2441
- incidentKey = active.incidentKey ?? null;
2442
- incidentMessage = active.errorMessage ?? null;
2443
- }
2539
+ active = await fetchActiveIncident(base, headers, pr.process_key);
2444
2540
  } catch (err) {
2445
2541
  console.error(`[poller] incidents ${pr.pr_key}: ${err}`);
2446
2542
  continue;
2447
2543
  }
2544
+ if (active === undefined) continue; // transport unhappy → keep last-known, retry next pass
2448
2545
 
2449
- if (
2450
- incidentKey !== (pr.incident_key ?? null) ||
2451
- incidentMessage !== (pr.incident_message ?? null)
2452
- ) {
2453
- await prs(data).update(pr.pr_key, {
2454
- incident_key: incidentKey,
2455
- incident_message: incidentMessage,
2456
- updated_at: now(),
2457
- });
2546
+ await writeIncidentMirror(pr, active, (patch) => prs(data).update(pr.pr_key, patch));
2547
+ }
2548
+ }
2549
+
2550
+ /** The delivery-graph half of {@link pollIncidentsImpl} (issue #931): a delivery RUN's instance
2551
+ * parks on an incident exactly as a convergence loop does, and was just as invisible — `/status`
2552
+ * kept reporting `running`. Mirror onto the run row's four incident columns. */
2553
+ async function reconcileDeliveryIncidents(
2554
+ data: DataLayer,
2555
+ base: string,
2556
+ headers: Record<string, string>,
2557
+ ) {
2558
+ const terminal: readonly string[] = DELIVERY_GRAPH_TERMINAL_STATUSES;
2559
+ const all = await deliveryGraphRunsTracking(data).all();
2560
+ for (const run of all) {
2561
+ if (!run.process_key || terminal.includes(run.derived_status)) {
2562
+ await clearIncidentMirror(run, (patch) => deliveryGraphRuns(data).update(run.run_key, patch));
2563
+ continue;
2564
+ }
2565
+
2566
+ let active: IncidentSearchItem | null | undefined;
2567
+ try {
2568
+ active = await fetchActiveIncident(base, headers, run.process_key);
2569
+ } catch (err) {
2570
+ console.error(`[poller] incidents ${run.run_key}: ${err}`);
2571
+ continue;
2458
2572
  }
2573
+ if (active === undefined) continue;
2574
+
2575
+ await writeIncidentMirror(run, active, (patch) => deliveryGraphRuns(data).update(run.run_key, patch));
2576
+ }
2577
+ }
2578
+
2579
+ /** Null all four incident columns when any is set (a terminal / instance-less record must not keep
2580
+ * a stale mirror). Shared by both reconcile halves so the clearing shape can never drift. */
2581
+ async function clearIncidentMirror(
2582
+ row: IncidentMirror,
2583
+ update: (patch: IncidentMirror & { updated_at: string }) => Promise<unknown>,
2584
+ ) {
2585
+ if (row.incident_key || row.incident_type || row.incident_message || row.incident_since) {
2586
+ await update({ incident_key: null, incident_type: null, incident_message: null, incident_since: null, updated_at: now() });
2587
+ }
2588
+ }
2589
+
2590
+ /** Write the mirrored incident onto the row — or clear it when `active` is null — updating (and
2591
+ * bumping `updated_at`) only on an actual change so a steady state doesn't churn the grid. */
2592
+ async function writeIncidentMirror(
2593
+ row: IncidentMirror,
2594
+ active: IncidentSearchItem | null,
2595
+ update: (patch: IncidentMirror & { updated_at: string }) => Promise<unknown>,
2596
+ ) {
2597
+ const next: IncidentMirror = {
2598
+ incident_key: active?.incidentKey ?? null,
2599
+ incident_type: active?.errorType ?? null,
2600
+ incident_message: active?.errorMessage ?? null,
2601
+ incident_since: active?.creationTime ?? null,
2602
+ };
2603
+ if (
2604
+ next.incident_key !== (row.incident_key ?? null) ||
2605
+ next.incident_type !== (row.incident_type ?? null) ||
2606
+ next.incident_message !== (row.incident_message ?? null) ||
2607
+ next.incident_since !== (row.incident_since ?? null)
2608
+ ) {
2609
+ await update({ ...next, updated_at: now() });
2459
2610
  }
2460
2611
  }
2461
2612
 
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.206.3",
3
- "gitSha": "f731dc60dc0cb7480b425e88d1d216b95d63a5c1",
4
- "gitTag": "v0.206.3",
5
- "builtAt": "2026-11-09T21:25:41.376Z"
2
+ "version": "0.207.0",
3
+ "gitSha": "e3955c8145a966a29806400872b7a9c362d1d992",
4
+ "gitTag": "v0.207.0",
5
+ "builtAt": "2026-11-09T23:52:15.727Z"
6
6
  }
@@ -0,0 +1,8 @@
1
+ -- Incident enrichment (issue #931). The #94 mirror carried only `incident_key` + `incident_message`
2
+ -- — enough to light a badge, not enough to answer "WHAT broke and SINCE WHEN" from the row, which is
3
+ -- what an operator triaging a parked loop needs (the merlin incidents were `JOB_NO_RETRIES` idle
4
+ -- timeouts; the errorType is the tell). `incident_type` is the engine incident's `errorType`;
5
+ -- `incident_since` its `creationTime` (ISO). Both NULL when the instance has no active incident —
6
+ -- the poller's `pollIncidents` pass clears all four columns together, idempotently.
7
+ ALTER TABLE pull_requests ADD COLUMN incident_type TEXT; -- the active incident's errorType (e.g. JOB_NO_RETRIES); NULL when none
8
+ ALTER TABLE pull_requests ADD COLUMN incident_since TEXT; -- the active incident's creationTime (ISO); NULL when none
@@ -0,0 +1,60 @@
1
+ -- Delivery-graph incident mirroring (issue #931). A delivery-graph RUN's engine instance can park
2
+ -- on a technical incident exactly as a convergence loop can — and was just as invisible: `/status`
3
+ -- kept reporting `running` while nothing moved. These four columns are the delivery-side twin of
4
+ -- the PR incident mirror (017 + 126), written by the SAME `pollIncidents` pass and surfaced on the
5
+ -- `/status` delivery rows. All NULL when the run's instance has no active incident.
6
+ ALTER TABLE delivery_graph_runs ADD COLUMN incident_key TEXT; -- engine incidentKey of the active incident parking this run's instance; NULL when none
7
+ ALTER TABLE delivery_graph_runs ADD COLUMN incident_type TEXT; -- the active incident's errorType; NULL when none
8
+ ALTER TABLE delivery_graph_runs ADD COLUMN incident_message TEXT; -- the active incident's errorMessage; NULL when none
9
+ ALTER TABLE delivery_graph_runs ADD COLUMN incident_since TEXT; -- the active incident's creationTime (ISO); NULL when none
10
+
11
+ -- Mirror the new columns through the `delivery_units` aggregate (ADR 0006) so the
12
+ -- `delivery_graph_runs__units` compat VIEW stays row-for-row with the base table (as 120 did for
13
+ -- `auto_resolve_escalations`): add the columns, then re-create the delivery-graph sync triggers and
14
+ -- the compat VIEW with them.
15
+ ALTER TABLE delivery_units ADD COLUMN incident_key TEXT;
16
+ ALTER TABLE delivery_units ADD COLUMN incident_type TEXT;
17
+ ALTER TABLE delivery_units ADD COLUMN incident_message TEXT;
18
+ ALTER TABLE delivery_units ADD COLUMN incident_since TEXT;
19
+
20
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_ai;
21
+ CREATE TRIGGER delivery_graph_runs__du_ai AFTER INSERT ON delivery_graph_runs
22
+ BEGIN
23
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, auto_resolve_escalations, incident_key, incident_type, incident_message, incident_since, created_at, updated_at)
24
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.auto_resolve_escalations, NEW.incident_key, NEW.incident_type, NEW.incident_message, NEW.incident_since, NEW.created_at, NEW.updated_at);
25
+ END;
26
+
27
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_au;
28
+ CREATE TRIGGER delivery_graph_runs__du_au AFTER UPDATE ON delivery_graph_runs
29
+ BEGIN
30
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, auto_resolve_escalations, incident_key, incident_type, incident_message, incident_since, created_at, updated_at)
31
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.auto_resolve_escalations, NEW.incident_key, NEW.incident_type, NEW.incident_message, NEW.incident_since, NEW.created_at, NEW.updated_at);
32
+ END;
33
+
34
+ DROP VIEW IF EXISTS delivery_graph_runs__units;
35
+ CREATE VIEW delivery_graph_runs__units AS
36
+ SELECT
37
+ du.legacy_key AS run_key,
38
+ du.process_key AS process_key,
39
+ du.process_definition_id AS process_definition_id,
40
+ du.digest AS digest,
41
+ du.status AS status,
42
+ du.side_effecting AS side_effecting,
43
+ du.node_count AS node_count,
44
+ du.human_node_count AS human_node_count,
45
+ du.side_effect_count AS side_effect_count,
46
+ du.title AS title,
47
+ du.phase AS phase,
48
+ du.phase_node_id AS phase_node_id,
49
+ du.human_labels AS human_labels,
50
+ du.created_at AS created_at,
51
+ du.updated_at AS updated_at,
52
+ du.acknowledged_at AS acknowledged_at,
53
+ du.auto_resolve_escalations AS auto_resolve_escalations,
54
+ du.incident_key AS incident_key,
55
+ du.incident_type AS incident_type,
56
+ du.incident_message AS incident_message,
57
+ du.incident_since AS incident_since
58
+ FROM delivery_units du
59
+ WHERE du.kind = 'delivery-graph';
60
+
@@ -0,0 +1,58 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the base column added by
2
+ -- 126_pull_requests_incident_type_since.sql (`incident_type`/`incident_since`). The read-model VIEW must pass
3
+ -- through EVERY base `pull_requests` column (the static pages↔schema contract guard +
4
+ -- app/pullRequestReadModel.test.ts DRIFT GUARD assert it), so adding base columns obliges a fresh
5
+ -- VIEW definition — 094/104/118/122 are immutable and cannot be edited in place.
6
+ --
7
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
8
+ -- app/pullRequestReadModel.ts (`pullRequestReadModel.sqlSelectFor(col, { baseAlias: "pr" })`) — the
9
+ -- same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot diverge.
10
+ -- This file is a mechanical re-emission of 122 with the two new base pass-throughs added; the
11
+ -- derived `list_bucket`/`ack_open` expressions are unchanged. The drift guard now points at THIS
12
+ -- migration (the latest VIEW definition).
13
+ --
14
+ -- Forward-only VIEW definition (DROP then CREATE), sourced off the managed `pull_requests__tracking`
15
+ -- re-export of the base table so a terminated PR classifies on ENGINE TRUTH. SQLite does not validate
16
+ -- a view body at CREATE time. The runner wraps each file in its own transaction, so this file must
17
+ -- NOT contain BEGIN/COMMIT. Numbered after 127.
18
+
19
+ DROP VIEW IF EXISTS pull_requests_read_model;
20
+
21
+ CREATE VIEW pull_requests_read_model AS
22
+ SELECT
23
+ pr.pr_key AS pr_key,
24
+ pr.repo AS repo,
25
+ pr.number AS number,
26
+ pr.url AS url,
27
+ pr.title AS title,
28
+ COALESCE(pr.derived_status, pr.status) AS status,
29
+ pr.current_round AS current_round,
30
+ pr.process_key AS process_key,
31
+ pr.waiting_since AS waiting_since,
32
+ pr.last_review_id AS last_review_id,
33
+ pr.outcome AS outcome,
34
+ pr.created_at AS created_at,
35
+ pr.updated_at AS updated_at,
36
+ pr.converged_at AS converged_at,
37
+ pr.merged_at AS merged_at,
38
+ pr.active_worker AS active_worker,
39
+ pr.lease_until AS lease_until,
40
+ pr.last_nudge_at AS last_nudge_at,
41
+ pr.fresh_head_run_head AS fresh_head_run_head,
42
+ pr.abandon_token AS abandon_token,
43
+ pr.incident_key AS incident_key,
44
+ pr.incident_type AS incident_type,
45
+ pr.incident_since AS incident_since,
46
+ pr.incident_message AS incident_message,
47
+ pr.last_round_head AS last_round_head,
48
+ pr.last_progress_job_key AS last_progress_job_key,
49
+ pr.last_progress_result AS last_progress_result,
50
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
51
+ pr.churn_escalated_round AS churn_escalated_round,
52
+ pr.root_request_key AS root_request_key,
53
+ pr.epic_phase_label AS epic_phase_label,
54
+ pr.acknowledged_at AS acknowledged_at,
55
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
56
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
57
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
58
+ FROM pull_requests__tracking pr;
@@ -0,0 +1,58 @@
1
+ -- Re-create the `delivery_graph_read_model` VIEW so it re-exports the base column added by
2
+ -- 127_delivery_graph_run_incidents.sql (the four `incident_*` mirror columns). The read-model VIEW must pass
3
+ -- through EVERY base `delivery_graph_runs` column (the static pages↔schema contract guard +
4
+ -- app/deliveryGraphReadModel.test.ts DRIFT GUARD assert it), so adding base columns obliges a fresh
5
+ -- VIEW definition — 087/096/121 are immutable and cannot be edited in place.
6
+ --
7
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
8
+ -- app/deliveryGraphReadModel.ts (`deliveryGraphReadModel.sqlSelectFor(col, { baseAlias: "dg" })`) —
9
+ -- the same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot
10
+ -- diverge. This file is a mechanical re-emission of 121 with the four new base pass-throughs added;
11
+ -- the derived `stage`/`stage_state`/`list_bucket`/`ack_open`/`park_label` expressions are unchanged.
12
+ -- The drift guard now points at THIS migration (the latest VIEW definition).
13
+ --
14
+ -- Forward-only VIEW definition (DROP then CREATE), sourced off the managed
15
+ -- `delivery_graph_runs__tracking` re-export of the base table so a terminated run classifies on
16
+ -- ENGINE TRUTH. The runner wraps each file in its own transaction, so this file must NOT contain
17
+ -- BEGIN/COMMIT. Numbered after 128.
18
+
19
+ DROP VIEW IF EXISTS delivery_graph_read_model;
20
+ DROP VIEW IF EXISTS delivery_graph_pr_counts;
21
+
22
+ CREATE VIEW IF NOT EXISTS "delivery_graph_pr_counts" AS
23
+ SELECT
24
+ "__urban_rollup_src"."root_request_key" AS "root_request_key",
25
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(("__urban_rollup_src"."root_request_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("__urban_rollup_src"."derived_status" = 'converged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'merged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight"
26
+ FROM "pull_requests__tracking" "__urban_rollup_src"
27
+ GROUP BY "__urban_rollup_src"."root_request_key";
28
+
29
+ CREATE VIEW delivery_graph_read_model AS
30
+ SELECT
31
+ dg.run_key AS run_key,
32
+ COALESCE(dg.derived_status, dg.status) AS status,
33
+ dg.process_key AS process_key,
34
+ dg.process_definition_id AS process_definition_id,
35
+ dg.digest AS digest,
36
+ dg.side_effecting AS side_effecting,
37
+ dg.node_count AS node_count,
38
+ dg.human_node_count AS human_node_count,
39
+ dg.side_effect_count AS side_effect_count,
40
+ dg.title AS title,
41
+ dg.phase AS phase,
42
+ dg.phase_node_id AS phase_node_id,
43
+ dg.human_labels AS human_labels,
44
+ dg.created_at AS created_at,
45
+ dg.updated_at AS updated_at,
46
+ dg.acknowledged_at AS acknowledged_at,
47
+ dg.auto_resolve_escalations AS auto_resolve_escalations,
48
+ dg.incident_key AS incident_key,
49
+ dg.incident_type AS incident_type,
50
+ dg.incident_message AS incident_message,
51
+ dg.incident_since AS incident_since,
52
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'Done' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("dg"."derived_status" = 'awaiting-approval'), 0) THEN 'Requested' WHEN COALESCE((COALESCE("pc"."prs_in_flight", 0) > 0), 0) THEN 'Converging' ELSE 'Implementing' END AS stage,
53
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'ok' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
54
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
55
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open,
56
+ CASE WHEN dg.phase_node_id IS NOT NULL THEN dg.phase ELSE NULL END AS park_label
57
+ FROM delivery_graph_runs__tracking dg
58
+ LEFT JOIN delivery_graph_pr_counts pc ON dg.run_key = pc.root_request_key;
@@ -359,7 +359,10 @@ instruction to the agent's prompt automatically when the node declares `emits`.
359
359
  omits the fact, the `default` branch is taken.
360
360
 
361
361
  **Result statuses are added automatically. Don't write them into your prompt.** Every agent
362
- node's prompt gets the allowed terminal statuses (`done` / `opened` / `skipped`). A completion
362
+ node's prompt gets the allowed terminal statuses (`done` / `opened` / `skipped`, plus the
363
+ unambiguous affirmative-completion aliases `completed` / `succeeded` / `success` — issue #932:
364
+ a fully-delivered node reporting one of those is a terminal success, never a contract
365
+ violation). A completion
363
366
  moves on only with one of those statuses and every required emit present. Anything else parks
364
367
  the run on a human `__contract` escalation — **except** an absent/null `status`, which a legacy
365
368
  worker that populates its required emits may still advance with (the status contract binds only