@nanobpm/nano-workforce 0.201.5 → 0.203.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 (83) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +48 -0
  3. package/app/admittedStaging.test.ts +71 -0
  4. package/app/agentActivationLedger.ts +5 -0
  5. package/app/agentCompletion.test.ts +33 -0
  6. package/app/agentCompletion.ts +108 -2
  7. package/app/agentic/README.md +48 -0
  8. package/app/agentic/claim-registry.test.ts +61 -0
  9. package/app/agentic/claim-registry.ts +35 -1
  10. package/app/agentic/families/claim.family.test.ts +216 -0
  11. package/app/agentic/families/claim.family.ts +100 -4
  12. package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
  13. package/app/contracts.ts +31 -0
  14. package/app/deliveryGraphDispatch.ts +6 -0
  15. package/app/deliveryGraphReadModel.test.ts +2 -2
  16. package/app/deliveryGraphRun.ts +18 -0
  17. package/app/deliveryRunner.ts +9 -1
  18. package/app/escalationSteward.test.ts +452 -0
  19. package/app/escalationSteward.ts +537 -0
  20. package/app/escalationStewardPoll.test.ts +123 -0
  21. package/app/feature.test.ts +16 -0
  22. package/app/feature.ts +11 -0
  23. package/app/featureDelivery.test.ts +23 -0
  24. package/app/mcpToolSurface.ts +17 -1
  25. package/app/plan.ts +49 -6
  26. package/app/planLowering.test.ts +26 -1
  27. package/app/planLowering.ts +5 -0
  28. package/app/promotionPoll.test.ts +19 -0
  29. package/app/pullRequestReadModel.test.ts +1 -1
  30. package/app/service.test.ts +36 -0
  31. package/app/service.ts +139 -5
  32. package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
  33. package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
  34. package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
  35. package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
  36. package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
  37. package/docs/agent-guide.md +69 -30
  38. package/docs/agentic-cockpit.md +34 -0
  39. package/docs/mcp-runbook.md +2 -0
  40. package/e2e/escalation-steward.e2e.ts +211 -0
  41. package/nano.app.json +10 -1
  42. package/openapi.yaml +365 -0
  43. package/operations/dispatchDeliveryGraph.ts +6 -1
  44. package/operations/listActivePrs.test.ts +60 -1
  45. package/operations/listActivePrs.ts +3 -3
  46. package/operations/listAutoResolutions.ts +83 -0
  47. package/operations/reviewAutoResolution.test.ts +137 -0
  48. package/operations/reviewAutoResolution.ts +42 -0
  49. package/operations/startConvergenceLoop.ts +5 -1
  50. package/operations/startEpicSet.admission.integration.test.ts +2 -2
  51. package/operations/startEpicSet.ts +8 -2
  52. package/operations/startFeature.ts +5 -0
  53. package/operations/startPlanFanout.ts +4 -1
  54. package/package.json +1 -1
  55. package/pages/_nav.json +12 -0
  56. package/pages/auto-resolutions.page.json +191 -0
  57. package/pages/board.page.json +17 -0
  58. package/pages/cockpit.page.json +17 -0
  59. package/pages/delivery-graph-detail.page.json +17 -0
  60. package/pages/delivery-graphs/staged.mount.js +17 -5
  61. package/pages/delivery-graphs.page.json +17 -0
  62. package/pages/epic-detail.page.json +17 -0
  63. package/pages/epic.page.json +19 -1
  64. package/pages/feature.page.json +19 -1
  65. package/pages/home.page.json +22 -0
  66. package/pages/lineage.page.json +17 -0
  67. package/pages/mcp.page.json +17 -0
  68. package/pages/overview.page.json +17 -0
  69. package/pages/tasks.page.json +17 -0
  70. package/pages/velocity.page.json +17 -0
  71. package/resources/processes/escalation-steward.bpmn +162 -0
  72. package/resources/prompts/escalation-steward.md +87 -0
  73. package/test/delivery-graphs-staged-embed.test.ts +13 -0
  74. package/test/derivation-parity/derivation-parity.test.ts +1 -0
  75. package/test/derivation-parity/flows.ts +12 -0
  76. package/workers/converge-feature/worker.ts +6 -1
  77. package/workers/delivery-connector/worker.test.ts +72 -1
  78. package/workers/delivery-connector/worker.ts +53 -2
  79. package/workers/escalation-steward-collect/worker.test.ts +180 -0
  80. package/workers/escalation-steward-collect/worker.ts +99 -0
  81. package/workers/escalation-steward-record/worker.test.ts +740 -0
  82. package/workers/escalation-steward-record/worker.ts +428 -0
  83. package/workers/record-wave/worker.ts +5 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.203.0](https://github.com/nanobpm/nano-workforce/compare/v0.202.0...v0.203.0) (2026-10-13)
2
+
3
+ ### Features
4
+
5
+ * **escalation-steward:** opt-in auto-resolution of escalations with a reviewable record (Closes [#889](https://github.com/nanobpm/nano-workforce/issues/889)) ([#892](https://github.com/nanobpm/nano-workforce/issues/892)) ([cd1c9eb](https://github.com/nanobpm/nano-workforce/commit/cd1c9eb58bc44b7af0c66d4c1a7bc615920a46d2)), closes [887/#888](https://github.com/887/nano-workforce/issues/888) [#833](https://github.com/nanobpm/nano-workforce/issues/833) [o/r#1](https://github.com/o/r/issues/1) [o/r#1](https://github.com/o/r/issues/1)
6
+
7
+ ## [0.202.0](https://github.com/nanobpm/nano-workforce/compare/v0.201.5...v0.202.0) (2026-10-12)
8
+
9
+ ### Features
10
+
11
+ * **agentic:** authoritative claim resync on supervisor reconnect (Closes [#893](https://github.com/nanobpm/nano-workforce/issues/893)) ([#894](https://github.com/nanobpm/nano-workforce/issues/894)) ([2f39fcb](https://github.com/nanobpm/nano-workforce/commit/2f39fcb680ccafa22f633b409e2f66674ae85a7c))
12
+
1
13
  ## [0.201.5](https://github.com/nanobpm/nano-workforce/compare/v0.201.4...v0.201.5) (2026-10-11)
2
14
 
3
15
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -1310,6 +1310,54 @@ re-park (a human node has no separate escalation twin to re-park onto). The gate
1310
1310
  like the escalation resume's no-required-target case), and a no-emit acknowledgement node
1311
1311
  has nothing to validate.
1312
1312
 
1313
+ ## 13.4 Escalation steward (`escalation-steward.bpmn`) — issue #889
1314
+
1315
+ An **opt-in**, timer-paced loop that reviews a run's OPEN escalations and submits a recommended
1316
+ choice for each, so work keeps moving when no human is watching — while every automatic decision
1317
+ stays **reviewable after the fact** in the Auto-resolutions view. It is strictly opt-in: only work
1318
+ whose start request set `autoResolveEscalations: true` (persisted as `auto_resolve_escalations` on
1319
+ the subject row) is ever touched; everything else is fail-closed out.
1320
+
1321
+ - **Pacing + master switch.** `pollEscalationSteward` (a `pollOnce` pass) starts ONE
1322
+ `escalation-steward` instance per interval — `NANO_ESCALATION_STEWARD_INTERVAL` (default `PT30M`,
1323
+ parsed through the canonical `isoDurationToMs`) — when the loop is enabled (`NANO_ESCALATION_STEWARD`,
1324
+ default on) and no prior tick is still ACTIVE. The pass is **best-effort and isolated**: a
1325
+ steward-specific failure is caught and logged so it never starves the poller's other liveness
1326
+ passes, and the tick timestamp advances only after a successful start (a failed start retries next
1327
+ pass).
1328
+ - **Eligibility + the claim fence.** The collect step (`pr.escalation-steward-collect`) gathers the
1329
+ open escalation user tasks whose owning subject OPTED IN — resolved off the subject row by
1330
+ `process_key`, falling back to the root-correlated `subject_key` so an escalation parked inside a
1331
+ callActivity child cell (`human-escalation`/`implement-cell`) inherits its parent run's opt-in —
1332
+ that are past a short minimum age (`NANO_ESCALATION_STEWARD_MIN_AGE_MS`, default 5 min, so a human
1333
+ already answering wins), of a steward-answerable kind, and not already claimed. It claims them with
1334
+ a durable compare-and-swap lease (`escalation_auto_resolutions` `claimed` placeholder + a partial
1335
+ UNIQUE index), so two overlapping ticks (or a tick racing a human) never double-answer; an expired
1336
+ lease is released so a crashed steward never wedges a task.
1337
+ - **The agent + the record.** The `senior:escalation-steward` agent reviews the claimed escalations
1338
+ and returns one decision each (`resolved` + typed form `variables`, or `abstained`). The record
1339
+ step (`pr.escalation-steward-record`) submits each resolution through the ONE canonical attributed
1340
+ door (`completeEscalationAsAgent`, AGENT attribution — never a human decision, ADR 0046) and writes
1341
+ exactly one durable `escalation_auto_resolutions` row per acted escalation.
1342
+ - **Fail-closed guards (defence-in-depth under the prompt's own abstain rules).** Before any
1343
+ submission the record step enforces, in one place: the **claim/ownership fence** (this run must
1344
+ hold a live claim for the key — a hallucinated or foreign key is **skipped**, leaving any other
1345
+ run's claim untouched, *not* recorded `abstained`: `recordResolution` finalises the claim row keyed
1346
+ only by `user_task_key`, so writing even an `abstained` row for a key this run does not own would
1347
+ finalise another run's live claim); the **destructive-option guard**
1348
+ (`abandon`/`force-merge`/credential, derived from the decision's own selected value scoped to the
1349
+ task's ACTUAL form — abstained, never submitted; a foreign safe key can never shadow a destructive
1350
+ own-form key, and a decision carrying conflicting selection fields is abstained as ambiguous); the
1351
+ **loop guard** (a subject auto-resolved `LOOP_GUARD_MAX_RESOLUTIONS` times
1352
+ that escalates again is abstained); and the **malformed-decision guards** (a non-object entry is
1353
+ skipped; a `resolved` decision with no valid `variables` object is recorded `abstained`, never a
1354
+ false `resolved`). Human-only / irreversible kinds (`merge-approval`, `acp-permission`,
1355
+ readiness gates, `feature-blocked`, `conformance-`/`empty-plan-`escalation) are refused before the
1356
+ agent is even dispatched.
1357
+ - **The reviewable record.** Every final decision (`resolved`/`abstained`) is listed in
1358
+ Auto-resolutions, attributed to the steward agent, where an operator can **Dismiss** (reviewed, no
1359
+ action) or **Intervene** (stepped in) with a note — a record is never a hidden machine decision.
1360
+
1313
1361
  ## 14. Open questions / future
1314
1362
 
1315
1363
  - **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
@@ -23,9 +23,20 @@ function memData() {
23
23
  find: (q: any) =>
24
24
  Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
25
25
  insert: (r: any) => {
26
+ // Enforce the PRIMARY KEY (plan_key) uniqueness the real SQLite table has, so the
27
+ // concurrent-insert race path (a colliding second insert throws) is exercisable.
28
+ if (rows.some((x) => x[key] === r[key])) {
29
+ return Promise.reject(new Error(`UNIQUE constraint failed: ${name}.${key}`));
30
+ }
26
31
  rows.push(r);
27
32
  return Promise.resolve(r);
28
33
  },
34
+ update: (k: any, patch: any) => {
35
+ const i = rows.findIndex((r) => r[key] === k);
36
+ if (i === -1) return Promise.resolve({ changed: 0 });
37
+ rows[i] = { ...rows[i], ...patch };
38
+ return Promise.resolve({ changed: 1 });
39
+ },
29
40
  };
30
41
  },
31
42
  } as any,
@@ -59,6 +70,66 @@ test("recordAdmittedEpic is idempotent on plan_key (no second row, existing wins
59
70
  assertEquals(again.base_branch, "epic/a"); // the existing staged row is returned, not overwritten
60
71
  });
61
72
 
73
+ test("recordAdmittedEpic widens the staged escalation opt-in monotonically on an idempotent retry", async () => {
74
+ const { data, tables } = memData();
75
+ // Stage the epic OPTED OUT (the caller omitted the flag → 0), as a crashed-before-lowering request
76
+ // would leave it.
77
+ const first = await recordAdmittedEpic(data, EPIC);
78
+ assertEquals(first.auto_resolve_escalations, 0, "an omitted opt-in stages as 0 (opted out)");
79
+ // Retry the SAME admission now opting in: the staged row must WIDEN to opted-in (issue #889 review),
80
+ // not stay opted out — otherwise the epic starts opted out despite the caller's opt-in.
81
+ const retried = await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 1 });
82
+ assertEquals(tables.get("admitted_epics")!.length, 1, "still one staged row (idempotent)");
83
+ assertEquals(retried.auto_resolve_escalations, 1, "the retry widened the staged opt-in to 1");
84
+ assertEquals(retried.created_at, first.created_at, "the original staged row is preserved");
85
+ assertEquals(tables.get("admitted_epics")![0].auto_resolve_escalations, 1, "the widening is persisted");
86
+ });
87
+
88
+ test("recordAdmittedEpic never narrows an opted-in staged row back to opted-out", async () => {
89
+ const { data, tables } = memData();
90
+ await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 1 });
91
+ // A retry that omits (or explicitly clears) the flag must NOT narrow the staged opt-in back to 0 —
92
+ // the flag is monotonic (mirrors submitPr's live-loop adoption, service.ts).
93
+ const retried = await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 0 });
94
+ assertEquals(retried.auto_resolve_escalations, 1, "an opted-in staged row is never narrowed to 0");
95
+ assertEquals(tables.get("admitted_epics")![0].auto_resolve_escalations, 1);
96
+ });
97
+
98
+ test("recordAdmittedEpic applies the monotonic widening on the concurrent-insert race path", async () => {
99
+ // Simulate the race the reviewer cited (issue #889): an opted-OUT admission's insert WINS, so by
100
+ // the time the opted-IN caller runs, the row already exists — but the opted-in caller's initial
101
+ // `get` raced ahead of that commit and observed NO row, so it proceeds to `insert`, collides on the
102
+ // PRIMARY KEY, and lands in the catch path. The catch must apply the SAME 0→1 widening before
103
+ // returning the raced row, or the opt-in is silently lost.
104
+ const { data, tables } = memData();
105
+ // The winning racer commits its opted-out row directly (as its own successful insert).
106
+ await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 0 });
107
+ assertEquals(tables.get("admitted_epics")![0].auto_resolve_escalations, 0, "the racer staged opted-out");
108
+
109
+ // Force the opted-in caller's FIRST `get` to miss (the race window: it read before the racer's
110
+ // commit became visible); the catch-path re-read then sees the committed row.
111
+ const realTable = (data as any).table;
112
+ let missOnce = true;
113
+ (data as any).table = (name: string, key: string) => {
114
+ const t = realTable(name, key);
115
+ if (name !== "admitted_epics") return t;
116
+ return {
117
+ ...t,
118
+ get: (k: any) => (missOnce ? ((missOnce = false), Promise.resolve(null)) : t.get(k)),
119
+ };
120
+ };
121
+
122
+ // The opted-in caller: get misses → insert collides (mock enforces PK uniqueness) → catch path.
123
+ const raced = await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 1 });
124
+ assertEquals(raced.auto_resolve_escalations, 1, "the catch path widens the raced opted-out row to 1");
125
+ assertEquals(
126
+ tables.get("admitted_epics")!.find((r) => r.plan_key === EPIC.plan_key)!.auto_resolve_escalations,
127
+ 1,
128
+ "the widening is persisted on the raced row",
129
+ );
130
+ assertEquals(tables.get("admitted_epics")!.length, 1, "still exactly one staged row (no duplicate)");
131
+ });
132
+
62
133
  test("recordAdmittedPlanDep stages an edge and stays out of plan_deps", async () => {
63
134
  const { data, tables } = memData();
64
135
  await recordAdmittedPlanDep(data, {
@@ -34,6 +34,11 @@ export const AGENT_SLA_JOB_TYPES: readonly string[] = [
34
34
  "senior:retro", // retro synthesize
35
35
  "senior:fix-ci", // merge-loop CI-fix cell (be_fixci_sla)
36
36
  "senior:rebase", // merge-loop rebase cell (be_rebase_sla)
37
+ // Tracked for AUDIT PROVENANCE, not an SLA boundary (issue #889 review): the escalation-steward
38
+ // record step reads this row's `job_key` to persist the ACTUAL answering agent's activation key on
39
+ // the durable auto-resolution (rather than its own record-task key), so the review surface points at
40
+ // the job that made the decision.
41
+ "senior:escalation-steward", // escalation-steward agent (escalation-steward.bpmn)
37
42
  ];
38
43
 
39
44
  /** One ledger row: whether (and when) a worker picked up an agent job for a given process instance. */
@@ -21,6 +21,8 @@ import {
21
21
  escalationFormId,
22
22
  latestCompletion,
23
23
  revertAgentCompletion,
24
+ snapshotEscalationFormOptions,
25
+ snapshotUserTaskFormOptions,
24
26
  type TaskCompletion,
25
27
  validateEscalationVariables,
26
28
  } from "./agentCompletion.ts";
@@ -958,3 +960,34 @@ test("feature-escalation demands non-blank answer on the answer path, but not on
958
960
  "the abandon path does not demand the hidden answer field",
959
961
  );
960
962
  });
963
+
964
+ test("snapshotUserTaskFormOptions: resolves the engine form_key first (delivery-graph human tasks)", () => {
965
+ // A delivery-graph human task's element id is dynamic and has NO static escalationFormId mapping, so
966
+ // the element-only snapshot returns null — but its engine form_key names the real deployed form. The
967
+ // form_key must win so the options are the REAL choices, not NULL (issue #889 review).
968
+ assertEquals(snapshotEscalationFormOptions("delivery-human-task__publish"), null, "no static form for a dynamic element id");
969
+ assertEquals(
970
+ snapshotUserTaskFormOptions("delivery-escalation", "delivery-human-task__publish"),
971
+ ["continue", "retry"],
972
+ "the engine form_key resolves the deployed form's real options",
973
+ );
974
+ });
975
+
976
+ test("snapshotUserTaskFormOptions: falls back to the element's static form when form_key is blank", () => {
977
+ // No engine form_key — fall back to the fixed-form element's static linkage (trial-merge-decision →
978
+ // [proceed, rebase, abandon]).
979
+ assertEquals(snapshotUserTaskFormOptions(null, "trial-merge-decision"), ["proceed", "rebase", "abandon"]);
980
+ assertEquals(snapshotUserTaskFormOptions(" ", "trial-merge-decision"), ["proceed", "rebase", "abandon"], "a blank form_key falls through to the element form");
981
+ });
982
+
983
+ test("snapshotUserTaskFormOptions: an unresolvable form_key never throws (safe read)", () => {
984
+ // `formContract` does a readFileSync that THROWS on a missing file — a form_key that names no bundled
985
+ // form must degrade to the element fallback / null, never throw (a throw in the claim loop wedges the
986
+ // steward). With neither a resolvable form_key NOR a mappable element, the result is null.
987
+ assertEquals(snapshotUserTaskFormOptions("no-such-form", "delivery-human-task__x"), null, "unresolvable key + dynamic element → null, no throw");
988
+ assertEquals(
989
+ snapshotUserTaskFormOptions("no-such-form", "trial-merge-decision"),
990
+ ["proceed", "rebase", "abandon"],
991
+ "an unresolvable form_key falls back to the element's static form",
992
+ );
993
+ });
@@ -296,6 +296,82 @@ export function validateEscalationVariables(
296
296
  return null;
297
297
  }
298
298
 
299
+ /** Snapshot the REAL option values an escalation's deployed `.form` offers (the union of every `select`
300
+ * field's allowed `values`), read from the canonical form contract host-side. The escalation-steward
301
+ * record step persists THIS — never the deciding agent's self-reported `options` array, which the agent
302
+ * may omit or misstate (issue #889 review) — so the reviewable record shows the choices that were
303
+ * genuinely on offer. Returns `null` when the element has no linked form (nothing to snapshot). */
304
+ export function snapshotEscalationFormOptions(elementId: string): string[] | null {
305
+ const formId = escalationFormId(elementId);
306
+ if (!formId) return null;
307
+ return optionUnionForForm(formId);
308
+ }
309
+
310
+ /** The SELECT-field KEYS a fixed-form escalation's deployed `.form` carries (e.g. `["action"]` for
311
+ * `trial-merge-decision`, `["directive"]` for `plan-review-decision`), read from the SAME canonical
312
+ * form contract the completer validates against — one derivation, no second field list. The
313
+ * escalation-steward record step uses this to scope its destructive-option guard to the field the
314
+ * task's ACTUAL form submits, so a foreign recognized key (a `resolution` on a `trial-merge-decision`,
315
+ * whose own select is `action`) can never shadow the real destructive choice (issue #889 review).
316
+ * Returns `null` when the element has no linked form (nothing to scope to — the caller falls back to
317
+ * its conservative recognized-key sweep). Never throws on an unreadable form (yields `null`). */
318
+ export function escalationFormSelectKeys(elementId: string): string[] | null {
319
+ const formId = escalationFormId(elementId);
320
+ if (!formId) return null;
321
+ let contract: FormContract;
322
+ try {
323
+ contract = formContract(formId);
324
+ } catch {
325
+ return null;
326
+ }
327
+ return Object.keys(contract.allowed);
328
+ }
329
+
330
+ /** The union of every `select` field's allowed option values in a deployed `.form`, or `null` when the
331
+ * form id is blank/unreadable (a missing or non-`.form` key never throws — see the try/catch). Shared
332
+ * by {@link snapshotEscalationFormOptions} (fixed-form escalations, keyed off the element) and
333
+ * {@link snapshotUserTaskFormOptions} (keyed off the engine-resolved `form_key`). */
334
+ function optionUnionForForm(formId: string): string[] | null {
335
+ let contract: FormContract;
336
+ try {
337
+ contract = formContract(formId);
338
+ } catch {
339
+ // `formContract` does a `readFileSync` and THROWS on a missing/unreadable `.form` — a `form_key`
340
+ // can be a key that doesn't name a bundled form. Treat that as "nothing to snapshot", never a throw
341
+ // that would incident the record tick.
342
+ return null;
343
+ }
344
+ const out: string[] = [];
345
+ for (const values of Object.values(contract.allowed)) {
346
+ for (const v of values) if (!out.includes(v)) out.push(v);
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /** Snapshot the REAL option values a parked user task's deployed form offers, resolving the form from
352
+ * the engine-reported `formKey` FIRST and falling back to the element's static form linkage — the SAME
353
+ * precedence the Tasks grid denormalises with (`app/service.ts`), so there is one derivation. This is
354
+ * what the escalation-steward record step persists for a DELIVERY-GRAPH human task, whose element id is
355
+ * dynamic (`delivery-human-task__<node>`) and so has NO static `escalationFormId` mapping — snapshotting
356
+ * off `element_id` alone would record `NULL` options for exactly those tasks (issue #889 review). Reads
357
+ * the `formKey` safely (a key that doesn't name a bundled `.form` yields `null`, never a throw).
358
+ * Returns `null` when neither source resolves a readable form. */
359
+ export function snapshotUserTaskFormOptions(
360
+ formKey: string | null | undefined,
361
+ elementId: string | null | undefined,
362
+ ): string[] | null {
363
+ const engineKey = typeof formKey === "string" ? formKey.trim() : "";
364
+ if (engineKey) {
365
+ const fromKey = optionUnionForForm(engineKey);
366
+ if (fromKey != null) return fromKey;
367
+ }
368
+ if (elementId) {
369
+ const formId = escalationFormId(elementId);
370
+ if (formId) return optionUnionForForm(formId);
371
+ }
372
+ return null;
373
+ }
374
+
299
375
  /** The canonical attributed completer. Records an attribution row in `task_completions` and THEN
300
376
  * completes the user task with the exact typed `variables` — so the ledger row can never be lost by a
301
377
  * resume that fires before the write. If the engine completion throws (a failed/rejected completion,
@@ -401,6 +477,30 @@ export async function latestCompletion(
401
477
  return newest;
402
478
  }
403
479
 
480
+ /** The newest NON-reverted completion an AGENT recorded for a user-task key under a specific
481
+ * `agentId`, or undefined. The escalation-steward record step uses this to make its
482
+ * completion→record pair idempotent across a service-job retry (issue #889 review): the engine
483
+ * completion lands first, then the record worker finalises the claim row — if that finalise fails
484
+ * transiently, the retry finds the user task already COMPLETED (the attributed door reports "no open
485
+ * completable task") and must NOT degrade the already-applied decision to a false `abstained`.
486
+ * Detecting the prior agent-attributed row lets the retry recover the original submitted variables
487
+ * and finalise the SAME decision instead. Reverted rows are excluded: a human overrode that
488
+ * completion, so it is not a live applied decision to recover. */
489
+ export async function priorAgentCompletion(
490
+ data: DataLayer,
491
+ userTaskKey: string,
492
+ agentId: string,
493
+ ): Promise<TaskCompletion | undefined> {
494
+ const rows = await taskCompletions(data).find({ user_task_key: userTaskKey });
495
+ let newest: TaskCompletion | undefined;
496
+ for (const row of rows) {
497
+ if (row.actor_kind !== "agent" || row.actor_id !== agentId) continue;
498
+ if (row.reverted === 1) continue;
499
+ if (!newest || row.id > newest.id) newest = row;
500
+ }
501
+ return newest;
502
+ }
503
+
404
504
  export interface AgentCompleteResult {
405
505
  ok: boolean;
406
506
  reason?: string;
@@ -460,14 +560,20 @@ function isCompletableElement(elementId: string, allowed: ReadonlySet<string>):
460
560
  export async function completeEscalationAsAgent(
461
561
  data: DataLayer,
462
562
  engine: EngineClient,
463
- input: { userTaskKey: string; variables: Record<string, unknown>; agentId: string },
563
+ input: { userTaskKey: string; variables: Record<string, unknown>; agentId: string; processInstanceKey?: string | null },
464
564
  ): Promise<AgentCompleteResult> {
465
565
  const userTaskKey = input.userTaskKey.trim();
466
566
  if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
467
567
  const agentId = input.agentId.trim();
468
568
  if (!agentId) return { ok: false, reason: "agentId is required" };
469
569
 
470
- const resolved = await resolveEscalationTask(engine, userTaskKey);
570
+ // When the caller already knows the task's owning process instance (the escalation-steward record
571
+ // worker does — it holds the claim row's `process_instance_key`), scope the resolve scan to that ONE
572
+ // instance instead of an engine-wide `openUserTasks()` per decision. A record tick finalises MANY
573
+ // claimed tasks in one pass, so an unfiltered global scan each is O(N²) REST load (issue #889 review);
574
+ // the instance filter makes it O(N) and never changes the outcome (the task lives in exactly that
575
+ // instance). A bare-key caller (a one-off human/agent door) omits it and keeps the engine-wide scan.
576
+ const resolved = await resolveEscalationTask(engine, userTaskKey, ESCALATION_TASK_ELEMENTS, input.processInstanceKey ?? undefined);
471
577
  if (!resolved.ok) return resolved;
472
578
 
473
579
  const invalid = validateEscalationVariables(resolved.elementId, input.variables);
@@ -79,6 +79,54 @@ derivation half:
79
79
  a chosen offset, so an exited session can be branched and replayed independently, offset-parity
80
80
  preserved. Byte-replay and resume-from-offset (`transcript-read.ts`) are untouched.
81
81
 
82
+ ## Job-ownership claims & authoritative reconnect resync (#713, #893)
83
+
84
+ The `claim` / `release` family (`families/claim.family.ts`) backs the {@link ClaimRegistry}
85
+ (`claim-registry.ts`) — the authoritative "which jobs a worker instance is running" visibility source
86
+ the supply snapshot reads. Each `claim` (wire code 8) / `release` (wire code 9) frame names its OWNING
87
+ `instance` explicitly, so one per-host supervisor connection can carry many workers' ownership frames.
88
+
89
+ **Authoritative resync (the supervisor's view wins).** On a WS reconnect a supervisor re-`register`s
90
+ every worker and re-`claim`s every active jobKey. Re-claims alone are *additive* — a claim that is not
91
+ re-asserted is never dropped, so a `release` lost during a hub outage would leave the job shown as
92
+ "working" until the worker exits entirely. To close that gap a supervisor may end its resync with an
93
+ **authoritative snapshot**: a `claim` frame enriched with the full `jobKeys` set it currently owns
94
+ (`{ instance, jobKeys: [...] }` — possibly empty, the meaningful "owns nothing now" resync). The hub
95
+ then calls `ClaimRegistry.replaceInstance(instance, jobKeys)`, which **drops every claim that instance
96
+ held but did not re-assert** and (re-)claims the snapshot's set. The snapshot rides the existing
97
+ `claim` wire family as a forward-compatible enrichment (the protocol's structural validators tolerate
98
+ the extra field), so **no new wire code** is needed.
99
+
100
+ The `claim` family thus carries **two payload variants** — the classic per-job claim
101
+ `{ instance, jobKey }` and the resync snapshot `{ instance, jobKeys }` (the `claim.snapshot` wire
102
+ contract in `app/contracts.ts`). The pinned `@nanobpm/agentic` validator predates the snapshot variant
103
+ and requires a non-empty `jobKey`, so the hub validates the **union** itself: a frame is well-formed
104
+ iff it is a valid snapshot (non-empty `instance` + a `jobKeys` array of **non-empty** strings) or a
105
+ valid per-job claim. This is a **union with a validated overlap**, not an exclusive-or: a frame
106
+ carrying *both* fields is accepted, but only when *both* halves validate, so the snapshot branch never
107
+ bypasses validation. The **presence** of `jobKeys` (not its validity) is the snapshot discriminant, so
108
+ a present-but-malformed `jobKeys` — a non-array, a non-string element, **or an empty-string element**
109
+ (held to the same non-empty bar as the per-job `jobKey`) — is a broken snapshot and is **rejected**,
110
+ never treated as absent and laundered through the per-job validator (which would accept it on any
111
+ frame that also carries a valid `jobKey`). The empty *array* remains the meaningful "owns nothing now"
112
+ resync — only empty *elements* are rejected. Versioning the snapshot into the canonical `@nanobpm/agentic` protocol payloads is the
113
+ nano-ide follow-up issue #893 scopes ("coordinate with the `@nanobpm/agentic` package owners"); the
114
+ producer half (one snapshot per instance, including empty sets) is nano-supervisor#63.
115
+
116
+ Guarantees `replaceInstance` keeps:
117
+
118
+ - A claim re-asserted in the snapshot is unchanged.
119
+ - A jobKey that has **moved to another instance** is never blanked by the old instance's resync (the
120
+ old instance no longer holds it, so the drop pass cannot touch it).
121
+ - Repeated resyncs are safe (idempotent).
122
+ - **Compatibility:** a supervisor that never sends a snapshot keeps the pre-#893 additive behaviour —
123
+ claims are then cleared only by an explicit `release` or the presence-driven `reconcile`.
124
+
125
+ Authoritative replacement is safe because ownership is advisory, short-lived visibility the supervisor
126
+ can always restate; the registry never gates a BPMN flow (ADR 0056). This is what lets a downstream hub
127
+ client (nano-supervisor#63) **bound** its RELEASE buffer during an outage instead of buffering every
128
+ RELEASE without limit.
129
+
82
130
  ## Invariants (ADR 0056)
83
131
 
84
132
  - **App-tier only** — never the engine. The Camunda-8 job protocol (worker⇄engine) is untouched;
@@ -112,6 +112,67 @@ test("reconnect-resync: re-register + re-claim rebuilds the same claim (never bl
112
112
  assert.equal(reg.count(), 1);
113
113
  });
114
114
 
115
+ test("replaceInstance: an authoritative resync drops a claim the supervisor did NOT re-assert (a lost RELEASE no longer strands the job)", () => {
116
+ const reg = new ClaimRegistry();
117
+ reg.claim("wk-a", "8420");
118
+ reg.claim("wk-a", "8421");
119
+ // The hub missed the `release` for 8420 during an outage. On reconnect the supervisor restates its
120
+ // authoritative snapshot: wk-a now owns only 8421. The non-re-asserted 8420 is dropped.
121
+ reg.replaceInstance("wk-a", ["8421"]);
122
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8421"]);
123
+ assert.equal(reg.isClaimed("8420"), false, "the stranded claim was dropped");
124
+ assert.equal(reg.count(), 1);
125
+ });
126
+
127
+ test("replaceInstance: a re-asserted claim is unchanged; an added one is claimed", () => {
128
+ const reg = new ClaimRegistry();
129
+ reg.claim("wk-a", "8420");
130
+ reg.replaceInstance("wk-a", ["8420", "8421"]);
131
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8420", "8421"]);
132
+ assert.equal(reg.ownerOf("8420"), "wk-a");
133
+ assert.equal(reg.ownerOf("8421"), "wk-a");
134
+ });
135
+
136
+ test("replaceInstance: an empty snapshot drops every claim the instance held", () => {
137
+ const reg = new ClaimRegistry();
138
+ reg.claim("wk-a", "8420");
139
+ reg.claim("wk-a", "8421");
140
+ reg.replaceInstance("wk-a", []);
141
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
142
+ assert.equal(reg.count(), 0);
143
+ });
144
+
145
+ test("replaceInstance: a jobKey that moved to another instance is NOT dropped by the old instance's resync", () => {
146
+ const reg = new ClaimRegistry();
147
+ reg.claim("wk-a", "8420");
148
+ reg.claim("wk-b", "8420"); // 8420 moved to wk-b
149
+ // wk-a's authoritative resync no longer mentions 8420 (it does not run it) — and must not blank the
150
+ // live owner wk-b.
151
+ reg.replaceInstance("wk-a", []);
152
+ assert.deepEqual(reg.jobKeysFor("wk-b"), ["8420"], "the current owner's claim survives");
153
+ assert.equal(reg.ownerOf("8420"), "wk-b");
154
+ });
155
+
156
+ test("replaceInstance: only the resyncing instance's claims are touched; siblings are untouched", () => {
157
+ const reg = new ClaimRegistry();
158
+ reg.claim("wk-a", "8420");
159
+ reg.claim("wk-b", "8500");
160
+ reg.replaceInstance("wk-a", []);
161
+ assert.deepEqual(reg.jobKeysFor("wk-a"), []);
162
+ assert.deepEqual(reg.jobKeysFor("wk-b"), ["8500"], "an unrelated instance's claim is untouched");
163
+ });
164
+
165
+ test("replaceInstance is idempotent and ignores empty jobKeys / an empty instance", () => {
166
+ const reg = new ClaimRegistry();
167
+ reg.claim("wk-a", "8420");
168
+ reg.replaceInstance("wk-a", ["8420", ""]); // the empty jobKey is ignored
169
+ reg.replaceInstance("wk-a", ["8420"]); // repeat — no churn
170
+ assert.deepEqual(reg.jobKeysFor("wk-a"), ["8420"]);
171
+ reg.replaceInstance("", ["9999"]); // an empty instance is ignored
172
+ assert.equal(reg.isClaimed("9999"), false);
173
+ assert.equal(reg.count(), 1);
174
+ });
175
+
115
176
  test("reconcile drops claims for absent instances, keeps present ones, and reports the released", () => {
116
177
  const reg = new ClaimRegistry();
117
178
  reg.claim("wk-a", "8420");
@@ -52,7 +52,9 @@ export interface ClaimSnapshot {
52
52
  * It needs no durable backing: claims are ephemeral and re-announced. On a WS reconnect the supervisor
53
53
  * re-`register`s every worker and re-`claim`s every active jobKey, so the registry is rebuilt from that
54
54
  * full-state resync — which is exactly what makes it survive the reconnect that the old relay-derived
55
- * path stranded the jobKey across.
55
+ * path stranded the jobKey across. A supervisor may close its resync with an AUTHORITATIVE snapshot via
56
+ * {@link replaceInstance} (issue #893), dropping any claim it did not re-assert so a lost `release`
57
+ * cannot strand a job; omitting it keeps the additive re-claim behaviour.
56
58
  */
57
59
  export class ClaimRegistry {
58
60
  /** worker instance → the set of jobKeys it currently owns (insertion-ordered). */
@@ -107,6 +109,38 @@ export class ClaimRegistry {
107
109
  this.#jobsOf.delete(instance);
108
110
  }
109
111
 
112
+ /**
113
+ * Authoritatively REPLACE every claim held by `instance` with `jobKeys` — the reconnect-resync
114
+ * contract (issue #893). A reconnecting supervisor restates the full, current ownership snapshot for
115
+ * one worker instance; its view WINS. Every claim this instance held that is NOT in `jobKeys` is
116
+ * dropped (so a `release` lost during a hub outage no longer strands the job as "working" until the
117
+ * worker exits entirely), and every jobKey in `jobKeys` is (re-)claimed — an idempotent no-op for one
118
+ * it already owned, a {@link claim} move for one another instance held.
119
+ *
120
+ * It keeps the registry's existing guarantees: a jobKey that has MOVED to another instance is never
121
+ * blanked by the OLD instance's resync (the old instance no longer holds it, so the drop pass cannot
122
+ * touch it), and repeated resyncs are safe. Only `instance`'s own claims are considered — a sibling's
123
+ * claims are untouched. An empty `instance` is ignored; empty jobKeys in the snapshot are skipped.
124
+ *
125
+ * This is ADDITIVE on the wire: a supervisor that never sends a resync snapshot keeps the pre-#893
126
+ * merge behaviour (claims cleared only by an explicit `release` or the presence-driven
127
+ * {@link reconcile}). Authoritative replacement is safe precisely because ownership is advisory,
128
+ * short-lived visibility the supervisor can always restate (ADR 0056 — the registry never gates a
129
+ * BPMN flow).
130
+ */
131
+ replaceInstance(instance: string, jobKeys: Iterable<string>): void {
132
+ if (instance === "") return;
133
+ const next = new Set<string>();
134
+ for (const jobKey of jobKeys) if (jobKey !== "") next.add(jobKey);
135
+ const current = this.#jobsOf.get(instance);
136
+ if (current) {
137
+ for (const jobKey of [...current]) {
138
+ if (!next.has(jobKey)) this.release(instance, jobKey);
139
+ }
140
+ }
141
+ for (const jobKey of next) this.claim(instance, jobKey);
142
+ }
143
+
110
144
  /**
111
145
  * Drop every claim whose owning instance is NOT in `presentInstances` — the bounded-memory
112
146
  * reconcile a maintenance tick drives against the live presence set, so a departed supervisor's