@nanobpm/nano-workforce 0.206.4 → 0.208.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +18 -0
  3. package/app/deliveryGraphReadModel.test.ts +4 -2
  4. package/app/deliveryGraphRun.ts +12 -0
  5. package/app/github.ts +29 -0
  6. package/app/incidentStatus.test.ts +175 -0
  7. package/app/mcpExclusions.test.ts +4 -3
  8. package/app/mcpToolSurface.ts +11 -2
  9. package/app/mergeLoopBehaviour.test.ts +136 -1
  10. package/app/pullRequestReadModel.test.ts +1 -1
  11. package/app/service.test.ts +266 -0
  12. package/app/service.ts +311 -96
  13. package/build-info.json +4 -4
  14. package/db/migrations/126_pull_requests_incident_type_since.sql +8 -0
  15. package/db/migrations/127_delivery_graph_run_incidents.sql +60 -0
  16. package/db/migrations/128_pull_requests_read_model_incidents.sql +58 -0
  17. package/db/migrations/129_delivery_graph_read_model_incidents.sql +58 -0
  18. package/docs/mcp-runbook.md +2 -2
  19. package/nano.app.json +8 -0
  20. package/openapi.yaml +238 -0
  21. package/operations/listIncidents.test.ts +70 -0
  22. package/operations/listIncidents.ts +78 -0
  23. package/operations/retryIncident.test.ts +280 -0
  24. package/operations/retryIncident.ts +201 -0
  25. package/package.json +1 -1
  26. package/pages/delivery-graphs.page.json +16 -3
  27. package/pages/home.page.json +19 -0
  28. package/pages/overview.page.json +9 -0
  29. package/resources/processes/merge-loop.bpmn +401 -213
  30. package/workers/merge/worker.test.ts +30 -9
  31. package/workers/merge/worker.ts +23 -12
  32. package/workers/reconverge/worker.test.ts +110 -0
  33. package/workers/reconverge/worker.ts +81 -0
  34. package/workers/request-review/worker.test.ts +54 -0
  35. package/workers/request-review/worker.ts +68 -0
package/app/service.ts CHANGED
@@ -288,6 +288,14 @@ export const MERGEABLE_REPOLL_INTERVAL = mergeableRepollInterval(
288
288
  * to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
289
289
  export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDGE_MINUTES) * 60_000;
290
290
 
