@nanobpm/nano-workforce 0.127.0 → 0.129.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 (51) hide show
  1. package/.github/workflows/release.yml +29 -6
  2. package/AGENTS.md +19 -0
  3. package/CHANGELOG.md +14 -0
  4. package/app/abandon.test.ts +16 -2
  5. package/app/abandon.ts +39 -17
  6. package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
  7. package/app/agentic/cockpit/cockpit-route.ts +17 -0
  8. package/app/agentic/cockpit/index.ts +16 -0
  9. package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
  10. package/app/agentic/cockpit/supply-boot.test.ts +2 -2
  11. package/app/agentic/cockpit/supply-boot.ts +76 -10
  12. package/app/agentic/cockpit/supply-render.test.ts +13 -4
  13. package/app/agentic/cockpit/supply-render.ts +14 -2
  14. package/app/agentic/cockpit/transcript-render.ts +6 -2
  15. package/app/agentic/cockpit/transcript-view.ts +9 -0
  16. package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
  17. package/app/agentic/cockpit/worker-detail-render.ts +88 -0
  18. package/app/agentic/cockpit/worker-detail-view.ts +43 -0
  19. package/app/agentic/correlation-store.test.ts +99 -0
  20. package/app/agentic/correlation-store.ts +162 -0
  21. package/app/agentic/families/presence.family.test.ts +12 -0
  22. package/app/agentic/families/presence.family.ts +14 -0
  23. package/app/agentic/families/relay.family.test.ts +72 -0
  24. package/app/agentic/families/relay.family.ts +130 -1
  25. package/app/agentic/transcript-read.test.ts +55 -3
  26. package/app/agentic/transcript-read.ts +49 -9
  27. package/app/conformance.test.ts +2 -1
  28. package/app/conformance.ts +9 -3
  29. package/app/featureDelivery.test.ts +2 -1
  30. package/app/instanceTracking.ts +97 -0
  31. package/app/lineage.test.ts +2 -1
  32. package/app/lineage.ts +15 -2
  33. package/app/promotionPoll.test.ts +2 -1
  34. package/app/retro.test.ts +2 -1
  35. package/app/retro.ts +9 -2
  36. package/app/service.test.ts +15 -14
  37. package/app/service.ts +17 -24
  38. package/db/migrations/078_agentic_correlation.sql +32 -0
  39. package/e2e/convergence-loop.e2e.ts +41 -8
  40. package/openapi.yaml +26 -0
  41. package/operations/acknowledgeEpic.test.ts +2 -1
  42. package/operations/checkAbandon.test.ts +2 -1
  43. package/operations/getAgenticTranscript.ts +3 -2
  44. package/operations/getLineage.test.ts +2 -1
  45. package/operations/listAgenticTranscripts.ts +2 -1
  46. package/package.json +3 -3
  47. package/pages/cockpit/cockpit.css +65 -2
  48. package/pages/cockpit/mount.js +187 -16
  49. package/test/trackingViews.ts +50 -0
  50. package/test/worldDb.ts +2 -1
  51. package/workers/retro-gather/worker.test.ts +2 -1
package/app/lineage.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  import type { DataLayer } from "@nanobpm/urban";
22
22
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
23
23
  import { type FeatureRun, featureRuns } from "./feature.ts";
24
+ import { derivedTrackingTable } from "./instanceTracking.ts";
24
25
  import { type Plan, type PlanTask, plans, planTasks } from "./plan.ts";
25
26
 
26
27
  const now = () => new Date().toISOString();
@@ -307,9 +308,19 @@ interface PrRow {
307
308
  // Epic-phase projection this module maintains (issue #304, migration 043): the parent epic's phase
308
309
  // label for an epic slice PR, NULL otherwise. Read here only to keep the write idempotent.
309
310
  epic_phase_label: string | null;
311
+ // The ADR-0065 derived tracking edge (`pull_requests__tracking.derived_status`). Present ONLY on
312
+ // rows read through the derived VIEW (`prRowsRead`); undefined on base-table reads/writes. The
313
+ // frontier stage is derived from THIS, not the base transient `status`, so an out-of-band-
314
+ // terminated slice reads `abandoned` rather than a stale `converging`.
315
+ derived_status?: string;
310
316
  }
311
317
 
