@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +35 -0
  3. package/app/deliveryGraph.test.ts +10 -0
  4. package/app/deliveryGraphCompiler.test.ts +354 -10
  5. package/app/deliveryGraphCompiler.ts +296 -40
  6. package/app/deliveryRunner.ts +14 -5
  7. package/app/implementEscalationReason.test.ts +216 -0
  8. package/app/implementEscalationReason.ts +213 -0
  9. package/app/implementReconcile.test.ts +168 -11
  10. package/app/implementReconcile.ts +112 -18
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/roundChurn.test.ts +388 -0
  13. package/app/roundChurn.ts +265 -0
  14. package/app/roundProgress.test.ts +224 -1
  15. package/app/service.test.ts +7 -0
  16. package/app/service.ts +12 -0
  17. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  18. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  19. package/docs/agent-guide.md +10 -7
  20. package/e2e/delivery-graph.e2e.ts +570 -1
  21. package/e2e/feature-run.e2e.ts +8 -1
  22. package/e2e/plan-fanout.e2e.ts +75 -2
  23. package/package.json +1 -1
  24. package/resources/processes/implement-cell.bpmn +42 -17
  25. package/resources/processes/plan-fanout.bpmn +236 -211
  26. package/resources/prompts/feature.md +16 -1
  27. package/workers/progress-check/worker.ts +98 -1
  28. package/workers/readiness-probe/worker.test.ts +72 -1
  29. package/workers/readiness-probe/worker.ts +39 -0
  30. package/workers/reconcile-implement/worker.test.ts +31 -3
  31. package/workers/reconcile-implement/worker.ts +5 -0
  32. package/workers/record-feature-escalation/worker.test.ts +58 -3
  33. 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: { head?: string | null; status?: "waiting_review" | "converging"; agentWatermark?: string | null },
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: still synthesises the #360 question (never a dead-end task)", async () => {
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, { question: NO_RESULT_QUESTION });
107
- assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
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; the extra `&& rawQuestion` is the type narrowing that lets us hand the string
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
- const question = agentEscalated && rawQuestion ? rawQuestion : NO_RESULT_QUESTION;
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: question === NO_RESULT_QUESTION });
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 };