@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.
- package/CHANGELOG.md +12 -0
- package/app/enginePreflight.ts +15 -1
- package/app/instanceTracking.ts +17 -0
- package/app/reconcile.test.ts +212 -0
- package/app/reconcile.ts +326 -0
- package/db/migrations/092_engine_reconcile.sql +53 -0
- package/e2e/plan-fanout.e2e.ts +37 -0
- package/main.ts +25 -0
- package/openapi.yaml +92 -0
- package/operations/reconcileEngineState.test.ts +81 -0
- package/operations/reconcileEngineState.ts +44 -0
- package/package.json +1 -1
- package/resources/processes/plan-fanout.bpmn +246 -207
- package/test/reconcileDb.ts +61 -0
- package/workers/record-plan/worker.test.ts +8 -2
- package/workers/record-plan/worker.ts +8 -2
- package/workers/record-plan-review/worker.test.ts +18 -0
- package/workers/record-plan-review/worker.ts +27 -3
|
@@ -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
|
-
|
|
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.
|
|
113
|
-
//
|
|
114
|
-
//
|
|
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,
|