@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
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// pr.escalation-steward-record — the steward tick's final step (issue #889). Takes the
|
|
2
|
+
// `senior:escalation-steward` agent's per-escalation decisions and, for each:
|
|
3
|
+
// • ABSTAIN → records an `abstained` row (the escalation is left for a human);
|
|
4
|
+
// • RESOLVE → submits the chosen variables through the ONE canonical attributed door
|
|
5
|
+
// (`completeEscalationAsAgent`, app/agentCompletion.ts) — which resolves the open task against
|
|
6
|
+
// `ESCALATION_TASK_ELEMENTS`, validates its real form contract, and completes it with AGENT
|
|
7
|
+
// attribution (never a human decision, ADR 0046) — and records a `resolved` row.
|
|
8
|
+
//
|
|
9
|
+
// Host-side guards run BEFORE any submission, so a mis-prompted or hallucinating agent can never
|
|
10
|
+
// force one (fail-closed — every malformed/unsafe decision degrades to `abstained`, never throws,
|
|
11
|
+
// so one bad entry can never incident the record task and wedge the steward's overlap guard):
|
|
12
|
+
// • the CLAIM/OWNERSHIP fence — this steward instance must hold a live claim for the key, so a
|
|
13
|
+
// hallucinated key can never complete an unclaimed (or another run's) task;
|
|
14
|
+
// • the destructive/irreversible-option guard (`isDestructiveOption`) — derived from the decision's
|
|
15
|
+
// OWN selected value (`choice`, else the well-known form fields in `variables`), never trusted
|
|
16
|
+
// from an optional auxiliary field alone; an `abandon`/`force-merge`/credential choice is recorded
|
|
17
|
+
// `abstained`, never submitted;
|
|
18
|
+
// • the loop guard (`loopGuardTripped`) — a subject already auto-resolved N times that escalates
|
|
19
|
+
// again is recorded `abstained`;
|
|
20
|
+
// • the malformed-variables guard — a `resolved` decision whose `variables` are missing/null/not a
|
|
21
|
+
// plain object is converted to `abstained` BEFORE completion, so no false `resolved` record is
|
|
22
|
+
// ever written for a submission that never happened.
|
|
23
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
24
|
+
import { getAgentJobActivation } from "../../app/agentActivationLedger.ts";
|
|
25
|
+
import { completeEscalationAsAgent, escalationFormSelectKeys, priorAgentCompletion, snapshotEscalationFormOptions } from "../../app/agentCompletion.ts";
|
|
26
|
+
import {
|
|
27
|
+
countSubjectResolutions,
|
|
28
|
+
ESCALATION_STEWARD_JOB_TYPE,
|
|
29
|
+
type EscalationAutoResolution,
|
|
30
|
+
escalationAutoResolutions,
|
|
31
|
+
isUnsafeAutoOption,
|
|
32
|
+
LOOP_GUARD_MAX_RESOLUTIONS,
|
|
33
|
+
loopGuardTripped,
|
|
34
|
+
recordResolution,
|
|
35
|
+
renewClaim,
|
|
36
|
+
} from "../../app/escalationSteward.ts";
|
|
37
|
+
|
|
38
|
+
interface In extends Record<string, unknown> {
|
|
39
|
+
stewardInstanceKey?: string;
|
|
40
|
+
/** The agent's decisions, as a JSON string (the job envelope carries lists as serialised strings). */
|
|
41
|
+
resolutions?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One decision the agent returned for a claimed escalation. */
|
|
45
|
+
interface StewardDecision {
|
|
46
|
+
userTaskKey: string;
|
|
47
|
+
/** `resolved` — submit `variables`; `abstained` — leave for a human. */
|
|
48
|
+
outcome: "resolved" | "abstained";
|
|
49
|
+
variables?: Record<string, unknown>;
|
|
50
|
+
/** The chosen option value (an auxiliary hint for the destructive-option guard). */
|
|
51
|
+
choice?: string;
|
|
52
|
+
rationale?: string;
|
|
53
|
+
evidence?: string;
|
|
54
|
+
options?: unknown[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True only for a non-null, non-array plain object — the shape `variables`/`decisions` entries must
|
|
58
|
+
* have. Anything else (`null`, an array, a scalar) is malformed and must never be dereferenced. */
|
|
59
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
60
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The well-known escalation-form SELECT field keys whose value carries the chosen option. This set
|
|
64
|
+
* MUST cover every `select`/`radio` key across `resources/forms/*.form`, so a destructive choice can
|
|
65
|
+
* never slip through on a key the guard doesn't read — the missing-key class is what let a
|
|
66
|
+
* `{directive:"abandon"}` (plan-review / empty-plan) or a `{mergeDecision:"force-merge"}`
|
|
67
|
+
* (merge-approval) submit unguarded and record `selected_option=NULL` (issue #889 review). The
|
|
68
|
+
* canonical keys today are `decision` (delivery), `directive` (plan-review / empty-plan),
|
|
69
|
+
* `resolution` (feature / readiness), `mergeDecision` (merge-approval) and `action` (trial-merge);
|
|
70
|
+
* `choice` is the auxiliary hint.
|
|
71
|
+
*
|
|
72
|
+
* A FREE-TEXT field (`answer`, see {@link FREE_TEXT_VALUE_KEYS}) is deliberately NOT here: it carries
|
|
73
|
+
* prose, not a discrete selection, so it must never participate in the selection-CONFLICT fence nor
|
|
74
|
+
* shadow/override a real select. Including `answer` here was the bug class the reviewer cited — a valid
|
|
75
|
+
* feature/readiness answer-path payload (`{ resolution: "answer", answer: "corrective guidance" }`,
|
|
76
|
+
* where the free-text `answer` accompanies the `resolution` SELECT) presents two distinct "selection"
|
|
77
|
+
* values and is always force-abstained as an ambiguous conflict, blocking a supported escalation path
|
|
78
|
+
* (issue #889 review). Two drift tests (`worker.test.ts`) key this both ways: every `.form` select key
|
|
79
|
+
* is present, and NO key that is free-text in EVERY form (never a `select`/`radio` anywhere) leaks in —
|
|
80
|
+
* so neither a new select key silently falls out of the guard nor a free-text field silently falls into
|
|
81
|
+
* the conflict fence. */
|
|
82
|
+
export const SELECTED_VALUE_KEYS = [
|
|
83
|
+
"choice",
|
|
84
|
+
"resolution",
|
|
85
|
+
"action",
|
|
86
|
+
"decision",
|
|
87
|
+
"directive",
|
|
88
|
+
"mergeDecision",
|
|
89
|
+
] as const;
|
|
90
|
+
|
|
91
|
+
/** Escalation-form FREE-TEXT field keys (`textarea`/`textfield`) whose value is prose, not a discrete
|
|
92
|
+
* selection. These are read ONLY as the last-resort submitted value for a free-text-only form (e.g.
|
|
93
|
+
* `pr-escalation`'s `answer`, which has no select at all) so the record still persists what was
|
|
94
|
+
* submitted as `selected_option`. They are NEVER fed to the selection-conflict fence or the
|
|
95
|
+
* destructive-option guard — prose is not an option choice, and treating it as one false-abstains the
|
|
96
|
+
* answer path (issue #889 review). `answer` is the only such accompaniment today. */
|
|
97
|
+
export const FREE_TEXT_VALUE_KEYS = ["answer"] as const;
|
|
98
|
+
|
|
99
|
+
/** Derive the option value a decision actually selected — scoped to the SELECT field(s) of the CLAIMED
|
|
100
|
+
* task's ACTUAL form (`elementId`), never a first-nonblank sweep across every recognized key. The
|
|
101
|
+
* destructive guard tests this, so it must reflect what the completion really submits for THIS task:
|
|
102
|
+
* a `trial-merge-decision` (whose form's select is `action`) carrying `{ resolution: "rebase",
|
|
103
|
+
* action: "abandon" }` must be judged on the destructive `action`, never waved through by the benign
|
|
104
|
+
* foreign `resolution` (issue #889 review — the first-nonblank sweep let a safe foreign key shadow a
|
|
105
|
+
* destructive own-form key).
|
|
106
|
+
*
|
|
107
|
+
* Selection order:
|
|
108
|
+
* 1. The task's OWN form select keys (from the canonical form contract via
|
|
109
|
+
* {@link escalationFormSelectKeys}) — the value the completion would genuinely submit.
|
|
110
|
+
* 2. CONFLICT fence (fail-closed): if variables carry MORE THAN ONE distinct nonblank recognized
|
|
111
|
+
* selection value (across the full {@link SELECTED_VALUE_KEYS} set), the decision is ambiguous —
|
|
112
|
+
* an agent stuffing a benign key beside a destructive one. Returns `null` so the caller abstains.
|
|
113
|
+
* 3. Fallback when no recognized SELECT value is present: the auxiliary `choice` hint, else the
|
|
114
|
+
* single free-text value (`answer`, {@link FREE_TEXT_VALUE_KEYS}) a select-less form (e.g.
|
|
115
|
+
* `pr-escalation`) submits — so the record still captures what was submitted. Free-text is read
|
|
116
|
+
* ONLY here, never in the conflict fence above (prose is not a selection, so a `resolution`-SELECT
|
|
117
|
+
* answer path carrying free-text `answer` is not a conflict — issue #889 review).
|
|
118
|
+
*
|
|
119
|
+
* Returns `{ value, conflict }`: `conflict` true means the decision is ambiguous and must be abstained
|
|
120
|
+
* regardless of any single value's safety. */
|
|
121
|
+
function selectedOption(d: StewardDecision, elementId: string | null): { value: string | null; conflict: boolean } {
|
|
122
|
+
if (!isPlainObject(d.variables)) {
|
|
123
|
+
return { value: typeof d.choice === "string" && d.choice.trim() !== "" ? d.choice : null, conflict: false };
|
|
124
|
+
}
|
|
125
|
+
const vars = d.variables;
|
|
126
|
+
// Collect every nonblank recognized SELECT value present, keyed by field. Free-text fields
|
|
127
|
+
// (FREE_TEXT_VALUE_KEYS) are deliberately excluded here — prose is not a selection, so it must never
|
|
128
|
+
// enter the conflict fence nor shadow a real select (issue #889 review).
|
|
129
|
+
const present = new Map<string, string>();
|
|
130
|
+
for (const k of SELECTED_VALUE_KEYS) {
|
|
131
|
+
const v = vars[k];
|
|
132
|
+
if (typeof v === "string" && v.trim() !== "") present.set(k, v.trim());
|
|
133
|
+
}
|
|
134
|
+
// Conflict fence: two or more DISTINCT recognized SELECT values is an ambiguous decision — never
|
|
135
|
+
// guess which the agent meant; abstain. (The same value repeated under two keys is not a conflict.)
|
|
136
|
+
const distinctValues = new Set(present.values());
|
|
137
|
+
if (distinctValues.size > 1) {
|
|
138
|
+
return { value: null, conflict: true };
|
|
139
|
+
}
|
|
140
|
+
// Scope to the task's OWN form select keys first — the value the completion actually submits.
|
|
141
|
+
const ownKeys = elementId != null ? escalationFormSelectKeys(elementId) : null;
|
|
142
|
+
if (ownKeys != null) {
|
|
143
|
+
for (const k of ownKeys) {
|
|
144
|
+
const v = present.get(k);
|
|
145
|
+
if (v != null) return { value: v, conflict: false };
|
|
146
|
+
}
|
|
147
|
+
// The form has select keys but none is set: no recognized selection on the real form. Fall through
|
|
148
|
+
// to any single recognized value, then the choice hint, then the free-text submitted value.
|
|
149
|
+
}
|
|
150
|
+
// Single recognized SELECT value (no conflict) — use it.
|
|
151
|
+
if (present.size === 1) {
|
|
152
|
+
const only = present.values().next().value;
|
|
153
|
+
if (only != null) return { value: only, conflict: false };
|
|
154
|
+
}
|
|
155
|
+
// Fall back to the auxiliary hint.
|
|
156
|
+
if (typeof d.choice === "string" && d.choice.trim() !== "") return { value: d.choice, conflict: false };
|
|
157
|
+
// Last resort: the free-text submitted value a select-less form (e.g. `pr-escalation`'s `answer`)
|
|
158
|
+
// carries — captured so the record is honest, but never fed to the conflict fence / destructive guard.
|
|
159
|
+
for (const k of FREE_TEXT_VALUE_KEYS) {
|
|
160
|
+
const v = vars[k];
|
|
161
|
+
if (typeof v === "string" && v.trim() !== "") return { value: v.trim(), conflict: false };
|
|
162
|
+
}
|
|
163
|
+
return { value: null, conflict: false };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Narrow a parsed JSON entry to a {@link StewardDecision}: only a non-null plain object qualifies
|
|
167
|
+
* (anything else — `null`, an array, a scalar — is malformed and skipped by the caller). A type
|
|
168
|
+
* predicate, not an `as` cast, so the narrowing is a real runtime guard. */
|
|
169
|
+
function isDecision(v: unknown): v is StewardDecision {
|
|
170
|
+
return isPlainObject(v);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The REAL form choices for a claimed task, from the snapshot taken host-side at CLAIM time (parsed
|
|
174
|
+
* from the claim row's `options` JSON). The claim snapshot already resolves a delivery-graph human
|
|
175
|
+
* task's dynamic form via its engine `form_key` — which `snapshotEscalationFormOptions(element_id)`
|
|
176
|
+
* cannot, since that task's element id has no static form linkage (issue #889 review). Fall back to the
|
|
177
|
+
* element's static form only when the claim carried no snapshot (a legacy/pre-snapshot claim row), and
|
|
178
|
+
* never throw on malformed stored JSON (degrade to the fallback / null). */
|
|
179
|
+
function claimOptions(claim: EscalationAutoResolution): string[] | null {
|
|
180
|
+
if (claim.options != null) {
|
|
181
|
+
try {
|
|
182
|
+
const parsed: unknown = JSON.parse(claim.options);
|
|
183
|
+
if (Array.isArray(parsed)) return parsed.filter((v): v is string => typeof v === "string");
|
|
184
|
+
} catch {
|
|
185
|
+
// Malformed stored JSON — fall through to re-deriving from the element's static form.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return claim.element_id != null ? snapshotEscalationFormOptions(claim.element_id) : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
|
|
192
|
+
const raw = typeof job.variables.resolutions === "string" ? job.variables.resolutions : "";
|
|
193
|
+
const decisions: StewardDecision[] = [];
|
|
194
|
+
if (raw.trim() !== "") {
|
|
195
|
+
try {
|
|
196
|
+
const parsed: unknown = JSON.parse(raw);
|
|
197
|
+
// Validate EACH entry as a non-null plain object before it is ever dereferenced — valid JSON such
|
|
198
|
+
// as `[null]` must never reach the loop and throw on `d.userTaskKey` (which would incident the
|
|
199
|
+
// record task and, because the steward process then stays ACTIVE, wedge the overlap guard).
|
|
200
|
+
if (Array.isArray(parsed)) {
|
|
201
|
+
for (const entry of parsed) {
|
|
202
|
+
if (isDecision(entry)) decisions.push(entry);
|
|
203
|
+
else app.log.warn("escalation-steward record: skipping a malformed (non-object) decision entry");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
app.log.error("escalation-steward record: unparseable resolutions", { error: err instanceof Error ? err.message : String(err) });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// This steward instance's key — the claim/ownership fence matches it against the claim row's
|
|
211
|
+
// `claimed_by`, so the record step only ever finalises tasks THIS run claimed.
|
|
212
|
+
const stewardKey = job.processInstanceKey != null ? String(job.processInstanceKey) : `steward-${Date.now()}`;
|
|
213
|
+
const agentId = `escalation-steward:${job.processInstanceKey != null ? String(job.processInstanceKey) : "unknown"}`;
|
|
214
|
+
// Provenance (issue #889 review): `job.jobKey` here is the `pr.escalation-steward-record` SERVICE
|
|
215
|
+
// job, NOT the preceding `senior:escalation-steward` AGENT job that actually made the decisions.
|
|
216
|
+
// Persisting the record key as `agent_job_key` would give the audit record the wrong provenance, so
|
|
217
|
+
// read the agent's activation key from the durable activation ledger (keyed by this SAME steward
|
|
218
|
+
// process instance + the agent job type) — the poller records it while the agent job is live. When
|
|
219
|
+
// the ledger has no row (the best-effort activation poll missed a fast agent job, or engine REST is
|
|
220
|
+
// not configured), preserve NULL rather than store the record task's own key — a key known to
|
|
221
|
+
// identify a DIFFERENT job is a confidently-wrong provenance, worse than an honest "unknown".
|
|
222
|
+
const agentActivation =
|
|
223
|
+
job.processInstanceKey != null
|
|
224
|
+
? await getAgentJobActivation(app.data, String(job.processInstanceKey), ESCALATION_STEWARD_JOB_TYPE).catch(() => undefined)
|
|
225
|
+
: undefined;
|
|
226
|
+
const agentJobKey = agentActivation?.job_key ?? null;
|
|
227
|
+
|
|
228
|
+
for (const d of decisions) {
|
|
229
|
+
const key = typeof d.userTaskKey === "string" ? d.userTaskKey.trim() : "";
|
|
230
|
+
if (!key) continue;
|
|
231
|
+
// Re-acquire (renew) THIS run's own claim lease BEFORE the ownership fence + finalise (issue #889
|
|
232
|
+
// review). The claim lease is a short crash-recovery TTL, but the `senior:escalation-steward`
|
|
233
|
+
// agent has no bounded runtime, so a long agent run can let the lease lapse before this record
|
|
234
|
+
// step runs. Without renewal the owner+lease CAS in `recordResolution` would REJECT the expired
|
|
235
|
+
// claim and DISCARD the agent's valid decision, and the next tick would repeat the work. The
|
|
236
|
+
// renew is owner-fenced (matched to THIS run), so a claim another run reclaimed — or a
|
|
237
|
+
// released/finalised row — renews nothing and is skipped, preserving the no-double-answer fence.
|
|
238
|
+
// The steward's overlap guard guarantees no OTHER tick touches claims while this run is ACTIVE, so
|
|
239
|
+
// matching `claimed_by` alone is a sufficient exclusivity fence for the renewal.
|
|
240
|
+
const renewed = await renewClaim(app.data, key, stewardKey);
|
|
241
|
+
if (!renewed) {
|
|
242
|
+
app.log.warn("escalation-steward record: claim no longer owned by this run (reclaimed/released), skipping decision", { userTaskKey: key });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Claim/ownership fence (fail-closed, applied to EVERY decision — not only `resolved`). This
|
|
246
|
+
// steward instance must hold a LIVE claim for the key. Without it a hallucinated key could
|
|
247
|
+
// complete an unclaimed — or a non-opted-in, or another run's — task. Crucially the fence is a
|
|
248
|
+
// SKIP, not a downgrade-to-abstain: `recordResolution` finalises the claim row, so recording ANY
|
|
249
|
+
// decision (even an `abstained`) for a key this run does not own would finalise ANOTHER run's live
|
|
250
|
+
// claim. Skip every decision not owned by this run instead. This read is a fast pre-filter only —
|
|
251
|
+
// the ATOMIC guard is the owner+lease-scoped CAS in `recordResolution` below, which closes the
|
|
252
|
+
// read→finalise race (an overlapping tick reclaiming the task after this read). The lease is
|
|
253
|
+
// UNEXPIRED here because the renew above just extended it for this owned claim.
|
|
254
|
+
const claimRows = await escalationAutoResolutions(app.data).find({ user_task_key: key });
|
|
255
|
+
const claim = claimRows.find((r) => r.outcome === "claimed");
|
|
256
|
+
const fenceNow = new Date().toISOString();
|
|
257
|
+
const ownedByThisRun = claim != null && claim.claimed_by === stewardKey && claim.claimed_until != null && claim.claimed_until >= fenceNow;
|
|
258
|
+
if (!ownedByThisRun) {
|
|
259
|
+
app.log.warn("escalation-steward record: no owned live claim for key, skipping decision (leaving the claim untouched)", { userTaskKey: key });
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Default to abstain on any malformed/unsafe decision — never submit on ambiguity.
|
|
264
|
+
let outcome: "resolved" | "abstained" = d.outcome === "resolved" ? "resolved" : "abstained";
|
|
265
|
+
let rationale = typeof d.rationale === "string" ? d.rationale : null;
|
|
266
|
+
// The option value the decision actually selected, derived host-side from its OWN form variables
|
|
267
|
+
// scoped to the CLAIMED task's ACTUAL form (never the agent's optional `choice` hint alone, and never
|
|
268
|
+
// a first-nonblank sweep across every recognized key — a foreign safe key must not shadow a
|
|
269
|
+
// destructive own-form key). Computed ONCE here so the destructive guard and the persisted
|
|
270
|
+
// `selected_option` review column read the SAME value (issue #889 review).
|
|
271
|
+
const sel = selectedOption(d, claim.element_id ?? null);
|
|
272
|
+
const selected = sel.value;
|
|
273
|
+
|
|
274
|
+
// Conflicting-selection fence (fail-closed): the decision carries TWO OR MORE DISTINCT recognized
|
|
275
|
+
// selection values (e.g. `{ resolution: "rebase", action: "abandon" }`) — an ambiguous, possibly
|
|
276
|
+
// adversarial payload the guard cannot attribute to a single safe choice. Abstain rather than guess.
|
|
277
|
+
if (outcome === "resolved" && sel.conflict) {
|
|
278
|
+
outcome = "abstained";
|
|
279
|
+
rationale = "abstained: decision carried conflicting selection fields (more than one distinct recognized option value) — ambiguous; left for a human";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Destructive/irreversible-option guard (host-side, fail-closed, ELEMENT-AWARE). Derive the
|
|
283
|
+
// selected value from the decision's own variables FIRST (not only the optional `choice`), so a
|
|
284
|
+
// `choice`-less `abandon` — or a benign `choice` paired with a destructive `variables` value — is
|
|
285
|
+
// caught. `isUnsafeAutoOption` also refuses an element-specific RED-GATE OVERRIDE: on
|
|
286
|
+
// `trial-merge-decision` a `proceed` overrides a `suite-failed` D3 trial merge (SPEC.md §13.2), so
|
|
287
|
+
// only the non-override `rebase` path is steward-safe there (issue #889 review).
|
|
288
|
+
if (outcome === "resolved") {
|
|
289
|
+
if (selected != null && isUnsafeAutoOption(claim.element_id ?? null, selected)) {
|
|
290
|
+
outcome = "abstained";
|
|
291
|
+
rationale = `abstained: '${selected}' is a destructive/irreversible or red-gate-override option on ${claim.element_id ?? "this element"} — left for a human`;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (outcome === "resolved") {
|
|
296
|
+
// Loop guard: look up the subject from the OWNED claim row and refuse to keep re-answering a
|
|
297
|
+
// subject that has already been auto-resolved LOOP_GUARD_MAX_RESOLUTIONS times. Count by BOTH
|
|
298
|
+
// `subject_type` AND `subject_key` (issue #889 review): a bare `subject_key` collides across
|
|
299
|
+
// subject types (a plan `o/r#1` and a PR `o/r#1` share the key), so an unscoped count would let
|
|
300
|
+
// one subject's resolutions force an UNRELATED subject to abstain on its first escalation. Use
|
|
301
|
+
// the owned claim's own subject identity, not `claimRows[0]` (which may be a foreign run's row).
|
|
302
|
+
const subjectType = claim.subject_type ?? null;
|
|
303
|
+
const subjectKey = claim.subject_key ?? null;
|
|
304
|
+
if (subjectKey) {
|
|
305
|
+
const prior = await countSubjectResolutions(app.data, subjectType, subjectKey);
|
|
306
|
+
if (loopGuardTripped(prior, LOOP_GUARD_MAX_RESOLUTIONS)) {
|
|
307
|
+
outcome = "abstained";
|
|
308
|
+
rationale = `abstained: subject ${subjectType ?? "?"}:${subjectKey} already auto-resolved ${prior}× and escalated again — the automatic answer isn't working; a human must decide`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Malformed-variables guard (fail-closed): a `resolved` decision needs a plain-object `variables`.
|
|
314
|
+
// Missing/null/array-valued variables would otherwise skip completion yet still record a false
|
|
315
|
+
// `resolved`; convert to `abstained` BEFORE the completion branch so the record stays honest. Bind
|
|
316
|
+
// the narrowed value once so the completion + record share it without a re-assertion.
|
|
317
|
+
const vars = isPlainObject(d.variables) ? d.variables : null;
|
|
318
|
+
if (outcome === "resolved" && vars === null) {
|
|
319
|
+
outcome = "abstained";
|
|
320
|
+
rationale = "abstained: `resolved` decision carried no valid `variables` object — left for a human";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Evidence-before-choosing guard (fail-closed, issue #889 review): the PR's promise is that every
|
|
324
|
+
// automatic resolution carries a reviewable audit record — a non-blank rationale AND the evidence
|
|
325
|
+
// the agent checked. A `resolved` decision that omits either would auto-complete an escalation
|
|
326
|
+
// with no reviewable basis, so convert it to `abstained` (left for a human) exactly as malformed
|
|
327
|
+
// variables are. Read the agent's evidence once so the guard and the record agree.
|
|
328
|
+
const evidence = typeof d.evidence === "string" && d.evidence.trim() !== "" ? d.evidence : null;
|
|
329
|
+
if (outcome === "resolved" && (rationale == null || rationale.trim() === "" || evidence === null)) {
|
|
330
|
+
outcome = "abstained";
|
|
331
|
+
rationale = "abstained: `resolved` decision carried no rationale/evidence — the audit record would be incomplete; left for a human";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let submitted: Record<string, unknown> | null = null;
|
|
335
|
+
if (outcome === "resolved" && vars !== null) {
|
|
336
|
+
try {
|
|
337
|
+
// The ONE canonical attributed door: resolves the open task against ESCALATION_TASK_ELEMENTS,
|
|
338
|
+
// validates its real form contract, and completes with AGENT attribution (ADR 0046). Pass the
|
|
339
|
+
// claim row's `process_instance_key` so the resolve scans that ONE instance, not an engine-wide
|
|
340
|
+
// `openUserTasks()` per decision — a record tick finalises many claimed tasks in one pass, so an
|
|
341
|
+
// unfiltered global scan each is O(N²) REST load (issue #889 review).
|
|
342
|
+
const result = await completeEscalationAsAgent(app.data, app.engine, {
|
|
343
|
+
userTaskKey: key,
|
|
344
|
+
variables: vars,
|
|
345
|
+
agentId,
|
|
346
|
+
processInstanceKey: claim.process_instance_key ?? null,
|
|
347
|
+
});
|
|
348
|
+
if (result.ok) {
|
|
349
|
+
submitted = vars;
|
|
350
|
+
} else {
|
|
351
|
+
// Idempotent-retry recovery (issue #889 review): the completion→record pair is NOT atomic.
|
|
352
|
+
// The engine completion lands first; the claim-row finalise below is a SEPARATE write. If
|
|
353
|
+
// that finalise failed transiently on a prior attempt, this service job is retried with the
|
|
354
|
+
// user task ALREADY COMPLETED — the door then reports "no open completable task". That must
|
|
355
|
+
// NOT degrade the already-applied decision to a false `abstained` (which would lose the
|
|
356
|
+
// submitted variables and mis-audit an applied automatic decision as a human hand-off).
|
|
357
|
+
// Detect the prior agent-attributed completion and finalise the SAME decision idempotently.
|
|
358
|
+
const prior = await priorAgentCompletion(app.data, key, agentId).catch(() => undefined);
|
|
359
|
+
if (prior) {
|
|
360
|
+
let recovered: Record<string, unknown> | null = null;
|
|
361
|
+
try {
|
|
362
|
+
const parsedVars: unknown = JSON.parse(prior.variables_json);
|
|
363
|
+
if (isPlainObject(parsedVars)) recovered = parsedVars;
|
|
364
|
+
} catch {
|
|
365
|
+
// Malformed stored variables_json — fall back to this attempt's variables below.
|
|
366
|
+
}
|
|
367
|
+
submitted = recovered ?? vars;
|
|
368
|
+
app.log.info("escalation-steward record: recovered a prior agent completion on retry — finalising the original decision idempotently", { userTaskKey: key, completionId: prior.id });
|
|
369
|
+
} else {
|
|
370
|
+
outcome = "abstained";
|
|
371
|
+
rationale = `abstained: completion refused (${result.reason ?? "unknown"}) — left for a human`;
|
|
372
|
+
app.log.warn("escalation-steward record: completion refused, recorded abstained", { userTaskKey: key, reason: result.reason });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} catch (err) {
|
|
376
|
+
// A failed completion (task already answered by a human, or gone) degrades to abstain — the
|
|
377
|
+
// escalation is simply left for a human. Never fail the whole tick on one task.
|
|
378
|
+
outcome = "abstained";
|
|
379
|
+
rationale = `abstained: completion failed (${err instanceof Error ? err.message : String(err)}) — left for a human`;
|
|
380
|
+
app.log.warn("escalation-steward record: completion failed, recorded abstained", { userTaskKey: key });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ATOMIC owner+lease CAS (issue #889 review): the completion above already landed, but the
|
|
385
|
+
// finalise is fenced on THIS run still holding an UNEXPIRED claim. If an overlapping tick released
|
|
386
|
+
// a lapsed lease and reclaimed the task between the read fence and now, this CAS changes ZERO rows
|
|
387
|
+
// and reports `false` — we then SKIP, leaving the real claim holder's row untouched rather than
|
|
388
|
+
// finalising it (the no-double-answer fence the reviewer cited).
|
|
389
|
+
const finalised = await recordResolution(
|
|
390
|
+
app.data,
|
|
391
|
+
key,
|
|
392
|
+
{
|
|
393
|
+
outcome,
|
|
394
|
+
submittedVariables: submitted,
|
|
395
|
+
// `selected_option` is the option the steward ACTUALLY submitted — so it is only meaningful on a
|
|
396
|
+
// `resolved` record. On an `abstained` record the steward submitted NOTHING, so persist NULL, not
|
|
397
|
+
// the derived (and possibly rejected/destructive) value: recording `abandon` on an abstention would
|
|
398
|
+
// tell the review surface the steward "chose abandon" when it in fact declined to choose (issue
|
|
399
|
+
// #889 review), contradicting the OpenAPI contract ("Null when abstained").
|
|
400
|
+
selectedOption: outcome === "resolved" ? selected : null,
|
|
401
|
+
rationale,
|
|
402
|
+
evidence,
|
|
403
|
+
// Carry through the REAL form choices snapshotted host-side at CLAIM time (from the task's
|
|
404
|
+
// engine-resolved `form_key`, issue #889 review) — never the deciding agent's self-reported
|
|
405
|
+
// `options`, which it may omit or misstate. The claim snapshot already resolves a delivery-graph
|
|
406
|
+
// human task's dynamic form (which `snapshotEscalationFormOptions(element_id)` alone cannot); fall
|
|
407
|
+
// back to the element's static form only when the claim carried no snapshot.
|
|
408
|
+
options: claimOptions(claim),
|
|
409
|
+
agentJobKey,
|
|
410
|
+
// Persist the leasing worker's provenance (issue #889 review): the activation ledger already
|
|
411
|
+
// captured WHICH worker picked up this exact agent job, and the Auto-resolutions grid's Worker
|
|
412
|
+
// column reads this field — leaving it null blanked a provenance the API/UI promise. NULL only
|
|
413
|
+
// when the ledger has no row (a fast job the best-effort activation poll missed), matching the
|
|
414
|
+
// `agentJobKey` null-when-unknown contract above. `model` has no ledger source, so it stays NULL.
|
|
415
|
+
worker: agentActivation?.worker ?? null,
|
|
416
|
+
model: null,
|
|
417
|
+
},
|
|
418
|
+
stewardKey,
|
|
419
|
+
);
|
|
420
|
+
if (!finalised) {
|
|
421
|
+
app.log.warn("escalation-steward record: lost the owner+lease finalise CAS — an overlapping run reclaimed the task; leaving its claim untouched", { userTaskKey: key });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
app.log.info("escalation-steward record", { processed: decisions.length });
|
|
425
|
+
return {};
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
export default handler;
|
|
@@ -215,7 +215,11 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
try {
|
|
218
|
-
|
|
218
|
+
// Propagate the epic's escalation-steward opt-in (issue #889) to the child convergence loop:
|
|
219
|
+
// read the plan row's durable flag and hand it to `submitPr` so the enrolled PR inherits it.
|
|
220
|
+
const planRow = await plans(app.data).get(planKey);
|
|
221
|
+
const autoResolve = planRow?.auto_resolve_escalations === 1;
|
|
222
|
+
await submitPr(app.data, app.engine, parsed, depPrKeys, MAX_ROUNDS, false, planKey, false, autoResolve);
|
|
219
223
|
} catch (err) {
|
|
220
224
|
app.log.error(`record-wave: handoff failed for ${parsed.prKey}`, {
|
|
221
225
|
err: String(err),
|