@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.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +48 -0
- package/app/admittedStaging.test.ts +71 -0
- package/app/agentActivationLedger.ts +5 -0
- package/app/agentCompletion.test.ts +33 -0
- package/app/agentCompletion.ts +108 -2
- package/app/agentic/README.md +48 -0
- package/app/agentic/claim-registry.test.ts +61 -0
- package/app/agentic/claim-registry.ts +35 -1
- package/app/agentic/families/claim.family.test.ts +216 -0
- package/app/agentic/families/claim.family.ts +100 -4
- package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
- package/app/contracts.ts +31 -0
- package/app/deliveryGraphDispatch.ts +6 -0
- package/app/deliveryGraphReadModel.test.ts +2 -2
- package/app/deliveryGraphRun.ts +18 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/escalationSteward.test.ts +452 -0
- package/app/escalationSteward.ts +537 -0
- package/app/escalationStewardPoll.test.ts +123 -0
- package/app/feature.test.ts +16 -0
- package/app/feature.ts +11 -0
- package/app/featureDelivery.test.ts +23 -0
- package/app/mcpToolSurface.ts +17 -1
- package/app/plan.ts +49 -6
- package/app/planLowering.test.ts +26 -1
- package/app/planLowering.ts +5 -0
- package/app/promotionPoll.test.ts +19 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +36 -0
- package/app/service.ts +139 -5
- package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
- package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
- package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
- package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
- package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
- package/docs/agent-guide.md +69 -30
- package/docs/agentic-cockpit.md +34 -0
- package/docs/mcp-runbook.md +2 -0
- package/e2e/escalation-steward.e2e.ts +211 -0
- package/nano.app.json +10 -1
- package/openapi.yaml +365 -0
- package/operations/dispatchDeliveryGraph.ts +6 -1
- package/operations/listActivePrs.test.ts +60 -1
- package/operations/listActivePrs.ts +3 -3
- package/operations/listAutoResolutions.ts +83 -0
- package/operations/reviewAutoResolution.test.ts +137 -0
- package/operations/reviewAutoResolution.ts +42 -0
- package/operations/startConvergenceLoop.ts +5 -1
- package/operations/startEpicSet.admission.integration.test.ts +2 -2
- package/operations/startEpicSet.ts +8 -2
- package/operations/startFeature.ts +5 -0
- package/operations/startPlanFanout.ts +4 -1
- package/package.json +1 -1
- package/pages/_nav.json +12 -0
- package/pages/auto-resolutions.page.json +191 -0
- package/pages/board.page.json +17 -0
- package/pages/cockpit.page.json +17 -0
- package/pages/delivery-graph-detail.page.json +17 -0
- package/pages/delivery-graphs/staged.mount.js +17 -5
- package/pages/delivery-graphs.page.json +17 -0
- package/pages/epic-detail.page.json +17 -0
- package/pages/epic.page.json +19 -1
- package/pages/feature.page.json +19 -1
- package/pages/home.page.json +22 -0
- package/pages/lineage.page.json +17 -0
- package/pages/mcp.page.json +17 -0
- package/pages/overview.page.json +17 -0
- package/pages/tasks.page.json +17 -0
- package/pages/velocity.page.json +17 -0
- package/resources/processes/escalation-steward.bpmn +162 -0
- package/resources/prompts/escalation-steward.md +87 -0
- package/test/delivery-graphs-staged-embed.test.ts +13 -0
- package/test/derivation-parity/derivation-parity.test.ts +1 -0
- package/test/derivation-parity/flows.ts +12 -0
- package/workers/converge-feature/worker.ts +6 -1
- package/workers/delivery-connector/worker.test.ts +72 -1
- package/workers/delivery-connector/worker.ts +53 -2
- package/workers/escalation-steward-collect/worker.test.ts +180 -0
- package/workers/escalation-steward-collect/worker.ts +99 -0
- package/workers/escalation-steward-record/worker.test.ts +740 -0
- package/workers/escalation-steward-record/worker.ts +428 -0
- 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
|
+
});
|
package/app/agentCompletion.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/app/agentic/README.md
CHANGED
|
@@ -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
|