291
+ /** Merge-loop head-current review request (issue #939). When the merge backstop finds no
292
+ * head-current Copilot review (the loop's own rebase/fix push moved the head), the merge loop
293
+ * requests the review itself and waits `REVIEW_REQUEST_WAIT` before re-checking — derived from the
294
+ * SAME nudge window the convergence poller uses, so there is one knob for "how long to give Copilot".
295
+ * It re-requests up to `MAX_REVIEW_REQUESTS` times before escalating (stating what was tried). */
296
+ export const REVIEW_REQUEST_WAIT = `PT${Math.round(REVIEW_NUDGE_MS / 60_000)}M`;
297
+ export const MAX_REVIEW_REQUESTS = 3;
298
+
291
299
  /** Grace window (ms) before the `escalated`→`running` self-heal (below) may act on a row. The sole
292
300
  * writer of `status="escalated"` — `record-feature-escalation` — runs on the `escalated` arm and stamps
293
301
  * `updated_at` IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing
@@ -388,13 +396,16 @@ export interface PullRequest {
388
396
  // running agent curls (GET /app/api/hooks/abandon?token=…) to learn whether this run was cancelled before
389
397
  // it performs a side effect. Minted at submit, reused across the convergence + merge instances.
390
398
  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.
399
+ // Technical-incident surfacing (017_pr_incident.sql, issue #94; enriched 126, issue #931), written
400
+ // by the poller's `pollIncidents` pass. `incident_key` is the engine incidentKey of the ACTIVE
401
+ // incident parking this PR's instance, `incident_type` its errorType, `incident_message` its
402
+ // errorMessage and `incident_since` its creationTime; all NULL when the instance has no active
403
+ // incident. Orthogonal to `status` — an incident is a cross-cutting liveness fault, not a
404
+ // workflow stage. `/status` derives the `incident` overlay from these columns (see `activePrs`).
396
405
  incident_key: string | null;
406
+ incident_type: string | null;
397
407
  incident_message: string | null;
408
+ incident_since: string | null;
398
409
  // Lineage projection (037_lineage.sql, issue #245): the stable ORIGIN identity (the issue =
399
410
  // feature_key / plan_key) threaded onto this PR by `submitPr`, and passed as a `createInstance`
400
411
  // variable onto the convergence + merge instances so every descendant carries the root. For a
@@ -629,9 +640,11 @@ export async function submitPr(
629
640
  humanApproval = false,
630
641
  autoResolveEscalations = false,
631
642
  autoMerge?: boolean,
643
+ handoffFrom: string | null = null,
644
+ headShaOverride: string | null = null,
632
645
  ) {
633
646
  return withPrSubmitLock(parsed.prKey, () =>
634
- submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge),
647
+ submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations, autoMerge, handoffFrom, headShaOverride),
635
648
  );
636
649
  }
637
650
 
@@ -646,6 +659,8 @@ async function submitPrCritical(
646
659
  humanApproval = false,
647
660
  autoResolveEscalations = false,
648
661
  autoMerge?: boolean,
662
+ handoffFrom: string | null = null,
663
+ headShaOverride: string | null = null,
649
664
  ) {
650
665
  // Merge intent (issue #916): `autoMerge` is the CANONICAL positive form end to end — the same name
651
666
  // the feature/convergence-loop/plan-fanout doors accept. The legacy inverted `convergeOnly`
@@ -677,7 +692,28 @@ async function submitPrCritical(
677
692
  // would skip it as non-terminal, never re-enrolling). Treat a keyless non-terminal row as
678
693
  // RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
679
694
  // treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
680
- if (trackedExisting && existing?.process_key != null && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
695
+ // Merge-loop → convergence hand-back (issue #939): the merge-loop instance that currently OWNS the
696
+ // row (`process_key`) re-enters convergence when a head-current review reports findings. That
697
+ // instance is live (it is the caller, and ends right after this submit), so it must not read as
698
+ // "already running" — only the exact owning instance may hand back; any other key still dedupes.
699
+ // The bypass additionally requires the row to be LIVE (non-terminal, #941 review): a redelivered
700
+ // `pr.reconverge` can arrive after its successor has already settled the PR (merged/abandoned),
701
+ // and the settled row's `process_key` still names that stale job's instance — an owner match alone
702
+ // would let the dead hand-off restart convergence and clear the settled state. So an internal
703
+ // hand-off against a TERMINAL row is fenced off entirely (below): it dedupes as `alreadyRunning`
704
+ // instead of resubmitting. Only a DELIBERATE PUBLIC submission (no `handoffFrom`) may re-open a
705
+ // settled PR — the internal hand-off channel never may. A partially-failed hand-off's retry
706
+ // against a keyless NON-terminal row still works (that row is not terminal, so it falls through
707
+ // and re-enrolls via the phantom-row path).
708
+ const trackedTerminal = trackedExisting != null && TERMINAL_STATUSES.includes(trackedExisting.derived_status);
709
+ const handingBack =
710
+ handoffFrom != null && existing?.process_key != null && existing.process_key === handoffFrom && !trackedTerminal;
711
+ if (handoffFrom != null && trackedTerminal) {
712
+ // Stale internal hand-off against a settled row: report the existing (terminal) owner without
713
+ // minting a fresh run or touching the settled state. The redelivered job completes idempotently.
714
+ return { prKey: parsed.prKey, alreadyRunning: true };
715
+ }
716
+ if (!handingBack && trackedExisting && existing?.process_key != null && !trackedTerminal) {
681
717
  // Human approval is monotonic (issue #826): a gated caller adopting a live loop — possibly an
682
718
  // ungated one another run started — narrows it to gated instead of inheriting its auto-merge.
683
719
  // Idempotent, so a retry against our own gated loop is a no-op. A loop the engine no longer reports
@@ -798,7 +834,14 @@ async function submitPrCritical(
798
834
  // fleet advertising `durable-resume`: with no participant it stays null, so the round redrives from
799
835
  // scratch (graceful degradation). Absent (null) on a first submit, which leaves the envelope
800
836
  // unchanged.
801
- const worldSha = await worldRestoreSha(data, parsed.prKey);
837
+ //
838
+ // `headShaOverride` (#941 round-4 review) WINS over the checkpoint: the checkpoint is written only
839
+ // by the convergence loop's `pr.persist-round`, so a merge-stage rebase/fix-ci push advances the PR
840
+ // head WITHOUT updating it. A merge→convergence hand-off (`pr.reconverge`) that restored the
841
+ // checkpoint would provision the fresh run at the PRE-MERGE tree, not the head whose review raised
842
+ // the findings — so the hand-off passes the live head SHA it read, and a fresh hand-off restores
843
+ // the CURRENT head. `null` (every other caller) keeps the checkpoint resume.
844
+ const worldSha = headShaOverride ?? (await worldRestoreSha(data, parsed.prKey));
802
845
  const { processInstanceKey } = await engine.createInstance({
803
846
  processDefinitionId: PROCESS_ID,
804
847
  variables: {
@@ -874,6 +917,11 @@ async function submitPrCritical(
874
917
  // run auto-applies stale decisions, and (crucially) the row stays in its OLD terminal shape until
875
918
  // the atomic reopen write lands, so no straggler ever sees a fresh-run row still owned by the old
876
919
  // instance.
920
+ let reopened = false;
921
+ // Snapshot BEFORE the reopen write — the row object may be a live reference the update mutates.
922
+ const prior = existing
923
+ ? { status: existing.status, current_round: existing.current_round, process_key: existing.process_key }
924
+ : null;
877
925
  try {
878
926
  if (existing) {
879
927
  // ATOMIC REOPEN (Copilot review of #819): reset the row to the fresh run's shape AND publish the
@@ -928,6 +976,7 @@ async function submitPrCritical(
928
976
  process_key: processKey,
929
977
  updated_at: ts,
930
978
  });
979
+ reopened = true;
931
980
  } else if (processKey != null) {
932
981
  // First submit: the row was INSERTED keyless before `createInstance` (the phantom-row pattern,
933
982
  // #704/#497); publish its `process_key` now that the instance exists.
@@ -946,6 +995,19 @@ async function submitPrCritical(
946
995
  await resetAdjudications(data, parsed.prKey);
947
996
  }
948
997
  } catch (err) {
998
+ // Restore the prior row (#941 review): the atomic reopen already advanced `process_key` to the
999
+ // instance we are about to cancel. Leaving that cancelled key on the row would make a dead run its
1000
+ // "owner" — once tracking derives `abandoned`, a retried merge-loop hand-off would be fenced as
1001
+ // stale (settled row) and the merge loop would end with no live convergence owner. Restoring the
1002
+ // pre-reopen shape (owner key + status) keeps the retry the owning hand-off, and a public retry
1003
+ // still sees the old terminal row as resubmittable.
1004
+ if (reopened && prior) {
1005
+ try {
1006
+ await table.update(parsed.prKey, { ...prior, updated_at: now() });
1007
+ } catch (restoreErr) {
1008
+ console.warn(`[submit] ${parsed.prKey} rollback-restore failed: ${restoreErr}`);
1009
+ }
1010
+ }
949
1011
  if (processInstanceKey != null) {
950
1012
  try {
951
1013
  await engine.cancelInstance({ processInstanceKey: String(processInstanceKey) });
@@ -1022,6 +1084,11 @@ export async function startMerge(
1022
1084
  mergeableRepollInterval: MERGEABLE_REPOLL_INTERVAL,
1023
1085
  mergeStallRounds: 0,
1024
1086
  mergeStallMax: MAX_MERGE_STALL_ROUNDS,
1087
+ // Head-current review request budget (issue #939): see REVIEW_REQUEST_WAIT.
1088
+ reviewRequestRound: 0,
1089
+ reviewRequestMax: MAX_REVIEW_REQUESTS,
1090
+ reviewRequestWait: REVIEW_REQUEST_WAIT,
1091
+ reviewRequest: null,
1025
1092
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
1026
1093
  rootRequestKey,
1027
1094
  abandonUrl: abUrl,
@@ -1057,6 +1124,40 @@ export interface MergePolicy {
1057
1124
  blockedBy: "env" | "human-approval" | null;
1058
1125
  }
1059
1126
 
1127
+ /** The status `/status` reports for a run whose instance is parked on an ACTIVE engine incident
1128
+ * (issue #931). Derived at READ time from the mirrored `incident_*` columns — the base `status`
1129
+ * workflow transient is never overwritten, so the overlay can never wedge the status machine and
1130
+ * lifts the moment the incident clears. */
1131
+ export const INCIDENT_STATUS = "incident";
1132
+
1133
+ /** The incident block every `/status` row carries (issue #931): the ACTIVE engine incident the
1134
+ * poller mirrored onto the record, else null. `key` is the engine incidentKey (the handle the
1135
+ * `retryIncident` action resolves), `type` the errorType (e.g. `JOB_NO_RETRIES`), `message` the
1136
+ * errorMessage, `since` the incident's creationTime. */
1137
+ export interface StatusIncident {
1138
+ key: string;
1139
+ type: string | null;
1140
+ message: string | null;
1141
+ since: string | null;
1142
+ }
1143
+
1144
+ /** Derive the `/status` incident block from a row's mirrored incident columns (issue #931). Null
1145
+ * when the row carries no active incident. */
1146
+ function statusIncidentFor(row: {
1147
+ incident_key: string | null;
1148
+ incident_type: string | null;
1149
+ incident_message: string | null;
1150
+ incident_since: string | null;
1151
+ }): StatusIncident | null {
1152
+ if (!row.incident_key) return null;
1153
+ return {
1154
+ key: row.incident_key,
1155
+ type: row.incident_type ?? null,
1156
+ message: row.incident_message ?? null,
1157
+ since: row.incident_since ?? null,
1158
+ };
1159
+ }
1160
+
1060
1161
  /** A PR currently in flight, as reported by the status endpoint. */
