@nanobpm/nano-workforce 0.30.0 → 0.32.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 CHANGED
@@ -1,3 +1,24 @@
1
+ # [0.32.0](https://github.com/nanobpm/nano-workforce/compare/v0.31.0...v0.32.0) (2026-08-09)
2
+
3
+
4
+ ### Features
5
+
6
+ * **poller:** surface technical incidents on the PR row ([#95](https://github.com/nanobpm/nano-workforce/issues/95)) ([596153c](https://github.com/nanobpm/nano-workforce/commit/596153c0f58a0d511f4a7bac66896dab98a6a5c8)), closes [#94](https://github.com/nanobpm/nano-workforce/issues/94)
7
+
8
+ # [0.31.0](https://github.com/nanobpm/nano-workforce/compare/v0.30.0...v0.31.0) (2026-08-09)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **retro:** record a blocked retro when the retro process fails to start ([#92](https://github.com/nanobpm/nano-workforce/issues/92)) ([22bf171](https://github.com/nanobpm/nano-workforce/commit/22bf17114160bcd269b26fe635afdcc44c249dbe)), closes [#84](https://github.com/nanobpm/nano-workforce/issues/84)
14
+ * surface no-op plan-fanout epics as incidents instead of green ([#86](https://github.com/nanobpm/nano-workforce/issues/86)) ([#88](https://github.com/nanobpm/nano-workforce/issues/88)) ([8178aad](https://github.com/nanobpm/nano-workforce/commit/8178aad61fbf409990d331a4b08c786782d44cd1))
15
+
16
+
17
+ ### Features
18
+
19
+ * **retro:** fold plan-review trace and task outcomes into the retro digest ([#91](https://github.com/nanobpm/nano-workforce/issues/91)) ([00d37e5](https://github.com/nanobpm/nano-workforce/commit/00d37e5a8c19238736d14b46401e7a3a41ee2083)), closes [#84](https://github.com/nanobpm/nano-workforce/issues/84) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#90](https://github.com/nanobpm/nano-workforce/issues/90)
20
+ * surface plan_reviews audit trace on epic and home pages ([#89](https://github.com/nanobpm/nano-workforce/issues/89)) ([2abaa25](https://github.com/nanobpm/nano-workforce/commit/2abaa253ac5bdd4d31a7bee1a0de412e5df56aae)), closes [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [#87](https://github.com/nanobpm/nano-workforce/issues/87)
21
+
1
22
  # [0.30.0](https://github.com/nanobpm/nano-workforce/compare/v0.29.0...v0.30.0) (2026-08-09)
2
23
 
3
24
 
package/SPEC.md CHANGED
@@ -420,7 +420,10 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
420
420
  - **`record-results`** — app worker `pr.record-results`. Zips `results` back onto
421
421
  `plan_tasks` by index, and for each opened `pr` calls the same idempotent
422
422
  `submitPr` as §4 — **the handoff**: every fleet-produced PR enrols into the
423
- review-convergence loop. Sets `plans` status `done`.
423
+ review-convergence loop. On success it sets `plans` status `done`; if the epic
424
+ finalizes having opened **zero** PRs (empty plan, or every task blocked/skipped)
425
+ it raises a non-retryable `NO_WORK_DISPATCHED` incident instead of completing
426
+ green — a no-op run must not masquerade as success (issue #86).
424
427
 
425
428
  **Payloads are untyped** (no `nano:shapes`/`io.nanobpm.dataEnvelope`): the vocab is
426
429
  scalar-only and cannot express the `tasks`/`results` lists, so the workers self-type
package/app/plan.test.ts CHANGED
@@ -53,8 +53,8 @@ Deno.test("valid positive integer → honoured", () => {
53
53
  //
54
54
  // `plan_reviews` is append-only and the review round is derived from `count(plan_reviews)`.
55
55
  // When `startPlan` re-plans a previously finished issue it must clear the prior review rows,
56
- // otherwise the stale count inflates the next round index and can trip `reviewExhausted` early,
57
- // bypassing the adversarial gate. This drives `startPlan` against an in-memory data layer and
56
+ // otherwise the stale count inflates the next round index and can reach the review-round cap
57
+ // early, bypassing the adversarial gate. This drives `startPlan` against an in-memory data layer and
58
58
  // asserts the `plan_reviews` rows for the plan key are gone after a re-plan.
59
59
  import { startPlan } from "./plan.ts";
60
60
 
package/app/plan.ts CHANGED
@@ -150,8 +150,9 @@ export function positiveIntEnv(name: string, fallback: number): number {
150
150
  return Number.isInteger(n) && n > 0 ? n : fallback;
151
151
  }
152
152
 
153
- /** Max adversarial plan-review rounds before the fan-out proceeds regardless (so a reviewer that
154
- * never approves can't dead-lock the plan). The last round's findings are still recorded. */
153
+ /** Max adversarial plan-review rounds. Reaching the cap WITHOUT approval is a hard failure: the
154
+ * fan-out raises a `PLAN_REJECTED` incident rather than dispatching an un-approved plan (issue
155
+ * #86). The last round's findings are still recorded. */
155
156
  export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
156
157
 
157
158
  /** A plan is "done" in exactly these states; everything else (planning, dispatched)
@@ -205,7 +206,7 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
205
206
  }
206
207
  // `plan_reviews` is append-only and the review round is derived from
207
208
  // `count(plan_reviews)`, so stale rows from the prior run would inflate the
208
- // next round index and trip `reviewExhausted` early (bypassing the gate).
209
+ // next round index and reach the review-round cap early (bypassing the gate).
209
210
  // Clear them here — the table is keyed on `plan_key`, so one delete drops the
210
211
  // whole set (mirrors how record-plan clears `plan_task_deps`).
211
212
  await planReviews(data).delete(parsed.planKey);
package/app/retro.test.ts CHANGED
@@ -97,6 +97,10 @@ function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>)
97
97
  function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
98
98
  (stores["pull_requests"] ??= []).push({ pr_key, status });
99
99
  }
100
+ // deno-lint-ignore no-explicit-any
101
+ function seedReview(stores: Record<string, any[]>, round: number, approved: number, findings: string | null) {
102
+ (stores["plan_reviews"] ??= []).push({ plan_key: PLAN, round, approved, findings, created_at: `t${round}`, job_key: null });
103
+ }
100
104
 
101
105
  Deno.test("autoRetroEnabled: on by default; disabled by 0/false/off/no", () => {
102
106
  const prev = process.env.NANO_AUTO_RETRO;
@@ -177,10 +181,42 @@ Deno.test("gatherRetro: separates learnings from notes and folds in deltas", asy
177
181
  assertEquals(d.repo, "acme/widgets");
178
182
  });
179
183
 
184
+ Deno.test("gatherRetro: folds in the plan-review trace and task-outcome shape", async () => {
185
+ const { data, stores } = memData();
186
+ seedPlan(stores);
187
+ seedTask(stores, { id: "t1", task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
188
+ seedTask(stores, { id: "t2", task_id: "t2", status: "skipped", pr_key: null });
189
+ seedTask(stores, { id: "t3", task_id: "t3", status: "opened", pr_key: "acme/widgets#11" });
190
+ // Two rejected rounds then an approval — out of order to prove the sort.
191
+ seedReview(stores, 1, 0, "still missing the migration ordering");
192
+ seedReview(stores, 0, 0, "task t2 depends on t3 but is sequenced first");
193
+ seedReview(stores, 2, 1, "");
194
+
195
+ const d = await gatherRetro(data, PLAN);
196
+ assertEquals(d.reviewRounds, 3);
197
+ assertEquals(d.planApproved, true);
198
+ assertEquals(d.reviewRejections.map((r) => r.round), [0, 1]);
199
+ assertEquals(d.reviewRejections[0].findings, "task t2 depends on t3 but is sequenced first");
200
+ assertEquals(d.taskOutcomes.total, 3);
201
+ assertEquals(d.taskOutcomes.byStatus, { opened: 2, skipped: 1 });
202
+ });
203
+
204
+ Deno.test("gatherRetro: no reviews → zero rounds, not approved, empty rejections", async () => {
205
+ const { data, stores } = memData();
206
+ seedPlan(stores);
207
+ const d = await gatherRetro(data, PLAN);
208
+ assertEquals(d.reviewRounds, 0);
209
+ assertEquals(d.planApproved, false);
210
+ assertEquals(d.reviewRejections, []);
211
+ assertEquals(d.taskOutcomes, { total: 0, byStatus: {} });
212
+ });
213
+
180
214
  Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with no learnings", () => {
181
215
  const empty = renderRetroBrief({
182
216
  planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
183
217
  learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [],
218
+ reviewRounds: 0, reviewRejections: [], planApproved: false,
219
+ taskOutcomes: { total: 0, byStatus: {} },
184
220
  counts: { learnings: 0, deltas: 0, notes: 0 },
185
221
  });
186
222
  assertStringIncludes(empty, "none");
@@ -192,20 +228,31 @@ Deno.test("renderRetroBrief: renders learnings + constraints; states 'none' with
192
228
  contractChanges: [{ taskId: "t1", change: "shape" }],
193
229
  constraints: [{ taskId: "t1", constraint: "must X" }],
194
230
  notes: [{ author_task: "t2", kind: "note", body: "watch the release lane" }],
231
+ reviewRounds: 2,
232
+ reviewRejections: [{ round: 0, findings: "wrong dependency order" }],
233
+ planApproved: true,
234
+ taskOutcomes: { total: 2, byStatus: { opened: 1, skipped: 1 } },
195
235
  counts: { learnings: 1, deltas: 1, notes: 1 },
196
236
  });
197
237
  assertStringIncludes(brief, "regen first");
198
238
  assertStringIncludes(brief, "must X");
199
239
  assertStringIncludes(brief, "watch the release lane");
200
240
  assertStringIncludes(brief, "acme/widgets");
241
+ assertStringIncludes(brief, "Plan review");
242
+ assertStringIncludes(brief, "wrong dependency order");
243
+ assertStringIncludes(brief, "Task outcomes");
201
244
  });
202
245
 
203
- Deno.test("isDigestEmpty: true only when there are no learnings, deltas, or notes", () => {
204
- const base = { planKey: PLAN, repo: "", issueUrl: "", title: null, learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [] };
246
+ Deno.test("isDigestEmpty: empty only with no learnings/deltas/notes AND no review rejections", () => {
247
+ const base = { planKey: PLAN, repo: "", issueUrl: "", title: null, learnings: [], touchedFiles: [], contractChanges: [], constraints: [], notes: [], reviewRounds: 0, reviewRejections: [], planApproved: false, taskOutcomes: { total: 0, byStatus: {} } };
205
248
  assert(isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 0 } }));
206
249
  assert(!isDigestEmpty({ ...base, counts: { learnings: 1, deltas: 0, notes: 0 } }));
207
250
  assert(!isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 2, notes: 0 } }));
208
251
  assert(!isDigestEmpty({ ...base, counts: { learnings: 0, deltas: 0, notes: 1 } }));
252
+ // A rejected review round is reflection material even with no learnings/deltas/notes.
253
+ assert(!isDigestEmpty({ ...base, reviewRounds: 2, reviewRejections: [{ round: 0, findings: "bad seq" }], counts: { learnings: 0, deltas: 0, notes: 0 } }));
254
+ // ...but task-outcome shape alone (a cleanly-approved plan) is not.
255
+ assert(isDigestEmpty({ ...base, reviewRounds: 1, planApproved: true, taskOutcomes: { total: 3, byStatus: { opened: 3 } }, counts: { learnings: 0, deltas: 0, notes: 0 } }));
209
256
  });
210
257
 
211
258
  Deno.test("recordRetro: inserts then updates the same plan_key row in place", async () => {
@@ -278,6 +325,55 @@ Deno.test("maybeStartRetro: starts the retro exactly once when the last PR lands
278
325
  assertEquals(started.length, 1, "fire-once guard");
279
326
  });
280
327
 
328
+ Deno.test("maybeStartRetro: a createInstance failure records a blocked retro (fire-once guard already consumed)", async () => {
329
+ const { data, stores } = memData();
330
+ seedPlan(stores);
331
+ seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
332
+ seedPr(stores, "acme/widgets#10", "merged");
333
+ await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
334
+ // deno-lint-ignore no-explicit-any require-await
335
+ const engine = { async createInstance() { throw new Error("gateway down"); } } as any as EngineClient;
336
+
337
+ const r = await maybeStartRetro(data, engine, "acme/widgets#10");
338
+ assertEquals(r, { started: false, planKey: PLAN, reason: "start-failed" });
339
+ // The guard is consumed (stamp + claim), so it will never retry...
340
+ assert(stores["plans"][0].retro_started_at, "retro_started_at is stamped");
341
+ assertEquals(stores["plan_retro_starts"].length, 1);
342
+ // ...but the failure is now visible as a blocked retro rather than a silent gap.
343
+ assertEquals(stores["plan_retros"].length, 1);
344
+ assertEquals(stores["plan_retros"][0].status, "blocked");
345
+ assertEquals(stores["plan_retros"][0].pr_key, null);
346
+ assertStringIncludes(String(stores["plan_retros"][0].summary), "gateway down");
347
+ });
348
+
349
+ Deno.test("maybeStartRetro: a secondary blocked-retro persistence failure still returns start-failed (not error)", async () => {
350
+ const { data, stores } = memData();
351
+ seedPlan(stores);
352
+ seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
353
+ seedPr(stores, "acme/widgets#10", "merged");
354
+ await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "regen first" });
355
+ // deno-lint-ignore no-explicit-any require-await
356
+ const engine = { async createInstance() { throw new Error("gateway down"); } } as any as EngineClient;
357
+ // recordRetro rethrows non-unique DB errors; simulate the blocked-retro insert hitting a
358
+ // FOREIGN KEY failure so the persistence in the createInstance-failure handler throws.
359
+ // deno-lint-ignore no-explicit-any
360
+ const failingData = {
361
+ // deno-lint-ignore no-explicit-any
362
+ table: (name: string, pk?: string) => {
363
+ const t = (data as any).table(name, pk);
364
+ if (name !== "plan_retros") return t;
365
+ return { ...t, insert: () => Promise.reject(new Error("FOREIGN KEY constraint failed")) };
366
+ },
367
+ } as any as DataLayer;
368
+
369
+ const r = await maybeStartRetro(failingData, engine, "acme/widgets#10");
370
+ // The secondary persistence failure must NOT be masked as `error` — the guard is still consumed,
371
+ // so the caller must see `start-failed`, and no phantom retro row is written.
372
+ assertEquals(r, { started: false, planKey: PLAN, reason: "start-failed" });
373
+ assert(stores["plans"][0].retro_started_at, "retro_started_at is stamped");
374
+ assertEquals(stores["plan_retros"].length, 0);
375
+ });
376
+
281
377
  Deno.test("maybeStartRetro: a pre-claimed retro start does not start a duplicate process", async () => {
282
378
  const { data, stores } = memData();
283
379
  seedPlan(stores);
@@ -323,6 +419,23 @@ Deno.test("maybeStartRetro: complete but empty → records a skipped retro, does
323
419
  assertEquals(stores["plan_retros"][0].status, "skipped");
324
420
  });
325
421
 
422
+ Deno.test("maybeStartRetro: a rejected review round alone is enough to fire the retro", async () => {
423
+ const { data, stores } = memData();
424
+ seedPlan(stores);
425
+ seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
426
+ seedPr(stores, "acme/widgets#10", "merged");
427
+ // No learnings/deltas/notes — only the plan-review trace carries a rejection.
428
+ seedReview(stores, 0, 0, "task ordering is wrong");
429
+ seedReview(stores, 1, 1, "");
430
+ const { engine, started } = fakeEngine();
431
+
432
+ const r = await maybeStartRetro(data, engine, "acme/widgets#10");
433
+ assertEquals(r.started, true);
434
+ assertEquals(r.planKey, PLAN);
435
+ assertEquals(started.length, 1);
436
+ assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
437
+ });
438
+
326
439
  Deno.test("maybeStartRetro: a PR not part of any plan is a no-op", async () => {
327
440
  const { data } = memData();
328
441
  const { engine, started } = fakeEngine();
package/app/retro.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
15
15
  // app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
16
16
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
17
- import { planTasks } from "./plan.ts";
17
+ import { planReviews, planTasks } from "./plan.ts";
18
18
  import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
19
19
  import { aggregateEpicDeltas } from "./taskDelta.ts";
20
20
  import { TERMINAL_STATUSES } from "./service.ts";
@@ -92,12 +92,24 @@ export interface RetroDigest {
92
92
  contractChanges: { taskId: string; change: string }[];
93
93
  constraints: { taskId: string; constraint: string }[];
94
94
  notes: { author_task: string; kind: string; body: string }[];
95
+ // Plan-review trace (006_plan_review.sql): how many adversarial review rounds the plan needed
96
+ // before fan-out, and the critique from every rejected round — the "what was wrong with the
97
+ // first cut of the decomposition" signal, which is prime retro material even when implementers
98
+ // posted no learnings of their own. `planApproved` reflects the final round's verdict.
99
+ reviewRounds: number;
100
+ reviewRejections: { round: number; findings: string }[];
101
+ planApproved: boolean;
102
+ // Execution shape: how the plan's tasks actually resolved (opened a PR, were skipped/blocked,
103
+ // etc.). Lets the retro reason over decomposition accuracy — e.g. a high skipped/blocked ratio
104
+ // hints the plan over-decomposed.
105
+ taskOutcomes: { total: number; byStatus: Record<string, number> };
95
106
  counts: { learnings: number; deltas: number; notes: number };
96
107
  }
97
108
 
98
109
  /** Gather a plan's reflection material: the `learning` blackboard entries (the headline), plus the
99
- * task-delta rollup (contract changes, discovered constraints, cross-slice file touches) and any
100
- * other non-learning blackboard notes for colour. Reads only no writes. */
110
+ * task-delta rollup (contract changes, discovered constraints, cross-slice file touches), the
111
+ * plan-review trace (rounds + rejection findings), the task-outcome shape, and any other
112
+ * non-learning blackboard notes for colour. Reads only — no writes. */
101
113
  export async function gatherRetro(data: DataLayer, planKey: string): Promise<RetroDigest> {
102
114
  const plan = await plansTbl(data).get(planKey);
103
115
  const entries = await readBlackboard(data, planKey);
@@ -108,6 +120,22 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
108
120
  .filter((e) => e.kind !== "learning")
109
121
  .map((e) => ({ author_task: e.author_task, kind: e.kind, body: e.body }));
110
122
  const deltas = await aggregateEpicDeltas(data, planKey);
123
+
124
+ // Plan-review trace, ordered by round. A rejected round is `approved === 0`; only rounds that
125
+ // carry findings are worth quoting (an empty rejection has nothing to teach).
126
+ const reviews = (await planReviews(data).find({ plan_key: planKey }))
127
+ .slice()
128
+ .sort((a, b) => a.round - b.round);
129
+ const reviewRejections = reviews
130
+ .filter((r) => r.approved === 0 && (r.findings ?? "").trim() !== "")
131
+ .map((r) => ({ round: r.round, findings: r.findings as string }));
132
+ const planApproved = reviews.length > 0 && reviews[reviews.length - 1].approved === 1;
133
+
134
+ // Task-outcome shape (counts by final status).
135
+ const tasks = await planTasks(data).find({ plan_key: planKey });
136
+ const byStatus: Record<string, number> = {};
137
+ for (const t of tasks) byStatus[t.status] = (byStatus[t.status] ?? 0) + 1;
138
+
111
139
  return {
112
140
  planKey,
113
141
  repo: plan?.repo ?? planKey.split("#")[0] ?? "",
@@ -118,6 +146,10 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
118
146
  contractChanges: deltas.contractChanges,
119
147
  constraints: deltas.constraints,
120
148
  notes,
149
+ reviewRounds: reviews.length,
150
+ reviewRejections,
151
+ planApproved,
152
+ taskOutcomes: { total: tasks.length, byStatus },
121
153
  counts: {
122
154
  learnings: learnings.length,
123
155
  deltas: deltas.deltas.length,
@@ -139,8 +171,29 @@ export function renderRetroBrief(d: RetroDigest): string {
139
171
  `Target repo: **${d.repo}**${d.issueUrl ? ` · issue: ${d.issueUrl}` : ""}`,
140
172
  d.title ? `Epic: ${d.title}` : "",
141
173
  "",
142
- `### Learnings agents posted while implementing (${d.learnings.length})`,
174
+ `### Plan review ${d.reviewRounds} round(s), ${d.planApproved ? "approved" : "not approved"}`,
143
175
  ];
176
+ if (d.reviewRejections.length === 0) {
177
+ lines.push(
178
+ d.reviewRounds === 0
179
+ ? "_(no review rounds recorded)_"
180
+ : "_(approved with no recorded rejections)_",
181
+ );
182
+ } else {
183
+ lines.push(`The plan was revised after ${d.reviewRejections.length} rejected round(s):`);
184
+ for (const r of d.reviewRejections) lines.push(`- **round ${r.round}**: ${r.findings}`);
185
+ }
186
+ if (d.taskOutcomes.total > 0) {
187
+ const shape = Object.entries(d.taskOutcomes.byStatus)
188
+ .sort((a, b) => a[0].localeCompare(b[0]))
189
+ .map(([s, n]) => `${s}: ${n}`)
190
+ .join(", ");
191
+ lines.push("", `### Task outcomes (${d.taskOutcomes.total} task(s))`, shape);
192
+ }
193
+ lines.push(
194
+ "",
195
+ `### Learnings agents posted while implementing (${d.learnings.length})`,
196
+ );
144
197
  if (d.learnings.length === 0) {
145
198
  lines.push("_(none — agents posted no `learning` entries for this epic)_");
146
199
  } else {
@@ -164,9 +217,14 @@ export function renderRetroBrief(d: RetroDigest): string {
164
217
  return lines.join("\n");
165
218
  }
166
219
 
167
- /** True when a digest carries nothing worth an agent run no learnings, no deltas, no notes. */
220
+ /** True when a digest carries nothing worth an agent run. A plan is worth retrospecting when
221
+ * implementers shared material (learnings/deltas/notes) OR the plan itself needed revision — a
222
+ * rejected review round's findings are reflection material in their own right, even when no
223
+ * learning was posted. (Task-outcome shape alone is NOT: a cleanly-approved plan whose tasks all
224
+ * ran has nothing to teach.) */
168
225
  export function isDigestEmpty(d: RetroDigest): boolean {
169
- return d.counts.learnings === 0 && d.counts.deltas === 0 && d.counts.notes === 0;
226
+ return d.counts.learnings === 0 && d.counts.deltas === 0 && d.counts.notes === 0 &&
227
+ d.reviewRejections.length === 0;
170
228
  }
171
229
 
172
230
  /** The persisted retro shape (written by pr.retro-record). */
@@ -264,14 +322,40 @@ export async function maybeStartRetro(
264
322
  // Stamp before starting so restarts/retries can take the cheap already-started path.
265
323
  await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
266
324
 
267
- const { processInstanceKey } = await engine.createInstance({
268
- processDefinitionId: RETRO_PROCESS_ID,
269
- variables: {
270
- planKey,
271
- repo: digest.repo,
272
- issueUrl: digest.issueUrl,
273
- },
274
- });
325
+ let processInstanceKey: string | number | undefined;
326
+ try {
327
+ ({ processInstanceKey } = await engine.createInstance({
328
+ processDefinitionId: RETRO_PROCESS_ID,
329
+ variables: {
330
+ planKey,
331
+ repo: digest.repo,
332
+ issueUrl: digest.issueUrl,
333
+ },
334
+ }));
335
+ } catch (err) {
336
+ // The fire-once guard is already consumed (retro_started_at stamped, plan_retro_starts
337
+ // claimed), so this plan will never re-enter the start path. If we returned here with no
338
+ // record, the epic surface would show a plan that "started a retro" with nothing to show and
339
+ // no way to retry. Persist a `blocked` retro instead so the failure stays visible and the
340
+ // system state is consistent. Recording must not mask the original error in the log.
341
+ log?.("error", `retro: could not start process for epic ${planKey}`, { err: String(err) });
342
+ // Persisting the blocked record is best-effort: recordRetro rethrows non-unique DB errors, and
343
+ // if that escaped here it would fall through to the outer catch and return `error` instead of
344
+ // `start-failed` — reintroducing the very silent-gap failure this path guards against (guard
345
+ // consumed, no durable record). Swallow a secondary persistence failure and log it separately
346
+ // so the createInstance failure path always reports `start-failed`.
347
+ try {
348
+ await recordRetro(data, planKey, {
349
+ status: "blocked",
350
+ summary: `Retro process could not be started: ${String(err)}`,
351
+ });
352
+ } catch (persistErr) {
353
+ log?.("error", `retro: could not persist blocked retro for epic ${planKey}`, {
354
+ err: String(persistErr),
355
+ });
356
+ }
357
+ return { started: false, planKey, reason: "start-failed" };
358
+ }
275
359
  log?.("info", `retro: started for epic ${planKey}`, { processInstanceKey, learnings: digest.counts.learnings });
276
360
  return { started: true, planKey };
277
361
  } catch (err) {
@@ -6,13 +6,14 @@
6
6
  // loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { assertEquals } from "jsr:@std/assert@1";
9
- import { submitPr } from "./service.ts";
9
+ import { pollIncidentsImpl, submitPr } from "./service.ts";
10
10
 
11
11
  // deno-lint-ignore no-explicit-any
12
12
  function memTable(rows: any[], key: string) {
13
13
  return {
14
14
  // deno-lint-ignore no-explicit-any
15
15
  get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
16
+ all: () => Promise.resolve([...rows]),
16
17
  // deno-lint-ignore no-explicit-any
17
18
  find: (q: any) =>
18
19
  Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
@@ -99,3 +100,154 @@ Deno.test("re-submit of a cancelled PR clears stale open escalations + the denor
99
100
  assertEquals(pr.process_key, "PI-9");
100
101
  });
101
102
  });
103
+
104
+ // Red/green regression for technical-incident surfacing (issue #94). A convergence/merge instance
105
+ // can hit an engine incident that parks the token; until `pollIncidents` nothing on the PR row
106
+ // reflected it, so the grid kept showing "converging" while the run was dead in the water. This
107
+ // drives the pass's reconciliation core against a stubbed `/v2/incidents/search`:
108
+ // 1. an ACTIVE incident is mirrored onto `incident_key` + `incident_message` (status untouched),
109
+ // 2. once the engine reports no active incident, the columns are cleared idempotently,
110
+ // 3. a PR with no live instance (no process_key / terminal status) is never queried and any
111
+ // stale incident on it is cleared.
112
+ function incidentFetch(byInstance: Record<string, unknown[]>) {
113
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
114
+ const u = typeof url === "string" ? url : url.toString();
115
+ if (!u.endsWith("/incidents/search")) {
116
+ throw new Error(`unexpected fetch: ${u}`);
117
+ }
118
+ const body = JSON.parse(String(init?.body ?? "{}")) as {
119
+ filter?: { processInstanceKey?: string };
120
+ };
121
+ const items = byInstance[body.filter?.processInstanceKey ?? ""] ?? [];
122
+ return Promise.resolve(
123
+ new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
124
+ );
125
+ };
126
+ }
127
+
128
+ Deno.test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it, leaving status untouched", async () => {
129
+ const row = {
130
+ pr_key: "owner/repo#7",
131
+ repo: "owner/repo",
132
+ number: 7,
133
+ status: "converging",
134
+ process_key: "PI-7",
135
+ incident_key: null as string | null,
136
+ incident_message: null as string | null,
137
+ updated_at: "t0",
138
+ };
139
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
140
+ pull_requests: { rows: [row], key: "pr_key" },
141
+ };
142
+ const data = {
143
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
144
+ // deno-lint-ignore no-explicit-any
145
+ } as any;
146
+ const headers = { "content-type": "application/json" };
147
+
148
+ const prevFetch = globalThis.fetch;
149
+
150
+ // Red-ish: with an ACTIVE incident on the instance, the pass must surface it (before this
151
+ // feature the columns stayed null and the incident was invisible).
152
+ globalThis.fetch = incidentFetch({
153
+ "PI-7": [{ incidentKey: "INC-1", errorMessage: "boom: unhandled error", state: "ACTIVE", creationTime: "2024-01-01T00:00:00Z" }],
154
+ }) as typeof fetch;
155
+ try {
156
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
157
+ } finally {
158
+ globalThis.fetch = prevFetch;
159
+ }
160
+ assertEquals(row.incident_key, "INC-1");
161
+ assertEquals(row.incident_message, "boom: unhandled error");
162
+ assertEquals(row.status, "converging"); // orthogonal: status is never touched
163
+
164
+ // Green: once the engine reports no active incident, the columns clear idempotently.
165
+ globalThis.fetch = incidentFetch({ "PI-7": [] }) as typeof fetch;
166
+ try {
167
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
168
+ } finally {
169
+ globalThis.fetch = prevFetch;
170
+ }
171
+ assertEquals(row.incident_key, null);
172
+ assertEquals(row.incident_message, null);
173
+ assertEquals(row.status, "converging");
174
+ });
175
+
176
+ Deno.test("pollIncidents never queries a PR with no live instance and clears any stale incident", async () => {
177
+ const noKey = {
178
+ pr_key: "owner/repo#8",
179
+ status: "converging",
180
+ process_key: null as string | null,
181
+ incident_key: "STALE-A",
182
+ incident_message: "left over",
183
+ updated_at: "t0",
184
+ };
185
+ const terminal = {
186
+ pr_key: "owner/repo#9",
187
+ status: "merged",
188
+ process_key: "PI-9",
189
+ incident_key: "STALE-B",
190
+ incident_message: "left over",
191
+ updated_at: "t0",
192
+ };
193
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
194
+ pull_requests: { rows: [noKey, terminal], key: "pr_key" },
195
+ };
196
+ const data = {
197
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
198
+ // deno-lint-ignore no-explicit-any
199
+ } as any;
200
+ const headers = { "content-type": "application/json" };
201
+
202
+ const prevFetch = globalThis.fetch;
203
+ // Any fetch here is a bug — neither PR has a live instance to inspect.
204
+ globalThis.fetch = (() => {
205
+ throw new Error("pollIncidents must not query a PR with no live instance");
206
+ }) as typeof fetch;
207
+ try {
208
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
209
+ } finally {
210
+ globalThis.fetch = prevFetch;
211
+ }
212
+ assertEquals(noKey.incident_key, null);
213
+ assertEquals(noKey.incident_message, null);
214
+ assertEquals(terminal.incident_key, null);
215
+ assertEquals(terminal.incident_message, null);
216
+ });
217
+
218
+ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a missing timestamp last", async () => {
219
+ const row = {
220
+ pr_key: "owner/repo#11",
221
+ status: "converging",
222
+ process_key: "PI-11",
223
+ incident_key: null as string | null,
224
+ incident_message: null as string | null,
225
+ updated_at: "t0",
226
+ };
227
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
228
+ pull_requests: { rows: [row], key: "pr_key" },
229
+ };
230
+ const data = {
231
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
232
+ // deno-lint-ignore no-explicit-any
233
+ } as any;
234
+ const headers = { "content-type": "application/json" };
235
+
236
+ const prevFetch = globalThis.fetch;
237
+ // A no-`creationTime` incident must not masquerade as the oldest (empty-string sort bug): the
238
+ // real earliest ISO timestamp wins even when a timestamp-less incident is returned first.
239
+ globalThis.fetch = incidentFetch({
240
+ "PI-11": [
241
+ { incidentKey: "INC-NOTS", errorMessage: "no timestamp", state: "ACTIVE" },
242
+ { incidentKey: "INC-OLD", errorMessage: "the first fault", state: "ACTIVE", creationTime: "2024-01-01T00:00:00Z" },
243
+ { incidentKey: "INC-NEW", errorMessage: "a later fault", state: "ACTIVE", creationTime: "2024-06-01T00:00:00Z" },
244
+ ],
245
+ }) as typeof fetch;
246
+ try {
247
+ await pollIncidentsImpl(data, "http://engine/v2", headers);
248
+ } finally {
249
+ globalThis.fetch = prevFetch;
250
+ }
251
+ assertEquals(row.incident_key, "INC-OLD");
252
+ assertEquals(row.incident_message, "the first fault");
253
+ });