@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.
@@ -17,7 +17,6 @@
17
17
  // in that case (the edges were invalid).
18
18
  import type { AppJobHandler } from "@nanobpm/urban";
19
19
  import { type CapabilityNeed, parseCapabilityNeeds } from "../../app/capabilityNeed.ts";
20
- import { deriveEpicPhase } from "../../app/epicPhase.ts";
21
20
  import { plans, planTaskDeps, planTaskNeeds, planTasks } from "../../app/plan.ts";
22
21
  import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
23
22
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
@@ -147,11 +146,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
147
146
  // denormalises it onto the `plans` row.
148
147
  updated_at: ts,
149
148
  };
150
- // Domain-phase projection (#261): recording the plan hands the epic to the `review-plan` agent,
151
- // so it enters the Reviewing phase (derived structurally from this worker's BPMN element id).
152
- // Guard against a null derivation (element id absent) clobbering the genesis phase.
153
- const epicPhase = deriveEpicPhase(job.elementId);
154
- if (epicPhase) patch.epic_phase = epicPhase;
155
149
  if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
156
150
  await plans(app.data).update(planKey, patch);
157
151
 
@@ -13,7 +13,6 @@
13
13
 
14
14
  import type { AppJobHandler } from "@nanobpm/urban";
15
15
  import { BpmnError } from "@nanobpm/urban";
16
- import { deriveEpicPhase } from "../../app/epicPhase.ts";
17
16
  import { plans, planTasks } from "../../app/plan.ts";
18
17
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
19
18
 
@@ -50,16 +49,16 @@ const handler: AppJobHandler<In> = async (job, app) => {
50
49
  throw new BpmnError("NO_WORK_DISPATCHED", `${planKey}: ${outcome}`);
51
50
  }
52
51
 
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
+ // The epic's terminal "Fleet dispatched" phase is no longer stamped here (S8, #542). While this
53
+ // finalizer's token is ACTIVE the epic reads `Finalizing` from the live element-instance model
54
+ // (`pollEpicPhase` `deriveEpicPhaseLive`); the terminal `Dispatched` is then derived from this
55
+ // `done` status by `pollEpicPhase` (`deriveTerminalEpicPhase`) a completion marker with no live
56
+ // token to read, so it is taken from the durable terminal status, not a fleeting ACTIVE token. The
57
+ // failed/no-work path above likewise stamps no phase — its terminal signal is status + outcome, and
58
+ // a `failed` epic is deliberately never labelled Dispatched.
59
59
  await plans(app.data).update(planKey, {
60
60
  status: "done",
61
61
  outcome: `${opened} PR(s) dispatched to convergence`,
62
- ...(epicPhase ? { epic_phase: epicPhase } : {}),
63
62
  updated_at: ts,
64
63
  });
65
64
 
@@ -145,8 +145,9 @@ test("record-wave retries the same wave when a task is still pending", async ()
145
145
  // Wave progress (current_wave/wave_label) was retired as a stored projection (epic #412) — derived
146
146
  // from `plan_tasks` by the plan_wave_label VIEW — so record-wave no longer writes it.
147
147
  assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
148
- // Domain-phase projection (#261): more waves remain, so the epic stays Implementing (wave n/t).
149
- assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Implementing (wave 2/2)");
148
+ // Domain-phase projection is no longer stamped by this worker (S8, #542) the epic phase is a pure
149
+ // read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts).
150
+ assertEquals("epic_phase" in (planUpdates[0].patch as Record<string, unknown>), false);
150
151
  });
151
152
 
152
153
  test("record-wave pins current_wave to the last index and clears gate_wave on the final wave", async () => {
@@ -178,9 +179,9 @@ test("record-wave pins current_wave to the last index and clears gate_wave on th
178
179
  assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, null);
179
180
  assertEquals("current_wave" in (planUpdates[0].patch as Record<string, unknown>), false);
180
181
  assertEquals("wave_label" in (planUpdates[0].patch as Record<string, unknown>), false);
181
- // Domain-phase projection (#261): the final wave landed with no successor and no trial merge, so
182
- // the epic enters Finalizing (record-results then advances to the Dispatched terminal).
183
- assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Finalizing");
182
+ // Domain-phase projection is no longer stamped by this worker (S8, #542) the epic phase is a pure
183
+ // read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts).
184
+ assertEquals("epic_phase" in (planUpdates[0].patch as Record<string, unknown>), false);
184
185
  });
