@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
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// Unit tests for the opt-in escalation steward domain core (issue #889).
|
|
2
|
+
//
|
|
3
|
+
// Covers the PURE eligibility/guard derivations and the record/claim I/O against the real
|
|
4
|
+
// provisioned SQLite data layer:
|
|
5
|
+
// • eligibility — opted-in only, answerable kinds only, min-age, not-already-claimed;
|
|
6
|
+
// • the claim/lease fence — two claims for one task can never both win (no double-answer);
|
|
7
|
+
// • the destructive-option + loop guards — abstain, never submit;
|
|
8
|
+
// • record writing + dismiss/intervene review transitions;
|
|
9
|
+
// • the opt-in map derivation across every subject table.
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertEquals } from "#test-assert";
|
|
15
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
16
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
17
|
+
import {
|
|
18
|
+
claimEscalations,
|
|
19
|
+
countSubjectResolutions,
|
|
20
|
+
escalationAutoResolutions,
|
|
21
|
+
isDestructiveOption,
|
|
22
|
+
isEligibleEscalation,
|
|
23
|
+
isStewardAnswerableElement,
|
|
24
|
+
isStewardAnswerableForm,
|
|
25
|
+
listAutoResolutions,
|
|
26
|
+
liveClaimedTaskKeys,
|
|
27
|
+
loopGuardTripped,
|
|
28
|
+
LOOP_GUARD_MAX_RESOLUTIONS,
|
|
29
|
+
optInByProcessKey,
|
|
30
|
+
optInBySubjectKey,
|
|
31
|
+
pendingAutoResolutionCount,
|
|
32
|
+
recordResolution,
|
|
33
|
+
renewClaim,
|
|
34
|
+
reviewResolution,
|
|
35
|
+
STEWARD_NEVER_RESOLVE_ELEMENTS,
|
|
36
|
+
} from "./escalationSteward.ts";
|
|
37
|
+
import type { UserTaskRow } from "./userTasks.ts";
|
|
38
|
+
|
|
39
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
40
|
+
|
|
41
|
+
async function withData(fn: (data: DataLayer) => Promise<void>): Promise<void> {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-steward-"));
|
|
43
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
44
|
+
try {
|
|
45
|
+
await fn(app.db);
|
|
46
|
+
} finally {
|
|
47
|
+
await app.stop?.();
|
|
48
|
+
rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function taskRow(over: Partial<UserTaskRow>): UserTaskRow {
|
|
53
|
+
return {
|
|
54
|
+
user_task_key: "UT-1",
|
|
55
|
+
element_id: "wait-answer",
|
|
56
|
+
kind_label: "PR review",
|
|
57
|
+
subject_type: "pr",
|
|
58
|
+
subject_key: "o/r#1",
|
|
59
|
+
subject_title: "o/r#1",
|
|
60
|
+
subject_url: "https://github.com/o/r/pull/1",
|
|
61
|
+
question: "Which approach?",
|
|
62
|
+
process_key: "PI-1",
|
|
63
|
+
form_key: "pr-escalation",
|
|
64
|
+
created_at: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 min old
|
|
65
|
+
updated_at: new Date().toISOString(),
|
|
66
|
+
...over,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Pure eligibility ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
test("isEligibleEscalation: requires opt-in (non-opted-in work is never touched)", () => {
|
|
73
|
+
const row = taskRow({});
|
|
74
|
+
const base = { minAgeMs: 300_000, claimedUserTaskKeys: new Set<string>(), at: Date.now() };
|
|
75
|
+
assertEquals(isEligibleEscalation(row, { ...base, optedIn: true }), true);
|
|
76
|
+
assertEquals(isEligibleEscalation(row, { ...base, optedIn: false }), false, "not opted in → ineligible");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("isEligibleEscalation: enforces the minimum age so a human already answering wins", () => {
|
|
80
|
+
const fresh = taskRow({ created_at: new Date().toISOString() }); // just opened
|
|
81
|
+
const base = { optedIn: true, claimedUserTaskKeys: new Set<string>(), at: Date.now() };
|
|
82
|
+
assertEquals(isEligibleEscalation(fresh, { ...base, minAgeMs: 300_000 }), false, "too fresh → ineligible");
|
|
83
|
+
assertEquals(isEligibleEscalation(taskRow({}), { ...base, minAgeMs: 300_000 }), true, "past min age → eligible");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("isEligibleEscalation: a task already claimed by a live steward run is skipped", () => {
|
|
87
|
+
const row = taskRow({});
|
|
88
|
+
const base = { optedIn: true, minAgeMs: 300_000, at: Date.now() };
|
|
89
|
+
assertEquals(isEligibleEscalation(row, { ...base, claimedUserTaskKeys: new Set(["UT-1"]) }), false, "claimed → ineligible");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("isStewardAnswerableElement: refuses human-only / irreversible / credential kinds", () => {
|
|
93
|
+
for (const el of ["merge-approval", "acp-permission", "readiness-escalation", "readiness-escalation-pf", "feature-blocked", "conformance-escalation", "empty-plan-escalation"]) {
|
|
94
|
+
assertEquals(isStewardAnswerableElement(el), false, `${el} must never be steward-answered`);
|
|
95
|
+
assertEquals(STEWARD_NEVER_RESOLVE_ELEMENTS.has(el), true);
|
|
96
|
+
}
|
|
97
|
+
// The agent-answerable escalation kinds ARE steward-answerable.
|
|
98
|
+
for (const el of ["wait-answer", "wait-merge-answer", "feature-escalation", "escalation", "plan-review-decision", "trial-merge-decision"]) {
|
|
99
|
+
assertEquals(isStewardAnswerableElement(el), true, `${el} is steward-answerable`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("isStewardAnswerableElement: gates on the canonical agent-answerable ALLOWLIST, not a denylist (issue #889 review)", () => {
|
|
104
|
+
// The canonical agent-answerable surface is `ESCALATION_TASK_ELEMENTS` (plus the dynamic
|
|
105
|
+
// delivery-human predicate). An element OUTSIDE that allowlist — an arbitrary/unknown user-task id
|
|
106
|
+
// that happens to not be in the NEVER_RESOLVE denylist — must FAIL CLOSED: otherwise the steward
|
|
107
|
+
// claims and dispatches it, the completer later refuses it, and the task is left with a spurious
|
|
108
|
+
// abstention that permanently excludes it from future steward ticks.
|
|
109
|
+
for (const unknown of ["some-random-task", "human-approval", "deploy-gate", "qa-signoff", ""]) {
|
|
110
|
+
assertEquals(isStewardAnswerableElement(unknown), false, `unknown/non-escalation '${unknown}' must fail closed (not in the allowlist)`);
|
|
111
|
+
}
|
|
112
|
+
// A delivery-human task IS in the canonical surface via the dynamic predicate, so it passes the
|
|
113
|
+
// element guard here (its FORM is gated separately by isStewardAnswerableForm).
|
|
114
|
+
assertEquals(isStewardAnswerableElement("delivery-human-task"), true, "delivery-human-task is in the canonical surface");
|
|
115
|
+
assertEquals(isStewardAnswerableElement("delivery-human-task__publish-step"), true, "an inlined delivery-human node is in the canonical surface");
|
|
116
|
+
// Belt-and-braces: a kind in the NEVER_RESOLVE guard is refused even though the allowlist may admit
|
|
117
|
+
// it (the explicit safety exclusion still wins). None currently overlap; assert the invariant holds.
|
|
118
|
+
for (const el of STEWARD_NEVER_RESOLVE_ELEMENTS) {
|
|
119
|
+
assertEquals(isStewardAnswerableElement(el), false, `${el} stays refused even though the allowlist may admit it`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("isStewardAnswerableForm: a delivery-human task is form-aware and fails closed for manual/credential forms", () => {
|
|
124
|
+
// A dynamic delivery-human element is steward-answerable ONLY on the known-safe decision/typed-value
|
|
125
|
+
// forms; the bespoke CATEGORY forms (manual publish / click-done ack) and any absent/unknown form
|
|
126
|
+
// must fail closed, because the agent completer does NOT validate dynamic delivery-human forms.
|
|
127
|
+
for (const el of ["delivery-human-task", "delivery-human-task__publish-step"]) {
|
|
128
|
+
assertEquals(isStewardAnswerableForm(el, "delivery-human-generic"), true, `${el}+generic is answerable`);
|
|
129
|
+
assertEquals(isStewardAnswerableForm(el, "delivery-escalation"), true, `${el}+escalation is answerable`);
|
|
130
|
+
assertEquals(isStewardAnswerableForm(el, "delivery-human-publish"), false, `${el}+publish (credential) fails closed`);
|
|
131
|
+
assertEquals(isStewardAnswerableForm(el, "delivery-human-ack"), false, `${el}+ack (manual) fails closed`);
|
|
132
|
+
assertEquals(isStewardAnswerableForm(el, "some-unknown-form"), false, `${el}+unknown fails closed`);
|
|
133
|
+
assertEquals(isStewardAnswerableForm(el, null), false, `${el}+absent fails closed`);
|
|
134
|
+
}
|
|
135
|
+
// A FIXED-form escalation is governed by its element id (the completer validates it), so its form is
|
|
136
|
+
// not gated here — even a null/credential-named form passes the form guard (the element guard owns it).
|
|
137
|
+
for (const el of ["wait-answer", "plan-review-decision", "trial-merge-decision"]) {
|
|
138
|
+
assertEquals(isStewardAnswerableForm(el, null), true, `${el} form is not gated`);
|
|
139
|
+
assertEquals(isStewardAnswerableForm(el, "delivery-human-publish"), true, `${el} form is not gated`);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("isEligibleEscalation: a delivery-human credential/manual task is never eligible even when opted in", () => {
|
|
144
|
+
const base = { optedIn: true, minAgeMs: 300_000, claimedUserTaskKeys: new Set<string>(), at: Date.now() };
|
|
145
|
+
// Opted-in, past-age, unclaimed — eligible ONLY when the resolved form is steward-safe.
|
|
146
|
+
const safe = taskRow({ user_task_key: "UT-DG", element_id: "delivery-human-task__step", form_key: "delivery-human-generic" });
|
|
147
|
+
assertEquals(isEligibleEscalation(safe, base), true, "delivery-human + generic form → eligible");
|
|
148
|
+
for (const form of ["delivery-human-publish", "delivery-human-ack", "no-such-form"]) {
|
|
149
|
+
const row = taskRow({ user_task_key: "UT-DG", element_id: "delivery-human-task__publish", form_key: form });
|
|
150
|
+
assertEquals(isEligibleEscalation(row, base), false, `delivery-human + ${form} → ineligible (fail closed)`);
|
|
151
|
+
}
|
|
152
|
+
const noForm = taskRow({ user_task_key: "UT-DG", element_id: "delivery-human-task__x", form_key: null });
|
|
153
|
+
assertEquals(isEligibleEscalation(noForm, base), false, "delivery-human + absent form → ineligible (fail closed)");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── Destructive-option + loop guards ─────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
test("isDestructiveOption: flags irreversible/destructive choices for abstention", () => {
|
|
159
|
+
for (const v of ["abandon", "abandon-run", "delete", "destroy", "discard", "force-merge", "merge-admin", "override-checks"]) {
|
|
160
|
+
assertEquals(isDestructiveOption(v), true, `${v} is destructive`);
|
|
161
|
+
}
|
|
162
|
+
for (const v of ["retry", "continue", "answer", "proceed", "revise", "rebase"]) {
|
|
163
|
+
assertEquals(isDestructiveOption(v), false, `${v} is safe`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("loopGuardTripped: trips once a subject has been auto-resolved the max times", () => {
|
|
168
|
+
assertEquals(loopGuardTripped(0), false);
|
|
169
|
+
assertEquals(loopGuardTripped(LOOP_GUARD_MAX_RESOLUTIONS - 1), false);
|
|
170
|
+
assertEquals(loopGuardTripped(LOOP_GUARD_MAX_RESOLUTIONS), true, "at the cap → abstain");
|
|
171
|
+
assertEquals(loopGuardTripped(LOOP_GUARD_MAX_RESOLUTIONS + 1), true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── Claim/lease fence + record I/O against the real data layer ──────────────────────────────────
|
|
175
|
+
|
|
176
|
+
test("claimEscalations: the durable fence lets only ONE steward claim a task (no double-answer)", async () => {
|
|
177
|
+
await withData(async (data) => {
|
|
178
|
+
const rows = [taskRow({})];
|
|
179
|
+
const first = await claimEscalations(data, rows, "steward-A");
|
|
180
|
+
assertEquals(first.length, 1, "the first claim wins");
|
|
181
|
+
// A second, overlapping steward run claiming the SAME task loses the fence (skipped, not an error).
|
|
182
|
+
const second = await claimEscalations(data, rows, "steward-B");
|
|
183
|
+
assertEquals(second.length, 0, "the second claim is fenced out — the task is never double-answered");
|
|
184
|
+
const live = await liveClaimedTaskKeys(data);
|
|
185
|
+
assertEquals(live.has("UT-1"), true, "the live claim is visible to the eligibility derivation");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("recordResolution: finalises a claim into a resolved record; the fence then releases the task", async () => {
|
|
190
|
+
await withData(async (data) => {
|
|
191
|
+
await claimEscalations(data, [taskRow({})], "steward-A");
|
|
192
|
+
await recordResolution(data, "UT-1", {
|
|
193
|
+
outcome: "resolved",
|
|
194
|
+
submittedVariables: { answer: "Retry with a corrective note." },
|
|
195
|
+
rationale: "nothing was pushed — retry",
|
|
196
|
+
evidence: "git ls-remote showed no branch",
|
|
197
|
+
options: ["retry", "abandon"],
|
|
198
|
+
agentJobKey: "J-1",
|
|
199
|
+
});
|
|
200
|
+
const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
|
|
201
|
+
assertEquals(rows.length, 1);
|
|
202
|
+
assertEquals(rows[0].outcome, "resolved");
|
|
203
|
+
assertEquals(rows[0].submitted_variables, JSON.stringify({ answer: "Retry with a corrective note." }));
|
|
204
|
+
assertEquals(rows[0].rationale, "nothing was pushed — retry");
|
|
205
|
+
assertEquals(rows[0].claimed_by, null, "finalised — the claim is released");
|
|
206
|
+
// A resolved row no longer holds the open-claim fence, so a FRESH escalation of the same task key
|
|
207
|
+
// (a re-escalation) can be claimed/recorded afresh.
|
|
208
|
+
const live = await liveClaimedTaskKeys(data);
|
|
209
|
+
assertEquals(live.has("UT-1"), false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("recordResolution: the finalise CAS is scoped to the claim OWNER — a non-owner loses the fence and finalises nothing", async () => {
|
|
214
|
+
await withData(async (data) => {
|
|
215
|
+
// steward-A holds the live claim on UT-1.
|
|
216
|
+
await claimEscalations(data, [taskRow({})], "steward-A");
|
|
217
|
+
// A NON-OWNER (steward-B, an overlapping tick that never owned this key) attempts to finalise it.
|
|
218
|
+
// The CAS must change ZERO rows and report the loss — otherwise steward-B would finalise steward-A's
|
|
219
|
+
// live claim (the no-double-answer fence the reviewer cited, issue #889 review).
|
|
220
|
+
const won = await recordResolution(data, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" } }, "steward-B");
|
|
221
|
+
assertEquals(won, false, "a non-owner must lose the finalise CAS");
|
|
222
|
+
const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
|
|
223
|
+
assertEquals(rows[0].outcome, "claimed", "the foreign claim is left untouched, not finalised");
|
|
224
|
+
assertEquals(rows[0].claimed_by, "steward-A", "still owned by the real claim holder");
|
|
225
|
+
// The true owner CAN finalise its own claim.
|
|
226
|
+
const wonByOwner = await recordResolution(data, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" } }, "steward-A");
|
|
227
|
+
assertEquals(wonByOwner, true, "the owner wins its own finalise CAS");
|
|
228
|
+
assertEquals((await escalationAutoResolutions(data).find({ user_task_key: "UT-1" }))[0].outcome, "resolved");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("recordResolution: the finalise CAS refuses an EXPIRED lease — a crashed owner's stale write finalises nothing", async () => {
|
|
233
|
+
await withData(async (data) => {
|
|
234
|
+
// steward-A claims UT-1 with an ALREADY-EXPIRED lease (a crashed/stalled run whose TTL elapsed).
|
|
235
|
+
await claimEscalations(data, [taskRow({})], "steward-A", -1);
|
|
236
|
+
// The expired owner must NOT be able to finalise — its lease lapsed, so an overlapping tick may
|
|
237
|
+
// have reclaimed the task; letting the stale owner write would finalise the NEW owner's claim.
|
|
238
|
+
const won = await recordResolution(data, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" } }, "steward-A");
|
|
239
|
+
assertEquals(won, false, "an expired lease loses the finalise CAS");
|
|
240
|
+
const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
|
|
241
|
+
assertEquals(rows[0].outcome, "claimed", "the expired claim is not finalised by its stale owner");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("renewClaim: the OWNER re-acquires an expired-but-owned lease (unbounded agent run) so its finalise CAS then wins", async () => {
|
|
246
|
+
await withData(async (data) => {
|
|
247
|
+
// steward-A claims UT-1 with an ALREADY-EXPIRED lease (the agent ran longer than the TTL). No
|
|
248
|
+
// overlapping tick reclaimed it, so A still owns the row — it must be able to re-acquire.
|
|
249
|
+
await claimEscalations(data, [taskRow({})], "steward-A", -1);
|
|
250
|
+
// Before renewal the finalise CAS refuses the expired lease (safety: a stale write finalises nothing).
|
|
251
|
+
assertEquals(await recordResolution(data, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" } }, "steward-A"), false);
|
|
252
|
+
// The owner renews its own claim (owner-fenced, NOT expiry-gated — the point is to recover a lapsed lease).
|
|
253
|
+
assertEquals(await renewClaim(data, "UT-1", "steward-A"), true, "the owner re-acquires its expired-but-owned lease");
|
|
254
|
+
// With the lease renewed, the owner's finalise CAS now wins and the record is written.
|
|
255
|
+
assertEquals(await recordResolution(data, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" } }, "steward-A"), true, "the owner's finalise CAS wins after renewal");
|
|
256
|
+
assertEquals((await escalationAutoResolutions(data).find({ user_task_key: "UT-1" }))[0].outcome, "resolved");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("renewClaim: a NON-OWNER cannot renew another run's claim (no lease theft)", async () => {
|
|
261
|
+
await withData(async (data) => {
|
|
262
|
+
// steward-A owns UT-1 (expired lease). A DIFFERENT run must NOT be able to renew/steal it — only
|
|
263
|
+
// the claim's own holder may re-acquire, so an overlapping tick can never resurrect a foreign claim.
|
|
264
|
+
await claimEscalations(data, [taskRow({})], "steward-A", -1);
|
|
265
|
+
assertEquals(await renewClaim(data, "UT-1", "steward-B"), false, "a non-owner loses the renew (owner-fenced)");
|
|
266
|
+
const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
|
|
267
|
+
assertEquals(rows[0].claimed_by, "steward-A", "the claim still belongs to its real owner");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("renewClaim: renewing a RELEASED (deleted) or already-finalised claim finalises nothing", async () => {
|
|
272
|
+
await withData(async (data) => {
|
|
273
|
+
// No claim row at all (released by a reclaim or never claimed) → renew changes nothing.
|
|
274
|
+
assertEquals(await renewClaim(data, "UT-GONE", "steward-A"), false, "no open claim row → renew loses");
|
|
275
|
+
// A finalised (`resolved`) row is no longer `claimed`, so renew must not resurrect it.
|
|
276
|
+
await claimEscalations(data, [taskRow({})], "steward-A");
|
|
277
|
+
await recordResolution(data, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" } }, "steward-A");
|
|
278
|
+
assertEquals(await renewClaim(data, "UT-1", "steward-A"), false, "a finalised row is not `claimed` → renew loses");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("countSubjectResolutions: only RESOLVED rows count toward the loop guard (abstentions do not)", async () => {
|
|
283
|
+
await withData(async (data) => {
|
|
284
|
+
// Two resolved + one abstained for the same subject.
|
|
285
|
+
for (const key of ["UT-1", "UT-2"]) {
|
|
286
|
+
await claimEscalations(data, [taskRow({ user_task_key: key })], "steward-A");
|
|
287
|
+
await recordResolution(data, key, { outcome: "resolved", submittedVariables: { answer: "x" } });
|
|
288
|
+
}
|
|
289
|
+
await claimEscalations(data, [taskRow({ user_task_key: "UT-3" })], "steward-A");
|
|
290
|
+
await recordResolution(data, "UT-3", { outcome: "abstained", rationale: "unclear" });
|
|
291
|
+
assertEquals(await countSubjectResolutions(data, "pr", "o/r#1"), 2, "only the two resolved rows count");
|
|
292
|
+
assertEquals(loopGuardTripped(await countSubjectResolutions(data, "pr", "o/r#1")), true, "the guard trips at the cap");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("listAutoResolutions + pendingAutoResolutionCount: only FINAL records are listed; filters work", async () => {
|
|
297
|
+
await withData(async (data) => {
|
|
298
|
+
// One resolved (pending review), one abstained (pending), one dismissed.
|
|
299
|
+
await claimEscalations(data, [taskRow({ user_task_key: "UT-1" })], "s");
|
|
300
|
+
await recordResolution(data, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" } });
|
|
301
|
+
await claimEscalations(data, [taskRow({ user_task_key: "UT-2", element_id: "feature-escalation", kind_label: "Feature escalation" })], "s");
|
|
302
|
+
await recordResolution(data, "UT-2", { outcome: "abstained", rationale: "unclear" });
|
|
303
|
+
await claimEscalations(data, [taskRow({ user_task_key: "UT-3" })], "s");
|
|
304
|
+
await recordResolution(data, "UT-3", { outcome: "resolved", submittedVariables: { answer: "y" } });
|
|
305
|
+
// An in-flight claim (never finalised) must NOT appear in the review surface.
|
|
306
|
+
await claimEscalations(data, [taskRow({ user_task_key: "UT-4" })], "s");
|
|
307
|
+
|
|
308
|
+
const all = await listAutoResolutions(data);
|
|
309
|
+
assertEquals(all.length, 3, "the in-flight claim is not listed");
|
|
310
|
+
assertEquals(await pendingAutoResolutionCount(data), 3);
|
|
311
|
+
const pending = await listAutoResolutions(data, { reviewState: "pending" });
|
|
312
|
+
assertEquals(pending.length, 3);
|
|
313
|
+
const byKind = await listAutoResolutions(data, { kind: "feature-escalation" });
|
|
314
|
+
assertEquals(byKind.length, 1);
|
|
315
|
+
assertEquals(byKind[0].element_id, "feature-escalation");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("reviewResolution: dismiss/intervene fold review_state off pending, idempotently", async () => {
|
|
320
|
+
await withData(async (data) => {
|
|
321
|
+
await claimEscalations(data, [taskRow({})], "s");
|
|
322
|
+
await recordResolution(data, "UT-1", { outcome: "resolved", submittedVariables: { answer: "x" } });
|
|
323
|
+
const id = (await escalationAutoResolutions(data).find({ user_task_key: "UT-1" }))[0].id;
|
|
324
|
+
|
|
325
|
+
assertEquals(await reviewResolution(data, id, "dismissed", "alice", "looks right"), true);
|
|
326
|
+
let row = (await escalationAutoResolutions(data).find({ user_task_key: "UT-1" }))[0];
|
|
327
|
+
assertEquals(row.review_state, "dismissed");
|
|
328
|
+
assertEquals(row.reviewed_by, "alice");
|
|
329
|
+
assertEquals(row.review_note, "looks right");
|
|
330
|
+
assertEquals(typeof row.reviewed_at, "string");
|
|
331
|
+
|
|
332
|
+
// A second review is a no-op (already off pending) — the record keeps its first review.
|
|
333
|
+
assertEquals(await reviewResolution(data, id, "intervened", "bob", "stepping in"), false);
|
|
334
|
+
row = (await escalationAutoResolutions(data).find({ user_task_key: "UT-1" }))[0];
|
|
335
|
+
assertEquals(row.review_state, "dismissed", "an already-reviewed record is unchanged");
|
|
336
|
+
assertEquals(await pendingAutoResolutionCount(data), 0);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("reviewResolution: an in-flight (claimed, not yet final) record can NOT be reviewed away", async () => {
|
|
341
|
+
await withData(async (data) => {
|
|
342
|
+
// A claim placeholder starts life `review_state='pending'` too — but it is an in-flight steward
|
|
343
|
+
// action, not a reviewable decision. Reviewing it must be a no-op so a guessed/old claim id can
|
|
344
|
+
// never be dismissed before it finalises (which would let its resolution bypass the review queue).
|
|
345
|
+
await claimEscalations(data, [taskRow({})], "s");
|
|
346
|
+
const id = (await escalationAutoResolutions(data).find({ user_task_key: "UT-1" }))[0].id;
|
|
347
|
+
|
|
348
|
+
assertEquals(await reviewResolution(data, id, "dismissed", "alice", "premature"), false, "an in-flight claim is not reviewable");
|
|
349
|
+
const row = (await escalationAutoResolutions(data).find({ user_task_key: "UT-1" }))[0];
|
|
350
|
+
assertEquals(row.outcome, "claimed", "still an in-flight claim");
|
|
351
|
+
assertEquals(row.review_state, "pending", "the claim's review state is untouched");
|
|
352
|
+
assertEquals(row.reviewed_by, null);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("optInByProcessKey: derives the opt-in flag from every subject table, keyed by process instance", async () => {
|
|
357
|
+
await withData(async (data) => {
|
|
358
|
+
const ts = new Date().toISOString();
|
|
359
|
+
// An opted-in PR and a NOT-opted-in feature run.
|
|
360
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
361
|
+
pr_key: "o/r#1", repo: "o/r", number: 1, url: "u", status: "converging", current_round: 0,
|
|
362
|
+
process_key: "PI-PR", auto_resolve_escalations: 1, created_at: ts, updated_at: ts,
|
|
363
|
+
});
|
|
364
|
+
await data.table("feature_runs", "feature_key").insert({
|
|
365
|
+
feature_key: "o/r#2", repo: "o/r", issue_number: 2, issue_url: "u", base_branch: "main",
|
|
366
|
+
status: "running", process_key: "PI-FEAT", auto_resolve_escalations: 0, created_at: ts, updated_at: ts,
|
|
367
|
+
});
|
|
368
|
+
const map = await optInByProcessKey(data);
|
|
369
|
+
assertEquals(map.get("PI-PR"), true, "the opted-in PR is eligible");
|
|
370
|
+
assertEquals(map.get("PI-FEAT"), false, "the non-opted-in feature is not");
|
|
371
|
+
assertEquals(map.get("PI-UNKNOWN"), undefined, "an untracked instance is absent (fail-closed)");
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("optInBySubjectKey: keys by subject_type:subject_key so colliding keys across tables never cross-match", async () => {
|
|
376
|
+
await withData(async (data) => {
|
|
377
|
+
const ts = new Date().toISOString();
|
|
378
|
+
// A PR and a plan whose primary keys COLLIDE in the shared string space ("X"): the PR opted OUT,
|
|
379
|
+
// the plan opted IN. An un-scoped map would let one clobber the other; the compound key keeps them
|
|
380
|
+
// distinct so a non-opted-in subject is never made eligible by a colliding opted-in one (#889 review).
|
|
381
|
+
await data.table("pull_requests", "pr_key").insert({
|
|
382
|
+
pr_key: "X", repo: "o/r", number: 1, url: "u", status: "converging", current_round: 0,
|
|
383
|
+
process_key: "PI-PR", auto_resolve_escalations: 0, created_at: ts, updated_at: ts,
|
|
384
|
+
});
|
|
385
|
+
await data.table("plans", "plan_key").insert({
|
|
386
|
+
plan_key: "X", repo: "o/r", issue_number: 9, issue_url: "u", status: "running",
|
|
387
|
+
process_key: "PI-PLAN", auto_resolve_escalations: 1, created_at: ts, updated_at: ts,
|
|
388
|
+
});
|
|
389
|
+
const map = await optInBySubjectKey(data);
|
|
390
|
+
assertEquals(map.get("pr:X"), false, "the opted-OUT PR stays opted out under its own type");
|
|
391
|
+
assertEquals(map.get("plan:X"), true, "the opted-IN plan stays opted in under its own type");
|
|
392
|
+
assertEquals(map.get("X"), undefined, "the bare (un-scoped) key never exists — no cross-table collision");
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("claimEscalations: snapshots a delivery-graph human task's REAL options from its engine form_key", async () => {
|
|
397
|
+
await withData(async (data) => {
|
|
398
|
+
// A delivery-graph human task's element id is DYNAMIC (`delivery-human-task__<node>`) and has no
|
|
399
|
+
// static `escalationFormId` mapping, so snapshotting options off `element_id` alone recorded NULL
|
|
400
|
+
// for exactly these tasks (issue #889 review). The real form is carried on the task's engine
|
|
401
|
+
// `form_key` — snapshot from THERE. `delivery-escalation`'s `decision` select is [continue, retry].
|
|
402
|
+
const claimed = await claimEscalations(
|
|
403
|
+
data,
|
|
404
|
+
[taskRow({ user_task_key: "UT-DG", element_id: "delivery-human-task__publish-step", kind_label: "Delivery decision", form_key: "delivery-escalation" })],
|
|
405
|
+
"steward-A",
|
|
406
|
+
);
|
|
407
|
+
assertEquals(claimed.length, 1);
|
|
408
|
+
assertEquals(
|
|
409
|
+
claimed[0].options,
|
|
410
|
+
JSON.stringify(["continue", "retry"]),
|
|
411
|
+
"the claim snapshots the deployed form resolved from form_key, not NULL",
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("claimEscalations: an unresolvable form_key degrades to NULL options, never throwing", async () => {
|
|
417
|
+
await withData(async (data) => {
|
|
418
|
+
// A `form_key` that names no bundled `.form` (and a dynamic element with no static form) must NOT
|
|
419
|
+
// throw inside the claim loop (which would wedge the steward) — it degrades to NULL options.
|
|
420
|
+
const claimed = await claimEscalations(
|
|
421
|
+
data,
|
|
422
|
+
[taskRow({ user_task_key: "UT-NF", element_id: "delivery-human-task__x", kind_label: "Delivery", form_key: "no-such-form-key" })],
|
|
423
|
+
"steward-A",
|
|
424
|
+
);
|
|
425
|
+
assertEquals(claimed.length, 1, "the claim still succeeds");
|
|
426
|
+
assertEquals(claimed[0].options, null, "an unresolvable form yields NULL options, not a throw");
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("read model: projects the steward's process as `process_key` (cockpit link) and the persisted `selected_option`", async () => {
|
|
431
|
+
await withData(async (data) => {
|
|
432
|
+
// A resolved record whose steward ran under PI-STEWARD (distinct from the subject's process PI-1).
|
|
433
|
+
await claimEscalations(data, [taskRow({ process_key: "PI-1" })], "PI-STEWARD");
|
|
434
|
+
await recordResolution(data, "UT-1", {
|
|
435
|
+
outcome: "resolved",
|
|
436
|
+
submittedVariables: { answer: "retry" },
|
|
437
|
+
selectedOption: "retry",
|
|
438
|
+
rationale: "ok",
|
|
439
|
+
evidence: "checked",
|
|
440
|
+
});
|
|
441
|
+
const rows = (await data.open().query(
|
|
442
|
+
`SELECT "process_key", "process_instance_key", "steward_instance_key", "selected_option" FROM "escalation_auto_resolutions__read_model" WHERE "user_task_key" = 'UT-1'`,
|
|
443
|
+
)) as unknown as { process_key: string | null; process_instance_key: string | null; steward_instance_key: string | null; selected_option: string | null }[];
|
|
444
|
+
assertEquals(rows.length, 1);
|
|
445
|
+
// The cockpit "Agent" transcript link keys on `process_key`, which must be the ANSWERING steward's
|
|
446
|
+
// process — NOT `process_instance_key` (the escalated subject's process) (issue #889 review).
|
|
447
|
+
assertEquals(rows[0].process_key, "PI-STEWARD", "the cockpit link focuses the steward transcript");
|
|
448
|
+
assertEquals(rows[0].process_instance_key, "PI-1", "the subject's engine instance stays distinct");
|
|
449
|
+
assertEquals(rows[0].steward_instance_key, "PI-STEWARD");
|
|
450
|
+
assertEquals(rows[0].selected_option, "retry", "the selected option is projected for the grid");
|
|
451
|
+
});
|
|
452
|
+
});
|