1061
1162
  export interface ActivePr {
1062
1163
  prKey: string;
@@ -1081,6 +1182,9 @@ export interface ActivePr {
1081
1182
  /** The effective merge policy (issue #916): what the run intends (`requested`), what the
1082
1183
  * deployment allows (`enabled`), and what will actually happen (`effective`/`blockedBy`). */
1083
1184
  merge: MergePolicy;
1185
+ /** The ACTIVE engine incident parking this PR's instance (issue #931), mirrored by the poller's
1186
+ * `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
1187
+ incident: StatusIncident | null;
1084
1188
  }
1085
1189
 
1086
1190
  /** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
@@ -1135,23 +1239,30 @@ export async function activePrs(
1135
1239
  mergeByPr.set(p.pr_key, await mergePolicyFor(p.process_key ?? null, engine));
1136
1240
  }),
1137
1241
  );
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
- }));
1242
+ return active.map((p) => {
1243
+ // Issue #931: an ACTIVE engine incident parked on the loop's instance must be VISIBLE — the row
1244
+ // kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
1245
+ // (the base `status` column is never touched) and carry the mirrored incident block.
1246
+ const incident = statusIncidentFor(p);
1247
+ return {
1248
+ prKey: p.pr_key,
1249
+ repo: p.repo,
1250
+ number: p.number,
1251
+ url: p.url,
1252
+ title: p.title ?? null,
1253
+ status: incident ? INCIDENT_STATUS : p.status,
1254
+ round: p.current_round,
1255
+ processKey: p.process_key ?? null,
1256
+ waitingSince: p.waiting_since ?? null,
1257
+ openEscalation: openEscByPr.get(p.pr_key) ?? null,
1258
+ updatedAt: p.updated_at,
1259
+ activeWorker: p.active_worker ?? null,
1260
+ leaseUntil: p.lease_until ?? null,
1261
+ autoResolveEscalations: p.auto_resolve_escalations === 1,
1262
+ merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
1263
+ incident,
1264
+ };
1265
+ });
1155
1266
  }
1156
1267
 
1157
1268
  /** The default merge policy when no instance variables are readable (mid-handoff row, engine
@@ -1225,6 +1336,9 @@ export interface ActiveDelivery {
1225
1336
  * eligible for steward auto-resolution. Derived from the durable `auto_resolve_escalations` flag,
1226
1337
  * never a clobbered process variable. Shown in `/status` per delivery. */
1227
1338
  autoResolveEscalations: boolean;
1339
+ /** The ACTIVE engine incident parking this run's instance (issue #931), mirrored by the poller's
1340
+ * `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
1341
+ incident: StatusIncident | null;
1228
1342
  }
1229
1343
 
1230
1344
  /** Every delivery-graph run not in a terminal state (done/failed/abandoned), newest-updated first.
@@ -1248,16 +1362,22 @@ export async function activeDeliveries(data: DataLayer): Promise<ActiveDelivery[
1248
1362
  if (openEscByRun.has(t.subject_key)) continue;
1249
1363
  openEscByRun.set(t.subject_key, toOpenEscalation(t));
1250
1364
  }
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
- }));
1365
+ return active.map((r) => {
1366
+ // Issue #931: same incident overlay as the PR rows — a delivery run parked on an engine
1367
+ // incident must not keep reading `running`.
1368
+ const incident = statusIncidentFor(r);
1369
+ return {
1370
+ runKey: r.run_key,
1371
+ title: r.title ?? null,
1372
+ status: incident ? INCIDENT_STATUS : r.status,
1373
+ phase: r.phase ?? null,
1374
+ processKey: r.process_key ?? null,
1375
+ openEscalation: openEscByRun.get(r.run_key) ?? null,
1376
+ updatedAt: r.updated_at,
1377
+ autoResolveEscalations: r.auto_resolve_escalations === 1,
1378
+ incident,
1379
+ };
1380
+ });
1261
1381
  }
1262
1382
 
1263
1383
  /** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
@@ -2351,30 +2471,88 @@ export async function pollJobActivationImpl(
2351
2471
  }
2352
2472
 
2353
2473
  /** 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 {
2474
+ * the unique incident id; `errorType` the machine fault class (e.g. `JOB_NO_RETRIES`);
2475
+ * `errorMessage` is the human-readable fault; `state` is the incident lifecycle (`ACTIVE` while it
2476
+ * parks the token, `RESOLVED` once cleared); `creationTime` orders concurrent incidents and is
2477
+ * surfaced as the incident's `since`. */
2478
+ export interface IncidentSearchItem {
2358
2479
  incidentKey?: string;
2480
+ errorType?: string | null;
2359
2481
  errorMessage?: string | null;
2360
2482
  state?: string;
2361
2483
  creationTime?: string | null;
2484
+ /** The job the incident sits on (a `JOB_NO_RETRIES` incident parks until that job carries
2485
+ * retries again) — the handle the `retryIncident` action bumps. */
2486
+ jobKey?: string;
2362
2487
  }
2363
2488
 
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).
2489
+ /** The four mirrored incident columns every incident-watched aggregate carries (PRs since #94/#931,
2490
+ * delivery-graph runs since #931). All-or-nothing: a row with no ACTIVE incident reads NULL on all
2491
+ * four. */
2492
+ interface IncidentMirror {
2493
+ incident_key: string | null;
2494
+ incident_type: string | null;
2495
+ incident_message: string | null;
2496
+ incident_since: string | null;
2497
+ }
2498
+
2499
+ /** Pick the ACTIVE incident to mirror for one instance: the OLDEST active one (the first thing that
2500
+ * broke — a stable choice if the instance somehow parks more than one). Re-filters on state
2501
+ * defensively in case the wire filter is ignored. An incident with no `creationTime` sorts *last*,
2502
+ * so a missing timestamp can never masquerade as the oldest. Returns null when none is active. */
2503
+ export function oldestActiveIncident(items: IncidentSearchItem[] | undefined): IncidentSearchItem | null {
2504
+ return (
2505
+ (items ?? [])
2506
+ .filter((i) => (i.state ?? "ACTIVE") === "ACTIVE")
2507
+ .sort((a, b) => (a.creationTime ?? "\uffff").localeCompare(b.creationTime ?? "\uffff"))[0] ?? null
2508
+ );
2509
+ }
2510
+
2511
+ /** Query the engine for the OLDEST ACTIVE incident on one process instance. Returns the incident,
2512
+ * `null` when the instance has no active incident, or `undefined` when the transport failed (the
2513
+ * caller keeps the last-known mirror and retries next pass — a failed query must never clear a real
2514
+ * incident nor invent one). */
2515
+ export async function fetchActiveIncident(
2516
+ base: string,
2517
+ headers: Record<string, string>,
2518
+ processInstanceKey: string,
2519
+ ): Promise<IncidentSearchItem | null | undefined> {
2520
+ try {
2521
+ const res = await fetch(`${base}/incidents/search`, {
2522
+ method: "POST",
2523
+ headers,
2524
+ body: JSON.stringify({
2525
+ filter: { processInstanceKey, state: "ACTIVE" },
2526
+ page: { limit: 20 },
2527
+ }),
2528
+ });
2529
+ if (!res.ok) return undefined; // engine unhappy → unknown, retry next pass
2530
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
2531
+ const body = (await res.json()) as { items?: IncidentSearchItem[] };
2532
+ return oldestActiveIncident(body.items);
2533
+ } catch {
2534
+ return undefined;
2535
+ }
2536
+ }
2537
+
2538
+ /** Incident-surfacing poll pass (issue #94; enriched and extended to delivery-graph runs in issue
2539
+ * #931). A convergence, merge, or delivery-graph process instance can hit a *technical* incident —
2540
+ * an unhandled engine error that parks the token — and nothing on the record row reflected it: the
2541
+ * grid kept showing the last workflow status (`converging`, `merging`, `running`, …) while the run
2542
+ * was actually dead in the water (a PR sat "converging" all day on an incident; on merlin two loops
2543
+ * sat dead for 1.3h/5h before anyone found them via a hand-run engine query).
2368
2544
  *
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. */
2545
+ * This pass reads the engine's Camunda-8 `/v2/incidents/search` for each tracked record that still
2546
+ * has a live instance (has a `process_key`, non-terminal derived status) and mirrors an ACTIVE
2547
+ * incident onto four orthogonal columns — `incident_key`, `incident_type`, `incident_message`,
2548
+ * `incident_since` — leaving `status` untouched. An incident is a cross-cutting liveness fault, not
2549
+ * a workflow stage, so it must not overload the status machine (`/status` derives the `incident`
2550
+ * overlay at READ time — see `activePrs`/`activeDeliveries`). Clearing is idempotent: when the
2551
+ * instance has no active incident (resolved, or never had one) the columns are nulled, so an
2552
+ * incident raised or resolved out-of-band converges to the truth on the next pass. Best-effort
2553
+ * transport: a failed query leaves the last-known values untouched and the next pass retries.
2554
+ * Updates (and bumps `updated_at`) only on an actual change so a steady state doesn't churn the
2555
+ * grid. */
2378
2556
  async function pollIncidents(
2379
2557
  data: DataLayer,
2380
2558
  restAddress: string,
@@ -2387,12 +2565,24 @@ async function pollIncidents(
2387
2565
  }
2388
2566
 
2389
2567
  /** 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. */
2568
+ * `headers`, reconcile every incident-watched record row (PRs and delivery-graph runs) against the
2569
+ * engine's active incidents. Split out so tests can exercise the reconciliation with a stubbed
2570
+ * `fetch` without re-deriving transport wiring. */
2392
2571
  export async function pollIncidentsImpl(
2393
2572
  data: DataLayer,
2394
2573
  base: string,
2395
2574
  headers: Record<string, string>,
2575
+ ) {
2576
+ await reconcilePrIncidents(data, base, headers);
2577
+ await reconcileDeliveryIncidents(data, base, headers);
2578
+ }
2579
+
2580
+ /** The PR half of {@link pollIncidentsImpl}: mirror the oldest ACTIVE incident of each live PR
2581
+ * instance onto the PR row's four incident columns. */
2582
+ async function reconcilePrIncidents(
2583
+ data: DataLayer,
2584
+ base: string,
2585
+ headers: Record<string, string>,
2396
2586
  ) {
2397
2587
  const all = await prsTracking(data).all();
2398
2588
  for (const pr of all) {
@@ -2404,58 +2594,83 @@ export async function pollIncidentsImpl(
2404
2594
  // instance. Reuses the canonical `TERMINAL_STATUSES` so incident logic can't drift from the rest
2405
2595
  // of the status machine. Writes stay on the base `prs`.
2406
2596
  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
- }
2597
+ await clearIncidentMirror(pr, (patch) => prs(data).update(pr.pr_key, patch));
2414
2598
  continue;
2415
2599
  }
2416
2600
 
2417
- let incidentKey: string | null = null;
2418
- let incidentMessage: string | null = null;
2601
+ let active: IncidentSearchItem | null | undefined;
2419
2602
  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
- }
2603
+ active = await fetchActiveIncident(base, headers, pr.process_key);
2444
2604
  } catch (err) {
2445
2605
  console.error(`[poller] incidents ${pr.pr_key}: ${err}`);
2446
2606
  continue;
2447
2607
  }
2608
+ if (active === undefined) continue; // transport unhappy → keep last-known, retry next pass
2448
2609
 
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
- });
2610
+ await writeIncidentMirror(pr, active, (patch) => prs(data).update(pr.pr_key, patch));
2611
+ }
2612
+ }
2613
+
2614
+ /** The delivery-graph half of {@link pollIncidentsImpl} (issue #931): a delivery RUN's instance
2615
+ * parks on an incident exactly as a convergence loop does, and was just as invisible — `/status`
2616
+ * kept reporting `running`. Mirror onto the run row's four incident columns. */
2617
+ async function reconcileDeliveryIncidents(
2618
+ data: DataLayer,
2619
+ base: string,
2620
+ headers: Record<string, string>,
2621
+ ) {
2622
+ const terminal: readonly string[] = DELIVERY_GRAPH_TERMINAL_STATUSES;
2623
+ const all = await deliveryGraphRunsTracking(data).all();
2624
+ for (const run of all) {
2625
+ if (!run.process_key || terminal.includes(run.derived_status)) {
2626
+ await clearIncidentMirror(run, (patch) => deliveryGraphRuns(data).update(run.run_key, patch));
2627
+ continue;
2628
+ }
2629
+
2630
+ let active: IncidentSearchItem | null | undefined;
2631
+ try {
2632
+ active = await fetchActiveIncident(base, headers, run.process_key);
2633
+ } catch (err) {
2634
+ console.error(`[poller] incidents ${run.run_key}: ${err}`);
2635
+ continue;
2458
2636
  }
2637
+ if (active === undefined) continue;
2638
+
2639
+ await writeIncidentMirror(run, active, (patch) => deliveryGraphRuns(data).update(run.run_key, patch));
2640
+ }
2641
+ }
2642
+
2643
+ /** Null all four incident columns when any is set (a terminal / instance-less record must not keep
2644
+ * a stale mirror). Shared by both reconcile halves so the clearing shape can never drift. */
2645
+ async function clearIncidentMirror(
2646
+ row: IncidentMirror,
2647
+ update: (patch: IncidentMirror & { updated_at: string }) => Promise<unknown>,
2648
+ ) {
2649
+ if (row.incident_key || row.incident_type || row.incident_message || row.incident_since) {
2650
+ await update({ incident_key: null, incident_type: null, incident_message: null, incident_since: null, updated_at: now() });
2651
+ }
2652
+ }
2653
+
2654
+ /** Write the mirrored incident onto the row — or clear it when `active` is null — updating (and
2655
+ * bumping `updated_at`) only on an actual change so a steady state doesn't churn the grid. */
2656
+ async function writeIncidentMirror(
2657
+ row: IncidentMirror,
2658
+ active: IncidentSearchItem | null,
2659
+ update: (patch: IncidentMirror & { updated_at: string }) => Promise<unknown>,
2660
+ ) {
2661
+ const next: IncidentMirror = {
2662
+ incident_key: active?.incidentKey ?? null,
2663
+ incident_type: active?.errorType ?? null,
2664
+ incident_message: active?.errorMessage ?? null,
2665
+ incident_since: active?.creationTime ?? null,
2666
+ };
2667
+ if (
2668
+ next.incident_key !== (row.incident_key ?? null) ||
2669
+ next.incident_type !== (row.incident_type ?? null) ||
2670
+ next.incident_message !== (row.incident_message ?? null) ||
2671
+ next.incident_since !== (row.incident_since ?? null)
2672
+ ) {
2673
+ await update({ ...next, updated_at: now() });
2459
2674
  }
2460
2675
  }
2461
2676
 
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.206.4",
3
- "gitSha": "b8c5e4e5cb59ce42bb2d6573e107d1865393fa27",
4
- "gitTag": "v0.206.4",
5
- "builtAt": "2026-11-09T21:50:28.782Z"
2
+ "version": "0.208.0",
3
+ "gitSha": "0e2d50e8610beb5e939cd3a143d28e62d690f272",
4
+ "gitTag": "v0.208.0",
5
+ "builtAt": "2026-11-10T03:09:52.732Z"
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
+