@nanobpm/nano-workforce 0.144.0 → 0.145.1
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/app/contracts.ts +7 -0
- package/app/deliveryGraphRun.test.ts +1 -1
- package/app/epicPhase.test.ts +86 -3
- package/app/epicPhase.ts +98 -4
- package/app/mergeLandedWait.test.ts +39 -0
- package/app/mergeLandedWait.ts +39 -0
- package/app/mergeLoopBehaviour.test.ts +28 -1
- package/app/pollEpicPhase.test.ts +159 -0
- package/app/service.ts +85 -7
- package/package.json +2 -2
- package/resources/processes/merge-loop.bpmn +277 -209
- package/workers/record-plan/worker.ts +0 -6
- package/workers/record-results/worker.ts +7 -8
- package/workers/record-wave/worker.test.ts +6 -5
- package/workers/record-wave/worker.ts +5 -21
- package/workers/select-wave/worker.test.ts +4 -3
- package/workers/select-wave/worker.ts +15 -29
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.145.1](https://github.com/nanobpm/nano-workforce/compare/v0.145.0...v0.145.1) (2026-08-26)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* bound the merge-loop wait-landed wait so a never-enqueued PR escalates ([#556](https://github.com/nanobpm/nano-workforce/issues/556)) ([#558](https://github.com/nanobpm/nano-workforce/issues/558)) ([0e14578](https://github.com/nanobpm/nano-workforce/commit/0e14578e23d3914f8ae82a9fd84280fbc05c6588))
|
|
6
|
+
|
|
7
|
+
## [0.145.0](https://github.com/nanobpm/nano-workforce/compare/v0.144.0...v0.145.0) (2026-08-25)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **stepper:** live element-instance epic-phase projection (S8, [#542](https://github.com/nanobpm/nano-workforce/issues/542)) ([#554](https://github.com/nanobpm/nano-workforce/issues/554)) ([fdc3ccc](https://github.com/nanobpm/nano-workforce/commit/fdc3ccc8ffe3797954c8502385634f7c0937571e)), closes [#464](https://github.com/nanobpm/nano-workforce/issues/464) [#541](https://github.com/nanobpm/nano-workforce/issues/541) [#546](https://github.com/nanobpm/nano-workforce/issues/546) [nano-ide#473](https://github.com/nanobpm/nano-ide/issues/473)
|
|
12
|
+
|
|
1
13
|
## [0.144.0](https://github.com/nanobpm/nano-workforce/compare/v0.143.0...v0.144.0) (2026-08-25)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/app/contracts.ts
CHANGED
|
@@ -136,6 +136,13 @@ export const ENV_CONTRACTS = {
|
|
|
136
136
|
owner: "app/service.ts",
|
|
137
137
|
semantics: "Minutes between review nudges.",
|
|
138
138
|
},
|
|
139
|
+
NANO_PR_MERGE_LANDED_WAIT_TIMEOUT: {
|
|
140
|
+
category: "env",
|
|
141
|
+
name: "NANO_PR_MERGE_LANDED_WAIT_TIMEOUT",
|
|
142
|
+
owner: "app/service.ts",
|
|
143
|
+
semantics:
|
|
144
|
+
"How long the merge loop waits for a queued PR to actually land before escalating (FEEL/ISO-8601 duration).",
|
|
145
|
+
},
|
|
139
146
|
NANO_PR_AUTO_MERGE: {
|
|
140
147
|
category: "env",
|
|
141
148
|
name: "NANO_PR_AUTO_MERGE",
|
|
@@ -115,7 +115,7 @@ test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches
|
|
|
115
115
|
// The engine can yield a NUMERIC key; the poller compares against the string process_key.
|
|
116
116
|
const engine = {
|
|
117
117
|
searchProcessInstances: async () => [{ processInstanceKey: 12345, state: "COMPLETED" }],
|
|
118
|
-
|
|
118
|
+
searchElementInstanceWaitStates: async () => [],
|
|
119
119
|
};
|
|
120
120
|
await pollDeliveryGraphPhase(data, engine as never);
|
|
121
121
|
assertEquals((await runs.get("rk"))?.status, "done");
|
package/app/epicPhase.test.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// epic view can show WHICH phase an epic is in — not only the process-instance terminal status.
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import { assertEquals } from "#test-assert";
|
|
8
|
-
import { deriveEpicPhase, EPIC_PHASE, implementingPhase } from "./epicPhase.ts";
|
|
8
|
+
import { deriveEpicPhase, deriveEpicPhaseLive, deriveTerminalEpicPhase, EPIC_PHASE, implementingPhase } from "./epicPhase.ts";
|
|
9
9
|
|
|
10
10
|
test("deriveEpicPhase maps each spine element to its domain phase", () => {
|
|
11
11
|
// Planning genesis + hand-off into Reviewing when the plan is recorded.
|
|
@@ -20,8 +20,10 @@ test("deriveEpicPhase maps each spine element to its domain phase", () => {
|
|
|
20
20
|
assertEquals(deriveEpicPhase("record-trial-merge"), EPIC_PHASE.TRIAL_MERGING);
|
|
21
21
|
assertEquals(deriveEpicPhase("trial-merge-decision"), EPIC_PHASE.TRIAL_MERGING);
|
|
22
22
|
assertEquals(deriveEpicPhase("resolve-trial-attention"), EPIC_PHASE.TRIAL_MERGING);
|
|
23
|
-
// Finalize step
|
|
24
|
-
|
|
23
|
+
// Finalize step ("Finalize plan") reads Finalizing while its token is ACTIVE; the terminal
|
|
24
|
+
// "Fleet dispatched" phase is derived from the terminal status, not this element (see
|
|
25
|
+
// deriveTerminalEpicPhase), so Finalizing is reachable and Dispatched is not raced off a live token.
|
|
26
|
+
assertEquals(deriveEpicPhase("record-results"), EPIC_PHASE.FINALIZING);
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
test("deriveEpicPhase wave-labels the Implementing band from the levelize records", () => {
|
|
@@ -59,4 +61,85 @@ test("implementingPhase clamps the 1-based label to the total and degrades grace
|
|
|
59
61
|
assertEquals(implementingPhase(0, 0), "Implementing");
|
|
60
62
|
assertEquals(implementingPhase(undefined, undefined), "Implementing");
|
|
61
63
|
assertEquals(implementingPhase("x", "y"), "Implementing");
|
|
64
|
+
// A NULL `current_wave` (unknown wave) with a known `wave_count` is ABSENT, not wave 0 — it must
|
|
65
|
+
// NOT mislabel as "wave 1/t" (`Number(null)` is 0). Missing wave data stays missing.
|
|
66
|
+
assertEquals(implementingPhase(null, 3), "Implementing");
|
|
67
|
+
assertEquals(implementingPhase(null, null), "Implementing");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("deriveEpicPhaseLive reads Finalizing from an ACTIVE finalizer token", () => {
|
|
71
|
+
// The finalize step is Finalizing while its token is ACTIVE — the phase is reachable in the live
|
|
72
|
+
// model (it is the furthest spine element short of the terminal Dispatched marker).
|
|
73
|
+
assertEquals(
|
|
74
|
+
deriveEpicPhaseLive([{ elementId: "record-results", state: "ACTIVE" }]),
|
|
75
|
+
EPIC_PHASE.FINALIZING,
|
|
76
|
+
);
|
|
77
|
+
// Finalizing (ordinal) outranks a still-live trial-merge token.
|
|
78
|
+
assertEquals(
|
|
79
|
+
deriveEpicPhaseLive([
|
|
80
|
+
{ elementId: "trial-merge", state: "ACTIVE" },
|
|
81
|
+
{ elementId: "record-results", state: "ACTIVE" },
|
|
82
|
+
]),
|
|
83
|
+
EPIC_PHASE.FINALIZING,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("deriveTerminalEpicPhase reads Dispatched only from a done epic that dispatched a fleet", () => {
|
|
88
|
+
// A done epic that opened ≥1 slice reaches the terminal "Fleet dispatched" phase.
|
|
89
|
+
assertEquals(deriveTerminalEpicPhase("done", 3), EPIC_PHASE.DISPATCHED);
|
|
90
|
+
assertEquals(deriveTerminalEpicPhase("done", 1), EPIC_PHASE.DISPATCHED);
|
|
91
|
+
// A taskless done (planner emitted no tasks — nothing dispatched) and non-done terminals are NOT
|
|
92
|
+
// Dispatched, so the caller leaves the last live phase untouched.
|
|
93
|
+
assertEquals(deriveTerminalEpicPhase("done", 0), null);
|
|
94
|
+
assertEquals(deriveTerminalEpicPhase("failed", 3), null);
|
|
95
|
+
assertEquals(deriveTerminalEpicPhase("abandoned", 3), null);
|
|
96
|
+
assertEquals(deriveTerminalEpicPhase("dispatched", 3), null);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── deriveEpicPhaseLive: the S8 live element-instance derivation (#542) ────────────────────────────
|
|
100
|
+
test("deriveEpicPhaseLive projects the FURTHEST active spine element onto its phase", () => {
|
|
101
|
+
// A pre-PR Reviewing epic: the plan is recorded (COMPLETED) and the review-plan agent is running.
|
|
102
|
+
assertEquals(
|
|
103
|
+
deriveEpicPhaseLive([
|
|
104
|
+
{ elementId: "record-plan", state: "COMPLETED" },
|
|
105
|
+
{ elementId: "review-plan", state: "ACTIVE" },
|
|
106
|
+
]),
|
|
107
|
+
EPIC_PHASE.REVIEWING,
|
|
108
|
+
);
|
|
109
|
+
// The implement multi-instance keeps select-wave/record-wave AND per-child implement-task tokens
|
|
110
|
+
// live at once; a later trial-merge token, once reached, is the epic's true furthest position.
|
|
111
|
+
assertEquals(
|
|
112
|
+
deriveEpicPhaseLive([
|
|
113
|
+
{ elementId: "implement-task", state: "ACTIVE" },
|
|
114
|
+
{ elementId: "record-wave", state: "ACTIVE" },
|
|
115
|
+
{ elementId: "trial-merge", state: "ACTIVE" },
|
|
116
|
+
]),
|
|
117
|
+
EPIC_PHASE.TRIAL_MERGING,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("deriveEpicPhaseLive wave-labels a live Implementing token from the wave context", () => {
|
|
122
|
+
assertEquals(
|
|
123
|
+
deriveEpicPhaseLive([{ elementId: "implement-task", state: "ACTIVE" }], { current: 1, total: 3 }),
|
|
124
|
+
"Implementing (wave 2/3)",
|
|
125
|
+
);
|
|
126
|
+
// Mid-cell fidelity (S8): an active implement job with no wave numbers yet still reads Implementing.
|
|
127
|
+
assertEquals(
|
|
128
|
+
deriveEpicPhaseLive([{ elementId: "implement-task", state: "ACTIVE" }]),
|
|
129
|
+
EPIC_PHASE.IMPLEMENTING,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("deriveEpicPhaseLive ignores non-ACTIVE tokens and non-spine plumbing, returning null when nothing marks a phase", () => {
|
|
134
|
+
// COMPLETED/TERMINATED tokens are past, not the live position — an all-completed set marks nothing.
|
|
135
|
+
assertEquals(
|
|
136
|
+
deriveEpicPhaseLive([
|
|
137
|
+
{ elementId: "plan", state: "COMPLETED" },
|
|
138
|
+
{ elementId: "review-plan", state: "TERMINATED" },
|
|
139
|
+
]),
|
|
140
|
+
null,
|
|
141
|
+
);
|
|
142
|
+
// A token parked only on non-spine plumbing (no ELEMENT_PHASE entry) leaves the phase untouched.
|
|
143
|
+
assertEquals(deriveEpicPhaseLive([{ elementId: "some-gateway", state: "ACTIVE" }]), null);
|
|
144
|
+
assertEquals(deriveEpicPhaseLive([]), null);
|
|
62
145
|
});
|
package/app/epicPhase.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
// app/epicPhase.ts — reify the epic's own domain lifecycle as a derived `epic_phase` (issue #261
|
|
1
|
+
// app/epicPhase.ts — reify the epic's own domain lifecycle as a derived `epic_phase` (issue #261,
|
|
2
|
+
// S8 #542 / ADR 0006 §4b).
|
|
3
|
+
//
|
|
4
|
+
// LIVE READ-MODEL DERIVATION (S8, #542). The epic phase is now a PURE read-model derivation off the
|
|
5
|
+
// live engine element-instance model — the write-time provenance stamp (each spine worker stamping
|
|
6
|
+
// the phase it enters) is RETIRED. `deriveEpicPhaseLive` reads the plan-fanout instance's live
|
|
7
|
+
// element instances (`EngineClient.searchElementInstances`, nano-ide#473) and projects the
|
|
8
|
+
// FURTHEST-REACHED active element onto the same structural `ELEMENT_PHASE` map the write-stamp used
|
|
9
|
+
// (derive-don't-duplicate: one structural source, two consumers retired to one). This lifts S7's
|
|
10
|
+
// coarse lifecycle-stage fidelity to true per-cell / mid-cell position — an active `implement` job or
|
|
11
|
+
// a pre-PR `review-plan` agent is read live from the token position, ahead of any work-table row.
|
|
12
|
+
// The `pollEpicPhase` poll pass (app/service.ts) owns the write, so no worker stamps `epic_phase`.
|
|
13
|
+
//
|
|
14
|
+
// Because plan-fanout.bpmn runs the WHOLE epic spine (`plan` → `review-plan` → the `implement`
|
|
15
|
+
// multi-instance subProcess → `trial-merge` → `record-results`) as ONE process instance — the
|
|
16
|
+
// `implement` fan-out is an embedded subProcess, not a callActivity child instance — a single
|
|
17
|
+
// element-instance search over the plan's `process_key` sees every spine cell. (When S4 callActivity
|
|
18
|
+
// composition lands, the same derivation extends to child instances via the engine's native
|
|
19
|
+
// parent/root keys, Magikcraft/nano-bpm#977 — the #464 option-B correlation decision.)
|
|
2
20
|
//
|
|
3
21
|
// `plans.status` only distinguishes `planning` / `dispatched` / `done` / `failed` / `abandoned` —
|
|
4
22
|
// and `dispatched` is the `plan-fanout.bpmn` PROCESS-INSTANCE terminal ("fan-out job done"), not the
|
|
@@ -42,6 +60,9 @@ export const EPIC_PHASE = {
|
|
|
42
60
|
* `toWave` coercion the wave workers already apply, so a NaN/absent counter degrades to an
|
|
43
61
|
* unlabelled `Implementing` rather than emitting `wave NaN/…`. */
|
|
44
62
|
const toWave = (v: unknown): number | null => {
|
|
63
|
+
// `null`/`undefined` are ABSENT, not zero: `Number(null)` is `0`, which would otherwise label a
|
|
64
|
+
// missing `current_wave` as `wave 1/t`. Treat them as unusable so missing wave data stays missing.
|
|
65
|
+
if (v === null || v === undefined) return null;
|
|
45
66
|
const n = Math.trunc(Number(v));
|
|
46
67
|
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
47
68
|
};
|
|
@@ -74,8 +95,10 @@ export function implementingPhase(current: unknown, total: unknown): string {
|
|
|
74
95
|
* • `select-wave` ("Select wave") → Implementing: it dispatches the wave and is the last host write
|
|
75
96
|
* before the write-silent `implement` MI, so it durably marks the implementation phase for the
|
|
76
97
|
* wave it launches (wave-labelled via {@link implementingPhase} at the call site).
|
|
77
|
-
* • `record-results` ("Finalize plan") →
|
|
78
|
-
* "Fleet dispatched"
|
|
98
|
+
* • `record-results` ("Finalize plan") → Finalizing: while the finalizer token is ACTIVE the epic
|
|
99
|
+
* is finalizing. Its TERMINAL "Fleet dispatched" phase is NOT read from this (fleeting) live
|
|
100
|
+
* token — a completion marker has no ACTIVE element to read once the instance ends — but derived
|
|
101
|
+
* from the durable terminal status (see {@link deriveTerminalEpicPhase}).
|
|
79
102
|
* `record-wave`'s next phase is data-dependent (trial-merge vs. next wave vs. finalize), so it is
|
|
80
103
|
* resolved at its call site rather than from the element id alone; its structural fallback here is
|
|
81
104
|
* the wave it just landed.
|
|
@@ -97,7 +120,7 @@ const ELEMENT_PHASE: Readonly<Record<string, string>> = {
|
|
|
97
120
|
"record-trial-merge": EPIC_PHASE.TRIAL_MERGING,
|
|
98
121
|
"trial-merge-decision": EPIC_PHASE.TRIAL_MERGING,
|
|
99
122
|
"resolve-trial-attention": EPIC_PHASE.TRIAL_MERGING,
|
|
100
|
-
"record-results": EPIC_PHASE.
|
|
123
|
+
"record-results": EPIC_PHASE.FINALIZING,
|
|
101
124
|
};
|
|
102
125
|
|
|
103
126
|
/** Optional wave context for a wave-bearing phase, sourced from the wave/levelize records. */
|
|
@@ -123,3 +146,74 @@ export function deriveEpicPhase(
|
|
|
123
146
|
if (base === EPIC_PHASE.IMPLEMENTING) return implementingPhase(wave?.current, wave?.total);
|
|
124
147
|
return base;
|
|
125
148
|
}
|
|
149
|
+
|
|
150
|
+
/** The epic spine's phase ORDER — the total order `deriveEpicPhaseLive` compares "furthest reached"
|
|
151
|
+
* by. It IS the declaration order of {@link EPIC_PHASE} (Planning → Reviewing → Implementing → Trial
|
|
152
|
+
* merging → Finalizing → Dispatched), the epic's natural forward spine, so the ordinal cannot drift
|
|
153
|
+
* from the phase vocabulary. */
|
|
154
|
+
const EPIC_PHASE_ORDER: readonly string[] = Object.values(EPIC_PHASE);
|
|
155
|
+
|
|
156
|
+
/** Constant-time phase→ordinal lookup for {@link deriveEpicPhaseLive}'s hot loop — precomputed once
|
|
157
|
+
* from {@link EPIC_PHASE_ORDER} so the per-element "furthest reached" compare is O(1) instead of a
|
|
158
|
+
* linear `indexOf` per ACTIVE instance (avoids O(n·k) on high-fanout epics; #542 review). */
|
|
159
|
+
const EPIC_PHASE_ORDINAL: ReadonlyMap<string, number> = new Map(
|
|
160
|
+
EPIC_PHASE_ORDER.map((phase, ordinal) => [phase, ordinal]),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/** The finest-grained element-instance signal `deriveEpicPhaseLive` reads — the structural subset of
|
|
164
|
+
* urban's `ElementInstanceSummary` it needs (the element's BPMN id and whether a token is currently
|
|
165
|
+
* AT it). Kept structural (not the full binding type) so the derivation unit-tests in isolation. */
|
|
166
|
+
export interface EpicElementInstance {
|
|
167
|
+
readonly elementId: string;
|
|
168
|
+
readonly state: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Derive the epic phase LIVE from the plan-fanout instance's element instances (S8 #542) — the pure
|
|
173
|
+
* read-model derivation that RETIRES the write-time stamp. Among the ACTIVE element instances (a token
|
|
174
|
+
* currently sitting AT the element — a running agent job, an open human gate, a readiness-probe loop),
|
|
175
|
+
* pick the one mapping FURTHEST along the epic spine ({@link EPIC_PHASE_ORDER}) and project it via the
|
|
176
|
+
* SAME structural {@link deriveEpicPhase} map — so the live derivation and the (now retired) stamp
|
|
177
|
+
* share one source. Returns `null` when no active element marks a phase (e.g. the instance is parked
|
|
178
|
+
* only on non-spine plumbing), so the caller leaves the last known phase untouched rather than
|
|
179
|
+
* clobbering it. A wave-bearing phase (`Implementing`) is wave-labelled from {@link WaveContext}.
|
|
180
|
+
*
|
|
181
|
+
* "Furthest reached" (max spine ordinal), not "least advanced": the `implement` multi-instance
|
|
182
|
+
* subProcess keeps `select-wave`/`record-wave` and per-child `implement-task` tokens live at once, all
|
|
183
|
+
* mapping to `Implementing`; a later `trial-merge` token, once reached, is the epic's true position, so
|
|
184
|
+
* the max is the faithful "where has this epic got to" read.
|
|
185
|
+
*/
|
|
186
|
+
export function deriveEpicPhaseLive(
|
|
187
|
+
elements: readonly EpicElementInstance[],
|
|
188
|
+
wave?: WaveContext,
|
|
189
|
+
): string | null {
|
|
190
|
+
let bestBase: string | null = null;
|
|
191
|
+
let bestOrdinal = -1;
|
|
192
|
+
for (const el of elements) {
|
|
193
|
+
if (el.state !== "ACTIVE") continue;
|
|
194
|
+
const base = deriveEpicPhase(el.elementId);
|
|
195
|
+
if (base === null) continue;
|
|
196
|
+
const ordinal = EPIC_PHASE_ORDINAL.get(base) ?? -1;
|
|
197
|
+
if (ordinal > bestOrdinal) {
|
|
198
|
+
bestOrdinal = ordinal;
|
|
199
|
+
bestBase = base;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (bestBase === null) return null;
|
|
203
|
+
return bestBase === EPIC_PHASE.IMPLEMENTING ? implementingPhase(wave?.current, wave?.total) : bestBase;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Derive the epic's TERMINAL phase from its durable status — the completion-marker counterpart to the
|
|
208
|
+
* live derivation (S8 #542 review). The "Fleet dispatched" phase is reached only when the plan-fanout
|
|
209
|
+
* instance ENDS, at which point there is no ACTIVE element to read; live-observing the fleeting ACTIVE
|
|
210
|
+
* `record-results` token via a coarse (default 60s) poll would miss it on nearly every fast finalize,
|
|
211
|
+
* freezing the row at the last live phase. So `Dispatched` is derived from the durable read-model
|
|
212
|
+
* (`plans.status`) instead: a `done` epic that dispatched ≥1 slice (`taskCount > 0`) reads Dispatched.
|
|
213
|
+
* Returns `null` for a taskless `done` (planner emitted no tasks — nothing was dispatched) and for any
|
|
214
|
+
* non-`done` terminal (`failed`/`abandoned`), so those never mislabel as Dispatched and the caller
|
|
215
|
+
* leaves the last live phase untouched.
|
|
216
|
+
*/
|
|
217
|
+
export function deriveTerminalEpicPhase(status: string, taskCount: number): string | null {
|
|
218
|
+
return status === "done" && taskCount > 0 ? EPIC_PHASE.DISPATCHED : null;
|
|
219
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Unit coverage for the merge-queue landing liveness timeout policy. The value is baked into every
|
|
2
|
+
// merge-loop instance's `landedWaitTimeout` process variable and evaluated by the `wait-landed-timeout`
|
|
3
|
+
// timer catch (the timer arm of the `eg-landed` event-based gateway), so a malformed operator env
|
|
4
|
+
// must never deploy an uninterpretable `<bpmn:timeDuration>` — it falls back to the default instead.
|
|
5
|
+
// Run with `node --test`.
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { DEFAULT_MERGE_LANDED_WAIT_TIMEOUT, mergeLandedWaitTimeout } from "./mergeLandedWait.ts";
|
|
10
|
+
|
|
11
|
+
test("mergeLandedWaitTimeout: blank / absent / malformed → default", () => {
|
|
12
|
+
assert.equal(mergeLandedWaitTimeout(undefined), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
|
|
13
|
+
assert.equal(mergeLandedWaitTimeout(""), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
|
|
14
|
+
assert.equal(mergeLandedWaitTimeout(" "), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
|
|
15
|
+
assert.equal(mergeLandedWaitTimeout("1h"), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT); // missing leading P/T
|
|
16
|
+
assert.equal(mergeLandedWaitTimeout("P"), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT); // no component
|
|
17
|
+
assert.equal(mergeLandedWaitTimeout("PT"), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT); // T with no time part
|
|
18
|
+
assert.equal(mergeLandedWaitTimeout("garbage"), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("mergeLandedWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
|
|
22
|
+
assert.equal(mergeLandedWaitTimeout("PT30M"), "PT30M");
|
|
23
|
+
assert.equal(mergeLandedWaitTimeout("pt2h"), "PT2H");
|
|
24
|
+
assert.equal(mergeLandedWaitTimeout("P1D"), "P1D");
|
|
25
|
+
assert.equal(mergeLandedWaitTimeout(" pt90m "), "PT90M");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("mergeLandedWaitTimeout: an explicit fallback is honoured for a bad value", () => {
|
|
29
|
+
assert.equal(mergeLandedWaitTimeout("nope", "PT10M"), "PT10M");
|
|
30
|
+
assert.equal(mergeLandedWaitTimeout("PT45M", "PT10M"), "PT45M");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the default is itself a well-formed ISO-8601 duration (never an uninterpretable timer)", () => {
|
|
34
|
+
// Validate the default against the grammar with a *distinct* fallback: if the default were
|
|
35
|
+
// malformed it would fall through to the sentinel, so equality to itself proves it parses.
|
|
36
|
+
const sentinel = "PT1S";
|
|
37
|
+
assert.notEqual(DEFAULT_MERGE_LANDED_WAIT_TIMEOUT, sentinel);
|
|
38
|
+
assert.equal(mergeLandedWaitTimeout(DEFAULT_MERGE_LANDED_WAIT_TIMEOUT, sentinel), DEFAULT_MERGE_LANDED_WAIT_TIMEOUT);
|
|
39
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Merge-queue landing liveness policy — kept as a pure module (no env, no I/O) so it is trivially
|
|
2
|
+
// testable, mirroring app/agentSla.ts. `app/service.ts` seeds the validated `landedWaitTimeout`
|
|
3
|
+
// process variable when it starts the merge-loop; the merge-loop's `wait-landed-timeout` timer catch
|
|
4
|
+
// (the timer arm of the `eg-landed` event-based gateway, racing `merge-landed` / `merge-evicted`)
|
|
5
|
+
// evaluates its `<bpmn:timeDuration>=landedWaitTimeout` at timer creation (FEEL-expression timer
|
|
6
|
+
// durations, engine-native).
|
|
7
|
+
//
|
|
8
|
+
// This closes the merge-queue landing liveness gap (issue #556): `attempt-merge` classifies a merge
|
|
9
|
+
// as `queued` on an ambiguous "merge queue" signal (or a REST "accepted but not yet landed"
|
|
10
|
+
// fallback) WITHOUT verifying the PR was actually enqueued. On a repo where a plain `gh pr merge`
|
|
11
|
+
// does not enqueue (e.g. Mergify, which needs an explicit `@Mergifyio queue`), the PR is never
|
|
12
|
+
// placed in any queue, yet the loop parks at `wait-landed` awaiting a `merge-landed` that can never
|
|
13
|
+
// be published — ACTIVE, no incident, no escalation, forever. Bounding the wait with a timer arm
|
|
14
|
+
// makes that impossible: when the timeout elapses the token routes to the existing merge escalation
|
|
15
|
+
// so a human is pulled in (add it to the queue / merge it, then reply to retry). It is a durable,
|
|
16
|
+
// in-process backstop — no external watchdog required, mirroring the convergence loop's
|
|
17
|
+
// `wait-review-timeout`.
|
|
18
|
+
|
|
19
|
+
import { isoDuration } from "./reviewWait.ts";
|
|
20
|
+
|
|
21
|
+
/** Default merge-queue landing timeout (ISO-8601 duration): how long the merge loop waits for a
|
|
22
|
+
* `queued` PR to actually land before the timer arm of the `eg-landed` event-based gateway fires and
|
|
23
|
+
* it escalates to a human. Deliberately generous — a native GitHub merge queue legitimately takes a
|
|
24
|
+
* while to build the prospective merged commit and run its required checks — while still bounded so a
|
|
25
|
+
* never-enqueued PR (the Mergify-eligible-but-unqueued wedge, #556) surfaces to a human rather than
|
|
26
|
+
* hanging forever. */
|
|
27
|
+
export const DEFAULT_MERGE_LANDED_WAIT_TIMEOUT = "PT1H";
|
|
28
|
+
|
|
29
|
+
/** Validate the operator-supplied merge-queue landing timeout (env
|
|
30
|
+
* `NANO_PR_MERGE_LANDED_WAIT_TIMEOUT`, ISO-8601 duration), falling back to
|
|
31
|
+
* {@link DEFAULT_MERGE_LANDED_WAIT_TIMEOUT} when absent, blank, or malformed — a bad env value must
|
|
32
|
+
* never deploy an uninterpretable timer expression. Derives its validation from the single canonical
|
|
33
|
+
* {@link isoDuration}. */
|
|
34
|
+
export function mergeLandedWaitTimeout(
|
|
35
|
+
raw: string | undefined,
|
|
36
|
+
def: string = DEFAULT_MERGE_LANDED_WAIT_TIMEOUT,
|
|
37
|
+
): string {
|
|
38
|
+
return isoDuration(raw, def);
|
|
39
|
+
}
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
const MODEL = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
42
42
|
|
|
43
43
|
const AGENT_SLA_MS = 30 * 60 * 1000; // matches the PT30M we start instances with
|
|
44
|
+
const LANDED_WAIT_MS = 30 * 60 * 1000; // matches the PT30M landedWaitTimeout we start instances with
|
|
44
45
|
|
|
45
46
|
type Output = Record<string, unknown>;
|
|
46
47
|
type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
|
|
@@ -80,6 +81,7 @@ const DEFAULT_VARS: Record<string, unknown> = {
|
|
|
80
81
|
rebaseRound: 0,
|
|
81
82
|
mergeRetryRound: 0,
|
|
82
83
|
agentSlaTimeout: "PT30M",
|
|
84
|
+
landedWaitTimeout: "PT30M",
|
|
83
85
|
abandonBrief: null,
|
|
84
86
|
failingChecksList: null,
|
|
85
87
|
status: null,
|
|
@@ -206,11 +208,36 @@ test("a queued merge parks on the event gateway; the landed message marks it mer
|
|
|
206
208
|
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
207
209
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
208
210
|
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
209
|
-
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElements("wait-landed", "wait-evicted");
|
|
211
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElements("wait-landed", "wait-evicted", "wait-landed-timeout");
|
|
210
212
|
await engine.publishMessage({ name: "merge-landed", correlationKey: "pr-1" });
|
|
211
213
|
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("mark-merged");
|
|
212
214
|
});
|
|
213
215
|
|
|
216
|
+
test("a queued merge that never lands escalates when the landing timeout fires (#556)", async () => {
|
|
217
|
+
// The Mergify wedge: `attempt-merge` classifies the merge `queued` on an ambiguous signal, but the
|
|
218
|
+
// repo never actually enqueued the PR, so `merge-landed` can never be published. Without the timer
|
|
219
|
+
// arm the token would park at `wait-landed` forever (ACTIVE, no incident). The timer bounds it.
|
|
220
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
221
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
222
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
223
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-landed");
|
|
224
|
+
await engine.advanceTime(LANDED_WAIT_MS + 1);
|
|
225
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
226
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-landed");
|
|
227
|
+
assert(!completedElementIds(engine).has("mark-merged"), "a never-landed queued merge must not mark-merged");
|
|
228
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "did not land within the merge-queue landing timeout", "the escalation must name the landing-timeout trigger");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("answering the landing-timeout escalation re-arms the merge poller (#556)", async () => {
|
|
232
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
233
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
234
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
235
|
+
await engine.advanceTime(LANDED_WAIT_MS + 1);
|
|
236
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
237
|
+
await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "queued it manually, retry" });
|
|
238
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
239
|
+
});
|
|
240
|
+
|
|
214
241
|
test("an evicted queued merge re-arms the poller rather than completing", async () => {
|
|
215
242
|
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
216
243
|
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Coverage for `pollEpicPhase` (S8, #542 / ADR 0006 §4b) — the poll pass that reconciles the epic's
|
|
2
|
+
// `plans.epic_phase` from the LIVE engine element-instance model, the pure read-model derivation that
|
|
3
|
+
// RETIRED the write-time stamp the spine workers used to write. Booted against the real provisioned
|
|
4
|
+
// SQLite data layer (so the `plans` table and the `plan_wave_progress` wave-frontier VIEW exist) with
|
|
5
|
+
// a stubbed `searchElementInstances`, proving: a live plan's phase advances to the furthest active
|
|
6
|
+
// spine element; the wave label rides the wave-progress rollup; a steady-state pass is a no-op; and a
|
|
7
|
+
// terminal (non-live) plan is never touched.
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { assertEquals } from "#test-assert";
|
|
13
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
14
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
15
|
+
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
16
|
+
import { plans, planTasks } from "./plan.ts";
|
|
17
|
+
import { pollEpicPhase } from "./service.ts";
|
|
18
|
+
|
|
19
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
20
|
+
|
|
21
|
+
async function withData(fn: (data: DataLayer) => Promise<void>): Promise<void> {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-epicphase-"));
|
|
23
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
24
|
+
try {
|
|
25
|
+
await fn(app.db);
|
|
26
|
+
} finally {
|
|
27
|
+
await app.stop?.();
|
|
28
|
+
rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const now = () => new Date().toISOString();
|
|
33
|
+
|
|
34
|
+
async function seedPlan(
|
|
35
|
+
data: DataLayer,
|
|
36
|
+
over: {
|
|
37
|
+
status?: string;
|
|
38
|
+
process_key?: string | null;
|
|
39
|
+
epic_phase?: string | null;
|
|
40
|
+
task_count?: number;
|
|
41
|
+
} = {},
|
|
42
|
+
) {
|
|
43
|
+
await plans(data).insert({
|
|
44
|
+
plan_key: "owner/repo#7",
|
|
45
|
+
repo: "owner/repo",
|
|
46
|
+
issue_number: 7,
|
|
47
|
+
issue_url: "https://github.com/owner/repo/issues/7",
|
|
48
|
+
title: "Epic",
|
|
49
|
+
status: over.status ?? "dispatched",
|
|
50
|
+
task_count: over.task_count ?? 0,
|
|
51
|
+
epic_phase: over.epic_phase ?? EPIC_PHASE.PLANNING,
|
|
52
|
+
process_key: "process_key" in over ? over.process_key : "pi-1",
|
|
53
|
+
created_at: now(),
|
|
54
|
+
updated_at: now(),
|
|
55
|
+
} as never);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("pollEpicPhase advances a live epic's phase to the furthest ACTIVE spine element", async () => {
|
|
59
|
+
await withData(async (data) => {
|
|
60
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.PLANNING });
|
|
61
|
+
// The plan is recorded (COMPLETED) and the review-plan agent is running → Reviewing.
|
|
62
|
+
const engine = {
|
|
63
|
+
searchElementInstances: async () => [
|
|
64
|
+
{ elementInstanceKey: "e1", processInstanceKey: "pi-1", elementId: "record-plan", state: "COMPLETED" },
|
|
65
|
+
{ elementInstanceKey: "e2", processInstanceKey: "pi-1", elementId: "review-plan", state: "ACTIVE" },
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
await pollEpicPhase(data, engine as never);
|
|
69
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.REVIEWING);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("pollEpicPhase wave-labels a live Implementing token from the plan_wave_progress rollup", async () => {
|
|
74
|
+
await withData(async (data) => {
|
|
75
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.REVIEWING });
|
|
76
|
+
// Two levelized waves (0,1); wave 0 is settled (skipped → not in-flight) and wave 1 is still in
|
|
77
|
+
// flight, so the frontier is wave 1 → current_wave 1, wave_count 2 → "Implementing (wave 2/2)".
|
|
78
|
+
await planTasks(data).insert({ id: 1, plan_key: "owner/repo#7", task_index: 0, task_id: "a", status: "skipped", wave: 0, created_at: now(), updated_at: now() } as never);
|
|
79
|
+
await planTasks(data).insert({ id: 2, plan_key: "owner/repo#7", task_index: 1, task_id: "b", status: "pending", wave: 1, created_at: now(), updated_at: now() } as never);
|
|
80
|
+
const engine = {
|
|
81
|
+
searchElementInstances: async () => [
|
|
82
|
+
{ elementInstanceKey: "e3", processInstanceKey: "pi-1", elementId: "implement-task", state: "ACTIVE" },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
await pollEpicPhase(data, engine as never);
|
|
86
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, "Implementing (wave 2/2)");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("pollEpicPhase is a no-op when the derived phase is unchanged, and leaves the phase when nothing marks one", async () => {
|
|
91
|
+
await withData(async (data) => {
|
|
92
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.REVIEWING });
|
|
93
|
+
// Only non-spine plumbing is active → derivation returns null → the last phase is untouched.
|
|
94
|
+
const engine = {
|
|
95
|
+
searchElementInstances: async () => [
|
|
96
|
+
{ elementInstanceKey: "e4", processInstanceKey: "pi-1", elementId: "some-gateway", state: "ACTIVE" },
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
await pollEpicPhase(data, engine as never);
|
|
100
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.REVIEWING);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("pollEpicPhase never touches a terminal (non-live) epic", async () => {
|
|
105
|
+
await withData(async (data) => {
|
|
106
|
+
await seedPlan(data, { status: "done", epic_phase: EPIC_PHASE.DISPATCHED });
|
|
107
|
+
let called = false;
|
|
108
|
+
const engine = {
|
|
109
|
+
searchElementInstances: async () => {
|
|
110
|
+
called = true;
|
|
111
|
+
return [];
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
await pollEpicPhase(data, engine as never);
|
|
115
|
+
assertEquals(called, false);
|
|
116
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.DISPATCHED);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("pollEpicPhase freezes a done epic that dispatched a fleet at the terminal Dispatched phase", async () => {
|
|
121
|
+
await withData(async (data) => {
|
|
122
|
+
await seedPlan(data, { status: "done", task_count: 2, epic_phase: EPIC_PHASE.TRIAL_MERGING });
|
|
123
|
+
let called = false;
|
|
124
|
+
const engine = {
|
|
125
|
+
searchElementInstances: async () => {
|
|
126
|
+
called = true;
|
|
127
|
+
return [];
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
await pollEpicPhase(data, engine as never);
|
|
131
|
+
// Derived from the terminal status, not the (skipped) live element search.
|
|
132
|
+
assertEquals(called, false);
|
|
133
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.DISPATCHED);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("pollEpicPhase never labels a taskless done epic Dispatched", async () => {
|
|
138
|
+
await withData(async (data) => {
|
|
139
|
+
// A done epic that dispatched nothing (planner emitted no tasks) must NOT read Dispatched.
|
|
140
|
+
await seedPlan(data, { status: "done", task_count: 0, epic_phase: EPIC_PHASE.PLANNING });
|
|
141
|
+
await pollEpicPhase(data, { searchElementInstances: async () => [] } as never);
|
|
142
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.PLANNING);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("pollEpicPhase skips a live epic that has no engine instance yet", async () => {
|
|
147
|
+
await withData(async (data) => {
|
|
148
|
+
await seedPlan(data, { status: "planning", process_key: null, epic_phase: EPIC_PHASE.PLANNING });
|
|
149
|
+
let called = false;
|
|
150
|
+
const engine = {
|
|
151
|
+
searchElementInstances: async () => {
|
|
152
|
+
called = true;
|
|
153
|
+
return [];
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
await pollEpicPhase(data, engine as never);
|
|
157
|
+
assertEquals(called, false);
|
|
158
|
+
});
|
|
159
|
+
});
|