@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 +21 -0
- package/SPEC.md +4 -1
- package/app/plan.test.ts +2 -2
- package/app/plan.ts +4 -3
- package/app/retro.test.ts +115 -2
- package/app/retro.ts +98 -14
- package/app/service.test.ts +153 -1
- package/app/service.ts +119 -4
- package/db/migrations/017_pr_incident.sql +14 -0
- package/package.json +1 -1
- package/pages/epic.page.json +22 -1
- package/pages/home.page.json +17 -0
- package/resources/processes/plan-fanout.bpmn +1 -1
- package/scripts/pages-contract.test.ts +228 -0
- package/workers/record-plan-review/worker.test.ts +82 -0
- package/workers/record-plan-review/worker.ts +31 -15
- package/workers/record-results/worker.test.ts +91 -0
- package/workers/record-results/worker.ts +37 -7
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.
|
|
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
|
|
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
|
|
154
|
-
*
|
|
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
|
|
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:
|
|
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)
|
|
100
|
-
*
|
|
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
|
-
`###
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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) {
|
package/app/service.test.ts
CHANGED
|
@@ -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
|
+
});
|