@nanobpm/nano-workforce 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ // pr.readiness-probe — the ReadinessProbe executor (ADR 0001 §2, issue #258).
2
+ //
3
+ // The service-task half of the durable wait-gate (`resources/processes/readiness-gate.bpmn`). It is
4
+ // handed a declared `ReadinessProbe` descriptor + a `gateKey` correlation key, and polls the probe
5
+ // with backoff until it goes green — at which point it publishes the `readiness-ready` message the
6
+ // gate's event-based gateway correlates, releasing the wait. It does NOT own the timeout: the gate's
7
+ // timer arm is the authoritative bound (a hung or forever-red probe is escalated by the engine, not
8
+ // by a poller-side sentinel — ADR 0001 §2 pinned decision 3). Because the probe only *reads*
9
+ // readiness, a worker restart simply re-activates this job and re-probes (idempotent / resumable).
10
+ //
11
+ // Secrets never appear in the descriptor, a process variable, or a log line: a credential is read at
12
+ // execution time from the typed env-contract (`credentialEnv` → `readEnv`) and the probe's
13
+ // target/output is redacted before logging (ADR 0004 pinned decision 2).
14
+ import type { AppJobHandler } from "@nanobpm/urban";
15
+ import { readEnvOr } from "../../app/contracts.ts";
16
+ import {
17
+ DEFAULT_EVERY_MS,
18
+ defaultProbeExec,
19
+ nextDelay,
20
+ normalizePoll,
21
+ type ProbeExec,
22
+ type ProbePoll,
23
+ type ProbeResult,
24
+ parseProbe,
25
+ probeBudgetMs,
26
+ probeOnce,
27
+ type ReadinessProbe,
28
+ redactTarget,
29
+ } from "../../app/readiness.ts";
30
+ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
31
+
32
+ // Input/output typed off the model data envelopes (`ReadinessProbeIn` / `ReadinessProbeOut` in
33
+ // readiness-gate.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
34
+ // `probe` is a `nano:reference` to the nested `ReadinessProbe` shape, so it derives to the descriptor.
35
+ type In = WorkerInputs["pr.readiness-probe"];
36
+ type Out = WorkerOutputs["pr.readiness-probe"];
37
+
38
+ /** The message the gate's event-based gateway correlates on `=gateKey` to release the wait. */
39
+ export const READINESS_READY_MESSAGE = "readiness-ready";
40
+
41
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
42
+
43
+ /** The effective poll cadence: the descriptor's values, with `everyMs` defaulting through the env
44
+ * contract (`NANO_READINESS_POLL_EVERY_MS`) when the descriptor omits it, then the built-in
45
+ * defaults/clamps in {@link normalizePoll}. Reads the env value from the injected `env` (not the
46
+ * ambient `process.env`) so the loop is deterministic under test. */
47
+ function effectivePoll(
48
+ poll: ProbePoll | undefined,
49
+ env: Record<string, string | undefined>,
50
+ ): ReturnType<typeof normalizePoll> {
51
+ const envEvery = Number(readEnvOr("NANO_READINESS_POLL_EVERY_MS", String(DEFAULT_EVERY_MS), env));
52
+ const everyMs = poll?.everyMs ?? (Number.isFinite(envEvery) && envEvery >= 1 ? envEvery : DEFAULT_EVERY_MS);
53
+ return normalizePoll({ everyMs, timeoutMs: poll?.timeoutMs, backoff: poll?.backoff });
54
+ }
55
+
56
+ /** The core poll loop, factored out with injectable I/O + clock + publisher so it is unit-testable
57
+ * without a network, a subprocess, or a real timer. Polls until ready (publishes, returns ready) or
58
+ * the local budget is exhausted (returns not-ready — the engine timer then bounds the wait). */
59
+ export async function pollUntilReady(deps: {
60
+ probe: ReadinessProbe;
61
+ gateKey: string;
62
+ probeTimeout?: string;
63
+ exec: ProbeExec;
64
+ env: Record<string, string | undefined>;
65
+ now: () => number;
66
+ wait: (ms: number) => Promise<void>;
67
+ publish: (detail: string) => Promise<void>;
68
+ log?: (msg: string) => void;
69
+ }): Promise<ProbeResult> {
70
+ const poll = effectivePoll(deps.probe.poll, deps.env);
71
+ // The local budget is bound to the gate PER INSTANCE: it adopts the same seeded `probeTimeout`
72
+ // process variable the gate's engine timers fire off (`=probeTimeout`), falling back to the env-
73
+ // derived twin only when it's absent. Recomputing from the ambient env would let the worker go
74
+ // silent while the engine timer is still waiting if the env changed after the instance was seeded.
75
+ const deadline = deps.now() + probeBudgetMs(deps.probeTimeout, deps.probe, deps.env);
76
+ const label = redactTarget(deps.probe);
77
+ let attempt = 0;
78
+ for (;;) {
79
+ const res: ProbeResult = await probeOnce(deps.probe, deps.exec, deps.env).catch((err) => ({
80
+ ready: false,
81
+ // Never surface the raw error message: a fetch/subprocess error can embed the target URL
82
+ // (with query tokens), command fragments, or other secrets. Log only the error class name.
83
+ detail: `probe error: ${err instanceof Error ? err.name : "Error"}`,
84
+ }));
85
+ deps.log?.(`readiness probe ${label} attempt ${attempt + 1}: ${res.detail}`);
86
+ if (res.ready) {
87
+ await deps.publish(res.detail);
88
+ return res;
89
+ }
90
+ attempt += 1;
91
+ const wait = nextDelay(attempt, poll);
92
+ if (deps.now() + wait >= deadline) {
93
+ return { ready: false, detail: "probe budget exhausted; engine timer bounds the wait" };
94
+ }
95
+ await deps.wait(wait);
96
+ }
97
+ }
98
+
99
+ /** Normalize the two required process variables the gate seeds, failing fast when either is unusable.
100
+ *
101
+ * Both are load-bearing for the worker↔gate binding, so a bad value must surface immediately rather
102
+ * than silently degrade:
103
+ * • `gateKey` is returned **raw** (only validated on a trimmed view). The gate's message subscription
104
+ * binds `correlationKey="=gateKey"` — the *untrimmed* process variable — so the publish key must
105
+ * match it byte-for-byte. Trimming the key we publish on would desync a whitespace-seeded `gateKey`
106
+ * from the gate's subscription, leaving a green probe to release the wait only via the timeout arm.
107
+ * • `probeTimeout` is required by the typed input envelope and drives BOTH the gate's engine timers
108
+ * (`=probeTimeout`). A missing/blank value used to fall back silently to the env-derived twin,
109
+ * breaking the per-instance bound (and masking a mis-seeded instance until it escalates); fail fast
110
+ * instead and pass the raw string through so worker and engine share one seeded bound. */
111
+ export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }): {
112
+ gateKey: string;
113
+ probeTimeout: string;
114
+ } {
115
+ const gateKey = String(vars.gateKey ?? "");
116
+ if (gateKey.trim() === "") throw new Error("readiness-probe: 'gateKey' is required (blank correlation key)");
117
+ const { probeTimeout } = vars;
118
+ if (typeof probeTimeout !== "string" || probeTimeout.trim() === "")
119
+ throw new Error("readiness-probe: 'probeTimeout' is required (per-instance timer bound)");
120
+ return { gateKey, probeTimeout };
121
+ }
122
+
123
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
124
+ const probe = parseProbe(job.variables.probe);
125
+ const { gateKey, probeTimeout } = readGateVars(job.variables);
126
+ const result = await pollUntilReady({
127
+ probe,
128
+ gateKey,
129
+ probeTimeout,
130
+ exec: defaultProbeExec(),
131
+ env: process.env,
132
+ now: () => Date.now(),
133
+ wait: sleep,
134
+ publish: async (detail) => {
135
+ await app.engine.publishMessage({
136
+ name: READINESS_READY_MESSAGE,
137
+ correlationKey: gateKey,
138
+ variables: { ready: true, detail },
139
+ });
140
+ },
141
+ log: (msg) => app.log.info(msg),
142
+ });
143
+ return { ready: result.ready, detail: result.detail };
144
+ };
145
+
146
+ export default handler;
@@ -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,6 +281,16 @@ 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
296
  // `gate_wave` is that durable marker; the poller (`pollWaveGates`) clears it and publishes
@@ -292,6 +303,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
292
303
  gate_wave: hasMoreWaves ? currentWave : null,
293
304
  current_wave: currentWaveProjection,
294
305
  wave_label: waveLabel,
306
+ epic_phase: epicPhase,
295
307
  updated_at: ts,
296
308
  });
297
309
  } 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) {