312
318
  const prRows = (data: DataLayer) => data.table<PrRow>("pull_requests", "pr_key");
319
+ /** Read-only accessor over the PR derived tracking VIEW (`pull_requests__tracking`). The lineage
320
+ * frontier classifies on the reconciler-derived edge, so `collectThreads` reads through this and
321
+ * `toLineagePr` folds `derived_status` onto `LineagePr.status`. Writes stay on `prRows`. */
322
+ const prRowsRead = (data: DataLayer) =>
323
+ derivedTrackingTable<PrRow & { derived_status: string }>(data, "pull_requests", "pr_key");
313
324
 
314
325
  /** The `lineage_thread_view` VIEW row (migration 064) — the read shape the Lineage page binds. The
315
326
  * view PASSES THROUGH the procedural frontier columns from `lineage_threads` and DERIVES the
@@ -382,7 +393,9 @@ function toLineagePr(row: PrRow): LineagePr {
382
393
  prKey: row.pr_key,
383
394
  title: row.title,
384
395
  url: row.url,
385
- status: row.status,
396
+ // Classify the frontier on the ADR-0065 derived edge when the row came through the tracking VIEW
397
+ // (`prRowsRead`); fall back to the base transient for any base-table row.
398
+ status: row.derived_status ?? row.status,
386
399
  round: row.current_round,
387
400
  processKey: row.process_key,
388
401
  outcome: row.outcome,
@@ -394,7 +407,7 @@ function toLineagePr(row: PrRow): LineagePr {
394
407
  async function collectThreads(
395
408
  data: DataLayer,
396
409
  ): Promise<{ threads: Map<string, LineageThread>; allPrs: PrRow[] }> {
397
- const allPrs = await prRows(data).all();
410
+ const allPrs = await prRowsRead(data).all();
398
411
  const prByKey = new Map<string, PrRow>();
399
412
  for (const pr of allPrs) prByKey.set(pr.pr_key, pr);
400
413
 
@@ -6,6 +6,7 @@
6
6
  // recording engine: open exactly one PR, never a duplicate on re-run, never for a converging epic,
7
7
  // and never for a `main`-based epic.
8
8
  import { test } from "node:test";
9
+ import { withTrackingViews } from "../test/trackingViews.ts";
9
10
  import { assert, assertEquals } from "#test-assert";
10
11
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
11
12
  import { resetDefaultBranchCache } from "./github.ts";
@@ -41,7 +42,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
41
42
  },
42
43
  };
43
44
  }
44
- const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
45
+ const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
45
46
  return { data, stores };
46
47
  }
47
48
 
package/app/retro.test.ts CHANGED
@@ -3,6 +3,7 @@ import { test } from "node:test";
3
3
  import { assert, assertEquals, assertStringIncludes } from "#test-assert";
4
4
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
5
5
  import { memBlackboardSource } from "../test/blackboardDb.ts";
6
+ import { withTrackingViews } from "../test/trackingViews.ts";
6
7
  import { appendEntry } from "./blackboard.ts";
7
8
  import { recordTaskDelta } from "./taskDelta.ts";
8
9
  import {
@@ -47,7 +48,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
47
48
  },
48
49
  };
49
50
  }
50
- const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
51
+ const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
51
52
  return { data, stores };
52
53
  }
53
54
 
package/app/retro.ts CHANGED
@@ -17,6 +17,7 @@ import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
17
17
  import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
18
18
  import { hasDeliveredImplementationForPlan } from "./conformance.ts";
19
19
  import { TERMINAL_STATUSES } from "./delivery.ts";
20
+ import { derivedTrackingTable } from "./instanceTracking.ts";
20
21
  import { planReviews, planTasks } from "./plan.ts";
21
22
  import { aggregateEpicDeltas } from "./taskDelta.ts";
22
23
 
@@ -55,7 +56,11 @@ interface PlanRow extends Record<string, unknown> {
55
56
 
56
57
  const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
57
58
  const prsTbl = (data: DataLayer) =>
58
- data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
59
+ derivedTrackingTable<{ pr_key: string; derived_status: string }>(
60
+ data,
61
+ "pull_requests",
62
+ "pr_key",
63
+ );
59
64
  const retroStartsTbl = (data: DataLayer) =>
60
65
  data.table<{ plan_key: string; started_at: string }>("plan_retro_starts", "plan_key");
61
66
 
@@ -77,8 +82,10 @@ export async function isPlanComplete(data: DataLayer, planKey: string): Promise<
77
82
  if (SETTLED_TASKLESS.has(t.status)) continue;
78
83
  // Any task that is meant to yield a PR must have a terminal PR to be settled.
79
84
  if (!t.pr_key) return false; // pending/escalated/etc. with no PR yet → still in flight
85
+ // Any task that is meant to yield a PR must have a terminal PR to be settled. Read the ADR-0065
86
+ // derived edge so an out-of-band-terminated (`abandoned`) PR is recognised as terminal here.
80
87
  const pr = await prsTbl(data).get(t.pr_key);
81
- if (!pr || !TERMINAL_PR_STATUSES.has(pr.status)) return false;
88
+ if (!pr || !TERMINAL_PR_STATUSES.has(pr.derived_status)) return false;
82
89
  }
83
90
  return true;
84
91
  }
@@ -8,6 +8,7 @@
8
8
  import { test } from "node:test";
9
9
  import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
10
10
  import { memDataFor } from "../test/worldDb.ts";
11
+ import { withTrackingViews } from "../test/trackingViews.ts";
11
12
  import { DurableResumeRegistry } from "./durableResume.ts";
12
13
  import { WorldStore } from "./world/index.ts";
13
14
  import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
@@ -71,7 +72,7 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
71
72
  pr_dependencies: { rows: [], key: "pr_key" },
72
73
  };
73
74
  const data = {
74
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
75
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
75
76
  } as any;
76
77
  const engine = {
77
78
  createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
@@ -137,7 +138,7 @@ test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it,
137
138
  pull_requests: { rows: [row], key: "pr_key" },
138
139
  };
139
140
  const data = {
140
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
141
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
141
142
  } as any;
142
143
  const headers = { "content-type": "application/json" };
143
144
 
@@ -190,7 +191,7 @@ test("pollIncidents never queries a PR with no live instance and clears any stal
190
191
  pull_requests: { rows: [noKey, terminal], key: "pr_key" },
191
192
  };
192
193
  const data = {
193
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
194
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
194
195
  } as any;
195
196
  const headers = { "content-type": "application/json" };
196
197
 
@@ -223,7 +224,7 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
223
224
  pull_requests: { rows: [row], key: "pr_key" },
224
225
  };
225
226
  const data = {
226
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
227
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
227
228
  } as any;
228
229
  const headers = { "content-type": "application/json" };
229
230
 
@@ -262,7 +263,7 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
262
263
  pr_dependencies: { rows: [], key: "pr_key" },
263
264
  };
264
265
  const data = {
265
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
266
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
266
267
  } as any;
267
268
  const engine = {
268
269
  // A large key delivered as a JS number — the exact case that breaks dev response validation
@@ -296,7 +297,7 @@ function captureConvergeOnly() {
296
297
  pr_dependencies: { rows: [], key: "pr_key" },
297
298
  };
298
299
  const data = {
299
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
300
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
300
301
  } as any;
301
302
  let captured: unknown;
302
303
  const engine = {
@@ -347,7 +348,7 @@ function captureRoot() {
347
348
  pr_dependencies: { rows: [], key: "pr_key" },
348
349
  };
349
350
  const data = {
350
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
351
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
351
352
  } as any;
352
353
  let captured: unknown;
353
354
  const engine = {
@@ -629,7 +630,7 @@ test("pollWaveGatesImpl is level-triggered: PRs merged before the token arrives
629
630
  },
630
631
  };
631
632
  const data = {
632
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
633
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
633
634
  } as any;
634
635
 
635
636
  const published: { name: string; correlationKey?: string }[] = [];
@@ -718,7 +719,7 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
718
719
  },
719
720
  };
720
721
  const data = {
721
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
722
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
722
723
  } as any;
723
724
 
724
725
  const published: { name: string; correlationKey?: string }[] = [];
@@ -822,7 +823,7 @@ test("pollWaveGatesImpl releases the wave when a member PR is closed-unmerged an
822
823
  merges: { rows: [], key: "id" },
823
824
  };
824
825
  const data = {
825
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
826
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
826
827
  } as any;
827
828
 
828
829
  const published: { name: string; correlationKey?: string }[] = [];
@@ -878,7 +879,7 @@ test("abandonClosedPr is idempotent — the terminal merges audit row is written
878
879
  merges: { rows: [], key: "id" },
879
880
  };
880
881
  const data = {
881
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
882
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
882
883
  } as any;
883
884
 
884
885
  await abandonClosedPr(data, "owner/repo#70", "closed without merging");
@@ -906,7 +907,7 @@ test("abandonClosedPr self-heals a missing pull_requests parent row before the F
906
907
  merges: { rows: [], key: "id" },
907
908
  };
908
909
  const data = {
909
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
910
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
910
911
  } as any;
911
912
 
912
913
  await abandonClosedPr(data, "owner/repo#71", "closed without merging");
@@ -932,7 +933,7 @@ test("abandonClosedPr rejects a malformed prKey with a clear error before any FK
932
933
  merges: { rows: [], key: "id" },
933
934
  };
934
935
  const data = {
935
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
936
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
936
937
  } as any;
937
938
 
938
939
  const err = await assertRejects(() => abandonClosedPr(data, "not-a-valid-pr-key", "closed without merging"));
@@ -1004,7 +1005,7 @@ function capsProbeExec(ready: boolean) {
1004
1005
 
1005
1006
  function capsDataLayer(stores: Record<string, { rows: any[]; key: string }>) {
1006
1007
  return {
1007
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
1008
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
1008
1009
  } as any;
1009
1010
  }
1010
1011
 
package/app/service.ts CHANGED
@@ -7,7 +7,6 @@
7
7
  //
8
8
  // Data access goes through the record-oriented gateway (`data.table<T>(name, pk)` — the RAD
9
9
  // `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
10
- import { readFileSync } from "node:fs";
11
10
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
11
  import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
13
12
  import { escalationFormId } from "./agentCompletion.ts";
@@ -51,6 +50,7 @@ import {
51
50
  type PrState,
52
51
  requestCopilotReview,
53
52
  } from "./github.ts";
53
+ import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
54
54
  import { pollLineage } from "./lineage.ts";
55
55
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
56
56
  import {
@@ -253,6 +253,16 @@ interface Escalation {
253
253
  }
254
254
 
255
255
  const prs = (data: DataLayer) => data.table<PullRequest>("pull_requests", "pr_key");
256
+ /** A PR row as seen through its derived tracking VIEW (`pull_requests__tracking`): the base columns
257
+ * plus urban's ADR-0065 `derived_status`, which folds the reconciler's terminal edge
258
+ * (out-of-band terminate → `abandoned`) over the worker-owned transient. */
259
+ type TrackedPullRequest = PullRequest & { derived_status: string };
260
+ /** Read-only accessor over the PR derived tracking VIEW. Use this — and read `derived_status`, not
261
+ * `status` — for any terminal-edge classification (delivery/promotion/feature reconciliation), so an
262
+ * out-of-band-terminated PR (whose base row is still `converging`) is correctly seen as `abandoned`.
263
+ * Worker-written terminals (`merged`/`converged`) pass through unchanged. Writes stay on `prs`. */
264
+ const prsTracking = (data: DataLayer) =>
265
+ derivedTrackingTable<TrackedPullRequest>(data, "pull_requests", "pr_key");
256
266
  const escs = (data: DataLayer) => data.table<Escalation>("escalations", "id");
