@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.
- package/CHANGELOG.md +14 -0
- package/SPEC.md +11 -0
- package/app/contracts.ts +23 -0
- package/app/epicPhase.test.ts +62 -0
- package/app/epicPhase.ts +125 -0
- package/app/plan.ts +12 -0
- package/app/readiness.test.ts +300 -0
- package/app/readiness.ts +493 -0
- package/app/reviewWait.test.ts +19 -0
- package/app/reviewWait.ts +20 -0
- package/db/migrations/038_plan_epic_phase.sql +12 -0
- package/e2e/readiness-gate.e2e.ts +285 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/resources/forms/readiness-escalation.form +29 -0
- package/resources/processes/readiness-gate.bpmn +343 -0
- package/workers/readiness-probe/worker.test.ts +236 -0
- package/workers/readiness-probe/worker.ts +146 -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 +12 -0
- package/workers/select-wave/worker.test.ts +4 -1
- package/workers/select-wave/worker.ts +9 -0
|
@@ -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) {
|