@nanobpm/nano-workforce 0.103.0 → 0.105.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 +14 -0
- package/README.md +1 -0
- package/app/abandon.ts +12 -3
- package/app/conformance.test.ts +220 -0
- package/app/conformance.ts +240 -0
- package/app/contracts.ts +8 -0
- package/app/dbFence.ts +18 -0
- package/app/durableResume.test.ts +89 -0
- package/app/durableResume.ts +141 -0
- package/app/migration052.test.ts +66 -0
- package/app/migration053.test.ts +84 -0
- package/app/plan.ts +6 -0
- package/app/retro.test.ts +32 -2
- package/app/retro.ts +26 -10
- package/app/service.test.ts +223 -3
- package/app/service.ts +146 -7
- package/app/waves.test.ts +12 -0
- package/app/world/store.ts +6 -6
- package/db/migrations/004_planning.sql +1 -1
- package/db/migrations/052_plan_conformance.sql +28 -0
- package/db/migrations/052_worker_durable_resume.sql +35 -0
- package/db/migrations/053_merges_abandon_dedupe.sql +29 -0
- package/nano.app.json +6 -1
- package/openapi.yaml +17 -0
- package/operations/enrolAgenticWorker.test.ts +64 -0
- package/operations/enrolAgenticWorker.ts +30 -1
- package/package.json +1 -1
- package/resources/processes/retro.bpmn +59 -8
- package/resources/prompts/conformance.md +105 -0
- package/resources/prompts/retro.md +5 -0
- package/test/worldDb.ts +16 -4
- package/workers/conformance-record/worker.test.ts +199 -0
- package/workers/conformance-record/worker.ts +95 -0
- package/workers/merge/worker.test.ts +4 -0
- package/workers/merge/worker.ts +13 -18
- package/workers/retro-gather/worker.test.ts +6 -0
- package/workers/retro-gather/worker.ts +17 -5
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// nano-workforce — the `durable-resume` ENROLMENT GATE (issue #325, ADR 0062 Slice 5/5).
|
|
2
|
+
//
|
|
3
|
+
// Durable agent-session resume splits into two halves already landed by the earlier slices: the MIND
|
|
4
|
+
// (the harness conversation — Slices 1–3, restored harness-side via `session/load` / native
|
|
5
|
+
// `--resume`) and the WORLD (the git working tree + irreversible effect ledger — Slice 4,
|
|
6
|
+
// `app/world`, restored by inverting the round's `git push` into `git fetch && git checkout <sha>`).
|
|
7
|
+
// This slice is the INTEGRATION that wires both halves into the running orchestration, behind an
|
|
8
|
+
// enrolment gate, so a re-leased `senior:pr-review` round (ADR 0002 lease-expiry redrive) RESUMES at
|
|
9
|
+
// the last push-checkpoint on a participating harness and gracefully DEGRADES — redriven from scratch,
|
|
10
|
+
// exactly as today — on a harness that does not advertise durable-resume.
|
|
11
|
+
//
|
|
12
|
+
// THE GATE (ADR 0056 §7). `durable-resume` is a WORKER ATTRIBUTE declared at ENROLMENT — never a
|
|
13
|
+
// routing token. The routing token `network.role#seat` is unchanged; there is no BPMN change and no
|
|
14
|
+
// job-type change. A worker's harness advertises durable-resume at enrol (the probe result from Slice
|
|
15
|
+
// 2/3); this registry records that per instance so the app can ask, before it emits the world-restore
|
|
16
|
+
// marker, "does the fleet serving this role include a participant?".
|
|
17
|
+
//
|
|
18
|
+
// WHY FLEET-LEVEL. At the moment the app emits the repo-provisioning envelope it does not yet know
|
|
19
|
+
// WHICH worker will lease the `senior:pr-review` job — any worker enrolled for that role may. So the
|
|
20
|
+
// gate is a fleet-level existence probe ({@link DurableResumeRegistry.anyParticipant}). This is
|
|
21
|
+
// well-defined for a MIXED fleet: emitting the world-restore `commitSha` when at least one participant
|
|
22
|
+
// is enrolled lets a participant RESUME, while a non-participant harness simply ignores the marker
|
|
23
|
+
// (the envelope validators are structurally forward-compatible) and clones the head branch tip —
|
|
24
|
+
// redriving from scratch. When NO participant is enrolled the marker is omitted entirely, so nothing
|
|
25
|
+
// regresses: resume is purely additive.
|
|
26
|
+
//
|
|
27
|
+
// Advisory, app-tier only (ADR 0056): this registry NEVER hard-locks or gates a BPMN sequence flow —
|
|
28
|
+
// it only decides whether an OPTIMISATION (world-restore) is offered inside an activation.
|
|
29
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The canonical name of the durable-resume enrolment attribute. A worker advertises it at enrol; the
|
|
33
|
+
* registry records it here. It is an ENROLMENT gate (ADR 0056 §7), never a routing token — do not put
|
|
34
|
+
* it in `network.role#seat`.
|
|
35
|
+
*/
|
|
36
|
+
export const DURABLE_RESUME_ATTR = "durable-resume";
|
|
37
|
+
|
|
38
|
+
/** A persisted enrolment row (`worker_durable_resume`): one worker instance's durable-resume flag. */
|
|
39
|
+
interface WorkerDurableResumeRow {
|
|
40
|
+
instance: string;
|
|
41
|
+
durable_resume: number;
|
|
42
|
+
updated_at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when `err` is the durable PRIMARY KEY fence firing — a SQLite `UNIQUE constraint failed`
|
|
46
|
+
* raised because a concurrent/duplicate enrol inserted the SAME instance BETWEEN our `findOne` and our
|
|
47
|
+
* `insert`. `recordEnrolment` is an upsert, so a collision means "the row now exists" — the same
|
|
48
|
+
* intended outcome as the update branch, not a surfaced error. Matched on the message substring the
|
|
49
|
+
* RAD `Table` surface propagates verbatim (mirrors `WorldStore`), because that surface hides the
|
|
50
|
+
* concrete driver error type. */
|
|
51
|
+
function isFenceCollision(err: unknown): boolean {
|
|
52
|
+
return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The durable registry of per-worker durable-resume participation, over the `worker_durable_resume`
|
|
57
|
+
* table (`db/migrations/052_worker_durable_resume.sql`). Backed by the app's SQLite DataLayer through
|
|
58
|
+
* the RAD `Table<T>` surface (`data.table(...)`) — NOT hand-written SQL — mirroring `WorldStore`.
|
|
59
|
+
*/
|
|
60
|
+
export class DurableResumeRegistry {
|
|
61
|
+
readonly #data: DataLayer;
|
|
62
|
+
|
|
63
|
+
constructor(data: DataLayer) {
|
|
64
|
+
this.#data = data;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#table() {
|
|
68
|
+
return this.#data.table<WorkerDurableResumeRow>("worker_durable_resume", "instance");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Canonicalise an instance key: trim surrounding whitespace and reject a blank one. The instance is
|
|
72
|
+
* the table PRIMARY KEY and drives the fleet-wide gate via {@link anyParticipant}, so a whitespace or
|
|
73
|
+
* differently-trimmed key would create an unreachable row — or, worse, open the gate on a blank key.
|
|
74
|
+
* Normalising here makes every registry entry point safe by default rather than relying on each call
|
|
75
|
+
* site to pre-trim. Returns the trimmed key, or `undefined` when it is empty/whitespace. */
|
|
76
|
+
static #normaliseInstance(instance: string): string | undefined {
|
|
77
|
+
const trimmed = instance.trim();
|
|
78
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Record a worker's durable-resume participation at enrolment (an idempotent UPSERT keyed by
|
|
83
|
+
* `instance`). A re-enrol overwrites the flag so a harness that gains — or loses — durable-resume
|
|
84
|
+
* support across a redeploy is reflected. The `findOne`-then-insert is racy under a concurrent
|
|
85
|
+
* duplicate enrol, so a PRIMARY KEY fence collision folds into the update path rather than surfacing
|
|
86
|
+
* as an error (the same end-state either way). A blank/whitespace `instance` is ignored (no-op) — it
|
|
87
|
+
* cannot key a reachable row and a blank key would let unrelated workers collide on one registry row.
|
|
88
|
+
*/
|
|
89
|
+
async recordEnrolment(instance: string, durableResume: boolean): Promise<void> {
|
|
90
|
+
const key = DurableResumeRegistry.#normaliseInstance(instance);
|
|
91
|
+
if (key === undefined) return;
|
|
92
|
+
const table = this.#table();
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
const flag = durableResume ? 1 : 0;
|
|
95
|
+
const existing = await table.findOne({ instance: key });
|
|
96
|
+
if (existing) {
|
|
97
|
+
await table.update(key, { durable_resume: flag, updated_at: now });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await table.insert({ instance: key, durable_resume: flag, updated_at: now });
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (!isFenceCollision(err)) throw err;
|
|
104
|
+
await table.update(key, { durable_resume: flag, updated_at: now });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether a specific worker instance is a durable-resume participant. `false` for an unknown
|
|
109
|
+
* instance (never enrolled) or a blank/whitespace key — the safe default (graceful degradation). */
|
|
110
|
+
async isParticipant(instance: string): Promise<boolean> {
|
|
111
|
+
const key = DurableResumeRegistry.#normaliseInstance(instance);
|
|
112
|
+
if (key === undefined) return false;
|
|
113
|
+
const row = await this.#table().findOne({ instance: key });
|
|
114
|
+
return row?.durable_resume === 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether the enrolled fleet includes AT LEAST ONE durable-resume participant — the fleet-level
|
|
118
|
+
* gate the world-restore emission consults. `false` when none is enrolled (nobody advertises
|
|
119
|
+
* durable-resume), so the resume marker is omitted and the round redrives from scratch. */
|
|
120
|
+
async anyParticipant(): Promise<boolean> {
|
|
121
|
+
const row = await this.#table().findOne({ durable_resume: 1 });
|
|
122
|
+
return row != null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether the fleet supports durable resume — the app-tier gate for emitting the world-restore
|
|
128
|
+
* `commitSha` (see `app/service.ts`). Best-effort: any read failure (a legacy DB predating migration
|
|
129
|
+
* 052, an in-flight desync) degrades to `false`, so the round redrives from scratch rather than
|
|
130
|
+
* blocking a submit/merge on the enrolment registry. When no data layer is mounted it is likewise
|
|
131
|
+
* `false` — resume is purely additive, so its absence is always the safe direction.
|
|
132
|
+
*/
|
|
133
|
+
export async function fleetSupportsDurableResume(data: DataLayer | undefined): Promise<boolean> {
|
|
134
|
+
if (!data) return false;
|
|
135
|
+
try {
|
|
136
|
+
return await new DurableResumeRegistry(data).anyParticipant();
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.warn(`[durable-resume] fleet participation read: ${err}`);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Regression guard for migration 052 (issue #325, ADR 0062 Slice 5/5): the durable `durable-resume`
|
|
2
|
+
// enrolment registry. The table is FK-free (enrolment is per-worker and connection-agnostic, with no
|
|
3
|
+
// `pull_requests`/`plans` parent), the `instance` PRIMARY KEY makes `recordEnrolment` an idempotent
|
|
4
|
+
// upsert, and `CHECK(durable_resume IN (0,1))` pins the gate's boolean domain.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { assertEquals, assertThrows } from "#test-assert";
|
|
10
|
+
|
|
11
|
+
function migratedDb(): DatabaseSync {
|
|
12
|
+
const db = new DatabaseSync(":memory:");
|
|
13
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
14
|
+
// Deliberately NO parent tables — the enrolment table must apply and accept rows on a bare db.
|
|
15
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/052_worker_durable_resume.sql", import.meta.url)), "utf8");
|
|
16
|
+
db.exec(sql);
|
|
17
|
+
return db;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const upsert = (db: DatabaseSync, instance: string, flag: number) =>
|
|
21
|
+
db
|
|
22
|
+
.prepare(
|
|
23
|
+
`INSERT INTO worker_durable_resume (instance, durable_resume, updated_at) VALUES (?, ?, 't')
|
|
24
|
+
ON CONFLICT(instance) DO UPDATE SET durable_resume = excluded.durable_resume`,
|
|
25
|
+
)
|
|
26
|
+
.run(instance, flag);
|
|
27
|
+
|
|
28
|
+
test("migration 052 applies cleanly with NO parent tables (FK-free) and records an enrolment", () => {
|
|
29
|
+
const db = migratedDb();
|
|
30
|
+
upsert(db, "w1", 1);
|
|
31
|
+
const row = db.prepare("SELECT instance, durable_resume FROM worker_durable_resume WHERE instance = ?").get("w1") as {
|
|
32
|
+
instance: string;
|
|
33
|
+
durable_resume: number;
|
|
34
|
+
};
|
|
35
|
+
assertEquals(row.instance, "w1");
|
|
36
|
+
assertEquals(row.durable_resume, 1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("instance PRIMARY KEY makes a re-enrol an upsert (one row per worker, latest flag wins)", () => {
|
|
40
|
+
const db = migratedDb();
|
|
41
|
+
upsert(db, "w1", 1);
|
|
42
|
+
upsert(db, "w1", 0);
|
|
43
|
+
const count = Number((db.prepare("SELECT COUNT(*) c FROM worker_durable_resume").get() as { c: number }).c);
|
|
44
|
+
assertEquals(count, 1, "no duplicate row for one instance");
|
|
45
|
+
const row = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = 'w1'").get() as { durable_resume: number };
|
|
46
|
+
assertEquals(row.durable_resume, 0, "the latest enrolment flag wins");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("durable_resume defaults to 0 (a non-participant) when unset", () => {
|
|
50
|
+
const db = migratedDb();
|
|
51
|
+
db.prepare("INSERT INTO worker_durable_resume (instance, updated_at) VALUES ('w1', 't')").run();
|
|
52
|
+
const row = db.prepare("SELECT durable_resume FROM worker_durable_resume WHERE instance = 'w1'").get() as { durable_resume: number };
|
|
53
|
+
assertEquals(row.durable_resume, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("CHECK(durable_resume IN (0,1)): the gate's boolean domain is pinned at the schema", () => {
|
|
57
|
+
const db = migratedDb();
|
|
58
|
+
assertThrows(
|
|
59
|
+
() => db.prepare("INSERT INTO worker_durable_resume (instance, durable_resume, updated_at) VALUES ('w1', 2, 't')").run(),
|
|
60
|
+
undefined,
|
|
61
|
+
"CHECK constraint failed",
|
|
62
|
+
);
|
|
63
|
+
upsert(db, "yes", 1);
|
|
64
|
+
upsert(db, "no", 0);
|
|
65
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM worker_durable_resume").get() as { c: number }).c), 2);
|
|
66
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Regression guard for migration 053 (#352, PR #354 review — suppressed advisory on app/service.ts:867):
|
|
2
|
+
// the DB-level fence that makes `abandonClosedPr`'s terminal audit write TRULY idempotent under a
|
|
3
|
+
// concurrent race, not merely best-effort. The partial `UNIQUE INDEX ux_merges_abandon_pr_closed ON
|
|
4
|
+
// merges(pr_key) WHERE outcome='abandoned' AND method='pr-closed'` IS the fence: the merge worker and
|
|
5
|
+
// the wave-gate self-heal path can both observe "no row" between the guard's `find` and its `insert`
|
|
6
|
+
// and both attempt the write, and this index is what turns the loser's insert into a catchable
|
|
7
|
+
// `UNIQUE constraint failed` instead of a duplicate audit row.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { DatabaseSync } from "node:sqlite";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
13
|
+
|
|
14
|
+
// The `merges` audit table as created by 004_merge.sql, minus the `pull_requests` FK parent (this
|
|
15
|
+
// test proves the index behaviour in isolation, exactly as migration049.test.ts hosts the world
|
|
16
|
+
// tables FK-free). Then apply 053 on top.
|
|
17
|
+
function migratedDb(): DatabaseSync {
|
|
18
|
+
const db = new DatabaseSync(":memory:");
|
|
19
|
+
db.exec(`CREATE TABLE merges (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
pr_key TEXT NOT NULL,
|
|
22
|
+
outcome TEXT NOT NULL,
|
|
23
|
+
method TEXT,
|
|
24
|
+
detail TEXT,
|
|
25
|
+
at TEXT NOT NULL
|
|
26
|
+
);`);
|
|
27
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
|
|
28
|
+
db.exec(sql);
|
|
29
|
+
return db;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const insertAbandon = (db: DatabaseSync, prKey: string) =>
|
|
33
|
+
db
|
|
34
|
+
.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES (?, 'abandoned', 'pr-closed', 'd', 't')")
|
|
35
|
+
.run(prKey);
|
|
36
|
+
|
|
37
|
+
test("migration 053 applies cleanly and enforces one abandoned/pr-closed row per pr_key", () => {
|
|
38
|
+
const db = migratedDb();
|
|
39
|
+
insertAbandon(db, "o/r#1");
|
|
40
|
+
// The race: a second observer inserting the SAME abandoned/pr-closed row now hits the fence.
|
|
41
|
+
assertThrows(() => insertAbandon(db, "o/r#1"), undefined, "UNIQUE constraint failed");
|
|
42
|
+
assertEquals(
|
|
43
|
+
Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE pr_key='o/r#1'").get() as { c: number }).c),
|
|
44
|
+
1,
|
|
45
|
+
"the loser's duplicate was rejected — one terminal audit row survives",
|
|
46
|
+
);
|
|
47
|
+
// A DIFFERENT PR's abandon is independent — the index is per pr_key, not global.
|
|
48
|
+
insertAbandon(db, "o/r#2");
|
|
49
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges").get() as { c: number }).c), 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("migration 053 only fences abandoned/pr-closed rows — merged/queued/blocked still repeat freely", () => {
|
|
53
|
+
const db = migratedDb();
|
|
54
|
+
const insertMerged = () =>
|
|
55
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','merged','squash','d','t')").run();
|
|
56
|
+
// A PR can carry several `merged` audit rows (retry / already-merged short-circuit) — the partial
|
|
57
|
+
// index must NOT constrain them (mergesPerDay dedupes with COUNT(DISTINCT pr_key)).
|
|
58
|
+
insertMerged();
|
|
59
|
+
insertMerged();
|
|
60
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE outcome='merged'").get() as { c: number }).c), 2);
|
|
61
|
+
// An abandoned row with a DIFFERENT method is also outside the partial predicate.
|
|
62
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
|
|
63
|
+
db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
|
|
64
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE method='other'").get() as { c: number }).c), 2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("migration 053 collapses pre-existing duplicate abandoned/pr-closed rows, keeping the earliest", () => {
|
|
68
|
+
// Simulate a database where the pre-fence race already wrote duplicates, then apply the migration.
|
|
69
|
+
const db = new DatabaseSync(":memory:");
|
|
70
|
+
db.exec(`CREATE TABLE merges (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, pr_key TEXT NOT NULL, outcome TEXT NOT NULL,
|
|
72
|
+
method TEXT, detail TEXT, at TEXT NOT NULL);`);
|
|
73
|
+
db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','first','t')").run();
|
|
74
|
+
db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','dup','t')").run();
|
|
75
|
+
db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#8','abandoned','pr-closed','solo','t')").run();
|
|
76
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
|
|
77
|
+
db.exec(sql); // dedupe + create index; must not throw despite the pre-existing duplicate
|
|
78
|
+
const rows = db.prepare("SELECT pr_key, detail FROM merges ORDER BY pr_key").all() as { pr_key: string; detail: string }[];
|
|
79
|
+
assertEquals(rows.length, 2, "the duplicate for o/r#9 was collapsed");
|
|
80
|
+
assert(
|
|
81
|
+
rows.some((r) => r.pr_key === "o/r#9" && r.detail === "first"),
|
|
82
|
+
"the EARLIEST (MIN(id)) row survived the collapse",
|
|
83
|
+
);
|
|
84
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -181,6 +181,12 @@ export const PLAN_TASK_STATUSES = [
|
|
|
181
181
|
"skipped",
|
|
182
182
|
"escalated",
|
|
183
183
|
"waiting-for-lane",
|
|
184
|
+
// Terminal: the task's PR was closed on GitHub without merging (abandoned / superseded /
|
|
185
|
+
// perpetually conflicting). Set by the canonical abandon writer (`abandonClosedPr`, app/service.ts)
|
|
186
|
+
// reached from BOTH the merge stage and the wave-merge gate. An `abandoned` task drops out of
|
|
187
|
+
// `waveMergeTargets` (so a dead member never wedges the wave barrier — #352) and stops
|
|
188
|
+
// `isPlanComplete`/the Epics table counting a phantom open task.
|
|
189
|
+
"abandoned",
|
|
184
190
|
] as const;
|
|
185
191
|
export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
|
|
186
192
|
|
package/app/retro.test.ts
CHANGED
|
@@ -166,6 +166,17 @@ test("gatherRetro: separates learnings from notes and folds in deltas", async ()
|
|
|
166
166
|
assertEquals(d.repo, "acme/widgets");
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
+
test("gatherRetro: uses pre-fetched blackboard entries instead of re-scanning", async () => {
|
|
170
|
+
const { data, stores } = memData();
|
|
171
|
+
seedPlan(stores);
|
|
172
|
+
// A learning lives in the store, but the caller passes an EMPTY pre-fetched snapshot — gatherRetro
|
|
173
|
+
// must honour what it was handed and not re-read the store.
|
|
174
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "should be ignored" });
|
|
175
|
+
const d = await gatherRetro(data, PLAN, []);
|
|
176
|
+
assertEquals(d.counts.learnings, 0);
|
|
177
|
+
assertEquals(d.notes.length, 0);
|
|
178
|
+
});
|
|
179
|
+
|
|
169
180
|
test("gatherRetro: folds in the plan-review trace and task-outcome shape", async () => {
|
|
170
181
|
const { data, stores } = memData();
|
|
171
182
|
seedPlan(stores);
|
|
@@ -382,11 +393,13 @@ test("maybeStartRetro: bails while the plan is incomplete", async () => {
|
|
|
382
393
|
assertEquals(stores["plans"][0].retro_started_at, null, "must not stamp an incomplete plan");
|
|
383
394
|
});
|
|
384
395
|
|
|
385
|
-
test("maybeStartRetro: complete but
|
|
396
|
+
test("maybeStartRetro: complete but nothing landed (PR abandoned) → records a skipped retro, does not start", async () => {
|
|
386
397
|
const { data, stores } = memData();
|
|
387
398
|
seedPlan(stores);
|
|
399
|
+
// The only task's PR was abandoned: the plan is complete (abandoned is terminal) but shipped no
|
|
400
|
+
// code, so there is neither reflection material nor an implementation to audit.
|
|
388
401
|
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
389
|
-
seedPr(stores, "acme/widgets#10", "
|
|
402
|
+
seedPr(stores, "acme/widgets#10", "abandoned");
|
|
390
403
|
const { engine, started } = fakeEngine();
|
|
391
404
|
|
|
392
405
|
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
@@ -397,6 +410,23 @@ test("maybeStartRetro: complete but empty → records a skipped retro, does not
|
|
|
397
410
|
assertEquals(stores["plan_retros"][0].status, "skipped");
|
|
398
411
|
});
|
|
399
412
|
|
|
413
|
+
test("maybeStartRetro: complete with landed code but no learnings → still starts (conformance has something to verify)", async () => {
|
|
414
|
+
const { data, stores } = memData();
|
|
415
|
+
seedPlan(stores);
|
|
416
|
+
// A merged PR but zero learnings/deltas/notes: the retro digest is empty, but there IS delivered
|
|
417
|
+
// implementation to audit for conformance — so the process must still start.
|
|
418
|
+
seedTask(stores, { id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
419
|
+
seedPr(stores, "acme/widgets#10", "merged");
|
|
420
|
+
const { engine, started } = fakeEngine();
|
|
421
|
+
|
|
422
|
+
const r = await maybeStartRetro(data, engine, "acme/widgets#10");
|
|
423
|
+
assertEquals(r.started, true);
|
|
424
|
+
assertEquals(r.planKey, PLAN);
|
|
425
|
+
assertEquals(started.length, 1);
|
|
426
|
+
assertEquals(started[0].processDefinitionId, "retro");
|
|
427
|
+
assert(stores["plans"][0].retro_started_at, "retro_started_at must be stamped");
|
|
428
|
+
});
|
|
429
|
+
|
|
400
430
|
test("maybeStartRetro: a rejected review round alone is enough to fire the retro", async () => {
|
|
401
431
|
const { data, stores } = memData();
|
|
402
432
|
seedPlan(stores);
|
package/app/retro.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
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, Logger } from "@nanobpm/urban";
|
|
17
|
-
import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
17
|
+
import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
18
|
+
import { hasDeliveredImplementationForPlan } from "./conformance.ts";
|
|
18
19
|
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
19
20
|
import { planReviews, planTasks } from "./plan.ts";
|
|
20
21
|
import { aggregateEpicDeltas } from "./taskDelta.ts";
|
|
@@ -110,14 +111,22 @@ export interface RetroDigest {
|
|
|
110
111
|
/** Gather a plan's reflection material: the `learning` blackboard entries (the headline), plus the
|
|
111
112
|
* task-delta rollup (contract changes, discovered constraints, cross-slice file touches), the
|
|
112
113
|
* plan-review trace (rounds + rejection findings), the task-outcome shape, and any other
|
|
113
|
-
* non-learning blackboard notes for colour. Reads only — no writes.
|
|
114
|
-
|
|
114
|
+
* non-learning blackboard notes for colour. Reads only — no writes.
|
|
115
|
+
*
|
|
116
|
+
* `entries` lets a caller that has already scanned the blackboard for this plan (e.g.
|
|
117
|
+
* `pr.retro-gather`, which also runs {@link gatherConformance}) pass those entries in so the plan is
|
|
118
|
+
* scanned once, not once per gatherer — see workers/retro-gather. Omitted, it reads them itself. */
|
|
119
|
+
export async function gatherRetro(
|
|
120
|
+
data: DataLayer,
|
|
121
|
+
planKey: string,
|
|
122
|
+
entries?: BlackboardEntry[],
|
|
123
|
+
): Promise<RetroDigest> {
|
|
115
124
|
const plan = await plansTbl(data).get(planKey);
|
|
116
|
-
const
|
|
117
|
-
const learnings =
|
|
125
|
+
const bbEntries = entries ?? (await readBlackboard(data, planKey));
|
|
126
|
+
const learnings = bbEntries
|
|
118
127
|
.filter((e) => e.kind === "learning")
|
|
119
128
|
.map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
|
|
120
|
-
const notes =
|
|
129
|
+
const notes = bbEntries
|
|
121
130
|
.filter((e) => e.kind !== "learning")
|
|
122
131
|
.map((e) => ({ author_task: e.author_task, kind: e.kind, body: e.body }));
|
|
123
132
|
const deltas = await aggregateEpicDeltas(data, planKey);
|
|
@@ -310,12 +319,19 @@ export async function maybeStartRetro(
|
|
|
310
319
|
if (!(await isPlanComplete(data, planKey))) return { started: false, planKey, reason: "incomplete" };
|
|
311
320
|
|
|
312
321
|
const digest = await gatherRetro(data, planKey);
|
|
313
|
-
|
|
322
|
+
// The retro digest can be empty (no learnings/deltas/notes, cleanly-approved plan) yet the epic
|
|
323
|
+
// still shipped real code — in which case conformance has something to verify even though the
|
|
324
|
+
// lessons agent has nothing to distil. So run whenever there is EITHER reflection material OR
|
|
325
|
+
// landed implementation to audit; only truly skip when there is neither. The landed-implementation
|
|
326
|
+
// probe is gathered lazily (only when the digest is empty) and via the lightweight
|
|
327
|
+
// hasDeliveredImplementationForPlan — which inspects only plan_tasks + PR status, with no
|
|
328
|
+
// blackboard scan — so we avoid discarded DB work on every terminal-PR event.
|
|
329
|
+
if (isDigestEmpty(digest) && !(await hasDeliveredImplementationForPlan(data, planKey))) {
|
|
314
330
|
if (!(await claimRetroStart(data, planKey))) return { started: false, planKey, reason: "already-started" };
|
|
315
|
-
// Nothing to reflect on — stamp anyway so we don't re-check on
|
|
316
|
-
// (now settled) plan, and record a skipped retro for visibility.
|
|
331
|
+
// Nothing to reflect on and nothing shipped to verify — stamp anyway so we don't re-check on
|
|
332
|
+
// every future terminal PR of a (now settled) plan, and record a skipped retro for visibility.
|
|
317
333
|
await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
|
|
318
|
-
await recordRetro(data, planKey, { status: "skipped", summary: "No learnings, deltas, or
|
|
334
|
+
await recordRetro(data, planKey, { status: "skipped", summary: "No learnings, deltas, notes, or landed implementation to retrospect." });
|
|
319
335
|
return { started: false, planKey, reason: "nothing-to-retro" };
|
|
320
336
|
}
|
|
321
337
|
|