@nanobpm/nano-workforce 0.200.9 → 0.201.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 +10 -0
- package/SPEC.md +35 -0
- package/app/deliveryGraph.test.ts +10 -0
- package/app/deliveryGraphCompiler.test.ts +354 -10
- package/app/deliveryGraphCompiler.ts +296 -40
- package/app/deliveryRunner.ts +14 -5
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/roundChurn.test.ts +388 -0
- package/app/roundChurn.ts +265 -0
- package/app/roundProgress.test.ts +224 -1
- package/app/service.test.ts +7 -0
- package/app/service.ts +12 -0
- package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
- package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
- package/docs/agent-guide.md +10 -7
- package/e2e/delivery-graph.e2e.ts +570 -1
- package/package.json +1 -1
- package/workers/progress-check/worker.ts +98 -1
- package/workers/readiness-probe/worker.test.ts +72 -1
- package/workers/readiness-probe/worker.ts +39 -0
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import type { AgentInstanceSummary, AppApi, AppJobHandler } from "@nanobpm/urban";
|
|
19
19
|
import { type HeadReader, makeDefaultReadHead } from "../../app/currentHead.ts";
|
|
20
20
|
import { compareCommits, fetchBranchHead, fetchPrHead, updateBranchRef } from "../../app/github.ts";
|
|
21
|
+
import { CHURN_WINDOW, type ChurnRound, detectChurn } from "../../app/roundChurn.ts";
|
|
21
22
|
import { decideProgress, isAddressedStatus } from "../../app/roundProgress.ts";
|
|
22
23
|
import { parsePr } from "../../app/service.ts";
|
|
23
24
|
import { WorldStore } from "../../app/world/index.ts";
|
|
@@ -238,6 +239,53 @@ function agentWorkFromEngine(engine: AppApi["engine"]): AgentWorkReader {
|
|
|
238
239
|
};
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
/** Reads a PR's durable round history (its `rounds` rows) projected to the fields {@link detectChurn}
|
|
243
|
+
* consumes, SCOPED to one convergence run. Injectable so churn tests never touch the datasource; the
|
|
244
|
+
* default binds the app's `rounds` table and filters by the calling job's `processInstanceKey`. */
|
|
245
|
+
export type RoundsReader = (prKey: string, processInstanceKey: string | null | undefined) => Promise<ChurnRound[]>;
|
|
246
|
+
|
|
247
|
+
/** Project raw `rounds` rows to the {@link ChurnRound} shape {@link detectChurn} consumes, SCOPED to
|
|
248
|
+
* one convergence run. Pure and exported so the run-scoping rule can be tested directly (with
|
|
249
|
+
* current-run, prior-run, and legacy NULL-key rows) without a datasource — the injectable reader in
|
|
250
|
+
* {@link detectChurn}'s tests returns a canned array and so never exercises this filter.
|
|
251
|
+
*
|
|
252
|
+
* When `processInstanceKey` is known, keep ONLY rows stamped with it; a legacy NULL-key row (pre-`102`
|
|
253
|
+
* history) or any OTHER run's row is dropped — a fail-OPEN policy, since dropping a row only ever
|
|
254
|
+
* REDUCES the churn signal (it can never fabricate one). When the key is absent (a testkit/synthetic
|
|
255
|
+
* job that persisted no key), fall back to the whole history, matching `persist-round`'s own null-key
|
|
256
|
+
* fallback. */
|
|
257
|
+
export function projectRunScopedRounds(
|
|
258
|
+
rows: ReadonlyArray<{ round_no: number; status: string; summary?: string | null; process_instance_key?: string | null }>,
|
|
259
|
+
processInstanceKey: string | null | undefined,
|
|
260
|
+
): ChurnRound[] {
|
|
261
|
+
const key = processInstanceKey != null ? String(processInstanceKey) : null;
|
|
262
|
+
const scoped = key === null ? rows : rows.filter((r) => r.process_instance_key === key);
|
|
263
|
+
return scoped.map((r) => ({ roundNo: r.round_no, status: r.status, summary: r.summary ?? null }));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Default round-history reader — the canonical `rounds` table over `app.data`, SCOPED to the current
|
|
267
|
+
* convergence run via {@link projectRunScopedRounds}. `rounds` history is retained across resubmissions
|
|
268
|
+
* (`submitPr` re-opens a PR at `current_round = 1` in a NEW process instance WITHOUT deleting prior-run
|
|
269
|
+
* rows — `migration 102`, `workers/persist-round/worker.ts`), so a bare `pr_key` read mixes runs: a
|
|
270
|
+
* prior run's higher-numbered `converged`/`blocked` row could permanently break the trailing run, and a
|
|
271
|
+
* prior run's trailing `addressed` streak could fabricate an early churn escalation. The projection
|
|
272
|
+
* drops every row not stamped with the caller's `processInstanceKey` (and falls back to the whole
|
|
273
|
+
* history when the key is absent). Any read failure surfaces as a rejected promise the caller `.catch`es
|
|
274
|
+
* to an empty history (churn then never fires, failing OPEN exactly like the head read). */
|
|
275
|
+
function defaultReadRounds(app: AppApi): RoundsReader {
|
|
276
|
+
return async (prKey, processInstanceKey) => {
|
|
277
|
+
const tbl = app.data.table<{
|
|
278
|
+
pr_key: string;
|
|
279
|
+
round_no: number;
|
|
280
|
+
status: string;
|
|
281
|
+
summary?: string;
|
|
282
|
+
process_instance_key?: string | null;
|
|
283
|
+
}>("rounds", "id");
|
|
284
|
+
const rows = await tbl.find({ pr_key: prKey });
|
|
285
|
+
return projectRunScopedRounds(rows, processInstanceKey);
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
241
289
|
/** Build the handler with injectable readers (see {@link HeadReader} / {@link AgentWorkReader}). The
|
|
242
290
|
* default export binds the real GitHub reader; the agent-work reader defaults to the engine's
|
|
243
291
|
* AgentInstance channel when not injected. Tests inject stubs. */
|
|
@@ -245,6 +293,7 @@ export function makeHandler(deps: {
|
|
|
245
293
|
readHead: HeadReader;
|
|
246
294
|
readAgentWork?: AgentWorkReader;
|
|
247
295
|
selfHeal?: SelfHealFn;
|
|
296
|
+
readRounds?: RoundsReader;
|
|
248
297
|
}): AppJobHandler<In, Out> {
|
|
249
298
|
return async (job, app) => {
|
|
250
299
|
const { prKey, status, repo, prNumber, round, huskRetries, roundEntryHead } = job.variables;
|
|
@@ -259,6 +308,7 @@ export function makeHandler(deps: {
|
|
|
259
308
|
last_progress_job_key: string | null;
|
|
260
309
|
last_progress_result: string | null;
|
|
261
310
|
last_progress_agent_watermark: string | null;
|
|
311
|
+
churn_escalated_round: number | null;
|
|
262
312
|
}>("pull_requests", "pr_key");
|
|
263
313
|
const row = await prs.get(prKey);
|
|
264
314
|
|
|
@@ -301,7 +351,12 @@ export function makeHandler(deps: {
|
|
|
301
351
|
};
|
|
302
352
|
const commit = async (
|
|
303
353
|
out: Out,
|
|
304
|
-
opts: {
|
|
354
|
+
opts: {
|
|
355
|
+
head?: string | null;
|
|
356
|
+
status?: "waiting_review" | "converging";
|
|
357
|
+
agentWatermark?: string | null;
|
|
358
|
+
churnEscalatedRound?: number;
|
|
359
|
+
},
|
|
305
360
|
): Promise<Out> => {
|
|
306
361
|
const ts = new Date().toISOString();
|
|
307
362
|
// Re-check ownership immediately before the write and drop it when this job no longer owns the
|
|
@@ -320,6 +375,10 @@ export function makeHandler(deps: {
|
|
|
320
375
|
// unreadable head, a non-addressed round, or an injected bare-verdict reader), so the next
|
|
321
376
|
// round still correlates against the last real attempt.
|
|
322
377
|
...(opts.agentWatermark !== undefined ? { last_progress_agent_watermark: opts.agentWatermark } : {}),
|
|
378
|
+
// Stamp the run-scoped churn-reset watermark when a churn escalation is raised (#870), so the
|
|
379
|
+
// human-answer resume — which re-enters the SAME numeric round and writes no `blocked` row —
|
|
380
|
+
// restarts the churn clock instead of re-raising the identical question on the resumed round.
|
|
381
|
+
...(opts.churnEscalatedRound !== undefined ? { churn_escalated_round: opts.churnEscalatedRound } : {}),
|
|
323
382
|
...(jobKey ? { last_progress_job_key: jobKey, last_progress_result: JSON.stringify(out) } : {}),
|
|
324
383
|
updated_at: ts,
|
|
325
384
|
});
|
|
@@ -429,6 +488,44 @@ export function makeHandler(deps: {
|
|
|
429
488
|
return commit(out, { head: currentHead, status: "converging", agentWatermark });
|
|
430
489
|
}
|
|
431
490
|
if (decision.progressed) {
|
|
491
|
+
// NON-CONVERGING CHURN guard (#870). The head DID advance (real commit), so the no-progress
|
|
492
|
+
// guard correctly continues — but a contested surface whose findings never end keeps the loop
|
|
493
|
+
// "progressing" every round without ever reaching a fixed point (e.g. a fail-closed analyzer
|
|
494
|
+
// whose bypass forms are unbounded). Before parking for the next review, check the durable round
|
|
495
|
+
// history: when the trailing run of consecutive `addressed` rounds keeps landing findings in the
|
|
496
|
+
// SAME file over a window, escalate for a human SCOPE decision instead of looping to maxRounds.
|
|
497
|
+
// Only on an addressed round with a readable head (a GitHub outage fails OPEN like everywhere
|
|
498
|
+
// else — never fabricate a churn escalation); the round-history read fails open to no-churn too.
|
|
499
|
+
if (currentHead && isAddressedStatus(status)) {
|
|
500
|
+
const history: ChurnRound[] = await (deps.readRounds ?? defaultReadRounds(app))(prKey, job.processInstanceKey).catch(
|
|
501
|
+
() => [],
|
|
502
|
+
);
|
|
503
|
+
const churn = detectChurn(history, CHURN_WINDOW, row?.churn_escalated_round ?? 0);
|
|
504
|
+
if (churn.churning && churn.question) {
|
|
505
|
+
app.log.info("non-converging churn detected — escalating for a human scope decision (#870)", {
|
|
506
|
+
prKey,
|
|
507
|
+
round: roundNo,
|
|
508
|
+
file: churn.file,
|
|
509
|
+
rounds: churn.rounds,
|
|
510
|
+
});
|
|
511
|
+
// Route to the SAME human escalation path the no-advance guard uses (gw-husk → default →
|
|
512
|
+
// persist-escalation-noprogress, which renders `noProgressQuestion`): progressed:false with
|
|
513
|
+
// huskRetry:false escapes the husk auto-retry straight to the human. Leave the status unset
|
|
514
|
+
// (the escalation worker owns it) and advance the baseline + watermark, exactly as the
|
|
515
|
+
// no-advance escalation `commit` below does. Also stamp the run-scoped churn-reset watermark
|
|
516
|
+
// at THIS round so the human-answer resume restarts the churn clock (see detectChurn).
|
|
517
|
+
return commit(
|
|
518
|
+
{
|
|
519
|
+
progressed: false,
|
|
520
|
+
huskRetries: 0,
|
|
521
|
+
huskRetry: false,
|
|
522
|
+
noProgressReason: "churn",
|
|
523
|
+
noProgressQuestion: churn.question,
|
|
524
|
+
},
|
|
525
|
+
{ head: currentHead, agentWatermark, churnEscalatedRound: roundNo },
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
432
529
|
// Genuine progress → the loop parks at wait-review. THIS is the review-wait park, written only
|
|
433
530
|
// once the husk retry has been ruled out, so a husk-retry round never transits `waiting_review`.
|
|
434
531
|
return commit(out, { head: currentHead, status: "waiting_review", agentWatermark });
|
|
@@ -3,7 +3,7 @@ import { test } from "node:test";
|
|
|
3
3
|
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
4
4
|
import type { CommandResult, HttpResponse, ProbeExec, ReadinessProbe } from "../../app/readiness.ts";
|
|
5
5
|
import { parseProbe } from "../../app/readiness.ts";
|
|
6
|
-
import handler, { probeSingleShot, READINESS_READY_MESSAGE, readGateVars, safeBind } from "./worker.ts";
|
|
6
|
+
import handler, { __setProbeExecForTest, probeSingleShot, READINESS_READY_MESSAGE, readGateVars, safeBind } from "./worker.ts";
|
|
7
7
|
|
|
8
8
|
function execReturning(seq: Array<HttpResponse>, counts: { http: number } = { http: 0 }): ProbeExec {
|
|
9
9
|
let i = 0;
|
|
@@ -239,6 +239,77 @@ test("handler: a blank gateKey fails fast (an empty correlationKey would never r
|
|
|
239
239
|
);
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
test("#876 review: a DOTTED command target (e.g. check.sh) is NOT an unresolved fact-ref — the worker probes it, never parks the gate", async () => {
|
|
243
|
+
// The #872 fail-closed guard must key on the compiler's NULL provenance only — a runtime fact-ref
|
|
244
|
+
// SYNTAX test cannot distinguish an unresolved `<node>.<fact>` literal from a valid dotted command
|
|
245
|
+
// target, and misclassifying `check.sh` would park the gate forever without ever running the probe.
|
|
246
|
+
const ran: string[] = [];
|
|
247
|
+
const exec: ProbeExec = {
|
|
248
|
+
async run(command: string): Promise<CommandResult> {
|
|
249
|
+
ran.push(command);
|
|
250
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
251
|
+
},
|
|
252
|
+
async httpGet(): Promise<HttpResponse> {
|
|
253
|
+
throw new Error("not an http probe");
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
const restore = __setProbeExecForTest(exec);
|
|
257
|
+
const published: string[] = [];
|
|
258
|
+
try {
|
|
259
|
+
const job = { variables: { probe: { kind: "command", target: "check.sh" }, gateKey: "g1", probeTimeout: "PT30M" } };
|
|
260
|
+
const app = {
|
|
261
|
+
log: { info() {}, warn() {} },
|
|
262
|
+
engine: {
|
|
263
|
+
async publishMessage(msg: { name: string }) {
|
|
264
|
+
published.push(msg.name);
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal job/app stub over the real handler.
|
|
269
|
+
const out = await handler(job as any, app as any);
|
|
270
|
+
assertEquals(out.ready, true, "a dotted command target probes normally and reports ready");
|
|
271
|
+
} finally {
|
|
272
|
+
__setProbeExecForTest(restore);
|
|
273
|
+
}
|
|
274
|
+
assertEquals(ran, ["check.sh"], "the probe RAN the dotted command target (it was not misclassified as an unresolved fact-ref)");
|
|
275
|
+
assertEquals(published, [READINESS_READY_MESSAGE], "a green dotted-target probe publishes readiness");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("#872: a NULL/blank late-bound target still fails closed (the gate parks and escalates, never probes a null handle)", async () => {
|
|
279
|
+
for (const target of [null, undefined, " "]) {
|
|
280
|
+
// Intermediate poll activation (no lastAttempt): fails closed, logs at INFO, never WARNs — the
|
|
281
|
+
// boundary-only WARN policy (PR #876 review) keeps a 30-minute gate from emitting ~120 WARN lines.
|
|
282
|
+
const job = { variables: { probe: { kind: "command", target }, gateKey: "g1", probeTimeout: "PT30M" } };
|
|
283
|
+
const infos: string[] = [];
|
|
284
|
+
const warnings: string[] = [];
|
|
285
|
+
const app = {
|
|
286
|
+
log: { info(msg: string) { infos.push(msg); }, warn(msg: string) { warnings.push(msg); } },
|
|
287
|
+
engine: { async publishMessage() { throw new Error("must never publish"); } },
|
|
288
|
+
};
|
|
289
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal job/app stub over the real handler.
|
|
290
|
+
const out = await handler(job as any, app as any);
|
|
291
|
+
assertEquals(out.ready, false, `a ${JSON.stringify(target)} target is not ready`);
|
|
292
|
+
assert(String(out.detail).includes("unresolved target"), "the detail names the unresolved target");
|
|
293
|
+
assertEquals(warnings.length, 0, `an intermediate ${JSON.stringify(target)} activation must NOT WARN`);
|
|
294
|
+
assertEquals(infos.length, 1, "the fail-closed park is logged at info on intermediate attempts");
|
|
295
|
+
|
|
296
|
+
// Final (boundary) activation: the SAME fail-closed park, but now it IS the escalation trigger, so
|
|
297
|
+
// it WARNs exactly once (and does not duplicate at info).
|
|
298
|
+
const lastJob = { variables: { ...job.variables, lastAttempt: true } };
|
|
299
|
+
const lastInfos: string[] = [];
|
|
300
|
+
const lastWarnings: string[] = [];
|
|
301
|
+
const lastApp = {
|
|
302
|
+
log: { info(msg: string) { lastInfos.push(msg); }, warn(msg: string) { lastWarnings.push(msg); } },
|
|
303
|
+
engine: { async publishMessage() { throw new Error("must never publish"); } },
|
|
304
|
+
};
|
|
305
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal job/app stub over the real handler.
|
|
306
|
+
const lastOut = await handler(lastJob as any, lastApp as any);
|
|
307
|
+
assertEquals(lastOut.ready, false, `a ${JSON.stringify(target)} target is not ready on the last attempt`);
|
|
308
|
+
assertEquals(lastWarnings.length, 1, "the boundary (lastAttempt) fail-closed park WARNs exactly once");
|
|
309
|
+
assertEquals(lastInfos.length, 0, "the boundary park does not also log at info");
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
242
313
|
test("READINESS_READY_MESSAGE is the name the gate correlates", () => {
|
|
243
314
|
assertEquals(READINESS_READY_MESSAGE, "readiness-ready");
|
|
244
315
|
});
|
|
@@ -49,6 +49,27 @@ function errorDetail(err: unknown): string {
|
|
|
49
49
|
return `probe error: ${err instanceof Error ? err.name : "Error"}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Fail-closed guard for an UNRESOLVED wait-gate target (issue #872, PR #876 review). A `wait` node's
|
|
53
|
+
* probe `target` is late-bound from an upstream emit: the compiler rewrites it to the observed value
|
|
54
|
+
* when that fact resolved to a non-null value, otherwise it writes NULL (the compiler owns the
|
|
55
|
+
* resolved/unresolved provenance — it is the only component that knows whether the target was actually
|
|
56
|
+
* bound). A probe left with a null/blank target MUST NOT pass through — it is not a real handle, so the
|
|
57
|
+
* gate is NOT satisfiable yet. Returning not-ready keeps the gate parked so its bounded timeout
|
|
58
|
+
* escalates to a human, instead of either incident-ing inside `parseProbe` (which throws on a blank
|
|
59
|
+
* target) or — worse — silently treating the null target as "ready". Returns `null` when the target is
|
|
60
|
+
* a usable literal (let `parseProbe` run).
|
|
61
|
+
*
|
|
62
|
+
* Deliberately NO fact-ref SYNTAX test here: a dotted, hash-free literal (`check.sh`) is a VALID
|
|
63
|
+
* `command` target yet is indistinguishable from an unresolved `<node>.<fact>` reference by shape
|
|
64
|
+
* alone — guarding on syntax parked such a gate forever without ever running its probe (PR #876
|
|
65
|
+
* review). Only the compiler's null provenance marks an unresolved target. */
|
|
66
|
+
function unresolvedTargetDetail(rawProbe: unknown): string | null {
|
|
67
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
|
|
68
|
+
const target = isRecord(rawProbe) ? rawProbe.target : undefined;
|
|
69
|
+
if (typeof target !== "string" || target.trim() === "") return "wait gate: unresolved target (not ready)";
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
/** Single activation of the readiness probe. The engine owns retry cadence and the timeout boundary;
|
|
53
74
|
* this function performs one `probeOnce`, publishes only when ready, and optionally performs exactly
|
|
54
75
|
* one empirical fallback on a model-marked last attempt. */
|
|
@@ -149,6 +170,24 @@ function resolveProbeExec(): ProbeExec {
|
|
|
149
170
|
}
|
|
150
171
|
|
|
151
172
|
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
173
|
+
// Fail closed on an unresolved late-bound target (issue #872): a null/blank target means the
|
|
174
|
+
// upstream emit this gate waits on never resolved — the gate is not satisfiable, so stay parked (and
|
|
175
|
+
// let the gate's bounded timeout escalate) rather than throw in parseProbe or pass a null target
|
|
176
|
+
// through as if ready. (The compiler writes the null provenance; a dotted LITERAL target — e.g. a
|
|
177
|
+
// `check.sh` command — is never unresolved and reaches `parseProbe` verbatim, PR #876 review.)
|
|
178
|
+
const unresolved = unresolvedTargetDetail(job.variables.probe);
|
|
179
|
+
if (unresolved !== null) {
|
|
180
|
+
// Boundary-only WARN policy (mirrors probeSingleShot, PR #876 review): an unresolved late-bound
|
|
181
|
+
// target stays unresolved for the WHOLE bounded wait, so the engine re-activates this job on every
|
|
182
|
+
// poll tick. Warning on each activation would emit ~120 WARN lines over a 30-minute gate at the
|
|
183
|
+
// 15s default cadence and drown the one that matters. Reserve WARN for the final attempt (the
|
|
184
|
+
// escalation trigger) and log intermediate activations at info — the same boundary-only policy
|
|
185
|
+
// probeSingleShot applies to a not-ready probe.
|
|
186
|
+
const msg = `readiness gate not ready: ${unresolved}`;
|
|
187
|
+
if (job.variables.lastAttempt === true) app.log.warn(msg);
|
|
188
|
+
else app.log.info(msg);
|
|
189
|
+
return { ready: false, detail: unresolved };
|
|
190
|
+
}
|
|
152
191
|
const probe = parseProbe(job.variables.probe);
|
|
153
192
|
const { gateKey } = readGateVars(job.variables);
|
|
154
193
|
const exec = resolveProbeExec();
|