185
186
 
186
187
  test("record-wave writes no wave-progress columns for a taskless plan (waveCount 0)", async () => {
@@ -15,7 +15,6 @@
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";
19
18
  import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
20
19
  import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
21
20
  import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
@@ -302,25 +301,11 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
302
301
  const nextWave = stillPendingCurrentWave ? currentWave : currentWave + 1;
303
302
  const hasMoreWaves = stillPendingCurrentWave || nextWave < waveCount;
304
303
 
305
- // Wave index the epic is now on (issue #137): while more waves remain, the wave about to run; on
306
- // the final wave, pinned to the last index so a finished epic reads N/N (nextWave would be
307
- // waveCount, one past the last band). This is a LOCAL value only it is used below to derive the
308
- // `epic_phase` (Implementing wave n/t) and the domain phase.
309
- const projectedCurrentWave = hasMoreWaves ? nextWave : Math.max(0, waveCount - 1);
310
- // Operator-visibility wave progress (current_wave / wave_label) was RETIRED as a stored projection
311
- // (epic #412) — it is now derived from `plan_tasks` by the `plan_wave_label` / `plan_read_model`
312
- // VIEWs (060/061), so this worker no longer denormalises it (select-wave no longer writes it
313
- // either). `projectedCurrentWave` above is not persisted; it only feeds the phase derivation.
314
-
315
- // Domain-phase projection (#261): the wave landed — stamp the phase the epic is ENTERING next,
316
- // which is data-dependent here (unlike the structural spine writers). A trial merge runs → Trial
317
- // merging; another wave follows → Implementing (next wave n/t); otherwise the finalizer runs →
318
- // Finalizing (record-results then advances to the Dispatched terminal).
319
- const epicPhase = runTrialMerge
320
- ? EPIC_PHASE.TRIAL_MERGING
321
- : hasMoreWaves
322
- ? implementingPhase(projectedCurrentWave, waveCount)
323
- : EPIC_PHASE.FINALIZING;
304
+ // The epic's domain phase is no longer stamped here (S8, #542) it is a pure read-model derivation
305
+ // off the live element-instance model (`pollEpicPhase` `deriveEpicPhaseLive`, app/epicPhase.ts),
306
+ // which reads the live token position (a running `implement` fan-out, `trial-merge`, or the
307
+ // finalizer) directly rather than this worker projecting the phase it is ABOUT to enter. This write
308
+ // now only arms the durable wave-merge barrier marker.
324
309
 
325
310
  // Wave-merge barrier: when another wave follows, park the plan-fanout instance at the
326
311
  // `wait-wave-merged` catch event until THIS wave's opened PRs have MERGED (not merely opened).
@@ -334,7 +319,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
334
319
  try {
335
320
  await plans(app.data).update(planKey, {
336
321
  gate_wave: hasMoreWaves ? currentWave : null,
337
- epic_phase: epicPhase,
338
322
  updated_at: ts,
339
323
  });
340
324
  } catch (err) {
@@ -83,9 +83,10 @@ test("select-wave dispatches the active wave without writing wave-progress colum
83
83
  assertEquals(plans[0].current_wave, undefined);
84
84
  assertEquals(plans[0].wave_count, undefined);
85
85
  assertEquals(plans[0].wave_label, undefined);
86
- // Domain-phase projection (#261): dispatching the wave marks the epic Implementing (wave n/t),
87
- // derived from this worker's BPMN element id + the levelize records.
88
- assertEquals(plans[0].epic_phase, "Implementing (wave 2/2)");
86
+ // Domain-phase projection is no longer stamped by this worker (S8, #542) the epic phase is now a
87
+ // pure read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts),
88
+ // so select-wave introduces no `epic_phase` onto the plan row.
89
+ assertEquals(plans[0].epic_phase, undefined);
89
90
  });
90
91
 
91
92
  test("select-wave captures the preflight's resolvedArtifacts onto plans.bound_artifacts (#292 S4)", async () => {
@@ -16,7 +16,6 @@
16
16
  // immediately (the same 0-task path the flat fan-out already relied on).
17
17
  import type { AppJobHandler } from "@nanobpm/urban";
18
18
  import type { CapabilityNeed } from "../../app/capabilityNeed.ts";
19
- import { deriveEpicPhase } from "../../app/epicPhase.ts";
20
19
  import { plans, planTaskDeps, planTaskNeeds, planTasks } from "../../app/plan.ts";
21
20
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
22
21
 
@@ -52,20 +51,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
52
51
  const statusById = new Map<string, string>();
53
52
  for (const r of rows) statusById.set(r.task_id, r.status);
54
53
 
55
- // Operator-visibility projection (issue #137): mark this as the wave the fleet is now
56
- // implementing, so the epics-index can show wave X/N at a glance. wave_count is derivable from
57
- // the levelized rows (max wave + 1), so the "X/N" label stays correct even if a re-levelize
58
- // changed the total. Best-effort + idempotent (a retry re-writes the same value) and
59
- // display-only — it must never gate control flow, which stays driven by the process
60
- // `currentWave`/`waveCount`/`gate_wave` state.
61
- const waveCount = rows.reduce((m, r) => Math.max(m, r.wave ?? 0), -1) + 1;
62
- // Domain-phase projection (#261): select-wave dispatches this wave and is the last host write
63
- // before the write-silent `implement` MI, so it durably marks the implementation phase for the
64
- // wave it launches — `Implementing (wave n/t)` from the levelize records (job.elementId +
65
- // current/total waves). A null derivation (element id absent) must not clobber the phase.
66
- const epicPhase = waveCount > 0
67
- ? deriveEpicPhase(job.elementId, { current: currentWave, total: waveCount })
68
- : null;
69
54
  // Inter-epic gate projection (#292 slice S4): reaching select-wave proves this epic's leading
70
55
  // capability PREFLIGHT (S3) already went GREEN, so capture the `resolvedArtifacts` the preflight
71
56
  // bound — the exact `pkg@version`s first carrying each producer's awaited capability — onto the
@@ -76,20 +61,21 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
76
61
  const boundArtifacts = Array.isArray(job.variables.resolvedArtifacts)
77
62
  ? job.variables.resolvedArtifacts.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
78
63
  : [];
79
- try {
80
- await plans(app.data).update(planKey, {
81
- // Operator-visibility wave progress (current_wave / wave_count / wave_label) was RETIRED as a
82
- // stored projection (epic #412) — it is now derived from `plan_tasks` by the `plan_wave_label`
83
- // / `plan_read_model` VIEWs (060/061), so select-wave no longer denormalises it. This write
84
- // still stamps the derived domain phase and the inter-epic gate's bound artifacts.
85
- ...(epicPhase ? { epic_phase: epicPhase } : {}),
86
- ...(boundArtifacts.length > 0 ? { bound_artifacts: JSON.stringify(boundArtifacts) } : {}),
87
- updated_at: ts,
88
- });
89
- } catch (err) {
90
- app.log.error(`select-wave: projecting plan row (epic phase / bound artifacts) failed for ${planKey}`, {
91
- err: String(err),
92
- });
64
+ // The epic's Implementing (wave n/t) phase is no longer stamped here (S8, #542) — it is a pure
65
+ // read-model derivation off the live element-instance model (`pollEpicPhase`, app/service.ts), which
66
+ // reads the running `implement` fan-out directly. This write now only carries the inter-epic gate's
67
+ // bound artifacts, so it is skipped entirely when there are none.
68
+ if (boundArtifacts.length > 0) {
69
+ try {
70
+ await plans(app.data).update(planKey, {
71
+ bound_artifacts: JSON.stringify(boundArtifacts),
72
+ updated_at: ts,
73
+ });
74
+ } catch (err) {
75
+ app.log.error(`select-wave: projecting plan row (bound artifacts) failed for ${planKey}`, {
76
+ err: String(err),
77
+ });
78
+ }
93
79
  }
94
80
 
95
81
  const deps = await planTaskDeps(app.data).find({ plan_key: planKey });