@nanobpm/nano-workforce 0.202.0 → 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 +6 -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/vocab/agent-sla-boundary.test.ts +6 -1
- package/app/contracts.ts +23 -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/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,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.202.0](https://github.com/nanobpm/nano-workforce/compare/v0.201.5...v0.202.0) (2026-10-12)
|
|
2
8
|
|
|
3
9
|
### Features
|
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);
|
|
@@ -150,7 +150,12 @@ test("externalAgentTasksMissingSlaBoundary rejects a timer with no timeDuration,
|
|
|
150
150
|
// exactly these processes so a regression on a *bounded* process is caught while the deferred
|
|
151
151
|
// two stay out. merge-loop.bpmn's agent tasks (`fix-ci` / `rebase`) were bounded in an earlier
|
|
152
152
|
// #849 commit, so it belongs here too — every already-bounded process stays regression-guarded.
|
|
153
|
-
|
|
153
|
+
// escalation-steward.bpmn's `steward-agent` was bounded for the #889 review (a hung steward
|
|
154
|
+
// instance otherwise parks ACTIVE forever, suppressing every later tick via the poller's overlap
|
|
155
|
+
// guard and wedging claim recovery); its boundary routes to `done` (the steward has no escalation
|
|
156
|
+
// path of its own — on SLA the tick ends and the 15-min claim TTL frees its claims for the next
|
|
157
|
+
// tick), which satisfies the guard's "routes somewhere" requirement.
|
|
158
|
+
const BOUNDED_PROCESSES = ["implement-cell.bpmn", "merge-cell.bpmn", "merge-loop.bpmn", "retro.bpmn", "escalation-steward.bpmn"] as const;
|
|
154
159
|
|
|
155
160
|
test("DEFECT-CLASS GUARD: every external agent task in a bounded process carries an interrupting timer SLA boundary", () => {
|
|
156
161
|
let anyAgentTasks = false;
|
package/app/contracts.ts
CHANGED
|
@@ -179,6 +179,29 @@ export const ENV_CONTRACTS = {
|
|
|
179
179
|
semantics: "How long to wait for a review before nudging/escalating (FEEL/ISO-8601 duration).",
|
|
180
180
|
default: "PT30M",
|
|
181
181
|
},
|
|
182
|
+
NANO_ESCALATION_STEWARD: {
|
|
183
|
+
category: "env",
|
|
184
|
+
name: "NANO_ESCALATION_STEWARD",
|
|
185
|
+
owner: "app/escalationSteward.ts",
|
|
186
|
+
semantics:
|
|
187
|
+
"Escalation-steward master switch (issue #889): `off`/`0`/`false`/`no` disables the timer loop entirely; anything else (or unset) leaves it enabled.",
|
|
188
|
+
},
|
|
189
|
+
NANO_ESCALATION_STEWARD_INTERVAL: {
|
|
190
|
+
category: "env",
|
|
191
|
+
name: "NANO_ESCALATION_STEWARD_INTERVAL",
|
|
192
|
+
owner: "app/escalationSteward.ts",
|
|
193
|
+
semantics:
|
|
194
|
+
"How often the escalation-steward timer fires (FEEL/ISO-8601 duration or `R/PT…` cycle), pacing the steward tick (issue #889).",
|
|
195
|
+
default: "R/PT30M",
|
|
196
|
+
},
|
|
197
|
+
NANO_ESCALATION_STEWARD_MIN_AGE_MS: {
|
|
198
|
+
category: "env",
|
|
199
|
+
name: "NANO_ESCALATION_STEWARD_MIN_AGE_MS",
|
|
200
|
+
owner: "app/escalationSteward.ts",
|
|
201
|
+
semantics:
|
|
202
|
+
"Minimum age (ms) an open escalation must reach before the steward considers it, so a human already answering wins (issue #889).",
|
|
203
|
+
default: "300000",
|
|
204
|
+
},
|
|
182
205
|
NANO_PR_REVIEW_NUDGE_MINUTES: {
|
|
183
206
|
category: "env",
|
|
184
207
|
name: "NANO_PR_REVIEW_NUDGE_MINUTES",
|
|
@@ -227,6 +227,9 @@ export async function dispatchDeliveryGraphRun(
|
|
|
227
227
|
title,
|
|
228
228
|
humanLabels: buildHumanLabels(compiled),
|
|
229
229
|
createdAt: existing?.created_at,
|
|
230
|
+
// Opt-in escalation steward (issue #889): persist the run's opt-in so the steward's eligibility
|
|
231
|
+
// derivation reads it off the durable row.
|
|
232
|
+
autoResolveEscalations: options.autoResolveEscalations === true,
|
|
230
233
|
};
|
|
231
234
|
|
|
232
235
|
// Claim the run durably BEFORE the side effect (the at-most-once dispatch fence). A losing racer
|
|
@@ -344,6 +347,9 @@ export async function dispatchDeliveryGraphRun(
|
|
|
344
347
|
repository: options.repository,
|
|
345
348
|
baseBranch: options.baseBranch,
|
|
346
349
|
repoless: options.repoless,
|
|
350
|
+
// Opt-in escalation steward (issue #889): seeded as a run-root variable so every node's
|
|
351
|
+
// escalation inherits it (and the delivery-connector propagates it to any enrolled PR).
|
|
352
|
+
autoResolveEscalations: options.autoResolveEscalations,
|
|
347
353
|
});
|
|
348
354
|
} catch (err) {
|
|
349
355
|
await markClaimFailed();
|
|
@@ -39,7 +39,7 @@ import { applyMigrationSet, readMigrationSetFromDisk } from "../test/migrations.
|
|
|
39
39
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
40
40
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
41
41
|
|
|
42
|
-
const READ_MODEL_MIGRATION = "
|
|
42
|
+
const READ_MODEL_MIGRATION = "121_delivery_graph_read_model_auto_resolve.sql";
|
|
43
43
|
|
|
44
44
|
// A minimal in-memory DB carrying the base `delivery_graph_runs` / `pull_requests` shapes the VIEW
|
|
45
45
|
// reads, plus stand-ins for the managed `<table>__tracking` derived VIEWs urban provisions at mount
|
|
@@ -53,7 +53,7 @@ function viewDb(): DatabaseSync {
|
|
|
53
53
|
run_key TEXT PRIMARY KEY, process_key TEXT, process_definition_id TEXT, digest TEXT,
|
|
54
54
|
status TEXT, side_effecting INTEGER, node_count INTEGER, human_node_count INTEGER,
|
|
55
55
|
side_effect_count INTEGER, title TEXT, phase TEXT, phase_node_id TEXT, human_labels TEXT,
|
|
56
|
-
created_at TEXT, updated_at TEXT, acknowledged_at TEXT, derived_status_override TEXT);
|
|
56
|
+
created_at TEXT, updated_at TEXT, acknowledged_at TEXT, auto_resolve_escalations INTEGER, derived_status_override TEXT);
|
|
57
57
|
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, root_request_key TEXT, status TEXT,
|
|
58
58
|
derived_status_override TEXT);`,
|
|
59
59
|
);
|
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
|
|
|
28
28
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
29
29
|
import { redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
30
30
|
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement, stripDeliveryEscalationTwinSuffix } from "./deliveryHuman.ts";
|
|
31
|
+
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
31
32
|
|
|
32
33
|
const now = () => new Date().toISOString();
|
|
33
34
|
|
|
@@ -52,6 +53,9 @@ export interface DeliveryGraphRun {
|
|
|
52
53
|
* the `delivery_graph_read_model` VIEW folds its `list_bucket` to 'history'; NULL while the run is
|
|
53
54
|
* live or terminal-but-undismissed (it stays in Active until an operator ticks it off). */
|
|
54
55
|
acknowledged_at: string | null;
|
|
56
|
+
/** Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this run's
|
|
57
|
+
* escalations are eligible for steward auto-resolution. Default 0. */
|
|
58
|
+
auto_resolve_escalations: number;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
/** The run lifecycle. `awaiting-approval` is RESERVED but no longer produced (issue #460 moved dispatch
|
|
@@ -98,6 +102,18 @@ export const DELIVERY_PHASE = {
|
|
|
98
102
|
export const deliveryGraphRuns = (data: DataLayer) =>
|
|
99
103
|
data.table<DeliveryGraphRun>("delivery_graph_runs", "run_key");
|
|
100
104
|
|
|
105
|
+
/** A `delivery_graph_runs` row as seen through its derived tracking VIEW
|
|
106
|
+
* (`delivery_graph_runs__tracking`): the base columns plus urban's ADR-0065 `derived_status`, which
|
|
107
|
+
* folds the reconciler's terminal edge (out-of-band terminate → `failed`) over the worker-owned
|
|
108
|
+
* transient. */
|
|
109
|
+
export type TrackedDeliveryGraphRun = DeliveryGraphRun & { derived_status: string };
|
|
110
|
+
/** Read-only accessor over the delivery-run derived tracking VIEW. Use this — and read
|
|
111
|
+
* `derived_status`, not `status` — for any terminal-edge classification (e.g. the `/status` active
|
|
112
|
+
* list), so an out-of-band-terminated run (whose base row is still `running`) is correctly seen as
|
|
113
|
+
* `failed`. Mirrors `prsTracking` / `featureRunsTracking`. Writes stay on `deliveryGraphRuns`. */
|
|
114
|
+
export const deliveryGraphRunsTracking = (data: DataLayer) =>
|
|
115
|
+
derivedTrackingTable<TrackedDeliveryGraphRun>(data, "delivery_graph_runs", "run_key");
|
|
116
|
+
|
|
101
117
|
/** One row of the `delivery_graph_run_identity` SIDE table (migration 113) — the run's LOSSLESS
|
|
102
118
|
* content-identity fingerprint, kept OFF the heavily-projected `delivery_graph_runs` base row so no
|
|
103
119
|
* drift-guarded read-model/compat view has to carry it. Keyed by `run_key`. */
|
|
@@ -493,6 +509,7 @@ export function buildDeliveryGraphRunRow(input: {
|
|
|
493
509
|
processDefinitionId?: string | null;
|
|
494
510
|
humanLabels?: Record<string, string>;
|
|
495
511
|
createdAt?: string;
|
|
512
|
+
autoResolveEscalations?: boolean;
|
|
496
513
|
}): DeliveryGraphRun {
|
|
497
514
|
const at = now();
|
|
498
515
|
return {
|
|
@@ -509,6 +526,7 @@ export function buildDeliveryGraphRunRow(input: {
|
|
|
509
526
|
phase: input.phase,
|
|
510
527
|
phase_node_id: null,
|
|
511
528
|
human_labels: input.humanLabels ? JSON.stringify(input.humanLabels) : null,
|
|
529
|
+
auto_resolve_escalations: input.autoResolveEscalations === true ? 1 : 0,
|
|
512
530
|
created_at: input.createdAt ?? at,
|
|
513
531
|
updated_at: at,
|
|
514
532
|
acknowledged_at: null,
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -55,6 +55,10 @@ export interface DeliveryRunTimeouts {
|
|
|
55
55
|
probePollEvery?: string;
|
|
56
56
|
/** Optional explicit assignee for `human` nodes + escalation tasks (else candidate-group routed). */
|
|
57
57
|
escalationAssignee?: string | null;
|
|
58
|
+
/** Opt-in escalation steward (issue #889): when true, this run's escalations (human nodes, service
|
|
59
|
+
* node escalation twins, and any `converge*` connector's enrolled PR) become eligible for steward
|
|
60
|
+
* auto-resolution. Seeded as a run-root variable and persisted on the `delivery_graph_runs` row. */
|
|
61
|
+
autoResolveEscalations?: boolean;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
export interface DeliveryRunOptions extends DeliveryRunTimeouts {
|
|
@@ -96,7 +100,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
|
|
|
96
100
|
repoless?: boolean;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
|
|
103
|
+
const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee" | "autoResolveEscalations">> = {
|
|
100
104
|
nodeTimeout: DELIVERY_NODE_DEFAULT_TIMEOUT,
|
|
101
105
|
probeTimeout: "PT30M",
|
|
102
106
|
probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
|
|
@@ -253,6 +257,10 @@ export async function runDeliveryGraph(
|
|
|
253
257
|
// bounds a node's whole retry budget; this seeds the shared `agentSlaTimeout` knob so any
|
|
254
258
|
// agent cell modelled with the fleet-wide SLA boundary resolves it too.
|
|
255
259
|
agentSlaTimeout: AGENT_SLA_TIMEOUT,
|
|
260
|
+
// Opt-in escalation steward (issue #889): seeded as a run-root variable so every node's
|
|
261
|
+
// escalation (and the delivery-connector's `converge*` enrollment) inherits the opt-in. The
|
|
262
|
+
// durable record the steward's eligibility reads is the `delivery_graph_runs` row (dispatch).
|
|
263
|
+
autoResolveEscalations: options.autoResolveEscalations === true,
|
|
256
264
|
},
|
|
257
265
|
});
|
|
258
266
|
// The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
|