@nanobpm/nano-workforce 0.100.0 → 0.101.1
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/app/contracts.ts +18 -2
- package/app/github.test.ts +36 -1
- package/app/github.ts +35 -2
- package/app/migration049.test.ts +112 -0
- package/app/queuedVerdict.test.ts +1 -0
- package/app/service.test.ts +16 -0
- package/app/service.ts +65 -5
- package/app/world/checkpoint.test.ts +193 -0
- package/app/world/checkpoint.ts +142 -0
- package/app/world/effect-ledger.test.ts +86 -0
- package/app/world/effect-ledger.ts +103 -0
- package/app/world/git.ts +53 -0
- package/app/world/index.ts +26 -0
- package/app/world/store.test.ts +443 -0
- package/app/world/store.ts +320 -0
- package/app/world-marker.test.ts +79 -0
- package/db/migrations/049_world_checkpoint.sql +84 -0
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +174 -148
- package/test/worldDb.ts +103 -0
- package/workers/merge/worker.test.ts +45 -0
- package/workers/merge/worker.ts +32 -4
- package/workers/persist-round/worker.ts +68 -0
package/test/worldDb.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// A test-only DataLayer over a real in-memory `node:sqlite` db with the world-restore schema
|
|
2
|
+
// (`db/migrations/049_world_checkpoint.sql`) applied, for exercising `app/world/store.ts` and the
|
|
3
|
+
// `app/world/checkpoint.ts` orchestration against a REAL SQLite engine — so the durable fence (the
|
|
4
|
+
// `UNIQUE(pr_key, idempotency_key)` constraint) and the monotonic offset are proven, not mocked.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
|
7
|
+
import { afterEach } from "node:test";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
10
|
+
|
|
11
|
+
const openDbs = new Set<DatabaseSync>();
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const raw of openDbs) {
|
|
14
|
+
if (openDbs.delete(raw)) raw.close();
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function coerce(p: unknown): SQLInputValue {
|
|
19
|
+
if (p === null || p === undefined) return null;
|
|
20
|
+
if (typeof p === "boolean") return p ? 1 : 0;
|
|
21
|
+
return p as SQLInputValue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const quote = (id: string) => `"${id.replace(/"/g, '""')}"`;
|
|
25
|
+
|
|
26
|
+
/** A minimal async `Table<T>`-shaped gateway over one real SQLite table — the insert/find/findOne/
|
|
27
|
+
* update/get subset the world store uses. Mirrors the runtime `Table<T>` semantics: `insert` omits
|
|
28
|
+
* `undefined` keys (schema default governs), `update` skips `undefined` keys and clears on `null`. */
|
|
29
|
+
function gateway(db: DatabaseSync, name: string, pk: string) {
|
|
30
|
+
return {
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
32
|
+
async insert(row: any): Promise<number | bigint> {
|
|
33
|
+
const keys = Object.keys(row).filter((k) => row[k] !== undefined);
|
|
34
|
+
const cols = keys.map(quote).join(", ");
|
|
35
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
36
|
+
const r = db.prepare(`INSERT INTO ${quote(name)} (${cols}) VALUES (${placeholders})`).run(...keys.map((k) => coerce(row[k])));
|
|
37
|
+
return pk === "id" ? r.lastInsertRowid : (row[pk] as number);
|
|
38
|
+
},
|
|
39
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
40
|
+
async find(where: any = {}): Promise<any[]> {
|
|
41
|
+
const keys = Object.keys(where);
|
|
42
|
+
const clause = keys.length ? `WHERE ${keys.map((k) => `${quote(k)} = ?`).join(" AND ")}` : "";
|
|
43
|
+
return db.prepare(`SELECT * FROM ${quote(name)} ${clause}`).all(...keys.map((k) => coerce(where[k])));
|
|
44
|
+
},
|
|
45
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
46
|
+
async findOne(where: any = {}): Promise<any> {
|
|
47
|
+
return (await this.find(where))[0];
|
|
48
|
+
},
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
50
|
+
async get(id: any): Promise<any> {
|
|
51
|
+
return db.prepare(`SELECT * FROM ${quote(name)} WHERE ${quote(pk)} = ?`).get(coerce(id));
|
|
52
|
+
},
|
|
53
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
|
|
54
|
+
async update(id: any, patch: any): Promise<number> {
|
|
55
|
+
const keys = Object.keys(patch).filter((k) => patch[k] !== undefined);
|
|
56
|
+
if (keys.length === 0) return 0;
|
|
57
|
+
const set = keys.map((k) => `${quote(k)} = ?`).join(", ");
|
|
58
|
+
const r = db.prepare(`UPDATE ${quote(name)} SET ${set} WHERE ${quote(pk)} = ?`).run(...keys.map((k) => coerce(patch[k])), coerce(id));
|
|
59
|
+
return Number(r.changes);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A minimal self-referential `DataSource`-shaped gateway over the real db: the `table` surface plus
|
|
65
|
+
* a real `tx()` that BEGIN/COMMIT/ROLLBACKs on the underlying SQLite connection, so the world store's
|
|
66
|
+
* atomic `recordCheckpoint` (checkpoint + effects in one transaction) is exercised against genuine
|
|
67
|
+
* transaction semantics rather than mocked. `tx(fn)` passes the SAME source object to `fn`, so a test
|
|
68
|
+
* that decorates `table` on the returned source sees its decoration inside the transaction too. */
|
|
69
|
+
type MemDataSource = {
|
|
70
|
+
table: (name: string, pk?: string) => ReturnType<typeof gateway>;
|
|
71
|
+
tx<T>(fn: (t: MemDataSource) => Promise<T>): Promise<T>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function openDataSource(db: DatabaseSync): MemDataSource {
|
|
75
|
+
const ds: MemDataSource = {
|
|
76
|
+
table: (name, pk = "id") => gateway(db, name, pk),
|
|
77
|
+
async tx(fn) {
|
|
78
|
+
db.exec("BEGIN");
|
|
79
|
+
try {
|
|
80
|
+
const r = await fn(ds);
|
|
81
|
+
db.exec("COMMIT");
|
|
82
|
+
return r;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
db.exec("ROLLBACK");
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
return ds;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A `DataLayer` stub over a fresh in-memory db with the world schema applied. */
|
|
93
|
+
export function memWorldData(): { data: DataLayer; db: DatabaseSync } {
|
|
94
|
+
const db = new DatabaseSync(":memory:");
|
|
95
|
+
openDbs.add(db);
|
|
96
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/049_world_checkpoint.sql", import.meta.url)), "utf8");
|
|
97
|
+
db.exec(sql);
|
|
98
|
+
const data = {
|
|
99
|
+
table: (name: string, pk = "id") => gateway(db, name, pk),
|
|
100
|
+
open: () => openDataSource(db),
|
|
101
|
+
} as unknown as DataLayer;
|
|
102
|
+
return { data, db };
|
|
103
|
+
}
|
|
@@ -245,3 +245,48 @@ test("pr.merge routes a transient base-moved race through the retry branch (no e
|
|
|
245
245
|
},
|
|
246
246
|
);
|
|
247
247
|
});
|
|
248
|
+
|
|
249
|
+
test("pr.merge abandons a closed-not-merged PR without escalating (terminal abandon, #342)", async () => {
|
|
250
|
+
// #342/#350: a PR CLOSED on GitHub without merging (e.g. superseded by a newer PR) can never
|
|
251
|
+
// land. The worker must NOT fall through to the land protocol — that returns blocked/conflict and
|
|
252
|
+
// escalates a merge no human can complete, orphaning the process on a dead PR. Instead it records
|
|
253
|
+
// a terminal `abandoned` audit row and returns `mergeStatus:"abandoned"` (the model's
|
|
254
|
+
// terminate/abandon end event), opening NO escalation. Symmetric with the already-merged
|
|
255
|
+
// short-circuit; both are decided from the same single live-state read.
|
|
256
|
+
await withGithub(
|
|
257
|
+
(url) => {
|
|
258
|
+
// Single-PR GET → PR is closed (state="closed") and NOT merged.
|
|
259
|
+
if (/\/pulls\/\d+$/.test(url))
|
|
260
|
+
return new Response(JSON.stringify({ merged: false, state: "closed", mergeable_state: "dirty" }));
|
|
261
|
+
return null; // no AGENTS.md / merge-protocol.json → DEFAULT gh-merge protocol
|
|
262
|
+
},
|
|
263
|
+
async (calls) => {
|
|
264
|
+
const { app, stores } = fakeApp();
|
|
265
|
+
const out = (await handler(
|
|
266
|
+
{ variables: { prKey: "acme/widgets#13", repo: "acme/widgets", prNumber: 13 } } as any,
|
|
267
|
+
app,
|
|
268
|
+
)) as Record<string, unknown>;
|
|
269
|
+
|
|
270
|
+
// Abandon short-circuit: loop-terminal `mergeStatus` only, NO escalation payload.
|
|
271
|
+
assertEquals(out, { mergeStatus: "abandoned" });
|
|
272
|
+
assertEquals(out.status, undefined);
|
|
273
|
+
assertEquals(out.question, undefined);
|
|
274
|
+
|
|
275
|
+
// Records exactly one terminal audit row tagged as the closed-PR abandon path.
|
|
276
|
+
assertEquals(stores.merges.length, 1);
|
|
277
|
+
assertEquals(stores.merges[0].outcome, "abandoned");
|
|
278
|
+
assertEquals(stores.merges[0].method, "pr-closed");
|
|
279
|
+
|
|
280
|
+
// Flips the PR row to the terminal `abandoned` status. This path drives a terminate end event
|
|
281
|
+
// that runs NO mark-merged/mark-abandoned worker, so the worker must set the terminal status
|
|
282
|
+
// itself — otherwise the row stays in a non-terminal in-flight status (e.g. `merging`) and is
|
|
283
|
+
// tracked/scanned forever even though the merge loop is done (#342 review).
|
|
284
|
+
const prRow = stores.pull_requests.find((r) => r.pr_key === "acme/widgets#13");
|
|
285
|
+
assertEquals(prRow?.status, "abandoned");
|
|
286
|
+
|
|
287
|
+
// Never attempts a merge PUT or posts an enqueue comment on the dead PR (only the read GET).
|
|
288
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
289
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
});
|
package/workers/merge/worker.ts
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
// shapes the escalation payload on a block.
|
|
13
13
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
14
14
|
import { matchTags, tag } from "@nanobpm/urban/effect";
|
|
15
|
-
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
15
|
+
import { ABANDONED_STATUS, abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
16
16
|
import { checkBaseTarget, classifyBaseGuard } from "../../app/baseGuard.ts";
|
|
17
|
-
import { enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
|
|
17
|
+
import { classifyPrLiveness, enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
|
|
18
18
|
import { classifyMergeLanding, DEFAULT_MERGE_PROTOCOL, loadMergeProtocol } from "../../app/mergeProtocol.ts";
|
|
19
19
|
import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
|
|
20
20
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
@@ -23,7 +23,7 @@ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
|
23
23
|
type In = WorkerInputs["pr.merge"];
|
|
24
24
|
|
|
25
25
|
interface Out extends Record<string, unknown> {
|
|
26
|
-
mergeStatus: "merged" | "queued" | "blocked" | "retry";
|
|
26
|
+
mergeStatus: "merged" | "queued" | "blocked" | "retry" | "abandoned";
|
|
27
27
|
status?: string;
|
|
28
28
|
question?: string;
|
|
29
29
|
}
|
|
@@ -54,7 +54,8 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
54
54
|
// FK parent, and BEFORE the base-guard/protocol logic. Best-effort: a transport hiccup falls through
|
|
55
55
|
// to the normal path rather than blocking a genuine merge.
|
|
56
56
|
const pre = await fetchPrState(repo, prNumber, token).catch(() => null);
|
|
57
|
-
|
|
57
|
+
const liveness = classifyPrLiveness(pre);
|
|
58
|
+
if (liveness === "merged") {
|
|
58
59
|
await app.data.table("merges", "id").insert({
|
|
59
60
|
pr_key: prKey,
|
|
60
61
|
outcome: "merged",
|
|
@@ -65,6 +66,33 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
65
66
|
return { mergeStatus: "merged" };
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// Closed-not-merged short-circuit (#342). A PR CLOSED on GitHub without merging (e.g. superseded
|
|
70
|
+
// by a newer PR) can never land: falling through to the land protocol would return
|
|
71
|
+
// blocked/conflict and escalate a merge no human can complete, orphaning the process on a dead
|
|
72
|
+
// PR (#350). Instead record a terminal `abandoned` audit row and drive the loop down its
|
|
73
|
+
// terminate/abandon end event — NOT `merge-esc-conflict`. This opens NO escalation and parks NO
|
|
74
|
+
// user task: a closed PR is terminal state, not a human decision. Symmetric with the merged
|
|
75
|
+
// short-circuit above and runs on the same live-state read, so one `fetchPrState` classifies both.
|
|
76
|
+
if (liveness === "closed") {
|
|
77
|
+
await app.data.table("merges", "id").insert({
|
|
78
|
+
pr_key: prKey,
|
|
79
|
+
outcome: "abandoned",
|
|
80
|
+
method: "pr-closed",
|
|
81
|
+
detail: "PR was closed on GitHub without merging (e.g. superseded) — abandoning the merge loop",
|
|
82
|
+
at: now,
|
|
83
|
+
});
|
|
84
|
+
// Terminal status write. This branch drives the model's terminate/abandon end event, which runs
|
|
85
|
+
// NO mark-merged worker (the merged path's `pr.mark-merged` is what sets `status:"merged"`). If
|
|
86
|
+
// we don't flip the row here it lingers on its in-flight status (e.g. the transient `merging`),
|
|
87
|
+
// so `activePrs`/delivery keep treating a dead PR as live. Symmetric with mark-merged's write;
|
|
88
|
+
// `ensurePr` above guarantees the row exists to update.
|
|
89
|
+
await app.data.table("pull_requests", "pr_key").update(prKey, {
|
|
90
|
+
status: ABANDONED_STATUS,
|
|
91
|
+
updated_at: now,
|
|
92
|
+
});
|
|
93
|
+
return { mergeStatus: "abandoned" };
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
// Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
|
|
69
97
|
// to the default branch — the merge would land into a dead branch and never reach `main`.
|
|
70
98
|
// GitHub only auto-retargets a PR when its base is *deleted* on merge; a merged-but-undeleted
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
8
8
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
9
9
|
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
10
|
+
import { type Effect, isCommitSha, isEffectKind, recordWorldCheckpoint, WorldStore } from "../../app/world/index.ts";
|
|
10
11
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
11
12
|
|
|
12
13
|
// Input is typed off the model data envelope (`PrPersistRoundIn` in convergence-loop.bpmn),
|
|
@@ -32,6 +33,54 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
|
|
|
32
33
|
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/** The world-restore marker (issue #324, ADR 0062 Slice 4/5, the WORLD half). When a round pushed
|
|
37
|
+
* changes, the c8ctl harness reports `{commitSha, effects?}` under this reserved key so the app
|
|
38
|
+
* records a durable push-checkpoint: the pushed SHA a replacement activation reconstructs the working
|
|
39
|
+
* tree to (inverting `git push` → `git fetch && git checkout <sha>`), plus the round's irreversible
|
|
40
|
+
* effect ledger (each fence-keyed) so a resume skips an already-applied effect. Absent (a `waiting`
|
|
41
|
+
* round, or a harness predating #324) → no checkpoint is recorded (nothing was pushed). */
|
|
42
|
+
const WORLD_MARKER_KEY = "worldMarker";
|
|
43
|
+
|
|
44
|
+
interface WorldMarker {
|
|
45
|
+
readonly commitSha: string;
|
|
46
|
+
readonly effects?: readonly Effect[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Normalize one externally-supplied effect from the harness `worldMarker`, or `null` when it is not
|
|
50
|
+
* a usable effect. This is a CONTRACT BOUNDARY: the marker arrives from out-of-process, so both the
|
|
51
|
+
* fence key AND the effect kind are untrusted. We (a) TRIM `idempotencyKey` so whitespace variants of
|
|
52
|
+
* one real effect collapse to a single fence key rather than manufacturing distinct ledger rows that
|
|
53
|
+
* defeat the fence, and (b) validate `kind` against the canonical {@link EFFECT_KINDS} so an unknown
|
|
54
|
+
* kind can never enter the durable ledger. `description` is trimmed to a non-empty note or dropped. */
|
|
55
|
+
function normalizeEffect(raw: unknown): Effect | null {
|
|
56
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
57
|
+
const e = raw as { kind?: unknown; idempotencyKey?: unknown; description?: unknown } | null | undefined;
|
|
58
|
+
if (!e || !isEffectKind(e.kind)) return null;
|
|
59
|
+
if (typeof e.idempotencyKey !== "string") return null;
|
|
60
|
+
const idempotencyKey = e.idempotencyKey.trim();
|
|
61
|
+
if (idempotencyKey === "") return null;
|
|
62
|
+
const description = typeof e.description === "string" ? e.description.trim() : "";
|
|
63
|
+
return { kind: e.kind, idempotencyKey, ...(description !== "" ? { description } : {}) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function worldMarkerOf(vars: Record<string, unknown>): WorldMarker | null {
|
|
67
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
68
|
+
const m = vars[WORLD_MARKER_KEY] as { commitSha?: unknown; effects?: unknown } | undefined;
|
|
69
|
+
if (!m || typeof m.commitSha !== "string") return null;
|
|
70
|
+
// `commitSha` is used as an EXACT checkout target on restore (`git fetch && git checkout <sha>`),
|
|
71
|
+
// so validate it is a well-formed 40-hex SHA — the SAME guard the emit boundary `repoEnvelopeVars`
|
|
72
|
+
// applies (via `isCommitSha`) so the two boundaries can't drift. An arbitrary ref (e.g. `main`) or
|
|
73
|
+
// an abbreviated SHA would reconstruct to a moved branch tip or fail restore, undermining the
|
|
74
|
+
// "reconstruct the exact tree at <sha>" contract. Trim first so a whitespace-tainted valid SHA
|
|
75
|
+
// still passes; a value that isn't a full object name degrades to no checkpoint (a `waiting` round).
|
|
76
|
+
const commitSha = m.commitSha.trim();
|
|
77
|
+
if (!isCommitSha(commitSha)) return null;
|
|
78
|
+
const effects = Array.isArray(m.effects)
|
|
79
|
+
? m.effects.map(normalizeEffect).filter((e): e is Effect => e !== null)
|
|
80
|
+
: undefined;
|
|
81
|
+
return { commitSha, ...(effects && effects.length > 0 ? { effects } : {}) };
|
|
82
|
+
}
|
|
83
|
+
|
|
35
84
|
const handler: AppJobHandler<In> = async (job, app) => {
|
|
36
85
|
// This worker is the "addressed"/"waiting" path, so `status` resolves to one of those
|
|
37
86
|
// domain values. `summary` is left undefined when absent: the write boundary omits it so the
|
|
@@ -75,6 +124,25 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
75
124
|
updated_at: now,
|
|
76
125
|
});
|
|
77
126
|
|
|
127
|
+
// World checkpoint (issue #324, ADR 0062 Slice 4/5): when this round pushed, record the durable
|
|
128
|
+
// push-checkpoint — the pushed SHA a replacement activation reconstructs the tree to, plus the
|
|
129
|
+
// round's fence-keyed effect ledger. The JOIN to the mind's `session.checkpoint(...)` (Slice 1)
|
|
130
|
+
// happens harness-side (out of process); here we persist the WORLD marker so restore can invert
|
|
131
|
+
// the push. Best-effort: a checkpoint-store failure must not fail an already-recorded round.
|
|
132
|
+
const marker = worldMarkerOf(job.variables);
|
|
133
|
+
if (marker) {
|
|
134
|
+
try {
|
|
135
|
+
await recordWorldCheckpoint(new WorldStore(app.data), {
|
|
136
|
+
prKey,
|
|
137
|
+
roundNo: round,
|
|
138
|
+
commitSha: marker.commitSha,
|
|
139
|
+
...(marker.effects ? { effects: marker.effects } : {}),
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
app.log.warn("world checkpoint record failed", { prKey, round, err: String(err) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
78
146
|
return {};
|
|
79
147
|
};
|
|
80
148
|
|