@nanobpm/nano-workforce 0.200.8 → 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 +16 -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/implementEscalationReason.test.ts +216 -0
- package/app/implementEscalationReason.ts +213 -0
- package/app/implementReconcile.test.ts +168 -11
- package/app/implementReconcile.ts +112 -18
- 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/e2e/feature-run.e2e.ts +8 -1
- package/e2e/plan-fanout.e2e.ts +75 -2
- package/package.json +1 -1
- package/resources/processes/implement-cell.bpmn +42 -17
- package/resources/processes/plan-fanout.bpmn +236 -211
- package/resources/prompts/feature.md +16 -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
- package/workers/reconcile-implement/worker.test.ts +31 -3
- package/workers/reconcile-implement/worker.ts +5 -0
- package/workers/record-feature-escalation/worker.test.ts +58 -3
- package/workers/record-feature-escalation/worker.ts +24 -10
|
@@ -18,6 +18,19 @@ The job payload (stdin JSON) carries:
|
|
|
18
18
|
|
|
19
19
|
You have `gh` / git authenticated for the target repository.
|
|
20
20
|
|
|
21
|
+
## Workspace confinement — work ONLY inside your run workspace
|
|
22
|
+
|
|
23
|
+
Your run has a private workspace directory (the git repository you start in — the
|
|
24
|
+
clone/worktree the harness checked out for you). **Do all your work there.** Never
|
|
25
|
+
`cd /tmp/<repo>`, clone into a shared path, or edit files outside your workspace:
|
|
26
|
+
that directory is torn down when your run ends, so edits made elsewhere are
|
|
27
|
+
**silently lost** (and can collide with a sibling run sharing the path). If you
|
|
28
|
+
need the repo, use the one you are already in — do not clone a second copy into
|
|
29
|
+
`/tmp`. A completion that leaves no commit on `feat/<task.id>` and no PR is treated
|
|
30
|
+
as a **failed, undelivered attempt** (see the reconcile/escalation behaviour the
|
|
31
|
+
app applies), so your work must land in a pushed commit on your branch, nowhere
|
|
32
|
+
else.
|
|
33
|
+
|
|
21
34
|
## Your branch (deterministic — the same across a resume)
|
|
22
35
|
|
|
23
36
|
Always use the branch **`feat/<task.id>`**. Because a resumed run gets a fresh
|
|
@@ -192,7 +205,9 @@ Write a JSON object of **result variables** to the file named by the
|
|
|
192
205
|
|
|
193
206
|
Rules:
|
|
194
207
|
|
|
195
|
-
- `status` — one of
|
|
208
|
+
- `status` — one of EXACTLY these four tokens (any other value — e.g.
|
|
209
|
+
`completed`, `done`, `success` — is **off-vocabulary**: the app cannot route on
|
|
210
|
+
it and will treat your slice as **not delivered**, so never invent one):
|
|
196
211
|
- `opened` — a PR was created (ready for review). Set `pr`.
|
|
197
212
|
- `escalated` — you need a human decision; set `question`, and set `pr` to the
|
|
198
213
|
**draft** PR you opened to preserve your work (if you managed to open one).
|
|
@@ -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();
|
|
@@ -28,7 +28,7 @@ test("adopts an open PR on the branch: emits reconciled + status=opened + pr", a
|
|
|
28
28
|
{ jobKey: "j1", variables: { subjectKey: "owner/repo#801", task: { id: "issue-801" }, status: null } } as never,
|
|
29
29
|
fakeApp,
|
|
30
30
|
);
|
|
31
|
-
assertEquals(out, { reconciled: true, status: "opened", pr: "owner/repo#801" });
|
|
31
|
+
assertEquals(out, { reconciled: true, status: "opened", pr: "owner/repo#801", retry: false, retryNudge: null, retried: false, deliveryVerified: true });
|
|
32
32
|
} finally {
|
|
33
33
|
globalThis.fetch = prevFetch;
|
|
34
34
|
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
@@ -54,7 +54,7 @@ test("wires baseBranch through: a wrong-base open PR is NOT adopted → escalate
|
|
|
54
54
|
{ jobKey: "j3", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null, baseBranch: "epic/feat-x" } } as never,
|
|
55
55
|
fakeApp,
|
|
56
56
|
);
|
|
57
|
-
assertEquals(out, { reconciled: false, status: null, pr: null });
|
|
57
|
+
assertEquals(out, { reconciled: false, status: null, pr: null, retry: false, retryNudge: null, retried: false, deliveryVerified: true });
|
|
58
58
|
} finally {
|
|
59
59
|
globalThis.fetch = prevFetch;
|
|
60
60
|
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
@@ -76,7 +76,35 @@ test("no open PR on the branch: falls through to escalate (reconciled=false)", a
|
|
|
76
76
|
{ jobKey: "j2", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null } } as never,
|
|
77
77
|
fakeApp,
|
|
78
78
|
);
|
|
79
|
-
assertEquals(out, { reconciled: false, status: null, pr: null });
|
|
79
|
+
assertEquals(out, { reconciled: false, status: null, pr: null, retry: false, retryNudge: null, retried: false, deliveryVerified: true });
|
|
80
|
+
} finally {
|
|
81
|
+
globalThis.fetch = prevFetch;
|
|
82
|
+
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
83
|
+
else process.env.GITHUB_TOKEN = prevToken;
|
|
84
|
+
if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
85
|
+
else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("claimed-completion (off-vocabulary status) + no PR → auto-retry once with a nudge (issue #865)", async () => {
|
|
90
|
+
const prevToken = process.env.GITHUB_TOKEN;
|
|
91
|
+
const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
92
|
+
const prevFetch = globalThis.fetch;
|
|
93
|
+
process.env.GITHUB_TOKEN = "t";
|
|
94
|
+
process.env.NANO_PR_GITHUB_TRANSPORT = "token";
|
|
95
|
+
globalThis.fetch = (async () => new Response(JSON.stringify([]), { status: 200 })) as typeof fetch;
|
|
96
|
+
try {
|
|
97
|
+
const out = await handler(
|
|
98
|
+
{
|
|
99
|
+
jobKey: "j4",
|
|
100
|
+
variables: { subjectKey: "owner/repo#41", task: { id: "issue-41" }, status: "completed" },
|
|
101
|
+
} as never,
|
|
102
|
+
fakeApp,
|
|
103
|
+
);
|
|
104
|
+
assertEquals(out.reconciled, false);
|
|
105
|
+
assertEquals(out.retry, true);
|
|
106
|
+
assertEquals(out.retried, true);
|
|
107
|
+
assertEquals(typeof out.retryNudge === "string" && out.retryNudge.length > 0, true);
|
|
80
108
|
} finally {
|
|
81
109
|
globalThis.fetch = prevFetch;
|
|
82
110
|
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
@@ -28,6 +28,8 @@ interface In extends Record<string, unknown> {
|
|
|
28
28
|
status?: unknown;
|
|
29
29
|
pr?: unknown;
|
|
30
30
|
baseBranch?: unknown;
|
|
31
|
+
question?: unknown;
|
|
32
|
+
implementRetried?: unknown;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** The cell's deterministic branch is `feat/<task.id>`; `task` is the implement-cell's slice object.
|
|
@@ -45,6 +47,8 @@ const handler: AppJobHandler<In, ReconcileImplementResult> = async (job, app) =>
|
|
|
45
47
|
taskId: taskId(job.variables.task),
|
|
46
48
|
pr: job.variables.pr,
|
|
47
49
|
baseBranch: job.variables.baseBranch,
|
|
50
|
+
question: job.variables.question,
|
|
51
|
+
retried: job.variables.implementRetried,
|
|
48
52
|
},
|
|
49
53
|
listPrsForHead,
|
|
50
54
|
process.env.GITHUB_TOKEN ?? "",
|
|
@@ -52,6 +56,7 @@ const handler: AppJobHandler<In, ReconcileImplementResult> = async (job, app) =>
|
|
|
52
56
|
app.log.info("reconcile-implement", {
|
|
53
57
|
subjectKey: job.variables.subjectKey ?? null,
|
|
54
58
|
reconciled: res.reconciled,
|
|
59
|
+
retry: res.retry,
|
|
55
60
|
pr: res.pr,
|
|
56
61
|
});
|
|
57
62
|
return res;
|
|
@@ -97,14 +97,43 @@ test("no-result: a missing status/question synthesises the #360 answerable quest
|
|
|
97
97
|
assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
test("blank-question escalated:
|
|
100
|
+
test("blank-question escalated: synthesises an ACCURATE reported-status reason, not the false no-result text (#865 review)", async () => {
|
|
101
|
+
// A reported `escalated` with a blank question reaches the builder. It must name the reported status
|
|
102
|
+
// accurately — never the false "no status was reported" (no-result) diagnosis — and never dead-end.
|
|
101
103
|
const app = fakeApp([]);
|
|
102
104
|
const out = await handler(
|
|
103
105
|
{ jobKey: "job-b", variables: { subjectKey: "owner/repo#2", status: "escalated", question: " " } } as never,
|
|
104
106
|
app,
|
|
105
107
|
);
|
|
106
|
-
assertEquals(out
|
|
107
|
-
assertEquals(
|
|
108
|
+
assertEquals(out.question.includes('reported status "escalated"'), true);
|
|
109
|
+
assertEquals(out.question.includes("gave no answerable question"), true);
|
|
110
|
+
assertEquals(out.question.includes("without a machine-readable result"), false);
|
|
111
|
+
assertEquals(app.stores.feature_escalations[0].question, out.question);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("escalated with a question + summary + transcript: preserves ALL context, not just the question (#865 review)", async () => {
|
|
115
|
+
// The #865-review defect: an `escalated` result carrying a question, summary, and transcript persisted
|
|
116
|
+
// ONLY the question, leaving pollUserTasks without the supporting decision context.
|
|
117
|
+
const rows = [{ feature_key: "owner/repo#9", status: "running" }];
|
|
118
|
+
const app = fakeApp(rows);
|
|
119
|
+
const out = await handler(
|
|
120
|
+
{
|
|
121
|
+
jobKey: "job-9",
|
|
122
|
+
variables: {
|
|
123
|
+
subjectKey: "owner/repo#9",
|
|
124
|
+
status: "escalated",
|
|
125
|
+
question: "Should I target the epic base or main?",
|
|
126
|
+
summary: "drafted the migration but the base is ambiguous",
|
|
127
|
+
transcriptUrl: "http://merlin.local:3000/t?stream=job%3A777",
|
|
128
|
+
},
|
|
129
|
+
} as never,
|
|
130
|
+
app,
|
|
131
|
+
);
|
|
132
|
+
assertEquals(out.question.startsWith("Should I target the epic base or main?"), true);
|
|
133
|
+
assertEquals(out.question.includes('The agent\'s own summary: "drafted the migration but the base is ambiguous".'), true);
|
|
134
|
+
assertEquals(out.question.includes("Transcript: http://merlin.local:3000/t?stream=job%3A777"), true);
|
|
135
|
+
assertEquals(rows[0].status, "escalated");
|
|
136
|
+
assertEquals(app.stores.feature_escalations[0].question, out.question);
|
|
108
137
|
});
|
|
109
138
|
|
|
110
139
|
test("fails fast (incident) when subjectKey is absent — never appends a corrupt undefined-keyed audit row", async () => {
|
|
@@ -132,3 +161,29 @@ test("a retried job (same jobKey) reuses its audit row, never duplicating", asyn
|
|
|
132
161
|
assertEquals(app.stores.feature_escalations.length, 1, "the retry reuses the row, no duplicate append");
|
|
133
162
|
assertEquals(app.stores.feature_escalations[0].job_key, "job-retry");
|
|
134
163
|
});
|
|
164
|
+
|
|
165
|
+
test("claimed-completion (off-vocabulary status) with no question: accurate reason, not the false no-result text (issue #865)", async () => {
|
|
166
|
+
// The #865 defect reproduction: the agent reported `status: "completed"` with a summary but no PR.
|
|
167
|
+
// The synthesised reason must name the reported status + fold in the summary and transcript link —
|
|
168
|
+
// never the false "no status was reported" diagnosis.
|
|
169
|
+
const rows = [{ feature_key: "owner/repo#41", status: "running" }];
|
|
170
|
+
const app = fakeApp(rows);
|
|
171
|
+
const out = await handler(
|
|
172
|
+
{
|
|
173
|
+
jobKey: "job-41",
|
|
174
|
+
variables: {
|
|
175
|
+
subjectKey: "owner/repo#41",
|
|
176
|
+
status: "completed",
|
|
177
|
+
summary: "implemented src/pin.rs and supporting changes",
|
|
178
|
+
transcriptUrl: "http://merlin.local:3000/t?stream=job%3A215572",
|
|
179
|
+
},
|
|
180
|
+
} as never,
|
|
181
|
+
app,
|
|
182
|
+
);
|
|
183
|
+
assertEquals(out.question.includes('reported status "completed"'), true);
|
|
184
|
+
assertEquals(out.question.includes("without a machine-readable result"), false);
|
|
185
|
+
assertEquals(out.question.includes('The agent\'s own summary: "implemented src/pin.rs and supporting changes".'), true);
|
|
186
|
+
assertEquals(out.question.includes("Transcript: http://merlin.local:3000/t?stream=job%3A215572"), true);
|
|
187
|
+
assertEquals(rows[0].status, "escalated");
|
|
188
|
+
assertEquals(app.stores.feature_escalations[0].question, out.question);
|
|
189
|
+
});
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
26
26
|
import { classifyEscalation } from "../../app/escalationTaxonomy.ts";
|
|
27
27
|
import { featureRuns, recordFeatureEscalation } from "../../app/feature.ts";
|
|
28
|
+
import { implementEscalationQuestion, NO_RESULT_QUESTION } from "../../app/implementEscalationReason.ts";
|
|
28
29
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
29
30
|
|
|
30
31
|
// Input typed off the model data envelope (`RecordEscalationIn` in implement-cell.bpmn) — ADR 0040.
|
|
@@ -36,12 +37,6 @@ interface Out extends Record<string, unknown> {
|
|
|
36
37
|
const str = (v: unknown): string | undefined =>
|
|
37
38
|
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
38
39
|
|
|
39
|
-
// The answerable prompt synthesised when the agent left no usable question — the implement-stage
|
|
40
|
-
// analogue of record-trial-merge's synthesised trial-merge question. It names the recoverable work (a
|
|
41
|
-
// PR may exist on the slice's branch) and the two answers the cell's `ic_gw_answer` gateway routes on.
|
|
42
|
-
const NO_RESULT_QUESTION =
|
|
43
|
-
'The implementation agent finished without a machine-readable result (no status was reported), so we cannot tell whether the slice succeeded. It may still have opened a PR (check for a branch targeting the epic base). Choose "Answer" and give guidance to re-run the slice — or choose "Abandon" to skip it and continue.';
|
|
44
|
-
|
|
45
40
|
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
46
41
|
const subjectKey = job.variables.subjectKey;
|
|
47
42
|
// Fail fast rather than keying the `feature_escalations` audit log (and the guarded `feature_runs`
|
|
@@ -61,11 +56,30 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
61
56
|
// so the parked task is never a dead end. Route through the single canonical taxonomy so this net can
|
|
62
57
|
// never drift from the tier logic every other raise site uses. A "task"-kind escalation is
|
|
63
58
|
// `decision-required` only when the agent left an answerable question, so this already covers the
|
|
64
|
-
// blank-question case
|
|
65
|
-
// through without an assertion.
|
|
59
|
+
// blank-question case.
|
|
66
60
|
const agentEscalated = classifyEscalation({ kind: "task", status: job.variables.status, question: rawQuestion }) ===
|
|
67
61
|
"decision-required";
|
|
68
|
-
|
|
62
|
+
// Build the escalation question through the single canonical builder (issue #865), ALWAYS — including
|
|
63
|
+
// when the agent raised its own answerable question. Passing the agent's `question` lets the builder
|
|
64
|
+
// lead with it AND fold in the agent's `summary` + transcript as supporting context (#865 review —
|
|
65
|
+
// otherwise an `escalated` result's summary/transcript were dropped and only the bare question
|
|
66
|
+
// survived). With no answerable question it synthesises an accurate reason — distinguishing a true
|
|
67
|
+
// no-result (blank status) from a claimed-completion-without-delivery (an affirmative-completion alias
|
|
68
|
+
// such as `completed`) and from a reported non-completion status — never the false "no status was
|
|
69
|
+
// reported" diagnosis for a reported `escalated`/`failed` (#865 review). The reason can never drift
|
|
70
|
+
// from the reconcile step because both read the same canonical builder.
|
|
71
|
+
const question = implementEscalationQuestion({
|
|
72
|
+
status: job.variables.status,
|
|
73
|
+
question: rawQuestion,
|
|
74
|
+
summary: job.variables.summary,
|
|
75
|
+
transcriptUrl: job.variables.transcriptUrl,
|
|
76
|
+
// Delivery EVIDENCE (#865 review): the preserved PR (an `escalated`/`failed` with a blank question
|
|
77
|
+
// retains it — never claim "opened no pull request") and whether the reconcile step's GitHub lookup
|
|
78
|
+
// actually CONFIRMED the delivery state (only then may the reason say "none was found"). Both are
|
|
79
|
+
// process variables the upstream `reconcile-implement` step emits into scope.
|
|
80
|
+
pr: job.variables.pr,
|
|
81
|
+
deliveryVerified: job.variables.deliveryVerified,
|
|
82
|
+
});
|
|
69
83
|
|
|
70
84
|
// Append to the canonical `feature_escalations` audit log (the surviving table `pollUserTasks` reads),
|
|
71
85
|
// keyed by `subjectKey` — the feature run's `feature_key`, or the epic's `plan_key` for a wave slice.
|
|
@@ -79,7 +93,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
79
93
|
await runs.update(subjectKey, { status: "escalated", updated_at: new Date().toISOString() });
|
|
80
94
|
}
|
|
81
95
|
|
|
82
|
-
app.log.info("record-escalation", { subjectKey, synthesised:
|
|
96
|
+
app.log.info("record-escalation", { subjectKey, synthesised: !(agentEscalated && rawQuestion) });
|
|
83
97
|
|
|
84
98
|
// Re-emit the resolved question so the `feature-escalation` form (and the answer loop) see it.
|
|
85
99
|
return { question };
|