@nanobpm/nano-workforce 0.201.5 → 0.203.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +48 -0
- package/app/admittedStaging.test.ts +71 -0
- package/app/agentActivationLedger.ts +5 -0
- package/app/agentCompletion.test.ts +33 -0
- package/app/agentCompletion.ts +108 -2
- package/app/agentic/README.md +48 -0
- package/app/agentic/claim-registry.test.ts +61 -0
- package/app/agentic/claim-registry.ts +35 -1
- package/app/agentic/families/claim.family.test.ts +216 -0
- package/app/agentic/families/claim.family.ts +100 -4
- package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
- package/app/contracts.ts +31 -0
- package/app/deliveryGraphDispatch.ts +6 -0
- package/app/deliveryGraphReadModel.test.ts +2 -2
- package/app/deliveryGraphRun.ts +18 -0
- package/app/deliveryRunner.ts +9 -1
- package/app/escalationSteward.test.ts +452 -0
- package/app/escalationSteward.ts +537 -0
- package/app/escalationStewardPoll.test.ts +123 -0
- package/app/feature.test.ts +16 -0
- package/app/feature.ts +11 -0
- package/app/featureDelivery.test.ts +23 -0
- package/app/mcpToolSurface.ts +17 -1
- package/app/plan.ts +49 -6
- package/app/planLowering.test.ts +26 -1
- package/app/planLowering.ts +5 -0
- package/app/promotionPoll.test.ts +19 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +36 -0
- package/app/service.ts +139 -5
- package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
- package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
- package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
- package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
- package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
- package/docs/agent-guide.md +69 -30
- package/docs/agentic-cockpit.md +34 -0
- package/docs/mcp-runbook.md +2 -0
- package/e2e/escalation-steward.e2e.ts +211 -0
- package/nano.app.json +10 -1
- package/openapi.yaml +365 -0
- package/operations/dispatchDeliveryGraph.ts +6 -1
- package/operations/listActivePrs.test.ts +60 -1
- package/operations/listActivePrs.ts +3 -3
- package/operations/listAutoResolutions.ts +83 -0
- package/operations/reviewAutoResolution.test.ts +137 -0
- package/operations/reviewAutoResolution.ts +42 -0
- package/operations/startConvergenceLoop.ts +5 -1
- package/operations/startEpicSet.admission.integration.test.ts +2 -2
- package/operations/startEpicSet.ts +8 -2
- package/operations/startFeature.ts +5 -0
- package/operations/startPlanFanout.ts +4 -1
- package/package.json +1 -1
- package/pages/_nav.json +12 -0
- package/pages/auto-resolutions.page.json +191 -0
- package/pages/board.page.json +17 -0
- package/pages/cockpit.page.json +17 -0
- package/pages/delivery-graph-detail.page.json +17 -0
- package/pages/delivery-graphs/staged.mount.js +17 -5
- package/pages/delivery-graphs.page.json +17 -0
- package/pages/epic-detail.page.json +17 -0
- package/pages/epic.page.json +19 -1
- package/pages/feature.page.json +19 -1
- package/pages/home.page.json +22 -0
- package/pages/lineage.page.json +17 -0
- package/pages/mcp.page.json +17 -0
- package/pages/overview.page.json +17 -0
- package/pages/tasks.page.json +17 -0
- package/pages/velocity.page.json +17 -0
- package/resources/processes/escalation-steward.bpmn +162 -0
- package/resources/prompts/escalation-steward.md +87 -0
- package/test/delivery-graphs-staged-embed.test.ts +13 -0
- package/test/derivation-parity/derivation-parity.test.ts +1 -0
- package/test/derivation-parity/flows.ts +12 -0
- package/workers/converge-feature/worker.ts +6 -1
- package/workers/delivery-connector/worker.test.ts +72 -1
- package/workers/delivery-connector/worker.ts +53 -2
- package/workers/escalation-steward-collect/worker.test.ts +180 -0
- package/workers/escalation-steward-collect/worker.ts +99 -0
- package/workers/escalation-steward-record/worker.test.ts +740 -0
- package/workers/escalation-steward-record/worker.ts +428 -0
- package/workers/record-wave/worker.ts +5 -1
package/app/feature.test.ts
CHANGED
|
@@ -140,6 +140,22 @@ test("startFeature: humanApproval is persisted as 0/1 and seeded onto the instan
|
|
|
140
140
|
}
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
test("startFeature: autoResolveEscalations is persisted as 0/1 and seeded onto the instance (issue #889)", async () => {
|
|
144
|
+
for (const autoResolve of [true, false]) {
|
|
145
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
146
|
+
let vars: any = null;
|
|
147
|
+
const engine = {
|
|
148
|
+
createInstance: (req: any) => {
|
|
149
|
+
vars = req.variables;
|
|
150
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
151
|
+
},
|
|
152
|
+
} as any;
|
|
153
|
+
await startFeature(memData(stores), engine, PARSED, "main", true, true, null, {}, false, autoResolve);
|
|
154
|
+
assertEquals(stores.feature_runs.rows[0].auto_resolve_escalations, autoResolve ? 1 : 0);
|
|
155
|
+
assertEquals(vars.autoResolveEscalations, autoResolve);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
143
159
|
test("startFeature: seeds the single task slice + base-branch brief onto the instance", async () => {
|
|
144
160
|
let captured: any = null;
|
|
145
161
|
const engine = {
|
package/app/feature.ts
CHANGED
|
@@ -64,6 +64,9 @@ export interface FeatureRun {
|
|
|
64
64
|
/** 1 ⇒ a human must approve the converged PR before the merge-loop runs (issue #826). Only
|
|
65
65
|
* meaningful with `auto_merge = 1`; the `startFeature` operation pins it to 0 otherwise. */
|
|
66
66
|
human_approval: number;
|
|
67
|
+
/** 1 ⇒ this run OPTED IN to the escalation steward (issue #889): its open escalations are eligible
|
|
68
|
+
* for the steward to auto-resolve (recorded reviewably). Default 0 — escalations stay human-only. */
|
|
69
|
+
auto_resolve_escalations: number;
|
|
67
70
|
outcome: string | null;
|
|
68
71
|
/** Human rollup detail. Projected by `pollFeatureDelivery` (fix: Feature history stuck at
|
|
69
72
|
* `converging`) and also written by `pr.record-blocked-ack` with the operator's disposition
|
|
@@ -357,6 +360,7 @@ export async function startFeature(
|
|
|
357
360
|
customInstructions: string | null = null,
|
|
358
361
|
readiness: FeatureReadinessOptions = {},
|
|
359
362
|
humanApproval = false,
|
|
363
|
+
autoResolveEscalations = false,
|
|
360
364
|
): Promise<StartFeatureResult> {
|
|
361
365
|
// Intake-time readiness gate (issue #295): the probes the run must satisfy before it implements,
|
|
362
366
|
// and the bound its preflight escalation timers fire off. Both are load-bearing together —
|
|
@@ -456,6 +460,7 @@ export async function startFeature(
|
|
|
456
460
|
converge: converge ? 1 : 0,
|
|
457
461
|
auto_merge: autoMerge ? 1 : 0,
|
|
458
462
|
human_approval: humanApproval ? 1 : 0,
|
|
463
|
+
auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
|
|
459
464
|
outcome: null,
|
|
460
465
|
delivery_label: null,
|
|
461
466
|
// Clear the operator tick-off so a re-dispatched run is NOT silently dropped into History when
|
|
@@ -479,6 +484,7 @@ export async function startFeature(
|
|
|
479
484
|
converge: converge ? 1 : 0,
|
|
480
485
|
auto_merge: autoMerge ? 1 : 0,
|
|
481
486
|
human_approval: humanApproval ? 1 : 0,
|
|
487
|
+
auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
|
|
482
488
|
outcome: null,
|
|
483
489
|
delivery_label: null,
|
|
484
490
|
created_at: ts,
|
|
@@ -516,6 +522,11 @@ export async function startFeature(
|
|
|
516
522
|
autoMerge,
|
|
517
523
|
// Park the converged PR at a human approval user task before the merge-loop (issue #826).
|
|
518
524
|
humanApproval,
|
|
525
|
+
// Opt-in escalation steward (issue #889): seeded as an engine-owned process variable so the
|
|
526
|
+
// run's escalations carry the opt-in, AND persisted on the `feature_runs` row (above) — the
|
|
527
|
+
// steward's eligibility derivation reads the ROW (the durable source), so an agent-completion
|
|
528
|
+
// clobber of the variable can never silently opt a run in or out (#887/#888).
|
|
529
|
+
autoResolveEscalations,
|
|
519
530
|
// A single-issue feature run OWNS its issue (the whole issue is the slice), so the agent may
|
|
520
531
|
// claim it with a "starting work" comment on a first run (resources/prompts/feature.md). Epic slices
|
|
521
532
|
// (plan-fanout) deliberately DO NOT set this — their `issue` is the shared parent epic, which
|
|
@@ -218,6 +218,29 @@ test("pollFeatureDelivery: the re-enroll keeps the run's human approval gate (is
|
|
|
218
218
|
assertEquals(vars?.humanApproval, true, "a healed enrollment must not silently drop the approval gate");
|
|
219
219
|
});
|
|
220
220
|
|
|
221
|
+
test("pollFeatureDelivery: the re-enroll propagates the run's escalation-steward opt-in (issue #889 review)", async () => {
|
|
222
|
+
const { data, stores } = memData();
|
|
223
|
+
// An OPTED-IN converging run whose PR row is missing — the re-enroll must carry the run's
|
|
224
|
+
// `auto_resolve_escalations` onto the fresh PR row/instance, not drop it to the `false` default.
|
|
225
|
+
stores.feature_runs = [
|
|
226
|
+
{ feature_key: "o/r#9", status: "converging", pr_key: "o/r#409", auto_merge: 1, human_approval: 0, auto_resolve_escalations: 1, delivery_label: null },
|
|
227
|
+
];
|
|
228
|
+
stores.pull_requests = [];
|
|
229
|
+
let vars: Record<string, unknown> | undefined;
|
|
230
|
+
const engine = {
|
|
231
|
+
searchProcessInstances: async () => [],
|
|
232
|
+
createInstance: async (req: { variables?: Record<string, unknown> }) => {
|
|
233
|
+
vars = req.variables;
|
|
234
|
+
return { processInstanceKey: "pi-409" };
|
|
235
|
+
},
|
|
236
|
+
} as any;
|
|
237
|
+
|
|
238
|
+
await pollFeatureDelivery(data, engine);
|
|
239
|
+
|
|
240
|
+
assertEquals(vars?.autoResolveEscalations, true, "a healed enrollment must not silently drop the steward opt-in");
|
|
241
|
+
assertEquals(stores.pull_requests[0]?.auto_resolve_escalations, 1, "the PR row persists the opt-in");
|
|
242
|
+
});
|
|
243
|
+
|
|
221
244
|
test("pollFeatureDelivery: a converging run whose PR row is PARTIALLY enrolled (present, non-terminal, process_key NULL) is re-enrolled — a create-instance crash after the row insert does not wedge it (PR #809)", async () => {
|
|
222
245
|
const { data, stores } = memData();
|
|
223
246
|
stores.feature_runs = [
|
package/app/mcpToolSurface.ts
CHANGED
|
@@ -63,6 +63,9 @@ export const CURATED_MCP_TOOLS: readonly string[] = [
|
|
|
63
63
|
"listEscalations",
|
|
64
64
|
"getLineage",
|
|
65
65
|
"getPrHistory",
|
|
66
|
+
// ── Escalation steward (issue #889) ──────────────────────────────────────
|
|
67
|
+
"listAutoResolutions",
|
|
68
|
+
"reviewAutoResolution",
|
|
66
69
|
// ── Engine-truth reads (wedge triage) ────────────────────────────────────
|
|
67
70
|
"urban_debug_search_process_instances",
|
|
68
71
|
"urban_debug_search_element_instance_wait_states",
|
|
@@ -122,8 +125,21 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
|
|
|
122
125
|
* `target` description in `openapi.yaml` documents the new accepted handle. Measured 87,559 bytes
|
|
123
126
|
* serialized (87,497 → 87,559 — over the old ceiling's 3-byte headroom). Deliberate, documented
|
|
124
127
|
* growth of a real new accepted input format — not schema fat.
|
|
128
|
+
*
|
|
129
|
+
* RAISE PROVENANCE — 88_000 → 89_500 (#889): the opt-in escalation steward adds two read/review
|
|
130
|
+
* tools — `listAutoResolutions` (the Auto-resolutions read, with the `AutoResolution` record schema)
|
|
131
|
+
* and `reviewAutoResolution` (dismiss/intervene) — plus the `autoResolveEscalations` opt-in field on
|
|
132
|
+
* the start bodies. Measured 88,866 bytes serialized (over the old ceiling's 866-byte headroom).
|
|
133
|
+
* Deliberate, documented growth of a real new reviewable-record surface — not schema fat.
|
|
134
|
+
*
|
|
135
|
+
* RAISE PROVENANCE — 89_500 → 91_000 (#889 review): ten `openapi.yaml` descriptions that reference
|
|
136
|
+
* `(issue #889)` were unquoted plain scalars, so the `#` comment marker silently truncated each at
|
|
137
|
+
* `(issue` — the projected MCP schemas carried the cut text. Quoting them restores the full,
|
|
138
|
+
* intended description text (a correctness fix, not new surface). Measured 90,420 bytes serialized
|
|
139
|
+
* (over the old ceiling's 920-byte headroom). Deliberate, documented growth of restored description
|
|
140
|
+
* text — not schema fat.
|
|
125
141
|
*/
|
|
126
|
-
export const MCP_SURFACE_BYTES_BUDGET =
|
|
142
|
+
export const MCP_SURFACE_BYTES_BUDGET = 91_000;
|
|
127
143
|
|
|
128
144
|
/**
|
|
129
145
|
* The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
|
package/app/plan.ts
CHANGED
|
@@ -98,6 +98,9 @@ export interface Plan {
|
|
|
98
98
|
// admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
|
|
99
99
|
// carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
|
|
100
100
|
base_branch: string | null;
|
|
101
|
+
// Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this epic's
|
|
102
|
+
// escalations (and its spawned child loops) are eligible for steward auto-resolution. Default 0.
|
|
103
|
+
auto_resolve_escalations: number;
|
|
101
104
|
// The derived epic delivery signal (029_plan_delivery.sql) was RETIRED as a stored column (epic
|
|
102
105
|
// #412): `delivery` / `delivery_label` are now a DERIVED SQL VIEW (`plan_delivery` /
|
|
103
106
|
// `plan_read_model`, 061), computed from the SAME pure `deriveDelivery`/`TERMINAL_STATUSES`
|
|
@@ -356,6 +359,10 @@ export interface AdmittedEpic {
|
|
|
356
359
|
issue_number: number;
|
|
357
360
|
issue_url: string;
|
|
358
361
|
base_branch: string;
|
|
362
|
+
/** Escalation-steward opt-in staged at admission (issue #889). Persisted so the lowering
|
|
363
|
+
* (`lowerAdmittedSet`) can forward it to `startPlan` — without it a batch-admitted epic silently
|
|
364
|
+
* stayed opted out even when the caller opted in. 0|1 (SQLite boolean); absent on pre-124 rows ⇒ 0. */
|
|
365
|
+
auto_resolve_escalations: number;
|
|
359
366
|
created_at: string;
|
|
360
367
|
}
|
|
361
368
|
export const admittedEpics = (data: DataLayer) =>
|
|
@@ -368,28 +375,55 @@ export const admittedPlanDeps = (data: DataLayer) =>
|
|
|
368
375
|
data.table<PlanDep>("admitted_plan_deps", "plan_key");
|
|
369
376
|
|
|
370
377
|
/** The fields an admission caller supplies for one staged admitted epic; `created_at` is stamped
|
|
371
|
-
* here (mirrors {@link PlanDepInput}).
|
|
372
|
-
|
|
378
|
+
* here (mirrors {@link PlanDepInput}). The escalation-steward opt-in (issue #889) is OPTIONAL on the
|
|
379
|
+
* input even though it is required on the persisted {@link AdmittedEpic} row: `recordAdmittedEpic`
|
|
380
|
+
* defaults an omitted value to `0` (opted out), so the typed API matches that default-off behavior
|
|
381
|
+
* and a caller that does not care about the steward need not spell it out. */
|
|
382
|
+
export type AdmittedEpicInput = Omit<AdmittedEpic, "created_at" | "auto_resolve_escalations"> & {
|
|
383
|
+
auto_resolve_escalations?: number;
|
|
384
|
+
};
|
|
373
385
|
|
|
374
386
|
/** Stage one admitted epic (issue #292 slice S2). Idempotent on `plan_key`: a re-submitted set that
|
|
375
387
|
* re-admits the same epic is a no-op returning the existing staged row, so the whole set door stays
|
|
376
|
-
* idempotent (mirroring {@link recordAdmittedPlanDep}).
|
|
388
|
+
* idempotent (mirroring {@link recordAdmittedPlanDep}).
|
|
389
|
+
*
|
|
390
|
+
* The escalation-steward opt-in is MONOTONIC on the existing row (issue #889 review): if an opted-out
|
|
391
|
+
* admission was staged and the request crashed before lowering, a retry that now opts in must WIDEN
|
|
392
|
+
* the staged row to opted-in — otherwise the epic starts opted out despite the caller's opt-in. This
|
|
393
|
+
* mirrors `submitPr`'s live-loop adoption (service.ts): an opted-in caller adopting an existing
|
|
394
|
+
* non-opted-in row widens it, never narrows an opted-in row back. Only ever flips 0→1. */
|
|
377
395
|
export async function recordAdmittedEpic(
|
|
378
396
|
data: DataLayer,
|
|
379
397
|
epic: AdmittedEpicInput,
|
|
380
398
|
): Promise<AdmittedEpic> {
|
|
381
399
|
const table = admittedEpics(data);
|
|
400
|
+
// The ONE canonical widening (issue #889 review): an opted-in caller observing a non-opted-in
|
|
401
|
+
// staged row widens it 0→1 and returns the widened row; otherwise returns the row unchanged. Shared
|
|
402
|
+
// by BOTH the read-existing path AND the concurrent-insert catch path, so the race below can never
|
|
403
|
+
// skip the widening the single-threaded path applies.
|
|
404
|
+
const widen = async (staged: AdmittedEpic): Promise<AdmittedEpic> => {
|
|
405
|
+
if (epic.auto_resolve_escalations === 1 && staged.auto_resolve_escalations !== 1) {
|
|
406
|
+
await table.update(epic.plan_key, { auto_resolve_escalations: 1 });
|
|
407
|
+
return { ...staged, auto_resolve_escalations: 1 };
|
|
408
|
+
}
|
|
409
|
+
return staged;
|
|
410
|
+
};
|
|
382
411
|
const existing = await table.get(epic.plan_key);
|
|
383
|
-
if (existing) return existing;
|
|
384
|
-
|
|
412
|
+
if (existing) return widen(existing);
|
|
413
|
+
// Default the opt-in to 0 (opted out) when a caller omits it, so the staged row always carries the
|
|
414
|
+
// flag explicitly and the lowering never reads an undefined it would silently coerce.
|
|
415
|
+
const row: AdmittedEpic = { ...epic, auto_resolve_escalations: epic.auto_resolve_escalations ?? 0, created_at: now() };
|
|
385
416
|
try {
|
|
386
417
|
await table.insert(row);
|
|
387
418
|
return row;
|
|
388
419
|
} catch (err) {
|
|
389
420
|
// Concurrent re-admission of the same epic races on the PRIMARY KEY (plan_key); honour the
|
|
390
421
|
// idempotent no-op contract by returning the winning row rather than surfacing the collision.
|
|
422
|
+
// The winning row may be an opted-OUT insert that beat an opted-IN caller — apply the SAME
|
|
423
|
+
// monotonic widening before returning it, or the raced opt-in is silently lost (issue #889
|
|
424
|
+
// review): the caller opted in but the staged row stays 0.
|
|
391
425
|
const raced = await table.get(epic.plan_key);
|
|
392
|
-
if (raced) return raced;
|
|
426
|
+
if (raced) return widen(raced);
|
|
393
427
|
throw err;
|
|
394
428
|
}
|
|
395
429
|
}
|
|
@@ -895,6 +929,9 @@ export interface StartPlanOptions {
|
|
|
895
929
|
readinessProbes?: ReadinessProbe[];
|
|
896
930
|
probeTimeout?: string;
|
|
897
931
|
probePollEvery?: string;
|
|
932
|
+
/** Opt-in escalation steward (issue #889): when true, this epic's escalations — and every child
|
|
933
|
+
* convergence/merge loop it spawns — become eligible for steward auto-resolution. Default false. */
|
|
934
|
+
autoResolveEscalations?: boolean;
|
|
898
935
|
}
|
|
899
936
|
|
|
900
937
|
/** Register a plan row (if new) and start the plan-fanout process. Idempotent on
|
|
@@ -981,6 +1018,7 @@ export async function startPlan(
|
|
|
981
1018
|
epic_phase: EPIC_PHASE.PLANNING,
|
|
982
1019
|
blackboard_token: token,
|
|
983
1020
|
base_branch: base,
|
|
1021
|
+
auto_resolve_escalations: opts.autoResolveEscalations === true ? 1 : 0,
|
|
984
1022
|
updated_at: ts,
|
|
985
1023
|
});
|
|
986
1024
|
} else {
|
|
@@ -996,6 +1034,7 @@ export async function startPlan(
|
|
|
996
1034
|
epic_phase: EPIC_PHASE.PLANNING,
|
|
997
1035
|
blackboard_token: token,
|
|
998
1036
|
base_branch: base,
|
|
1037
|
+
auto_resolve_escalations: opts.autoResolveEscalations === true ? 1 : 0,
|
|
999
1038
|
created_at: ts,
|
|
1000
1039
|
updated_at: ts,
|
|
1001
1040
|
});
|
|
@@ -1022,6 +1061,10 @@ export async function startPlan(
|
|
|
1022
1061
|
// `operators` candidate group); an operator/agent can claim/reassign via the task inbox.
|
|
1023
1062
|
escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
|
|
1024
1063
|
escalationAssignee: null,
|
|
1064
|
+
// Opt-in escalation steward (issue #889): carried on the instance so child processes inherit it,
|
|
1065
|
+
// AND persisted on the `plans` row (the durable source the steward's eligibility reads), so an
|
|
1066
|
+
// agent-completion clobber of the variable can never silently opt an epic in or out (#887/#888).
|
|
1067
|
+
autoResolveEscalations: opts.autoResolveEscalations === true,
|
|
1025
1068
|
// Agent-task liveness SLA (issue #849): PRE-SEED the canonical bound for the fan-out's
|
|
1026
1069
|
// external agent tasks. Each slice's implementation agent (via the implement-cell
|
|
1027
1070
|
// callActivity's explicit `agentSlaTimeout` input mapping) is bounded today; the wave's own
|
package/app/planLowering.test.ts
CHANGED
|
@@ -58,7 +58,7 @@ function makeData() {
|
|
|
58
58
|
return { data, engine, tables, started };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function stageEpic(tables: Map<string, Record<string, unknown>[]>, planKey: string, base: string) {
|
|
61
|
+
function stageEpic(tables: Map<string, Record<string, unknown>[]>, planKey: string, base: string, autoResolve = 0) {
|
|
62
62
|
const rows = tables.get("admitted_epics") ?? [];
|
|
63
63
|
tables.set("admitted_epics", rows);
|
|
64
64
|
const [repo, num] = planKey.split("#");
|
|
@@ -68,6 +68,7 @@ function stageEpic(tables: Map<string, Record<string, unknown>[]>, planKey: stri
|
|
|
68
68
|
issue_number: Number(num),
|
|
69
69
|
issue_url: `https://github.com/${repo}/issues/${num}`,
|
|
70
70
|
base_branch: base,
|
|
71
|
+
auto_resolve_escalations: autoResolve,
|
|
71
72
|
created_at: "t0",
|
|
72
73
|
});
|
|
73
74
|
}
|
|
@@ -184,3 +185,27 @@ test("lowerAdmittedSet is idempotent: re-running neither double-starts an epic n
|
|
|
184
185
|
assertEquals(started.length, 2, "no epic re-started on a re-run");
|
|
185
186
|
assertEquals((tables.get("plan_deps") ?? []).length, 1, "no duplicate durable edge on a re-run");
|
|
186
187
|
});
|
|
188
|
+
|
|
189
|
+
// ── issue #889: the escalation-steward opt-in reaches batch-admitted epics ──────────────────────
|
|
190
|
+
test("lowerAdmittedSet forwards the staged escalation-steward opt-in to startPlan (issue #889 — batch epic path)", async () => {
|
|
191
|
+
const { data, engine, tables, started } = makeData();
|
|
192
|
+
// Stage one opted-IN epic and one opted-OUT epic. The batch door (`startEpicSet`) records the
|
|
193
|
+
// caller's per-epic `autoResolveEscalations` onto the staging row; the lowering must forward it to
|
|
194
|
+
// `startPlan` (which seeds the `autoResolveEscalations` instance variable + the durable plans row),
|
|
195
|
+
// exactly as the single-issue door does. Before the fix the flag was dropped at the staging seam,
|
|
196
|
+
// so every batch-admitted epic stayed opted out.
|
|
197
|
+
stageEpic(tables, "o/r#1", "epic/in", 1);
|
|
198
|
+
stageEpic(tables, "o/r#2", "epic/out", 0);
|
|
199
|
+
|
|
200
|
+
await noFetch(() => lowerAdmittedSet(data, engine, ["o/r#1", "o/r#2"]));
|
|
201
|
+
|
|
202
|
+
const byKey = new Map(started.map((s) => [s.variables?.["planKey"], s.variables ?? {}]));
|
|
203
|
+
assertEquals(byKey.get("o/r#1")?.["autoResolveEscalations"], true, "opted-in epic propagates the steward opt-in");
|
|
204
|
+
assertEquals(byKey.get("o/r#2")?.["autoResolveEscalations"], false, "opted-out epic stays opted out");
|
|
205
|
+
|
|
206
|
+
// And the durable plans rows carry it (the steward eligibility reads the durable row, not the var).
|
|
207
|
+
const plans = tables.get("plans") ?? [];
|
|
208
|
+
const planByKey = new Map(plans.map((p) => [p["plan_key"], p]));
|
|
209
|
+
assertEquals(planByKey.get("o/r#1")?.["auto_resolve_escalations"], 1, "opted-in plans row persisted");
|
|
210
|
+
assertEquals(planByKey.get("o/r#2")?.["auto_resolve_escalations"], 0, "opted-out plans row persisted");
|
|
211
|
+
});
|
package/app/planLowering.ts
CHANGED
|
@@ -166,6 +166,11 @@ export async function lowerAdmittedSet(
|
|
|
166
166
|
readinessProbes: gate?.probes,
|
|
167
167
|
probeTimeout: gate?.probeTimeout,
|
|
168
168
|
probePollEvery: gate?.probePollEvery,
|
|
169
|
+
// Forward the escalation-steward opt-in staged at admission (issue #889) so a batch-admitted
|
|
170
|
+
// epic honours the caller's choice exactly like the single-issue door. `=== 1` coerces the
|
|
171
|
+
// SQLite 0|1 (and any pre-124 row that predates the column reads as 0 ⇒ opted out, the safe
|
|
172
|
+
// default).
|
|
173
|
+
autoResolveEscalations: staged.auto_resolve_escalations === 1,
|
|
169
174
|
});
|
|
170
175
|
}
|
|
171
176
|
|
|
@@ -163,6 +163,25 @@ test("pollPromotion: a landed epic on an epic/* base opens exactly one epic/*→
|
|
|
163
163
|
assert(prRow, "promotion PR row registered by submitPr");
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
+
test("pollPromotion: the promotion PR inherits the epic's escalation-steward opt-in (issue #889 review)", async () => {
|
|
167
|
+
const { data, stores } = memData();
|
|
168
|
+
const { engine, instances } = recordingEngine();
|
|
169
|
+
// An OPTED-IN epic — its promotion PR must be enrolled with the steward opt-in, not the `false` default.
|
|
170
|
+
stores.plans = [
|
|
171
|
+
{ plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: null, promotion_state: null, auto_resolve_escalations: 1 },
|
|
172
|
+
];
|
|
173
|
+
stores.plan_tasks = [{ id: 1, plan_key: "o/r#295", pr_key: "o/r#299" }];
|
|
174
|
+
stores.pull_requests = [{ pr_key: "o/r#299", status: "merged" }];
|
|
175
|
+
const state = freshRepo();
|
|
176
|
+
|
|
177
|
+
await withGithub(state, () => pollPromotion(data, engine, "tok"));
|
|
178
|
+
|
|
179
|
+
assertEquals(instances.length, 1);
|
|
180
|
+
assertEquals(instances[0].variables.autoResolveEscalations, true, "the promotion PR is enrolled opted-in");
|
|
181
|
+
const prRow = stores.pull_requests.find((p) => p.pr_key === "o/r#500");
|
|
182
|
+
assertEquals(prRow?.auto_resolve_escalations, 1, "the PR row persists the opt-in");
|
|
183
|
+
});
|
|
184
|
+
|
|
166
185
|
test("pollPromotion: re-running is idempotent — no duplicate promotion PR", async () => {
|
|
167
186
|
const { data, stores } = memData();
|
|
168
187
|
const { engine } = recordingEngine();
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
34
34
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
35
35
|
|
|
36
|
-
const READ_MODEL_MIGRATION = "
|
|
36
|
+
const READ_MODEL_MIGRATION = "122_pull_requests_read_model_auto_resolve.sql";
|
|
37
37
|
|
|
38
38
|
// The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
|
|
39
39
|
// hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
|
package/app/service.test.ts
CHANGED
|
@@ -1142,6 +1142,42 @@ test("a gated submitPr adopting a live loop narrows it to humanApproval (issue #
|
|
|
1142
1142
|
});
|
|
1143
1143
|
});
|
|
1144
1144
|
|
|
1145
|
+
// Steward opt-in is monotonic too (issue #889 review): an opted-in caller adopting a live loop widens
|
|
1146
|
+
// the durable subject row to opted-in (the steward reads `auto_resolve_escalations` off the row, not a
|
|
1147
|
+
// process variable), and a non-opted-in retry must never downgrade an already-opted-in row. This is
|
|
1148
|
+
// safety-critical propagation — without it an opted-in adopter silently inherits a non-opted-in loop and
|
|
1149
|
+
// its escalations stay human-only. These pin both directions of the monotonic write at service.ts.
|
|
1150
|
+
test("steward opt-in adoption is monotonic: an opted-in adopter upgrades a live opted-out row (issue #889)", async () => {
|
|
1151
|
+
await withGithubOff(async () => {
|
|
1152
|
+
const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
1153
|
+
// Live loop started WITHOUT the opt-in (auto_resolve_escalations unset → 0).
|
|
1154
|
+
const live = adoptLoop("converging", "ACTIVE");
|
|
1155
|
+
// An opted-in adopter (autoResolveEscalations = true, the 9th arg) must widen the row to opted-in.
|
|
1156
|
+
assertEquals(
|
|
1157
|
+
await submitPr(live.data, live.engine, pr, [], 20, false, null, false, true),
|
|
1158
|
+
{ prKey: pr.prKey, alreadyRunning: true },
|
|
1159
|
+
);
|
|
1160
|
+
const row = (await live.data.table("pull_requests", "pr_key").get(pr.prKey)) as { auto_resolve_escalations?: number } | null;
|
|
1161
|
+
assertEquals(row?.auto_resolve_escalations, 1, "opted-in adopter upgrades the live row to opted-in");
|
|
1162
|
+
});
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
test("steward opt-in adoption never downgrades: an opted-out retry leaves an opted-in row alone (issue #889)", async () => {
|
|
1166
|
+
await withGithubOff(async () => {
|
|
1167
|
+
const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
|
|
1168
|
+
// Live loop already opted in.
|
|
1169
|
+
const live = adoptLoop("converging", "ACTIVE");
|
|
1170
|
+
(live.data.table("pull_requests", "pr_key") as any).update(pr.prKey, { auto_resolve_escalations: 1 });
|
|
1171
|
+
// A non-opted-in adopter (autoResolveEscalations = false) must NOT clear the existing opt-in.
|
|
1172
|
+
assertEquals(
|
|
1173
|
+
await submitPr(live.data, live.engine, pr, [], 20, false, null, false, false),
|
|
1174
|
+
{ prKey: pr.prKey, alreadyRunning: true },
|
|
1175
|
+
);
|
|
1176
|
+
const row = (await live.data.table("pull_requests", "pr_key").get(pr.prKey)) as { auto_resolve_escalations?: number } | null;
|
|
1177
|
+
assertEquals(row?.auto_resolve_escalations, 1, "an opted-out retry never downgrades an opted-in row");
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1145
1181
|
test("human approval is pinned off when NANO_PR_AUTO_MERGE is off — the PR never merges (issue #826)", async () => {
|
|
1146
1182
|
// `AUTO_MERGE` is read once at import, so load a fresh copy of the module under the env.
|
|
1147
1183
|
const prev = process.env["NANO_PR_AUTO_MERGE"];
|
package/app/service.ts
CHANGED
|
@@ -34,10 +34,11 @@ import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
|
34
34
|
import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
|
|
35
35
|
import { DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
36
36
|
import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
|
|
37
|
-
import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels, reconcileOriginalInstanceBeforeRelaunch, reconcileStaleLaunchClaim } from "./deliveryGraphRun.ts";
|
|
37
|
+
import { DELIVERY_GRAPH_TERMINAL_STATUSES, deliveryGraphRuns, deliveryGraphRunsTracking, deriveDeliveryPhase, parseHumanLabels, reconcileOriginalInstanceBeforeRelaunch, reconcileStaleLaunchClaim } from "./deliveryGraphRun.ts";
|
|
38
38
|
import { decodeLivePrompt, deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement, needsLiveDeliveryPrompt } from "./deliveryHuman.ts";
|
|
39
39
|
import { fleetSupportsDurableResume } from "./durableResume.ts";
|
|
40
40
|
import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
|
|
41
|
+
import { ESCALATION_STEWARD_PROCESS_ID } from "./escalationSteward.ts";
|
|
41
42
|
import { deriveFeatureCompletion, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns, featureRunsTracking, foldCompletedFeatureRun } from "./feature.ts";
|
|
42
43
|
import {
|
|
43
44
|
classifyMergeability,
|
|
@@ -98,7 +99,7 @@ import { ENGINE_TERMINAL_STATES } from "./reconcile.ts";
|
|
|
98
99
|
// (submitPr/startMerge) and re-exported below so the long-standing `import { repoEnvelopeVars } from
|
|
99
100
|
// "./service.ts"` call sites (and its tests) keep resolving.
|
|
100
101
|
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
101
|
-
import { clampNudgeMinutes, isReviewStale, reviewWaitTimeout } from "./reviewWait.ts";
|
|
102
|
+
import { clampNudgeMinutes, isoDurationToMs, isReviewStale, reviewWaitTimeout } from "./reviewWait.ts";
|
|
102
103
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
103
104
|
import {
|
|
104
105
|
buildUserTaskRow,
|
|
@@ -399,6 +400,9 @@ export interface PullRequest {
|
|
|
399
400
|
// `pr_key` so the Lineage UI join resolves; a legacy NULL is tolerated the same way by the
|
|
400
401
|
// lineage read projection (`pollLineage`), which self-roots on `pr_key`.
|
|
401
402
|
root_request_key: string | null;
|
|
403
|
+
// Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this PR's open
|
|
404
|
+
// escalations are eligible for the steward to auto-resolve (recorded reviewably). Default 0.
|
|
405
|
+
auto_resolve_escalations: number;
|
|
402
406
|
}
|
|
403
407
|
|
|
404
408
|
interface PrDependency {
|
|
@@ -621,9 +625,10 @@ export async function submitPr(
|
|
|
621
625
|
convergeOnly = false,
|
|
622
626
|
rootRequestKey: string | null = null,
|
|
623
627
|
humanApproval = false,
|
|
628
|
+
autoResolveEscalations = false,
|
|
624
629
|
) {
|
|
625
630
|
return withPrSubmitLock(parsed.prKey, () =>
|
|
626
|
-
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval),
|
|
631
|
+
submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations),
|
|
627
632
|
);
|
|
628
633
|
}
|
|
629
634
|
|
|
@@ -636,6 +641,7 @@ async function submitPrCritical(
|
|
|
636
641
|
convergeOnly = false,
|
|
637
642
|
rootRequestKey: string | null = null,
|
|
638
643
|
humanApproval = false,
|
|
644
|
+
autoResolveEscalations = false,
|
|
639
645
|
) {
|
|
640
646
|
// Human approval before merge (issue #826) gates only a PR that `pr.finalize` would hand to the
|
|
641
647
|
// merge-loop — its own predicate, `AUTO_MERGE && convergeOnly !== true`. A run that won't merge
|
|
@@ -677,6 +683,15 @@ async function submitPrCritical(
|
|
|
677
683
|
await engine.setVariables({ scopeKey: loopKey, variables: { humanApproval: true } });
|
|
678
684
|
}
|
|
679
685
|
}
|
|
686
|
+
// Steward opt-in is monotonic too (issue #889 review): an opted-in caller adopting a live loop —
|
|
687
|
+
// possibly a non-opted-in one another run started — widens it to opted-in so the steward can serve
|
|
688
|
+
// its escalations, rather than silently inheriting the loop's non-opted-in state. The steward reads
|
|
689
|
+
// the flag off the SUBJECT row (`optInByProcessKey`), not a process variable, so the durable row
|
|
690
|
+
// update is the whole propagation — no `setVariables` is needed (or possible: the loop never reads
|
|
691
|
+
// it). Idempotent; a retry against our own opted-in loop is a no-op write.
|
|
692
|
+
if (autoResolveEscalations && existing.auto_resolve_escalations !== 1) {
|
|
693
|
+
await table.update(parsed.prKey, { auto_resolve_escalations: 1 });
|
|
694
|
+
}
|
|
680
695
|
return { prKey: parsed.prKey, alreadyRunning: true };
|
|
681
696
|
}
|
|
682
697
|
|
|
@@ -759,6 +774,7 @@ async function submitPrCritical(
|
|
|
759
774
|
current_round: 1,
|
|
760
775
|
abandon_token: abandonToken,
|
|
761
776
|
root_request_key: effectiveRoot,
|
|
777
|
+
auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
|
|
762
778
|
created_at: ts,
|
|
763
779
|
updated_at: ts,
|
|
764
780
|
});
|
|
@@ -808,6 +824,10 @@ async function submitPrCritical(
|
|
|
808
824
|
// Human approval before merge (issue #826): a converged PR parks at the `merge-approval` user
|
|
809
825
|
// task before `pr.finalize` hands it to the merge-loop. Pinned off when the PR won't merge.
|
|
810
826
|
humanApproval: gated,
|
|
827
|
+
// Opt-in escalation steward (issue #889): carried on the instance AND persisted on the
|
|
828
|
+
// `pull_requests` row (the durable source the steward's eligibility reads), so a clobbered
|
|
829
|
+
// variable can never silently opt a PR in or out (#887/#888).
|
|
830
|
+
autoResolveEscalations,
|
|
811
831
|
// Cooperative abandon check (#76): the capability URL + the abort brief appended to the
|
|
812
832
|
// review-round agent's prompt, so it can stop before pushing if the run is cancelled.
|
|
813
833
|
abandonUrl: abUrl,
|
|
@@ -884,6 +904,9 @@ async function submitPrCritical(
|
|
|
884
904
|
merged_at: null,
|
|
885
905
|
abandon_token: abandonToken,
|
|
886
906
|
root_request_key: effectiveRoot,
|
|
907
|
+
// Record the opt-in on the row (the durable source the steward's eligibility reads) so a
|
|
908
|
+
// fresh run's flag is authoritative even across a reopen (issue #889).
|
|
909
|
+
auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
|
|
887
910
|
// Publish the fresh run's identity in the SAME write as the status flip (may be null if the
|
|
888
911
|
// instance could not be created — a phantom row a retry re-enrolls, as on first submit).
|
|
889
912
|
process_key: processKey,
|
|
@@ -1018,6 +1041,9 @@ export interface ActivePr {
|
|
|
1018
1041
|
activeWorker: string | null;
|
|
1019
1042
|
/** ISO ts the current activation lease expires; null when not activated. */
|
|
1020
1043
|
leaseUntil: string | null;
|
|
1044
|
+
/** Whether this PR opted in to the escalation steward (issue #889) — its open escalations are
|
|
1045
|
+
* eligible for steward auto-resolution. Shown in `/status` per PR. */
|
|
1046
|
+
autoResolveEscalations: boolean;
|
|
1021
1047
|
}
|
|
1022
1048
|
|
|
1023
1049
|
/** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
|
|
@@ -1073,6 +1099,56 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
|
|
|
1073
1099
|
updatedAt: p.updated_at,
|
|
1074
1100
|
activeWorker: p.active_worker ?? null,
|
|
1075
1101
|
leaseUntil: p.lease_until ?? null,
|
|
1102
|
+
autoResolveEscalations: p.auto_resolve_escalations === 1,
|
|
1103
|
+
}));
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
|
|
1107
|
+
* "Show it in `/status` for each PR or delivery"). Mirrors {@link ActivePr} for the delivery side. */
|
|
1108
|
+
export interface ActiveDelivery {
|
|
1109
|
+
runKey: string;
|
|
1110
|
+
title: string | null;
|
|
1111
|
+
status: string;
|
|
1112
|
+
phase: string | null;
|
|
1113
|
+
processKey: string | null;
|
|
1114
|
+
openEscalation: OpenEscalation | null;
|
|
1115
|
+
updatedAt: string;
|
|
1116
|
+
/** Whether this run opted in to the escalation steward (issue #889) — its open escalations are
|
|
1117
|
+
* eligible for steward auto-resolution. Derived from the durable `auto_resolve_escalations` flag,
|
|
1118
|
+
* never a clobbered process variable. Shown in `/status` per delivery. */
|
|
1119
|
+
autoResolveEscalations: boolean;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** Every delivery-graph run not in a terminal state (done/failed/abandoned), newest-updated first.
|
|
1123
|
+
* Backs the delivery half of the GET status endpoint (issue #889 §1): a delivery run that opted in to
|
|
1124
|
+
* the escalation steward must surface that durable flag alongside its live escalation, exactly as a PR
|
|
1125
|
+
* does. The structured `openEscalation` pointer is derived from the SAME `user_tasks` read model as
|
|
1126
|
+
* `activePrs` (subject_type `delivery`, keyed by `run_key`), so a delivery node's parked human/escalation
|
|
1127
|
+
* task carries its completable `userTaskKey` here too. */
|
|
1128
|
+
export async function activeDeliveries(data: DataLayer): Promise<ActiveDelivery[]> {
|
|
1129
|
+
// `derived_status` is a plain `string` (the tracking VIEW's fold column), while the terminal set is
|
|
1130
|
+
// the narrow `DeliveryGraphRunStatus` union — classify via a `readonly string[]` view (the SAME
|
|
1131
|
+
// idiom `TERMINAL_STATUSES: readonly string[]` gives the PR side) rather than a banned `as` cast.
|
|
1132
|
+
const terminal: readonly string[] = DELIVERY_GRAPH_TERMINAL_STATUSES;
|
|
1133
|
+
const active = (await deliveryGraphRunsTracking(data).all())
|
|
1134
|
+
.filter((r) => !terminal.includes(r.derived_status))
|
|
1135
|
+
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
|
|
1136
|
+
const openEscByRun = new Map<string, OpenEscalation>();
|
|
1137
|
+
for (const t of (await userTasks(data).find({ subject_type: "delivery" })).sort((a, b) =>
|
|
1138
|
+
a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0,
|
|
1139
|
+
)) {
|
|
1140
|
+
if (openEscByRun.has(t.subject_key)) continue;
|
|
1141
|
+
openEscByRun.set(t.subject_key, toOpenEscalation(t));
|
|
1142
|
+
}
|
|
1143
|
+
return active.map((r) => ({
|
|
1144
|
+
runKey: r.run_key,
|
|
1145
|
+
title: r.title ?? null,
|
|
1146
|
+
status: r.status,
|
|
1147
|
+
phase: r.phase ?? null,
|
|
1148
|
+
processKey: r.process_key ?? null,
|
|
1149
|
+
openEscalation: openEscByRun.get(r.run_key) ?? null,
|
|
1150
|
+
updatedAt: r.updated_at,
|
|
1151
|
+
autoResolveEscalations: r.auto_resolve_escalations === 1,
|
|
1076
1152
|
}));
|
|
1077
1153
|
}
|
|
1078
1154
|
|
|
@@ -2559,7 +2635,10 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
|
|
|
2559
2635
|
}
|
|
2560
2636
|
if (prStatus === null) {
|
|
2561
2637
|
const parsed = parsePr(plan.promotion_pr);
|
|
2562
|
-
|
|
2638
|
+
// Thread the epic's escalation-steward opt-in (issue #889 review) so a re-enrolled
|
|
2639
|
+
// promotion PR inherits the parent epic's enrolment rather than dropping to the `false`
|
|
2640
|
+
// default (which would also overwrite a previously persisted opt-in with 0).
|
|
2641
|
+
if (parsed) await submitPr(data, engine, parsed, [], MAX_ROUNDS, false, null, false, plan.auto_resolve_escalations === 1);
|
|
2563
2642
|
}
|
|
2564
2643
|
continue;
|
|
2565
2644
|
}
|
|
@@ -2596,7 +2675,9 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
|
|
|
2596
2675
|
updated_at: now(),
|
|
2597
2676
|
});
|
|
2598
2677
|
const parsed = parsePr(promotionPrKey);
|
|
2599
|
-
|
|
2678
|
+
// Thread the epic's escalation-steward opt-in (issue #889 review) so the freshly opened
|
|
2679
|
+
// promotion PR is enrolled with the parent epic's enrolment, not the `false` default.
|
|
2680
|
+
if (parsed) await submitPr(data, engine, parsed, [], MAX_ROUNDS, false, null, false, plan.auto_resolve_escalations === 1);
|
|
2600
2681
|
console.log(
|
|
2601
2682
|
`[poller] promotion PR ${result.created ? "opened" : "reused"} ${promotionPrKey} (${base} -> ${target})`,
|
|
2602
2683
|
);
|
|
@@ -2705,6 +2786,10 @@ export async function pollFeatureDelivery(
|
|
|
2705
2786
|
run.auto_merge !== 1,
|
|
2706
2787
|
run.feature_key,
|
|
2707
2788
|
run.human_approval === 1,
|
|
2789
|
+
// Thread the run's escalation-steward opt-in (issue #889 review) so a re-enrolled PR
|
|
2790
|
+
// inherits the feature run's enrolment — and a partial-enrollment repair never overwrites
|
|
2791
|
+
// the PR row's previously persisted opt-in with the `false` default.
|
|
2792
|
+
run.auto_resolve_escalations === 1,
|
|
2708
2793
|
);
|
|
2709
2794
|
}
|
|
2710
2795
|
}
|
|
@@ -3779,6 +3864,46 @@ export async function pollUserTasks(
|
|
|
3779
3864
|
}
|
|
3780
3865
|
}
|
|
3781
3866
|
|
|
3867
|
+
/** Escalation-steward tick (issue #889): start ONE `escalation-steward` instance per interval when the
|
|
3868
|
+
* loop is enabled (`NANO_ESCALATION_STEWARD` not off) and the previous tick's instance has finished.
|
|
3869
|
+
* The collect step's own overlap guard is the authoritative no-op for a still-active prior run; this
|
|
3870
|
+
* pass adds the interval pacing + the master switch. Self-scheduled via `pollOnce`; the interval is
|
|
3871
|
+
* `NANO_ESCALATION_STEWARD_INTERVAL` (default `R/PT30M` — the poll reads its `PT…` duration part).
|
|
3872
|
+
* Best-effort + idempotent — a failed start is logged and retried next pass, never wedges the loop. */
|
|
3873
|
+
let lastStewardTick = 0;
|
|
3874
|
+
export async function pollEscalationSteward(engine: EngineClient) {
|
|
3875
|
+
const disabled = /^(0|off|false|no)$/i.test(readEnvOr("NANO_ESCALATION_STEWARD", "1"));
|
|
3876
|
+
if (disabled) return;
|
|
3877
|
+
// Parse the interval through the ONE canonical ISO-8601 duration parser (`isoDurationToMs`,
|
|
3878
|
+
// app/reviewWait.ts) so the pacing can never drift from the grammar every other timer in the app
|
|
3879
|
+
// uses. The env may carry a bare duration (`PT30M`) or a repeat interval (`R/PT30M`); strip an
|
|
3880
|
+
// optional `R…/` prefix and parse the `P…` period. A malformed value falls back to 30 min; a
|
|
3881
|
+
// zero/negative interval is clamped to that fallback so a steward never starts every poll.
|
|
3882
|
+
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
|
|
3883
|
+
const cycle = readEnvOr("NANO_ESCALATION_STEWARD_INTERVAL", "PT30M");
|
|
3884
|
+
const period = cycle.includes("/") ? cycle.slice(cycle.lastIndexOf("/") + 1) : cycle;
|
|
3885
|
+
const parsedMs = isoDurationToMs(period, "PT30M");
|
|
3886
|
+
const intervalMs = parsedMs > 0 ? parsedMs : DEFAULT_INTERVAL_MS;
|
|
3887
|
+
const nowMs = Date.now();
|
|
3888
|
+
if (nowMs - lastStewardTick < intervalMs) return;
|
|
3889
|
+
// Overlap guard (cheap, pre-start): don't start a new tick while a prior one is still ACTIVE.
|
|
3890
|
+
const active = await engine.searchProcessInstances({ processDefinitionId: ESCALATION_STEWARD_PROCESS_ID, state: "ACTIVE" });
|
|
3891
|
+
if (active.length > 0) return;
|
|
3892
|
+
const minAgeMs = Number(readEnvOr("NANO_ESCALATION_STEWARD_MIN_AGE_MS", "300000"));
|
|
3893
|
+
// Advance the tick ONLY after a successful start: if `createInstance` throws, leaving
|
|
3894
|
+
// `lastStewardTick` untouched lets the next pass retry immediately rather than suppressing retries
|
|
3895
|
+
// for the whole interval (the "retried next pass" contract).
|
|
3896
|
+
// Seed the canonical agent-task SLA (`agentSlaTimeout`, issue #849) so the steward-agent's
|
|
3897
|
+
// interrupting timer boundary (`be_steward_sla`) bounds a hung agent run — without it a wedged
|
|
3898
|
+
// agent parks the instance ACTIVE forever and the overlap guard above suppresses every later tick
|
|
3899
|
+
// (issue #889 review).
|
|
3900
|
+
await engine.createInstance({
|
|
3901
|
+
processDefinitionId: ESCALATION_STEWARD_PROCESS_ID,
|
|
3902
|
+
variables: { minAgeMs, agentSlaTimeout: AGENT_SLA_TIMEOUT },
|
|
3903
|
+
});
|
|
3904
|
+
lastStewardTick = nowMs;
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3782
3907
|
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
3783
3908
|
* (when the engine REST endpoint is supplied) the job-activation visibility pass and the
|
|
3784
3909
|
* technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`.
|
|
@@ -3799,6 +3924,15 @@ export async function pollOnce(
|
|
|
3799
3924
|
await pollFeatureDelivery(data, engine);
|
|
3800
3925
|
await pollLineage(data);
|
|
3801
3926
|
await pollUserTasks(data, engine, engineRest);
|
|
3927
|
+
// The escalation steward is an OPTIONAL, best-effort pass: a steward-specific failure (a search or
|
|
3928
|
+
// instance-start error) must never reject `pollOnce` and starve the unrelated epic / taskless-plan /
|
|
3929
|
+
// delivery-graph reconciliation passes that follow it. Catch + log it independently so the canonical
|
|
3930
|
+
// poller always continues (issue #889 review).
|
|
3931
|
+
try {
|
|
3932
|
+
await pollEscalationSteward(engine);
|
|
3933
|
+
} catch (err) {
|
|
3934
|
+
console.error(`[poller] escalation-steward tick: ${err instanceof Error ? err.message : String(err)}`);
|
|
3935
|
+
}
|
|
3802
3936
|
await pollEpicPhase(data, engine, engineRest);
|
|
3803
3937
|
await pollTasklessPlanTermination(data, engine);
|
|
3804
3938
|
await pollDeliveryGraphPhase(data, engine);
|