257
267
  const deps = (data: DataLayer) => data.table<PrDependency>("pr_dependencies", "pr_key");
258
268
 
@@ -1948,7 +1958,8 @@ export async function derivePlanDelivery(
1948
1958
  let status = statusByPrKey?.get(t.pr_key);
1949
1959
  if (status === undefined && !statusByPrKey) {
1950
1960
  // On-demand caller: fetch just this slice's PR row rather than loading the whole table.
1951
- status = (await prs(data).get(t.pr_key))?.status;
1961
+ // Read the ADR-0065 derived edge so an out-of-band-terminated slice reads `abandoned`.
1962
+ status = (await prsTracking(data).get(t.pr_key))?.derived_status;
1952
1963
  }
1953
1964
  prStatuses.push(status ?? MISSING_PR_STATUS);
1954
1965
  }
@@ -2028,8 +2039,9 @@ export async function pollWaitGate(data: DataLayer) {
2028
2039
  * default branch, so there is nothing to promote. Best-effort + per-plan isolated. */
2029
2040
  export async function pollPromotion(data: DataLayer, engine: EngineClient, token: string) {
2030
2041
  // Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
2042
+ // Read the ADR-0065 derived edge (`derived_status`) so a terminated slice reads `abandoned`.
2031
2043
  const statusByPrKey = new Map<string, string>();
2032
- for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
2044
+ for (const pr of await prsTracking(data).all()) statusByPrKey.set(pr.pr_key, pr.derived_status);
2033
2045
  for (const plan of await plans(data).all()) {
2034
2046
  const base = plan.base_branch;
2035
2047
  // A non-`epic/*` base is never promotable — short-circuit before the per-plan delivery join.
@@ -2107,8 +2119,9 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
2107
2119
  * Never touches a run that isn't `converging` — additive/derived only, idempotent, best-effort. */
2108
2120
  export async function pollFeatureDelivery(data: DataLayer) {
2109
2121
  // Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
2122
+ // Read the ADR-0065 derived edge (`derived_status`) so a terminated run reads `abandoned`.
2110
2123
  const statusByPrKey = new Map<string, string>();
2111
- for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
2124
+ for (const pr of await prsTracking(data).all()) statusByPrKey.set(pr.pr_key, pr.derived_status);
2112
2125
  // Only `converging` runs are ever reconciled — query them via the `feature_runs(status)` index
2113
2126
  // (db/migrations/028) instead of scanning all history, so this pass stays O(in-flight), not
2114
2127
  // O(total runs), as the table grows.
@@ -2130,26 +2143,6 @@ export async function pollFeatureDelivery(data: DataLayer) {
2130
2143
  }
2131
2144
  }
2132
2145
 
2133
- /** The app manifest, read and parsed exactly ONCE at module load. `activeStatusesFor` is invoked
2134
- * three times during module initialization (the PR/plan/feature constants below); parsing here keeps
2135
- * that to a single synchronous `readFileSync` + `JSON.parse` instead of one per lookup. */
2136
- const APP_MANIFEST: { instanceTracking?: { table: string; activeStatuses?: string[] }[] } = JSON.parse(
2137
- readFileSync(new URL("../nano.app.json", import.meta.url), "utf8"),
2138
- );
2139
-
2140
- /** Read a tracked table's parked-and-active statuses from the single source of truth
2141
- * (`instanceTracking.<table>.activeStatuses` in nano.app.json), so an app-side scan can never drift
2142
- * from the reconciler's notion of "in-flight". Throws if the binding is missing/empty. */
2143
- function activeStatusesFor(table: string): readonly string[] {
2144
- const binding = APP_MANIFEST.instanceTracking?.find((b) => b.table === table);
2145
- if (!binding?.activeStatuses?.length) {
2146
- throw new Error(
2147
- `nano.app.json: instanceTracking[table="${table}"].activeStatuses is missing or empty`,
2148
- );
2149
- }
2150
- return binding.activeStatuses;
2151
- }
2152
-
2153
2146
  /** The `pull_requests` statuses a PR instance can be parked-and-active on, DERIVED from the single
2154
2147
  * source of truth (`instanceTracking.pull_requests.activeStatuses` in nano.app.json) so the app-side
2155
2148
  * scan can never drift from the reconciler's notion of "in-flight". `pollUserTasks` scans only these
@@ -0,0 +1,32 @@
1
+ -- Durable per-job worker attribution + engine context (#485, provisioning #232).
2
+ --
3
+ -- The in-memory correlation registry (app/agentic/correlation.ts) is the live jobKey ⇄ worker join,
4
+ -- but it is RELEASED on job end / worker disconnect and is empty after a restart. So a COMPLETED
5
+ -- (past) session — what the cockpit "past sessions" / worker-history view reads — otherwise loses
6
+ -- which worker ran it (instance / identity / host) and its process-instance / plan context. The
7
+ -- package-mirrored transcript store (024_agentic_transcript.sql, byte-for-byte guarded) carries no
8
+ -- correlation columns, so this app-side table closes the gap WITHOUT touching that mirrored schema.
9
+ --
10
+ -- The relay slice records a row here at job-completion time; the transcript read path falls back to
11
+ -- it when the live registry has released the job. Advisory / read-only (ADR 0056) — it NEVER gates a
12
+ -- BPMN sequence flow.
13
+ --
14
+ -- Single source of truth: this DDL mirrors AGENTIC_CORRELATION_SCHEMA_SQL in
15
+ -- app/agentic/correlation-store.ts byte-for-byte; a drift-guard test (correlation-store.test.ts) pins
16
+ -- the two together.
17
+ CREATE TABLE IF NOT EXISTS agentic_correlation (
18
+ job_key TEXT PRIMARY KEY,
19
+ stream TEXT NOT NULL,
20
+ instance TEXT NOT NULL,
21
+ identity TEXT,
22
+ host TEXT,
23
+ process_instance_key TEXT,
24
+ bpmn_process_id TEXT,
25
+ element_id TEXT,
26
+ plan_key TEXT,
27
+ linked_at TEXT,
28
+ completed_at TEXT NOT NULL
29
+ );
30
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_instance ON agentic_correlation (instance);
31
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_process_instance ON agentic_correlation (process_instance_key);
32
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_plan ON agentic_correlation (plan_key);
@@ -22,6 +22,7 @@ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { after, before, describe, test } from "node:test";
24
24
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
25
+ import { trackingTargetFor } from "../app/instanceTracking.ts";
25
26
 
26
27
  // The app root is this repo's root (one level up from `e2e/`) — where nano.app.json + openapi.yaml
27
28
  // + db/migrations + resources/processes live.
@@ -160,16 +161,20 @@ describe("nano-workforce e2e (urban-testkit pilot)", () => {
160
161
 
161
162
  // The operation registered the PR aggregate (instanceTracking table) and started a real engine
162
163
  // instance — synchronously, before any worker ran (we never settled).
163
- const prs = app.db.table<{ pr_key: string; status: string; process_key: string | null }>(
164
- "pull_requests",
165
- "pr_key",
166
- );
164
+ const prs = app.db.table<{
165
+ pr_key: string;
166
+ status: string;
167
+ process_key: string | null;
168
+ abandon_token: string | null;
169
+ }>("pull_requests", "pr_key");
167
170
  const row = await prs.findOne({ pr_key: prKey });
168
171
  assert.ok(row, "a pull_requests row was registered");
169
172
  assert.equal(row?.status, "converging", "the PR is tracked as actively converging");
170
173
  assert.ok(row?.process_key, "the row carries the engine process-instance key");
174
+ assert.ok(row?.abandon_token, "the row carries a #76 abandon-check capability token");
171
175
 
172
176
  const processInstanceKey = row!.process_key!;
177
+ const abandonToken = row!.abandon_token!;
173
178
  const before = await app.engine.searchProcessInstances({
174
179
  processInstanceKeys: [processInstanceKey],
175
180
  });
@@ -182,11 +187,39 @@ describe("nano-workforce e2e (urban-testkit pilot)", () => {
182
187
  assert.equal(stillActive?.status, "converging", "row not yet reconciled before any poll fires");
183
188
 
184
189
  // Advance past the instanceTracking pollMs (derived from nano.app.json above, plus a margin):
185
- // the reconciler observes TERMINATED and applies the manifest `onTerminated.set` status
186
- // `abandoned`.
190
+ // the reconciler observes TERMINATED and feeds urban's instance projection. Under ADR-0065
191
+ // (urban 0.81.0, the writer→source inversion) it NO LONGER writes `abandoned` onto the base row;
192
+ // the terminal edge is DERIVED on read via the managed `pull_requests__tracking` VIEW.
187
193
  await app.advanceTime(PR_POLL_MS + 1000);
188
- const reconciled = await prs.findOne({ pr_key: prKey });
189
- assert.equal(reconciled?.status, "abandoned", "reconciler abandoned the terminated PR's row");
194
+
195
+ // (1) The base row keeps only the worker-owned transient — it stays `converging`, NOT rewritten.
196
+ const baseRow = await prs.findOne({ pr_key: prKey });
197
+ assert.equal(
198
+ baseRow?.status,
199
+ "converging",
200
+ "ADR-0065: the reconciler no longer writes the terminal edge onto the base row",
201
+ );
202
+
203
+ // (2) The derived tracking VIEW reports the terminal edge (`abandoned`) via `derived_status`. Its
204
+ // name/column are resolved by the app's SSOT helper, which defers to urban's own target resolver.
205
+ const target = trackingTargetFor("pull_requests");
206
+ const view = app.db.table<{ pr_key: string } & Record<string, unknown>>(target.view, "pr_key");
207
+ const derived = await view.findOne({ pr_key: prKey });
208
+ assert.equal(
209
+ derived?.[target.statusColumn],
210
+ "abandoned",
211
+ "the derived read-model reports the terminated PR as abandoned",
212
+ );
213
+
214
+ // (3) End-to-end, the #76 cooperative abandon-check endpoint (which a servicing agent curls
215
+ // before any irreversible action) now reports `abandoned: true` — the whole point of the edge.
216
+ const abandonCheck = await api.call<{ prKey: string; status: string; abandoned: boolean }>(
217
+ "checkAbandon",
218
+ { query: { token: abandonToken } },
219
+ );
220
+ assert.equal(abandonCheck.status, 200, "the abandon check resolves the known token");
221
+ assert.equal(abandonCheck.body.abandoned, true, "a servicing agent is told to abort the run");
222
+ assert.equal(abandonCheck.body.status, "abandoned", "the reported status is the derived edge");
190
223
  });
191
224
 
192
225
  test("coverage gate: every operation the pilot claims to own was exercised", () => {
package/openapi.yaml CHANGED
@@ -646,6 +646,16 @@ components:
646
646
  planKey:
647
647
  type: string
648
648
  description: The plan / epic key this job was part of (e.g. owner/repo#142), when still known (advisory).
649
+ instance:
650
+ type: string
651
+ description: The worker instance that ran the session, recovered from durable attribution — present even
652
+ after the worker exited or the process restarted (advisory).
653
+ identity:
654
+ type: string
655
+ description: The worker's durable identity (presence identity), when recorded (advisory).
656
+ host:
657
+ type: string
658
+ description: The worker's host, when recorded (advisory).
649
659
  AgenticTranscriptList:
650
660
  type: object
651
661
  description: The list of captured agent sessions (past + open) — the cockpit "past sessions" feed.
@@ -742,6 +752,15 @@ components:
742
752
  planKey:
743
753
  type: string
744
754
  description: The plan / epic key, when still known (advisory).
755
+ instance:
756
+ type: string
757
+ description: The worker instance that ran the session, from durable attribution (advisory).
758
+ identity:
759
+ type: string
760
+ description: The worker's durable identity, when recorded (advisory).
761
+ host:
762
+ type: string
763
+ description: The worker's host, when recorded (advisory).
745
764
  entries:
746
765
  type: array
747
766
  description: The retained chunks with `offset >= from`, in offset order.
@@ -2448,6 +2467,13 @@ paths:
2448
2467
  schema:
2449
2468
  type: string
2450
2469
  description: Return only transcripts whose (still-known) correlation names this plan / epic key.
2470
+ - name: instance
2471
+ in: query
2472
+ required: false
2473
+ schema:
2474
+ type: string
2475
+ description: Return only sessions run by this worker instance (durable attribution) — powers the
2476
+ per-worker history view. Survives worker exit / process restart.
2451
2477
  - name: since
2452
2478
  in: query
2453
2479
  required: false
@@ -18,6 +18,7 @@ import { assertEquals } from "#test-assert";
18
18
  import type { AppApi } from "@nanobpm/urban";
19
19
  import { deriveEpicBucket, epicIsAcknowledgeable } from "../app/delivery.ts";
20
20
  import { noopLog } from "../test/log.ts";
21
+ import { withTrackingViews } from "../test/trackingViews.ts";
21
22
  import handler from "./acknowledgeEpic.ts";
22
23
 
23
24
  // An in-memory data layer wired through the `plans` gateway (now a plain record table). `extra` seeds
@@ -52,7 +53,7 @@ function memApp(
52
53
  };
53
54
  }
54
55
  const app = {
55
- data: { table: (n: string, pk?: string) => tbl(n, pk) },
56
+ data: { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) },
56
57
  log: noopLog(),
57
58
  } as any as AppApi;
58
59
  return { app, rows: stores.plans };
@@ -3,6 +3,7 @@ import { test } from "node:test";
3
3
  import { assertEquals } from "#test-assert";
4
4
  import type { AppApi } from "@nanobpm/urban";
5
5
  import { noopLog } from "../test/log.ts";
6
+ import { withTrackingViews } from "../test/trackingViews.ts";
6
7
  import handler from "./checkAbandon.ts";
7
8
 
8
9
  function memApp(): { app: AppApi } {
@@ -19,7 +20,7 @@ function memApp(): { app: AppApi } {
19
20
  },
20
21
  };
21
22
  }
22
- const app = { data: { table: (n: string) => tbl(n) }, log: noopLog() } as any as AppApi;
23
+ const app = { data: { table: withTrackingViews((n: string) => tbl(n)) }, log: noopLog() } as any as AppApi;
23
24
  return { app };
24
25
  }
25
26
 
@@ -28,13 +28,14 @@ export default defineOperation("getAgenticTranscript", async ({ params, query, r
28
28
  return { status: 400, body: { error: "invalid from: expected a non-negative integer offset" } };
29
29
  }
30
30
 
31
- const store = currentRelayTranscriptService()?.store;
31
+ const service = currentRelayTranscriptService();
32
+ const store = service?.store;
32
33
  if (!store) {
33
34
  // No transcript store mounted (relay unmounted or unpersisted) - nothing to replay.
34
35
  return { status: 404, body: { error: "no transcript for stream" } };
35
36
  }
36
37
 
37
- const data = readTranscriptFrom(params.stream, from, store, currentCorrelation());
38
+ const data = readTranscriptFrom(params.stream, from, store, currentCorrelation(), service?.correlationStore);
38
39
  if (data === undefined) {
39
40
  return { status: 404, body: { error: "no transcript for stream" } };
40
41
  }
@@ -6,6 +6,7 @@ import { test } from "node:test";
6
6
  import { assert, assertEquals } from "#test-assert";
7
7
  import type { AppApi } from "@nanobpm/urban";
8
8
  import { noopLog } from "../test/log.ts";
9
+ import { withTrackingViews } from "../test/trackingViews.ts";
9
10
  import handler from "./getLineage.ts";
10
11
 
11
12
  function memApp(stores: Record<string, any[]>): AppApi {
@@ -20,7 +21,7 @@ function memApp(stores: Record<string, any[]>): AppApi {
20
21
  },
21
22
  };
22
23
  };
23
- return { data: { table }, log: noopLog() } as any as AppApi;
24
+ return { data: { table: withTrackingViews(table) }, log: noopLog() } as any as AppApi;
24
25
  }
25
26
 
26
27
  function input(query: Record<string, string> = {}, headers: Record<string, string> = {}) {
@@ -47,10 +47,11 @@ export default defineOperation("listAgenticTranscripts", async ({ query, req },
47
47
  ...(query.jobKey !== undefined ? { jobKey: query.jobKey } : {}),
48
48
  ...(query.processInstanceKey !== undefined ? { processInstanceKey: query.processInstanceKey } : {}),
49
49
  ...(query.planKey !== undefined ? { planKey: query.planKey } : {}),
50
+ ...(query.instance !== undefined ? { instance: query.instance } : {}),
50
51
  ...(query.since !== undefined ? { since: query.since } : {}),
51
52
  ...(query.until !== undefined ? { until: query.until } : {}),
52
53
  };
53
- const transcripts = listTranscripts(store, currentCorrelation(), filter);
54
+ const transcripts = listTranscripts(store, currentCorrelation(), filter, service?.correlationStore);
54
55
  const body: AgenticTranscriptList = {
55
56
  count: transcripts.length,
56
57
  generatedAt: new Date().toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.127.0",
3
+ "version": "0.129.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -59,12 +59,12 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@nanobpm/agentic": "^0.4.0",
62
- "@nanobpm/urban": "^0.80.0",
62
+ "@nanobpm/urban": "^0.81.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@biomejs/biome": "^2.4.11",
67
- "@nanobpm/urban-testkit": "^0.12.16",
67
+ "@nanobpm/urban-testkit": "^0.13.1",
68
68
  "@nanobpm/workflow": "^0.14.0",
69
69
  "@semantic-release/changelog": "^6.0.3",
70
70
  "@semantic-release/git": "^10.0.1",