@nanobpm/nano-workforce 0.162.1 → 0.163.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.
@@ -0,0 +1,61 @@
1
+ // A test-only DataLayer over a real in-memory `node:sqlite` db with the WHOLE migration set applied,
2
+ // for exercising the engine-reset reconciliation surface (`app/reconcile.ts`, issue #622) and its
3
+ // operator-command delegate (`operations/reconcileEngineState.ts`) against the REAL shipping schema —
4
+ // so the tables/columns/indexes reconcile reads and writes are exactly what deploys, not a fake.
5
+ //
6
+ // Canonical harness shared by `app/reconcile.test.ts` and `operations/reconcileEngineState.test.ts`
7
+ // (derivation over duplication: one in-memory DataLayer builder, not two divergent copies).
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+ import { DatabaseSync } from "node:sqlite";
10
+ import { afterEach } from "node:test";
11
+ import { fileURLToPath } from "node:url";
12
+ import { type DataLayer, makeGateway, type SqliteDb } from "@nanobpm/urban";
13
+ import { applyMigrationSet } from "#test-migrations";
14
+
15
+ const MIGRATIONS_DIR = fileURLToPath(new URL("../db/migrations", import.meta.url));
16
+
17
+ // Every raw handle `freshData()` opens is tracked here and released after each test, so call sites
18
+ // (all of them) don't leak native SQLite handles across the run. Mirrors `test/worldDb.ts` and
19
+ // `test/blackboardDb.ts` (derivation over duplication: the same auto-close idiom, not a new one).
20
+ const openDbs = new Set<DatabaseSync>();
21
+ afterEach(() => {
22
+ for (const raw of openDbs) {
23
+ if (openDbs.delete(raw)) raw.close();
24
+ }
25
+ });
26
+
27
+ /** Adapt a raw `node:sqlite` handle to urban's tiny `SqliteDb` seam so `makeGateway` yields the real
28
+ * record-oriented `DataSource` reconcile binds to (no fakes — the shipping gateway). */
29
+ export function sqliteDb(raw: DatabaseSync): SqliteDb {
30
+ return {
31
+ exec: (sql) => raw.exec(sql),
32
+ run: (sql, params = []) => {
33
+ const r = raw.prepare(sql).run(...(params as never[]));
34
+ return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
35
+ },
36
+ all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
37
+ raw.prepare(sql).all(...(params as never[])) as T[],
38
+ close: () => raw.close(),
39
+ };
40
+ }
41
+
42
+ export function readMigrationFiles(): { name: string; sql: string }[] {
43
+ return readdirSync(MIGRATIONS_DIR)
44
+ .filter((n) => n.endsWith(".sql"))
45
+ .map((name) => ({ name, sql: readFileSync(`${MIGRATIONS_DIR}/${name}`, "utf8") }));
46
+ }
47
+
48
+ /** A DataLayer over a fresh in-memory DB with the whole migration set applied. The raw handle is
49
+ * tracked and auto-closed after each test (see `openDbs`), and FK enforcement is enabled so the
50
+ * migrations are exercised under the real constraints they ship with. */
51
+ export function freshData(): { data: DataLayer; raw: DatabaseSync } {
52
+ const raw = new DatabaseSync(":memory:");
53
+ openDbs.add(raw);
54
+ // SQLite disables FK enforcement by default; enable it so migrations with foreign keys are
55
+ // exercised (and any FK violations surface) exactly as they would on the shipping schema.
56
+ raw.exec("PRAGMA foreign_keys = ON;");
57
+ applyMigrationSet(raw, readMigrationFiles());
58
+ const gw = makeGateway(sqliteDb(raw));
59
+ const data = { open: () => gw } as unknown as DataLayer;
60
+ return { data, raw };
61
+ }
@@ -61,7 +61,7 @@ function fakeApp() {
61
61
 
62
62
  test("record-plan dispatches a taskful plan and levelizes its tasks (wave progress is now VIEW-derived)", async () => {
63
63
  const { app, plans } = fakeApp();
64
- await handler(
64
+ const out = await handler(
65
65
  {
66
66
  variables: {
67
67
  planKey: "owner/repo#137",
@@ -74,6 +74,8 @@ test("record-plan dispatches a taskful plan and levelizes its tasks (wave progre
74
74
  app,
75
75
  );
76
76
  assertEquals(plans[0].status, "dispatched");
77
+ // taskCount drives the plan-fanout gateway (`gw-plan-empty`): non-zero ⇒ proceed to review (#623).
78
+ assertEquals((out as any).taskCount, 2);
77
79
  // Wave progress (wave_count/current_wave/wave_label) was retired as a stored projection (epic
78
80
  // #412) — it is derived from `plan_tasks` by the plan_wave_label/plan_read_model VIEWs — so
79
81
  // record-plan no longer writes it onto the plans row.
@@ -84,11 +86,15 @@ test("record-plan dispatches a taskful plan and levelizes its tasks (wave progre
84
86
 
85
87
  test("record-plan marks a taskless plan done (no wave-progress columns written)", async () => {
86
88
  const { app, plans } = fakeApp();
87
- await handler(
89
+ const out = await handler(
88
90
  { variables: { planKey: "owner/repo#137", tasks: [], note: "planner emitted no tasks" } } as any,
89
91
  app,
90
92
  );
91
93
  assertEquals(plans[0].status, "done");
94
+ // taskCount 0 routes the plan-fanout gateway (`gw-plan-empty`) to the terminal taskless-done arm,
95
+ // short-circuiting the adversarial plan-review loop that would otherwise livelock (issue #623).
96
+ assertEquals((out as any).taskCount, 0);
97
+ assertEquals(plans[0].outcome, "planner emitted no tasks");
92
98
  assertEquals(plans[0].wave_count, undefined);
93
99
  assertEquals(plans[0].current_wave, undefined);
94
100
  assertEquals(plans[0].wave_label, undefined);
@@ -38,6 +38,11 @@ interface NormalTask {
38
38
  interface Out extends Record<string, unknown> {
39
39
  currentWave: number;
40
40
  waveCount: number;
41
+ // Task count of the recorded plan. The plan-fanout gateway (`gw-plan-empty`) reads this to
42
+ // SHORT-CIRCUIT an intentionally-empty plan (`{tasks:[]}`) to a terminal taskless-done arm
43
+ // BEFORE the adversarial plan-review gate (issue #623). Feeding an empty plan into review
44
+ // caused a plan↔plan-review livelock — it can neither be approved nor produce findings.
45
+ taskCount: number;
41
46
  }
42
47
 
43
48
  const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
@@ -149,8 +154,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
149
154
  if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
150
155
  await plans(app.data).update(planKey, patch);
151
156
 
152
- // Kick off the wave loop at wave 0.
153
- return { currentWave: 0, waveCount };
157
+ // Kick off the wave loop at wave 0. `taskCount` lets the BPMN gateway terminate an empty plan
158
+ // before the review loop (issue #623).
159
+ return { currentWave: 0, waveCount, taskCount: tasks.length };
154
160
  };
155
161
 
156
162
  export default handler;
@@ -65,6 +65,24 @@ test("unapproved, non-final round revises (planApproved=false, no escalation)",
65
65
  assertEquals((out as any).planFindings, "fix X");
66
66
  });
67
67
 
68
+ test("unapproved with EMPTY findings escalates immediately — contentless disapproval is malformed (issue #623)", async () => {
69
+ // First round of a 3-round cap: NOT the final round, so the old behaviour would revise and loop.
70
+ // A disapproval with no findings is malformed (findings are required per plan-review.md) and
71
+ // gives the planner nothing to act on — escalate to a human instead of spinning on re-plan.
72
+ const app = fakeApp(priorRounds("o/r#2b", 0));
73
+ const out = await call(app, { planKey: "o/r#2b", approved: false, findings: "" });
74
+ assertEquals((out as any).planApproved, false);
75
+ assertEquals((out as any).planEscalated, true);
76
+ assertEquals((out as any).planReviewRound, 0);
77
+ });
78
+
79
+ test("unapproved with MISSING findings escalates immediately (issue #623)", async () => {
80
+ const app = fakeApp(priorRounds("o/r#2c", 0));
81
+ const out = await call(app, { planKey: "o/r#2c", approved: false });
82
+ assertEquals((out as any).planApproved, false);
83
+ assertEquals((out as any).planEscalated, true);
84
+ });
85
+
68
86
  test("unapproved FINAL round escalates instead of throwing or proceeding", async () => {
69
87
  // Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒ human escalation.
70
88
  const app = fakeApp(priorRounds("o/r#3", MAX_PLAN_REVIEW_ROUNDS - 1));
@@ -17,6 +17,11 @@
17
17
  // unhandled incident. Proceeding used to dispatch an un-vetted plan and — when the plan was empty —
18
18
  // let the whole epic complete GREEN having done nothing. A missing/ambiguous `approved` is treated
19
19
  // as NOT approved (revise until the cap, then escalate).
20
+ //
21
+ // A disapproval with EMPTY findings is malformed (issue #623): plan-review.md REQUIRES findings when
22
+ // `approved` is false. Re-planning against a contentless disapproval gives the planner nothing to
23
+ // act on, so it re-emits the same plan and the loop spins (the plan↔plan-review livelock). This
24
+ // worker escalates such a verdict to a human immediately instead of burning re-plan rounds.
20
25
 
21
26
  import type { AppJobHandler } from "@nanobpm/urban";
22
27
  import {
@@ -109,9 +114,28 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
109
114
  };
110
115
  }
111
116
 
112
- // Not approved this round. Escalate once the per-epoch round cap is reached (issue #86):
113
- // previously the fan-out PROCEEDED regardless, dispatching an un-vetted plan. The round is
114
- // 0-based, so `round + 1 >= cap` is the last permitted round.
117
+ // Not approved this round. A disapproval with EMPTY findings is malformed (issue #623): per
118
+ // resources/prompts/plan-review.md, findings are REQUIRED when `approved` is false. Re-planning
119
+ // against a contentless disapproval gives the planner nothing to act on, so it re-emits the same
120
+ // plan and the loop spins (plan↔plan-review livelock, esp. for an empty plan). Escalate to a human
121
+ // immediately rather than burning re-plan rounds on a malformed verdict.
122
+ if (roundFindings === "") {
123
+ app.log.warn(`record-plan-review: ${planKey} not approved with EMPTY findings — malformed, escalating`, {
124
+ epoch: recordedEpoch,
125
+ round,
126
+ });
127
+ return {
128
+ planApproved: false,
129
+ planEscalated: true,
130
+ planFindings: roundFindings,
131
+ planReviewEpoch: recordedEpoch,
132
+ planReviewRound: round,
133
+ };
134
+ }
135
+
136
+ // Escalate once the per-epoch round cap is reached (issue #86): previously the fan-out PROCEEDED
137
+ // regardless, dispatching an un-vetted plan. The round is 0-based, so `round + 1 >= cap` is the
138
+ // last permitted round.
115
139
  if (round + 1 >= MAX_PLAN_REVIEW_ROUNDS) {
116
140
  app.log.warn(`record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
117
141
  epoch: recordedEpoch,