@nanobpm/nano-workforce 0.81.0 → 0.82.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 +14 -0
- package/SPEC.md +19 -5
- package/app/epicPhase.test.ts +62 -0
- package/app/epicPhase.ts +125 -0
- package/app/plan.ts +12 -0
- package/app/service.test.ts +188 -1
- package/app/service.ts +114 -24
- package/db/migrations/007_wave_gate.sql +6 -3
- package/db/migrations/038_plan_epic_phase.sql +12 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/workers/record-plan/worker.ts +6 -0
- package/workers/record-results/worker.ts +8 -0
- package/workers/record-wave/worker.test.ts +5 -0
- package/workers/record-wave/worker.ts +17 -3
- package/workers/select-wave/worker.test.ts +4 -1
- package/workers/select-wave/worker.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.82.1](https://github.com/nanobpm/nano-workforce/compare/v0.82.0...v0.82.1) (2026-08-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **plan-fanout:** make the wave-merge barrier level-triggered ([#262](https://github.com/nanobpm/nano-workforce/issues/262)) ([#264](https://github.com/nanobpm/nano-workforce/issues/264)) ([ae939d8](https://github.com/nanobpm/nano-workforce/commit/ae939d8e96f136f820fcae9149c120806ddc6f1b))
|
|
7
|
+
|
|
8
|
+
# [0.82.0](https://github.com/nanobpm/nano-workforce/compare/v0.81.0...v0.82.0) (2026-08-17)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* reify epic domain lifecycle as derived plans.epic_phase ([#261](https://github.com/nanobpm/nano-workforce/issues/261)) ([#265](https://github.com/nanobpm/nano-workforce/issues/265)) ([4cc9dee](https://github.com/nanobpm/nano-workforce/commit/4cc9deee86b800206d264d96269e6a98e8753883)), closes [#266](https://github.com/nanobpm/nano-workforce/issues/266) [nwf#245](https://github.com/nwf/issues/245) [nano-ide#254](https://github.com/nano-ide/issues/254)
|
|
14
|
+
|
|
1
15
|
# [0.81.0](https://github.com/nanobpm/nano-workforce/compare/v0.80.0...v0.81.0) (2026-08-17)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -530,6 +530,17 @@ History: done/failed/abandoned) with a `plan_tasks` child grid showing each task
|
|
|
530
530
|
status and the PR it produced (`pr_key` cross-references the Pull requests grid for
|
|
531
531
|
convergence status).
|
|
532
532
|
|
|
533
|
+
**Epic domain phase** (issue #261): `plans.status` only distinguishes the process-instance
|
|
534
|
+
terminal (`dispatched` = "fan-out job done"), not the epic's *domain* lifecycle. The read model
|
|
535
|
+
therefore also carries a derived, display-only `plans.epic_phase` — **Planning → Reviewing →
|
|
536
|
+
Implementing (wave n/t) → Trial merging → Finalizing → Dispatched** — projected at write time from
|
|
537
|
+
`plan-fanout.bpmn`'s named activities via each spine worker's BPMN element id (`app/epicPhase.ts`,
|
|
538
|
+
the single binding; nwf is the first consumer of the urban phase-projection primitive, nano-ide#266).
|
|
539
|
+
The `Implementing` band is wave-labelled from the levelize records (`plan_tasks` waves). The epic /
|
|
540
|
+
epic-detail pages surface it as a **Phase** column. It never gates control flow (that stays driven by
|
|
541
|
+
the process `currentWave`/`waveCount`/`gate_wave`); a post-dispatch cross-instance rollup into
|
|
542
|
+
Converging/Merging is a later seam (nwf#245 / nano-ide#254).
|
|
543
|
+
|
|
533
544
|
### 13.1 Dependency waves + merge barrier (issues #20, #26, release-notes-concierge)
|
|
534
545
|
|
|
535
546
|
The flat `implement → record-results` shape above evolved into a **wave loop**. The
|
|
@@ -554,11 +565,14 @@ the loop runs one parallel `implement` MI fan-out per wave:
|
|
|
554
565
|
`dependsOn`), and advances `currentWave`.
|
|
555
566
|
- **Wave-merge barrier** (`wait-wave-merged`): when a wave has a successor,
|
|
556
567
|
`record-wave` sets `plans.gate_wave` to that wave's index and the process parks at
|
|
557
|
-
the `wait-wave-merged` catch event. The poller's `
|
|
558
|
-
`wave-merged` message (correlated on `planKey`)
|
|
559
|
-
has merged** (`app/waves.ts` `waveMergeTargets`
|
|
560
|
-
`blocked`/`skipped`/keyless tasks clear vacuously)
|
|
561
|
-
|
|
568
|
+
the `wait-wave-merged` catch event. The poller's `pollWaveGatesImpl` pass is
|
|
569
|
+
**level-triggered**: it publishes the `wave-merged` message (correlated on `planKey`)
|
|
570
|
+
once **every opened PR in that wave has merged** (`app/waves.ts` `waveMergeTargets`
|
|
571
|
+
selects the PRs to wait on; `blocked`/`skipped`/keyless tasks clear vacuously) **and**
|
|
572
|
+
it observes an OPEN `wait-wave-merged` subscription for the plan — so a merge that
|
|
573
|
+
lands while the token is still upstream can't drop the signal. The poller **never**
|
|
574
|
+
clears `gate_wave`; `record-wave` owns the marker's lifecycle (re-arming it to the next
|
|
575
|
+
wave, or clearing it to NULL on the final wave). So a `dependsOn` means the dependent wave is not **implemented** until
|
|
562
576
|
its prerequisites have **landed on the base branch** — not merely opened. This lets
|
|
563
577
|
a blocking prerequisite (e.g. app scaffolding) fully converge and merge before the
|
|
564
578
|
next wave builds on it. `gate_wave` lives in `db/migrations/007_wave_gate.sql`.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Read-model derivation test for the epic domain phase (issue #261). `deriveEpicPhase` /
|
|
2
|
+
// `implementingPhase` are the single source of truth for the write-time projection each spine
|
|
3
|
+
// worker stamps onto `plans.epic_phase`. The projection binds structurally to plan-fanout.bpmn's
|
|
4
|
+
// named activities via the job's BPMN element id (mirroring the urban #266 phase primitive), so the
|
|
5
|
+
// epic view can show WHICH phase an epic is in — not only the process-instance terminal status.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import { deriveEpicPhase, EPIC_PHASE, implementingPhase } from "./epicPhase.ts";
|
|
9
|
+
|
|
10
|
+
test("deriveEpicPhase maps each spine element to its domain phase", () => {
|
|
11
|
+
// Planning genesis + hand-off into Reviewing when the plan is recorded.
|
|
12
|
+
assertEquals(deriveEpicPhase("plan"), EPIC_PHASE.PLANNING);
|
|
13
|
+
assertEquals(deriveEpicPhase("ensure-base-branch"), EPIC_PHASE.PLANNING);
|
|
14
|
+
assertEquals(deriveEpicPhase("record-plan"), EPIC_PHASE.REVIEWING);
|
|
15
|
+
assertEquals(deriveEpicPhase("review-plan"), EPIC_PHASE.REVIEWING);
|
|
16
|
+
assertEquals(deriveEpicPhase("record-plan-review"), EPIC_PHASE.REVIEWING);
|
|
17
|
+
assertEquals(deriveEpicPhase("plan-review-decision"), EPIC_PHASE.REVIEWING);
|
|
18
|
+
// Trial-merge band.
|
|
19
|
+
assertEquals(deriveEpicPhase("trial-merge"), EPIC_PHASE.TRIAL_MERGING);
|
|
20
|
+
assertEquals(deriveEpicPhase("record-trial-merge"), EPIC_PHASE.TRIAL_MERGING);
|
|
21
|
+
assertEquals(deriveEpicPhase("trial-merge-decision"), EPIC_PHASE.TRIAL_MERGING);
|
|
22
|
+
assertEquals(deriveEpicPhase("resolve-trial-attention"), EPIC_PHASE.TRIAL_MERGING);
|
|
23
|
+
// Finalize step's lasting result is the "Fleet dispatched" terminal.
|
|
24
|
+
assertEquals(deriveEpicPhase("record-results"), EPIC_PHASE.DISPATCHED);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("deriveEpicPhase wave-labels the Implementing band from the levelize records", () => {
|
|
28
|
+
// select-wave / record-wave / the implement MI + wait-wave-merged all read as Implementing,
|
|
29
|
+
// labelled with the 1-based wave from the wave/levelize records (0-based `current`).
|
|
30
|
+
assertEquals(
|
|
31
|
+
deriveEpicPhase("select-wave", { current: 0, total: 3 }),
|
|
32
|
+
"Implementing (wave 1/3)",
|
|
33
|
+
);
|
|
34
|
+
assertEquals(
|
|
35
|
+
deriveEpicPhase("record-wave", { current: 2, total: 3 }),
|
|
36
|
+
"Implementing (wave 3/3)",
|
|
37
|
+
);
|
|
38
|
+
assertEquals(
|
|
39
|
+
deriveEpicPhase("wait-wave-merged", { current: 1, total: 3 }),
|
|
40
|
+
"Implementing (wave 2/3)",
|
|
41
|
+
);
|
|
42
|
+
assertEquals(deriveEpicPhase("implement-task", { current: 0, total: 1 }), "Implementing (wave 1/1)");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("deriveEpicPhase returns null for a non-spine element so a stray write never clobbers", () => {
|
|
46
|
+
assertEquals(deriveEpicPhase(undefined), null);
|
|
47
|
+
assertEquals(deriveEpicPhase(null), null);
|
|
48
|
+
assertEquals(deriveEpicPhase(""), null);
|
|
49
|
+
assertEquals(deriveEpicPhase("some-unrelated-element"), null);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("implementingPhase clamps the 1-based label to the total and degrades gracefully", () => {
|
|
53
|
+
assertEquals(implementingPhase(0, 2), "Implementing (wave 1/2)");
|
|
54
|
+
// A `current` at/over the last index (record-wave pins current_wave to waveCount-1 on the final
|
|
55
|
+
// wave) never reads past n/n.
|
|
56
|
+
assertEquals(implementingPhase(5, 3), "Implementing (wave 3/3)");
|
|
57
|
+
// Unusable wave numbers (taskless plan / NaN counter) degrade to a bare Implementing — never
|
|
58
|
+
// "wave NaN/…".
|
|
59
|
+
assertEquals(implementingPhase(0, 0), "Implementing");
|
|
60
|
+
assertEquals(implementingPhase(undefined, undefined), "Implementing");
|
|
61
|
+
assertEquals(implementingPhase("x", "y"), "Implementing");
|
|
62
|
+
});
|
package/app/epicPhase.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// app/epicPhase.ts — reify the epic's own domain lifecycle as a derived `epic_phase` (issue #261).
|
|
2
|
+
//
|
|
3
|
+
// `plans.status` only distinguishes `planning` / `dispatched` / `done` / `failed` / `abandoned` —
|
|
4
|
+
// and `dispatched` is the `plan-fanout.bpmn` PROCESS-INSTANCE terminal ("fan-out job done"), not the
|
|
5
|
+
// epic's domain phase. `plan-fanout.bpmn` already models the rich lifecycle as named activities
|
|
6
|
+
// (Ensure base branch → Plan → Review plan → Select wave → Implement task → Trial merge → Finalize
|
|
7
|
+
// → "Fleet dispatched"); this module reifies that lifecycle as a stored, display-only projection so
|
|
8
|
+
// the epic view can show which phase the epic is in.
|
|
9
|
+
//
|
|
10
|
+
// Convention over declaration: the phases ARE the activities plan-fanout.bpmn already names. Each
|
|
11
|
+
// spine worker derives its projection from its OWN BPMN element id (`job.elementId`) — no annotation
|
|
12
|
+
// map on the model, no second reconciliation pass — mirroring the urban structural phase-projection
|
|
13
|
+
// primitive (nano-ide#266), which derives the phase from the furthest element reached in
|
|
14
|
+
// write-provenance. This module is the single binding (nwf is #266's first consumer).
|
|
15
|
+
//
|
|
16
|
+
// Write-time projection: because the phase only advances when a worker writes, each spine worker
|
|
17
|
+
// stamps the phase the epic is ENTERING as a result of its write — the write points ARE the phase
|
|
18
|
+
// boundaries. Two structural defaults are coarsened where the raw activity label would mislead
|
|
19
|
+
// (documented on `ELEMENT_PHASE` below): `select-wave` reads as `Implementing (wave n/t)` because it
|
|
20
|
+
// dispatches and durably marks the (write-silent) `implement` multi-instance subProcess, and
|
|
21
|
+
// `record-results` reads as the `Dispatched` terminal ("Fleet dispatched").
|
|
22
|
+
//
|
|
23
|
+
// Cross-instance rollup (later): post-dispatch, the epic's effective phase extends into the
|
|
24
|
+
// convergence/merge loops carried on separate top-level instances correlated by lineage
|
|
25
|
+
// (`rootRequestKey`, nwf#245 / nano-ide#254). Once #266's Tier-2 rollup lands, `epic_phase` can
|
|
26
|
+
// advance past `Dispatched` into Converging/Merging with no new wiring here — the seam is this
|
|
27
|
+
// module's derivation staying the single source.
|
|
28
|
+
|
|
29
|
+
/** The epic's domain phases — the vocabulary the derivation projects onto `plans.epic_phase`.
|
|
30
|
+
* Shared with the feature-view stage vocabulary (nwf#254), which uses the same stored-projection
|
|
31
|
+
* pattern. `Implementing` is wave-labelled at derivation time (see {@link implementingPhase}). */
|
|
32
|
+
export const EPIC_PHASE = {
|
|
33
|
+
PLANNING: "Planning",
|
|
34
|
+
REVIEWING: "Reviewing",
|
|
35
|
+
IMPLEMENTING: "Implementing",
|
|
36
|
+
TRIAL_MERGING: "Trial merging",
|
|
37
|
+
FINALIZING: "Finalizing",
|
|
38
|
+
DISPATCHED: "Dispatched",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** Coerce a wave index/count to a non-negative integer, or null when it isn't one. Mirrors the
|
|
42
|
+
* `toWave` coercion the wave workers already apply, so a NaN/absent counter degrades to an
|
|
43
|
+
* unlabelled `Implementing` rather than emitting `wave NaN/…`. */
|
|
44
|
+
const toWave = (v: unknown): number | null => {
|
|
45
|
+
const n = Math.trunc(Number(v));
|
|
46
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `Implementing (wave n/t)` — special-cased from the wave/levelize records (`plan_tasks` waves),
|
|
51
|
+
* NOT the raw multi-instance counter. `current` is the 0-based wave index carried on the process
|
|
52
|
+
* (`currentWave` / the projected `current_wave`); the label is 1-based and clamped to `total` so a
|
|
53
|
+
* final wave reads `n/n`. Falls back to a bare `Implementing` when the wave numbers aren't usable
|
|
54
|
+
* (e.g. a taskless plan with `total` 0), so the phase never renders `wave NaN`.
|
|
55
|
+
*/
|
|
56
|
+
export function implementingPhase(current: unknown, total: unknown): string {
|
|
57
|
+
const t = toWave(total);
|
|
58
|
+
const c = toWave(current);
|
|
59
|
+
if (t !== null && t > 0 && c !== null) {
|
|
60
|
+
const n = Math.min(c + 1, t);
|
|
61
|
+
return `${EPIC_PHASE.IMPLEMENTING} (wave ${n}/${t})`;
|
|
62
|
+
}
|
|
63
|
+
return EPIC_PHASE.IMPLEMENTING;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Structural binding: `plan-fanout.bpmn` element id → the domain phase the epic is IN while that
|
|
68
|
+
* element (or the write-silent agent step it hands off to) runs. Complete over the epic's spine, so
|
|
69
|
+
* the projection is derivable from provenance alone (the urban #266 semantics). Two entries are
|
|
70
|
+
* deliberately COARSENED from their raw activity label because the structural default misleads:
|
|
71
|
+
* • `record-plan` ("Record plan & levelize") → Reviewing: recording the plan hands the epic to the
|
|
72
|
+
* `review-plan` agent, so the review phase should already read while that (write-silent) agent
|
|
73
|
+
* runs. `record-plan-review` re-affirms Reviewing on each round/escalation.
|
|
74
|
+
* • `select-wave` ("Select wave") → Implementing: it dispatches the wave and is the last host write
|
|
75
|
+
* before the write-silent `implement` MI, so it durably marks the implementation phase for the
|
|
76
|
+
* wave it launches (wave-labelled via {@link implementingPhase} at the call site).
|
|
77
|
+
* • `record-results` ("Finalize plan") → Dispatched: the finalize step's lasting result is the
|
|
78
|
+
* "Fleet dispatched" terminal end event.
|
|
79
|
+
* `record-wave`'s next phase is data-dependent (trial-merge vs. next wave vs. finalize), so it is
|
|
80
|
+
* resolved at its call site rather than from the element id alone; its structural fallback here is
|
|
81
|
+
* the wave it just landed.
|
|
82
|
+
*/
|
|
83
|
+
const ELEMENT_PHASE: Readonly<Record<string, string>> = {
|
|
84
|
+
"ensure-base-branch": EPIC_PHASE.PLANNING,
|
|
85
|
+
"plan": EPIC_PHASE.PLANNING,
|
|
86
|
+
"record-plan": EPIC_PHASE.REVIEWING,
|
|
87
|
+
"review-plan": EPIC_PHASE.REVIEWING,
|
|
88
|
+
"record-plan-review": EPIC_PHASE.REVIEWING,
|
|
89
|
+
"plan-review-decision": EPIC_PHASE.REVIEWING,
|
|
90
|
+
"select-wave": EPIC_PHASE.IMPLEMENTING,
|
|
91
|
+
"implement": EPIC_PHASE.IMPLEMENTING,
|
|
92
|
+
"implement-task": EPIC_PHASE.IMPLEMENTING,
|
|
93
|
+
"feature-escalation": EPIC_PHASE.IMPLEMENTING,
|
|
94
|
+
"record-wave": EPIC_PHASE.IMPLEMENTING,
|
|
95
|
+
"wait-wave-merged": EPIC_PHASE.IMPLEMENTING,
|
|
96
|
+
"trial-merge": EPIC_PHASE.TRIAL_MERGING,
|
|
97
|
+
"record-trial-merge": EPIC_PHASE.TRIAL_MERGING,
|
|
98
|
+
"trial-merge-decision": EPIC_PHASE.TRIAL_MERGING,
|
|
99
|
+
"resolve-trial-attention": EPIC_PHASE.TRIAL_MERGING,
|
|
100
|
+
"record-results": EPIC_PHASE.DISPATCHED,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Optional wave context for a wave-bearing phase, sourced from the wave/levelize records. */
|
|
104
|
+
export interface WaveContext {
|
|
105
|
+
current?: unknown;
|
|
106
|
+
total?: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Derive the epic phase for a spine element from its BPMN element id, or `null` when the element
|
|
111
|
+
* doesn't mark a phase — so a non-spine write (e.g. a poller reconcile pass) never clobbers
|
|
112
|
+
* `epic_phase`. A wave-bearing phase (`Implementing`) is wave-labelled from {@link WaveContext} when
|
|
113
|
+
* supplied. This is the single structural deriver; workers pass `job.elementId` so the phase name is
|
|
114
|
+
* never hardcoded at the call site.
|
|
115
|
+
*/
|
|
116
|
+
export function deriveEpicPhase(
|
|
117
|
+
elementId: string | undefined | null,
|
|
118
|
+
wave?: WaveContext,
|
|
119
|
+
): string | null {
|
|
120
|
+
if (!elementId) return null;
|
|
121
|
+
const base = ELEMENT_PHASE[elementId];
|
|
122
|
+
if (base === undefined) return null;
|
|
123
|
+
if (base === EPIC_PHASE.IMPLEMENTING) return implementingPhase(wave?.current, wave?.total);
|
|
124
|
+
return base;
|
|
125
|
+
}
|
package/app/plan.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// hand-written SQL — matching app/service.ts.
|
|
12
12
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
|
+
import { EPIC_PHASE } from "./epicPhase.ts";
|
|
14
15
|
import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
|
|
15
16
|
import { coalesceTitle, ensureBaseBranch, fetchDefaultBranch, fetchIssueTitle } from "./github.ts";
|
|
16
17
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
@@ -82,6 +83,12 @@ export interface Plan {
|
|
|
82
83
|
// in app/service.ts); `delivery_label` is the human rollup for the epic detail view. Display-only.
|
|
83
84
|
delivery: string | null;
|
|
84
85
|
delivery_label: string | null;
|
|
86
|
+
// Derived epic domain phase (038_plan_epic_phase.sql, #261): the epic's own lifecycle phase —
|
|
87
|
+
// Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
|
|
88
|
+
// projected at write time from plan-fanout.bpmn's named activities (app/epicPhase.ts), so the epic
|
|
89
|
+
// view can show which phase the epic is IN rather than only the process-instance terminal status.
|
|
90
|
+
// Display-only; NULL until the lifecycle first stamps it (grandfathers pre-#261 rows).
|
|
91
|
+
epic_phase: string | null;
|
|
85
92
|
created_at: string;
|
|
86
93
|
updated_at: string;
|
|
87
94
|
}
|
|
@@ -436,6 +443,9 @@ export async function startPlan(
|
|
|
436
443
|
issue_url: parsed.url,
|
|
437
444
|
title,
|
|
438
445
|
outcome: null,
|
|
446
|
+
// Genesis of the domain lifecycle (#261): the epic re-enters Planning. Cleared of any stale
|
|
447
|
+
// terminal phase from the prior run so the re-plan reads correctly from the first pass.
|
|
448
|
+
epic_phase: EPIC_PHASE.PLANNING,
|
|
439
449
|
blackboard_token: token,
|
|
440
450
|
base_branch: base,
|
|
441
451
|
updated_at: ts,
|
|
@@ -449,6 +459,8 @@ export async function startPlan(
|
|
|
449
459
|
title,
|
|
450
460
|
status: "planning",
|
|
451
461
|
task_count: 0,
|
|
462
|
+
// Genesis of the domain lifecycle (#261): a fresh epic starts in Planning.
|
|
463
|
+
epic_phase: EPIC_PHASE.PLANNING,
|
|
452
464
|
blackboard_token: token,
|
|
453
465
|
base_branch: base,
|
|
454
466
|
created_at: ts,
|
package/app/service.test.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// GitHub transport forced off so it is hermetic.
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals } from "#test-assert";
|
|
10
|
-
import { parsePr, pollIncidentsImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
|
|
10
|
+
import { parsePr, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr } from "./service.ts";
|
|
11
11
|
|
|
12
12
|
function memTable(rows: any[], key: string) {
|
|
13
13
|
return {
|
|
@@ -502,3 +502,190 @@ test("parsePr still resolves a well-formed prKey and PR URL", () => {
|
|
|
502
502
|
assertEquals(parsePr(" owner/repo#42 ")?.number, 42);
|
|
503
503
|
assertEquals(parsePr("https://github.com/owner/repo/pull/7")?.repo, "owner/repo");
|
|
504
504
|
});
|
|
505
|
+
|
|
506
|
+
// Red/green regression for the level-triggered wave-merge barrier (issue #262). The barrier is
|
|
507
|
+
// armed (`plans.gate_wave = W`) at wave handoff, long BEFORE the token traverses the slow
|
|
508
|
+
// `trial-merge` agent job and finally opens the `wait-wave-merged` subscription. The old
|
|
509
|
+
// `pollWaveGates` was edge-triggered: the first pass that saw wave W's PRs merged cleared
|
|
510
|
+
// `gate_wave` and published `wave-merged` EXACTLY ONCE. If that happened while the token was still
|
|
511
|
+
// upstream (no open subscription), the message was dropped and — with `gate_wave` now null — never
|
|
512
|
+
// republished, so the epic wedged forever once the token arrived. The fix reconciles the merged
|
|
513
|
+
// state against the engine's OPEN-subscription state every pass, publishing only into an open
|
|
514
|
+
// subscription and never clearing `gate_wave` optimistically.
|
|
515
|
+
//
|
|
516
|
+
// Stubs `/message-subscriptions/search` (keyed by processInstanceKey) so the subscription can be
|
|
517
|
+
// toggled open between passes, and forces the GitHub transport off — the wave's PRs are tracked
|
|
518
|
+
// `merged` rows, so `isDepMerged` resolves them from the DB with no network.
|
|
519
|
+
function subscriptionFetch(open: Set<string>) {
|
|
520
|
+
return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
521
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
522
|
+
if (!u.endsWith("/message-subscriptions/search")) {
|
|
523
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
524
|
+
}
|
|
525
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
|
526
|
+
filter?: { processInstanceKey?: string };
|
|
527
|
+
};
|
|
528
|
+
const pik = body.filter?.processInstanceKey ?? "";
|
|
529
|
+
const items = open.has(pik)
|
|
530
|
+
? [{ messageName: "wave-merged", correlationKey: "owner/repo#67", messageSubscriptionState: "CREATED" }]
|
|
531
|
+
: [];
|
|
532
|
+
return Promise.resolve(
|
|
533
|
+
new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
534
|
+
);
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
test("pollWaveGatesImpl is level-triggered: PRs merged before the token arrives never lose the wave-merged signal (#262)", async () => {
|
|
539
|
+
await withGithubOff(async () => {
|
|
540
|
+
const PLAN_KEY = "owner/repo#67";
|
|
541
|
+
const PI = "PI-13794";
|
|
542
|
+
// Wave 1 opened two PRs; both are already MERGED (tracked rows → isDepMerged resolves from DB).
|
|
543
|
+
const plan = {
|
|
544
|
+
plan_key: PLAN_KEY,
|
|
545
|
+
process_key: PI,
|
|
546
|
+
gate_wave: 1 as number | null,
|
|
547
|
+
updated_at: "t0",
|
|
548
|
+
};
|
|
549
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
550
|
+
plans: { rows: [plan], key: "plan_key" },
|
|
551
|
+
plan_tasks: {
|
|
552
|
+
rows: [
|
|
553
|
+
{ id: "owner/repo#67:a", plan_key: PLAN_KEY, wave: 1, status: "opened", pr_key: "owner/repo#68" },
|
|
554
|
+
{ id: "owner/repo#67:b", plan_key: PLAN_KEY, wave: 1, status: "opened", pr_key: "owner/repo#69" },
|
|
555
|
+
],
|
|
556
|
+
key: "id",
|
|
557
|
+
},
|
|
558
|
+
pull_requests: {
|
|
559
|
+
rows: [
|
|
560
|
+
{ pr_key: "owner/repo#68", status: "merged" },
|
|
561
|
+
{ pr_key: "owner/repo#69", status: "merged" },
|
|
562
|
+
],
|
|
563
|
+
key: "pr_key",
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
const data = {
|
|
567
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
568
|
+
} as any;
|
|
569
|
+
|
|
570
|
+
const published: { name: string; correlationKey?: string }[] = [];
|
|
571
|
+
const engine = {
|
|
572
|
+
publishMessage: (input: { name: string; correlationKey?: string }) => {
|
|
573
|
+
published.push(input);
|
|
574
|
+
return Promise.resolve();
|
|
575
|
+
},
|
|
576
|
+
} as any;
|
|
577
|
+
const headers = { "content-type": "application/json" };
|
|
578
|
+
|
|
579
|
+
const openSubs = new Set<string>(); // token still upstream of wait-wave-merged → NO open subscription
|
|
580
|
+
const prevFetch = globalThis.fetch;
|
|
581
|
+
|
|
582
|
+
// Pass 1 — the losing ordering: wave 1's PRs are all merged, but the token is parked upstream on
|
|
583
|
+
// the slow `trial-merge` job, so there is no open `wait-wave-merged` subscription yet. The old
|
|
584
|
+
// single-shot barrier would publish-into-the-void and CLEAR `gate_wave`, stranding the epic.
|
|
585
|
+
globalThis.fetch = subscriptionFetch(openSubs) as typeof fetch;
|
|
586
|
+
try {
|
|
587
|
+
await pollWaveGatesImpl(data, engine, "", "http://engine/v2", headers);
|
|
588
|
+
} finally {
|
|
589
|
+
globalThis.fetch = prevFetch;
|
|
590
|
+
}
|
|
591
|
+
// The signal must NOT have been fired into the void, and the gate must remain armed (not stranded).
|
|
592
|
+
assertEquals(published.length, 0, "must not publish wave-merged with no open subscription");
|
|
593
|
+
assertEquals(plan.gate_wave, 1, "gate_wave must stay armed until the barrier is actually released");
|
|
594
|
+
|
|
595
|
+
// Pass 2 — the token has now advanced to `wait-wave-merged`, opening the subscription. The
|
|
596
|
+
// level-triggered barrier re-publishes and correlates, releasing the token into wave 2.
|
|
597
|
+
openSubs.add(PI);
|
|
598
|
+
globalThis.fetch = subscriptionFetch(openSubs) as typeof fetch;
|
|
599
|
+
try {
|
|
600
|
+
await pollWaveGatesImpl(data, engine, "", "http://engine/v2", headers);
|
|
601
|
+
} finally {
|
|
602
|
+
globalThis.fetch = prevFetch;
|
|
603
|
+
}
|
|
604
|
+
assertEquals(published.length, 1, "must publish wave-merged once the subscription is open");
|
|
605
|
+
assertEquals(published[0]?.name, "wave-merged");
|
|
606
|
+
assertEquals(published[0]?.correlationKey, PLAN_KEY);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// Guards the false-positive failure class flagged in review: `waveMergedSubscriptionOpen` must treat
|
|
611
|
+
// a search item with a missing/null/mismatched `messageName`, `correlationKey`, or
|
|
612
|
+
// `messageSubscriptionState` as NOT-open. Defaulting an unverifiable field to its expected value
|
|
613
|
+
// would publish `wave-merged` into a subscription we never confirmed open — buffering a message that
|
|
614
|
+
// trips a LATER wave's barrier, i.e. re-introducing the exact #262 wedge this change prevents. A
|
|
615
|
+
// false negative only costs a retry next pass; a false positive is a wedge, so unknown ⇒ don't match.
|
|
616
|
+
function ambiguousSubscriptionFetch(items: unknown[]) {
|
|
617
|
+
return (url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
|
|
618
|
+
const u = typeof url === "string" ? url : url.toString();
|
|
619
|
+
if (!u.endsWith("/message-subscriptions/search")) {
|
|
620
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
621
|
+
}
|
|
622
|
+
return Promise.resolve(
|
|
623
|
+
new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
|
|
624
|
+
);
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
test("pollWaveGatesImpl never releases the barrier on an unverifiable subscription item (missing/null/mismatched fields)", async () => {
|
|
629
|
+
await withGithubOff(async () => {
|
|
630
|
+
const PLAN_KEY = "owner/repo#67";
|
|
631
|
+
const PI = "PI-13794";
|
|
632
|
+
const plan = {
|
|
633
|
+
plan_key: PLAN_KEY,
|
|
634
|
+
process_key: PI,
|
|
635
|
+
gate_wave: 1 as number | null,
|
|
636
|
+
updated_at: "t0",
|
|
637
|
+
};
|
|
638
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
639
|
+
plans: { rows: [plan], key: "plan_key" },
|
|
640
|
+
plan_tasks: {
|
|
641
|
+
rows: [
|
|
642
|
+
{ id: "owner/repo#67:a", plan_key: PLAN_KEY, wave: 1, status: "opened", pr_key: "owner/repo#68" },
|
|
643
|
+
{ id: "owner/repo#67:b", plan_key: PLAN_KEY, wave: 1, status: "opened", pr_key: "owner/repo#69" },
|
|
644
|
+
],
|
|
645
|
+
key: "id",
|
|
646
|
+
},
|
|
647
|
+
pull_requests: {
|
|
648
|
+
rows: [
|
|
649
|
+
{ pr_key: "owner/repo#68", status: "merged" },
|
|
650
|
+
{ pr_key: "owner/repo#69", status: "merged" },
|
|
651
|
+
],
|
|
652
|
+
key: "pr_key",
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
const data = {
|
|
656
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
657
|
+
} as any;
|
|
658
|
+
|
|
659
|
+
const published: { name: string; correlationKey?: string }[] = [];
|
|
660
|
+
const engine = {
|
|
661
|
+
publishMessage: (input: { name: string; correlationKey?: string }) => {
|
|
662
|
+
published.push(input);
|
|
663
|
+
return Promise.resolve();
|
|
664
|
+
},
|
|
665
|
+
} as any;
|
|
666
|
+
const headers = { "content-type": "application/json" };
|
|
667
|
+
const prevFetch = globalThis.fetch;
|
|
668
|
+
|
|
669
|
+
// Each of these items is ambiguous — it omits or mismatches a field the barrier requires. None
|
|
670
|
+
// may be treated as an OPEN subscription for THIS plan, so none may release wave 1's gate.
|
|
671
|
+
const ambiguousItems: unknown[][] = [
|
|
672
|
+
[{}], // empty item — no fields at all
|
|
673
|
+
[{ messageName: "wave-merged", correlationKey: PLAN_KEY }], // missing state
|
|
674
|
+
[{ messageName: "wave-merged", correlationKey: PLAN_KEY, messageSubscriptionState: null }], // null state
|
|
675
|
+
[{ correlationKey: PLAN_KEY, messageSubscriptionState: "CREATED" }], // missing messageName
|
|
676
|
+
[{ messageName: "some-other-message", correlationKey: PLAN_KEY, messageSubscriptionState: "CREATED" }],
|
|
677
|
+
[{ messageName: "wave-merged", messageSubscriptionState: "CREATED" }], // missing correlationKey
|
|
678
|
+
[{ messageName: "wave-merged", correlationKey: "owner/repo#999", messageSubscriptionState: "CREATED" }],
|
|
679
|
+
];
|
|
680
|
+
for (const items of ambiguousItems) {
|
|
681
|
+
globalThis.fetch = ambiguousSubscriptionFetch(items) as typeof fetch;
|
|
682
|
+
try {
|
|
683
|
+
await pollWaveGatesImpl(data, engine, "", "http://engine/v2", headers);
|
|
684
|
+
} finally {
|
|
685
|
+
globalThis.fetch = prevFetch;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
assertEquals(published.length, 0, "must not publish wave-merged on an unverifiable subscription item");
|
|
689
|
+
assertEquals(plan.gate_wave, 1, "gate_wave must stay armed while no OPEN subscription is confirmed");
|
|
690
|
+
});
|
|
691
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -1141,17 +1141,104 @@ export async function pollIncidentsImpl(
|
|
|
1141
1141
|
}
|
|
1142
1142
|
}
|
|
1143
1143
|
|
|
1144
|
-
/**
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1144
|
+
/** The `wave-merged` message name (`resources/processes/plan-fanout.bpmn`): the `wait-wave-merged`
|
|
1145
|
+
* catch event opens a subscription for it (correlated on `=planKey`) once the token arrives, and
|
|
1146
|
+
* the poller publishes it to release the next wave. Single source of truth for the string shared by
|
|
1147
|
+
* the publish and the subscription probe. */
|
|
1148
|
+
const WAVE_MERGED_MESSAGE = "wave-merged";
|
|
1149
|
+
|
|
1150
|
+
/** The subset of a Camunda-8 `/v2/message-subscriptions/search` result item this app reads to tell
|
|
1151
|
+
* whether the plan-fanout instance is *currently parked* at `wait-wave-merged`. `messageName` is the
|
|
1152
|
+
* awaited message; `correlationKey` is the plan key the catch event binds; `messageSubscriptionState`
|
|
1153
|
+
* is `CREATED` while the subscription is open (waiting) and `CORRELATED`/`DELETED` once consumed. */
|
|
1154
|
+
interface MessageSubscriptionSearchItem {
|
|
1155
|
+
messageName?: string;
|
|
1156
|
+
correlationKey?: string | null;
|
|
1157
|
+
messageSubscriptionState?: string;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** Is the plan-fanout instance `processKey` right now parked at `wait-wave-merged` with an OPEN
|
|
1161
|
+
* (`CREATED`) subscription correlated on `planKey`? Reads the engine's Camunda-8
|
|
1162
|
+
* `/v2/message-subscriptions/search` (the same raw-REST search surface `pollIncidents`/
|
|
1163
|
+
* `pollJobActivation` use). Returns `true` (open — safe to release), `false` (no open subscription —
|
|
1164
|
+
* the token is either upstream of the wait or has already passed through it), or `null` (transport
|
|
1165
|
+
* unhappy / unparseable body — "unknown", so the caller neither publishes nor acts on a guess and
|
|
1166
|
+
* simply retries next tick). This is the load-bearing check that makes the barrier level-triggered:
|
|
1167
|
+
* we only ever publish `wave-merged` into a subscription we've observed OPEN, so a signal can never
|
|
1168
|
+
* be dropped into the void (the #262 wedge) nor buffered to trip a *later* wave's barrier. */
|
|
1169
|
+
async function waveMergedSubscriptionOpen(
|
|
1170
|
+
base: string,
|
|
1171
|
+
headers: Record<string, string>,
|
|
1172
|
+
processKey: string,
|
|
1173
|
+
planKey: string,
|
|
1174
|
+
): Promise<boolean | null> {
|
|
1175
|
+
try {
|
|
1176
|
+
const res = await fetch(`${base}/message-subscriptions/search`, {
|
|
1177
|
+
method: "POST",
|
|
1178
|
+
headers,
|
|
1179
|
+
body: JSON.stringify({
|
|
1180
|
+
filter: {
|
|
1181
|
+
processInstanceKey: processKey,
|
|
1182
|
+
messageName: WAVE_MERGED_MESSAGE,
|
|
1183
|
+
messageSubscriptionState: "CREATED",
|
|
1184
|
+
},
|
|
1185
|
+
page: { limit: 20 },
|
|
1186
|
+
}),
|
|
1187
|
+
});
|
|
1188
|
+
if (!res.ok) return null; // engine unhappy → "unknown", retry next pass
|
|
1189
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
1190
|
+
const body = (await res.json()) as { items?: MessageSubscriptionSearchItem[] };
|
|
1191
|
+
// Re-filter defensively in case the engine ignores a filter field: an OPEN (`CREATED`)
|
|
1192
|
+
// subscription for the `wave-merged` message, correlated on THIS plan key, is the barrier we may
|
|
1193
|
+
// publish into. Every field must be PRESENT and match explicitly — we never default a
|
|
1194
|
+
// missing/null `messageName`, `correlationKey`, or `messageSubscriptionState` to its expected
|
|
1195
|
+
// value. Doing so would treat an unverifiable item as OPEN and re-introduce the exact #262
|
|
1196
|
+
// failure class (publishing into a subscription we never confirmed open, buffering a message that
|
|
1197
|
+
// trips a later wave's barrier). An item that omits a field is "unknown", so we simply don't
|
|
1198
|
+
// match it: a false negative only costs a retry next pass, whereas a false positive is a wedge.
|
|
1199
|
+
// State is compared case-insensitively so a future casing tweak can't silently drop the match.
|
|
1200
|
+
return (body.items ?? []).some(
|
|
1201
|
+
(it) =>
|
|
1202
|
+
it.messageName === WAVE_MERGED_MESSAGE &&
|
|
1203
|
+
it.correlationKey === planKey &&
|
|
1204
|
+
typeof it.messageSubscriptionState === "string" &&
|
|
1205
|
+
it.messageSubscriptionState.toUpperCase() === "CREATED",
|
|
1206
|
+
);
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
console.error(`[poller] wave-merged subscription ${planKey}: ${err}`);
|
|
1209
|
+
return null; // transport threw → "unknown", retry next pass
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** Wave-merge barrier poll pass (issue #262). After `record-wave` hands off a wave that has a
|
|
1214
|
+
* successor, the plan-fanout instance eventually parks at the `wait-wave-merged` catch event and
|
|
1215
|
+
* `plans.gate_wave` records that wave's index. Here we reconcile, on EVERY pass and idempotently,
|
|
1216
|
+
* the external GitHub fact "every OPENED PR in that wave has MERGED" against the engine fact "is
|
|
1217
|
+
* there an OPEN `wait-wave-merged` subscription for this plan right now?", publishing `wave-merged`
|
|
1218
|
+
* (correlated on the plan key) to release the next wave's implementation whenever BOTH hold.
|
|
1148
1219
|
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
|
|
1220
|
+
* This is deliberately LEVEL-triggered, not the old single-shot edge trigger. The gate is armed at
|
|
1221
|
+
* wave handoff — long before the token traverses `select-wave → trial-merge (a slow agent job) →
|
|
1222
|
+
* … → wait-wave-merged` and opens the subscription. If the wave's PRs merged while the token was
|
|
1223
|
+
* still upstream, the old code published its one `wave-merged` into NO open subscription (dropped)
|
|
1224
|
+
* AND cleared `gate_wave`, so it never republished and the epic wedged forever once the token
|
|
1225
|
+
* finally arrived (#262). We fix the class:
|
|
1226
|
+
* • We publish ONLY when {@link waveMergedSubscriptionOpen} confirms the token is parked at the
|
|
1227
|
+
* wait — never into the void — so a merged-before-arrival wave simply waits, and a later pass
|
|
1228
|
+
* (once the token arrives) republishes and correlates.
|
|
1229
|
+
* • We NEVER clear `gate_wave` here. `record-wave` owns its lifecycle (it re-arms it to the next
|
|
1230
|
+
* wave, or clears it to `null` on the final wave), so a signal published into the void can't
|
|
1231
|
+
* strand the gate, and double-advance is guarded by the open-subscription check — which, by the
|
|
1232
|
+
* handoff ordering, always matches the wave whose wait is currently open — not by a premature
|
|
1233
|
+
* clear. A wave whose tasks all ended `blocked`/`skipped` (no opened PR to wait on) has an empty
|
|
1234
|
+
* merge-target set and so is treated as merged; `record-wave` advances it on the next pass. */
|
|
1235
|
+
export async function pollWaveGatesImpl(
|
|
1236
|
+
data: DataLayer,
|
|
1237
|
+
engine: EngineClient,
|
|
1238
|
+
token: string,
|
|
1239
|
+
base: string,
|
|
1240
|
+
headers: Record<string, string>,
|
|
1241
|
+
) {
|
|
1155
1242
|
for (const plan of await plans(data).all()) {
|
|
1156
1243
|
const gateWave = plan.gate_wave;
|
|
1157
1244
|
if (gateWave == null) continue;
|
|
@@ -1165,18 +1252,14 @@ async function pollWaveGates(data: DataLayer, engine: EngineClient, token: strin
|
|
|
1165
1252
|
break;
|
|
1166
1253
|
}
|
|
1167
1254
|
}
|
|
1168
|
-
if (!allMerged) continue;
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
console.error(`[poller] revert wave-gate ${planKey} -> ${gateWave} failed: ${revertErr}`);
|
|
1177
|
-
}
|
|
1178
|
-
throw err;
|
|
1179
|
-
}
|
|
1255
|
+
if (!allMerged) continue; // wave not landed yet → leave the gate armed, retry next pass
|
|
1256
|
+
// The wave has landed on GitHub. Release the barrier ONLY if the token is actually parked at
|
|
1257
|
+
// `wait-wave-merged` (an OPEN subscription for this plan): otherwise publishing would be a
|
|
1258
|
+
// silent no-op that the old code paired with a gate clear — the edge-triggered wedge (#262).
|
|
1259
|
+
if (!plan.process_key) continue; // no instance key to correlate against yet
|
|
1260
|
+
const open = await waveMergedSubscriptionOpen(base, headers, plan.process_key, planKey);
|
|
1261
|
+
if (open !== true) continue; // not parked here yet / already released / unknown → NEVER clear the gate; retry next pass
|
|
1262
|
+
await engine.publishMessage({ name: WAVE_MERGED_MESSAGE, correlationKey: planKey, variables: {} });
|
|
1180
1263
|
console.log(`[poller] wave ${gateWave} merged -> ${planKey}`);
|
|
1181
1264
|
} catch (err) {
|
|
1182
1265
|
console.error(`[poller] wave-gate ${planKey}: ${err}`);
|
|
@@ -1559,7 +1642,11 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1559
1642
|
|
|
1560
1643
|
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
1561
1644
|
* (when the engine REST endpoint is supplied) the job-activation visibility pass and the
|
|
1562
|
-
* technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`.
|
|
1645
|
+
* technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`.
|
|
1646
|
+
*
|
|
1647
|
+
* The wave-merge barrier is now level-triggered and probes the engine's message-subscription state
|
|
1648
|
+
* over the same raw-REST search surface, so it runs only when `engineRest` is supplied (as in
|
|
1649
|
+
* production — `main.ts` always passes it). */
|
|
1563
1650
|
export async function pollOnce(
|
|
1564
1651
|
data: DataLayer,
|
|
1565
1652
|
engine: EngineClient,
|
|
@@ -1568,7 +1655,6 @@ export async function pollOnce(
|
|
|
1568
1655
|
) {
|
|
1569
1656
|
await pollReviews(data, engine, token);
|
|
1570
1657
|
await pollMerges(data, engine, token);
|
|
1571
|
-
await pollWaveGates(data, engine, token);
|
|
1572
1658
|
await pollDelivery(data);
|
|
1573
1659
|
await pollFeatureDelivery(data);
|
|
1574
1660
|
await pollLineage(data);
|
|
@@ -1576,6 +1662,10 @@ export async function pollOnce(
|
|
|
1576
1662
|
await pollFeatureBlocked(data, engine);
|
|
1577
1663
|
await pollUserTasks(data, engine);
|
|
1578
1664
|
if (engineRest) {
|
|
1665
|
+
const base = engineRest.restAddress.replace(/\/+$/, "");
|
|
1666
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
1667
|
+
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
1668
|
+
await pollWaveGatesImpl(data, engine, token, base, headers);
|
|
1579
1669
|
await pollJobActivation(data, engineRest.restAddress, engineRest.token);
|
|
1580
1670
|
await pollIncidents(data, engineRest.restAddress, engineRest.token);
|
|
1581
1671
|
}
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
--
|
|
11
11
|
-- `plans.gate_wave` is the durable marker for the barrier: when `record-wave` hands
|
|
12
12
|
-- off a wave that has a successor, it records that wave's index here and the process
|
|
13
|
-
-- parks at the `wait-wave-merged` catch event. The poller (`
|
|
14
|
-
--
|
|
15
|
-
--
|
|
13
|
+
-- parks at the `wait-wave-merged` catch event. The poller (`pollWaveGatesImpl`) is
|
|
14
|
+
-- level-triggered: it publishes `wave-merged` once every opened PR in that wave has
|
|
15
|
+
-- merged AND it observes an OPEN `wait-wave-merged` subscription for the plan, and it
|
|
16
|
+
-- NEVER clears `gate_wave` — `record-wave` owns the marker's lifecycle (re-arming it to
|
|
17
|
+
-- the next wave, or clearing it to NULL on the final wave). NULL means the plan is not
|
|
18
|
+
-- currently parked at the wave barrier.
|
|
16
19
|
ALTER TABLE plans ADD COLUMN gate_wave INTEGER;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 038_plan_epic_phase.sql — issue #261: reify the epic's own domain lifecycle as a derived,
|
|
2
|
+
-- write-time-projected `epic_phase`, so the epic/plan view can show WHICH phase an epic is in —
|
|
3
|
+
-- Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
|
|
4
|
+
-- instead of only the process-instance terminal status (`plans.status`, whose `dispatched` is the
|
|
5
|
+
-- `plan-fanout.bpmn` fan-out terminal, not the epic's domain phase).
|
|
6
|
+
--
|
|
7
|
+
-- Forward-only, additive (expand): a nullable TEXT column, display-only. NULL until the plan
|
|
8
|
+
-- lifecycle first stamps it (grandfathering pre-#261 rows), so it never gates control flow. The
|
|
9
|
+
-- value is derived structurally from `plan-fanout.bpmn`'s named activities via each spine worker's
|
|
10
|
+
-- BPMN element id (`app/epicPhase.ts`) and written through the existing plan write path — mirroring
|
|
11
|
+
-- the wave-progress / delivery display projections already on this table.
|
|
12
|
+
ALTER TABLE plans ADD COLUMN epic_phase TEXT;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"columns": [
|
|
59
59
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "34%", "linkField": "issue_url" },
|
|
60
|
+
{ "field": "epic_phase", "header": "Phase" },
|
|
60
61
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
61
62
|
{ "field": "delivery", "header": "Delivery" },
|
|
62
63
|
{ "field": "wave_label", "header": "Wave" },
|
package/pages/epic.page.json
CHANGED
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
],
|
|
76
76
|
"columns": [
|
|
77
77
|
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "34%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
78
|
+
{ "field": "epic_phase", "header": "Phase" },
|
|
78
79
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
79
80
|
{ "field": "delivery", "header": "Delivery" },
|
|
80
81
|
{ "field": "base_branch", "header": "Base branch" },
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// warning; the ordering is lost but every task still runs. No `plan_task_deps` are recorded
|
|
17
17
|
// in that case (the edges were invalid).
|
|
18
18
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
19
|
+
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
19
20
|
import { planTaskDeps, planTasks } from "../../app/plan.ts";
|
|
20
21
|
import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
|
|
21
22
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
@@ -127,6 +128,11 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
127
128
|
wave_label: tasks.length > 0 ? `1/${waveCount}` : null,
|
|
128
129
|
updated_at: ts,
|
|
129
130
|
};
|
|
131
|
+
// Domain-phase projection (#261): recording the plan hands the epic to the `review-plan` agent,
|
|
132
|
+
// so it enters the Reviewing phase (derived structurally from this worker's BPMN element id).
|
|
133
|
+
// Guard against a null derivation (element id absent) clobbering the genesis phase.
|
|
134
|
+
const epicPhase = deriveEpicPhase(job.elementId);
|
|
135
|
+
if (epicPhase) patch.epic_phase = epicPhase;
|
|
130
136
|
if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
|
|
131
137
|
await app.data.table("plans", "plan_key").update(planKey, patch);
|
|
132
138
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
15
15
|
import { BpmnError } from "@nanobpm/urban";
|
|
16
|
+
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
16
17
|
import { planTasks } from "../../app/plan.ts";
|
|
17
18
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
18
19
|
|
|
@@ -49,9 +50,16 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
49
50
|
throw new BpmnError("NO_WORK_DISPATCHED", `${planKey}: ${outcome}`);
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// Domain-phase projection (#261): the finalizer landed with opened PRs — the epic reaches its
|
|
54
|
+
// terminal "Fleet dispatched" phase (derived structurally from this worker's BPMN element id).
|
|
55
|
+
// The failed/no-work path above leaves epic_phase untouched: its terminal signal is status +
|
|
56
|
+
// outcome, and stamping "Dispatched" against a failed epic would misread. A null derivation
|
|
57
|
+
// (element id absent) must not clobber the last implementing phase.
|
|
58
|
+
const epicPhase = deriveEpicPhase(job.elementId);
|
|
52
59
|
await app.data.table("plans", "plan_key").update(planKey, {
|
|
53
60
|
status: "done",
|
|
54
61
|
outcome: `${opened} PR(s) dispatched to convergence`,
|
|
62
|
+
...(epicPhase ? { epic_phase: epicPhase } : {}),
|
|
55
63
|
updated_at: ts,
|
|
56
64
|
});
|
|
57
65
|
|
|
@@ -144,6 +144,8 @@ test("record-wave retries the same wave when a task is still pending", async ()
|
|
|
144
144
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, 1);
|
|
145
145
|
// Retry keeps the projection on the same (still-pending) wave.
|
|
146
146
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 1);
|
|
147
|
+
// Domain-phase projection (#261): more waves remain, so the epic stays Implementing (wave n/t).
|
|
148
|
+
assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Implementing (wave 2/2)");
|
|
147
149
|
});
|
|
148
150
|
|
|
149
151
|
test("record-wave pins current_wave to the last index and clears gate_wave on the final wave", async () => {
|
|
@@ -174,6 +176,9 @@ test("record-wave pins current_wave to the last index and clears gate_wave on th
|
|
|
174
176
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, null);
|
|
175
177
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 2);
|
|
176
178
|
assertEquals((planUpdates[0].patch as Record<string, unknown>).wave_label, "3/3");
|
|
179
|
+
// Domain-phase projection (#261): the final wave landed with no successor and no trial merge, so
|
|
180
|
+
// the epic enters Finalizing (record-results then advances to the Dispatched terminal).
|
|
181
|
+
assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Finalizing");
|
|
177
182
|
});
|
|
178
183
|
|
|
179
184
|
test("record-wave keeps all wave-progress fields NULL for a taskless plan (waveCount 0)", async () => {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// and, crucially, so a later wave's `dependsOn` can reference the PR keys earlier waves produced.
|
|
16
16
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
17
17
|
import { appendEntry } from "../../app/blackboard.ts";
|
|
18
|
+
import { EPIC_PHASE, implementingPhase } from "../../app/epicPhase.ts";
|
|
18
19
|
import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
|
|
19
20
|
import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
|
|
20
21
|
import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
|
|
@@ -280,11 +281,23 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
280
281
|
const currentWaveProjection = waveCount > 0 ? projectedCurrentWave : null;
|
|
281
282
|
const waveLabel = waveCount > 0 ? `${projectedCurrentWave + 1}/${waveCount}` : null;
|
|
282
283
|
|
|
284
|
+
// Domain-phase projection (#261): the wave landed — stamp the phase the epic is ENTERING next,
|
|
285
|
+
// which is data-dependent here (unlike the structural spine writers). A trial merge runs → Trial
|
|
286
|
+
// merging; another wave follows → Implementing (next wave n/t); otherwise the finalizer runs →
|
|
287
|
+
// Finalizing (record-results then advances to the Dispatched terminal).
|
|
288
|
+
const epicPhase = runTrialMerge
|
|
289
|
+
? EPIC_PHASE.TRIAL_MERGING
|
|
290
|
+
: hasMoreWaves
|
|
291
|
+
? implementingPhase(projectedCurrentWave, waveCount)
|
|
292
|
+
: EPIC_PHASE.FINALIZING;
|
|
293
|
+
|
|
283
294
|
// Wave-merge barrier: when another wave follows, park the plan-fanout instance at the
|
|
284
295
|
// `wait-wave-merged` catch event until THIS wave's opened PRs have MERGED (not merely opened).
|
|
285
|
-
// `gate_wave` is that durable marker; the poller (`
|
|
286
|
-
// `wave-merged` once the wave has landed
|
|
287
|
-
//
|
|
296
|
+
// `gate_wave` is that durable marker; the level-triggered poller (`pollWaveGatesImpl`) publishes
|
|
297
|
+
// `wave-merged` once the wave has landed AND it observes an OPEN subscription, but NEVER clears
|
|
298
|
+
// `gate_wave` — record-wave owns the marker's lifecycle. Re-arm it to the next wave here, or
|
|
299
|
+
// clear it on the final wave so a re-planned issue can't inherit a stale gate. Best-effort: a
|
|
300
|
+
// failed marker write must not fail the wave (the poller
|
|
288
301
|
// reconciles from `plan_tasks`/`pull_requests`), but the loop still relies on it to know which
|
|
289
302
|
// wave to watch, so we log a failure loudly.
|
|
290
303
|
try {
|
|
@@ -292,6 +305,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
292
305
|
gate_wave: hasMoreWaves ? currentWave : null,
|
|
293
306
|
current_wave: currentWaveProjection,
|
|
294
307
|
wave_label: waveLabel,
|
|
308
|
+
epic_phase: epicPhase,
|
|
295
309
|
updated_at: ts,
|
|
296
310
|
});
|
|
297
311
|
} catch (err) {
|
|
@@ -67,7 +67,7 @@ test("select-wave projects the active wave onto plans.current_wave", async () =>
|
|
|
67
67
|
];
|
|
68
68
|
const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63", current_wave: 0 }];
|
|
69
69
|
const out = await handler(
|
|
70
|
-
{ variables: { planKey: "owner/repo#63", currentWave: 1 } } as any,
|
|
70
|
+
{ variables: { planKey: "owner/repo#63", currentWave: 1 }, elementId: "select-wave" } as any,
|
|
71
71
|
fakeApp(rows, [], plans),
|
|
72
72
|
);
|
|
73
73
|
assertEquals((out as { waveTasks: unknown[] }).waveTasks.length, 1);
|
|
@@ -76,6 +76,9 @@ test("select-wave projects the active wave onto plans.current_wave", async () =>
|
|
|
76
76
|
// is pre-formatted for the epics-index at-a-glance column.
|
|
77
77
|
assertEquals(plans[0].wave_count, 2);
|
|
78
78
|
assertEquals(plans[0].wave_label, "2/2");
|
|
79
|
+
// Domain-phase projection (#261): dispatching the wave marks the epic Implementing (wave n/t),
|
|
80
|
+
// derived from this worker's BPMN element id + the levelize records.
|
|
81
|
+
assertEquals(plans[0].epic_phase, "Implementing (wave 2/2)");
|
|
79
82
|
});
|
|
80
83
|
|
|
81
84
|
test("select-wave nulls all three progress fields when there are no levelized rows", async () => {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// Emitting an empty `waveTasks` is fine: the MI activity over an empty collection completes
|
|
16
16
|
// immediately (the same 0-task path the flat fan-out already relied on).
|
|
17
17
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
18
|
+
import { deriveEpicPhase } from "../../app/epicPhase.ts";
|
|
18
19
|
import { plans, planTaskDeps, planTasks } from "../../app/plan.ts";
|
|
19
20
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
20
21
|
|
|
@@ -53,6 +54,13 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
53
54
|
// display-only — it must never gate control flow, which stays driven by the process
|
|
54
55
|
// `currentWave`/`waveCount`/`gate_wave` state.
|
|
55
56
|
const waveCount = rows.reduce((m, r) => Math.max(m, r.wave ?? 0), -1) + 1;
|
|
57
|
+
// Domain-phase projection (#261): select-wave dispatches this wave and is the last host write
|
|
58
|
+
// before the write-silent `implement` MI, so it durably marks the implementation phase for the
|
|
59
|
+
// wave it launches — `Implementing (wave n/t)` from the levelize records (job.elementId +
|
|
60
|
+
// current/total waves). A null derivation (element id absent) must not clobber the phase.
|
|
61
|
+
const epicPhase = waveCount > 0
|
|
62
|
+
? deriveEpicPhase(job.elementId, { current: currentWave, total: waveCount })
|
|
63
|
+
: null;
|
|
56
64
|
try {
|
|
57
65
|
await plans(app.data).update(planKey, {
|
|
58
66
|
// Keep the three progress fields consistent: with no levelized rows (waveCount 0) there is
|
|
@@ -60,6 +68,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
60
68
|
current_wave: waveCount > 0 ? currentWave : null,
|
|
61
69
|
wave_count: waveCount > 0 ? waveCount : null,
|
|
62
70
|
wave_label: waveCount > 0 ? `${currentWave + 1}/${waveCount}` : null,
|
|
71
|
+
...(epicPhase ? { epic_phase: epicPhase } : {}),
|
|
63
72
|
updated_at: ts,
|
|
64
73
|
});
|
|
65
74
|
} catch (err) {
|