@nanobpm/nano-workforce 0.100.0 → 0.101.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 +7 -0
- package/app/contracts.ts +18 -2
- package/app/migration049.test.ts +112 -0
- package/app/service.test.ts +16 -0
- package/app/service.ts +47 -4
- 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/test/worldDb.ts +103 -0
- package/workers/persist-round/worker.ts +68 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Tests for the effect ledger + fence (issue #324, ADR 0062 Slice 4/5, the WORLD half). The fence is
|
|
2
|
+
// the guarantee behind the acceptance criterion "no duplicate push/comment (fence holds)": on a
|
|
3
|
+
// resume the post-checkpoint effect tail is replayed, and an already-applied effect must be SKIPPED,
|
|
4
|
+
// never re-executed. These prove the pure fold in isolation (no DB, no git).
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assert, assertEquals } from "#test-assert";
|
|
7
|
+
import { type Effect, type Fence, fenceReplay } from "./effect-ledger.ts";
|
|
8
|
+
|
|
9
|
+
/** An in-memory fence over a `Set` of applied idempotency keys. */
|
|
10
|
+
function memFence(applied: string[] = []): Fence & { keys: Set<string> } {
|
|
11
|
+
const keys = new Set(applied);
|
|
12
|
+
return {
|
|
13
|
+
keys,
|
|
14
|
+
isApplied: (k: string) => keys.has(k),
|
|
15
|
+
markApplied: (e: Effect) => {
|
|
16
|
+
keys.add(e.idempotencyKey);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const push = (sha: string): Effect => ({ kind: "push", idempotencyKey: sha });
|
|
22
|
+
const comment = (id: string): Effect => ({ kind: "pr-comment", idempotencyKey: id });
|
|
23
|
+
|
|
24
|
+
test("fenceReplay skips an already-applied effect and never re-applies it (the fence holds)", async () => {
|
|
25
|
+
const fence = memFence(["sha-1", "comment-9"]);
|
|
26
|
+
const applied: string[] = [];
|
|
27
|
+
const outcome = await fenceReplay([push("sha-1"), comment("comment-9")], fence, (e) => {
|
|
28
|
+
applied.push(e.idempotencyKey);
|
|
29
|
+
});
|
|
30
|
+
assertEquals(applied, [], "no effect is re-applied — both were already in the fence");
|
|
31
|
+
assertEquals(
|
|
32
|
+
outcome.skipped.map((e) => e.idempotencyKey),
|
|
33
|
+
["sha-1", "comment-9"],
|
|
34
|
+
"both effects are reported skipped",
|
|
35
|
+
);
|
|
36
|
+
assertEquals(outcome.applied.length, 0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("fenceReplay applies only the genuinely-missing tail effect (crash after push, before comment)", async () => {
|
|
40
|
+
// The replacement activation crashed after the push landed but before its trailing comment: the
|
|
41
|
+
// push is fenced, the comment is not. Only the comment must replay.
|
|
42
|
+
const fence = memFence(["sha-1"]);
|
|
43
|
+
const applied: string[] = [];
|
|
44
|
+
const outcome = await fenceReplay([push("sha-1"), comment("comment-9")], fence, (e) => {
|
|
45
|
+
applied.push(e.idempotencyKey);
|
|
46
|
+
});
|
|
47
|
+
assertEquals(applied, ["comment-9"], "only the missing comment is applied");
|
|
48
|
+
assertEquals(outcome.applied.map((e) => e.idempotencyKey), ["comment-9"]);
|
|
49
|
+
assertEquals(outcome.skipped.map((e) => e.idempotencyKey), ["sha-1"]);
|
|
50
|
+
assert(fence.keys.has("comment-9"), "the applied effect is now recorded so a later resume skips it");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("fenceReplay applies effects strictly in order (a comment referencing a push never precedes it)", async () => {
|
|
54
|
+
const fence = memFence();
|
|
55
|
+
const order: string[] = [];
|
|
56
|
+
await fenceReplay([push("sha-1"), comment("c-1"), comment("c-2")], fence, (e) => {
|
|
57
|
+
order.push(e.idempotencyKey);
|
|
58
|
+
});
|
|
59
|
+
assertEquals(order, ["sha-1", "c-1", "c-2"], "the fold preserves recorded order");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("fenceReplay collapses a duplicate idempotency key within one tail to a single apply", async () => {
|
|
63
|
+
// Two ledger entries with one key denote ONE real effect; the second is always a skip even before
|
|
64
|
+
// the durable fence sees it (a malformed tail must not double-apply).
|
|
65
|
+
const fence = memFence();
|
|
66
|
+
const applied: string[] = [];
|
|
67
|
+
const outcome = await fenceReplay([comment("c-1"), comment("c-1")], fence, (e) => {
|
|
68
|
+
applied.push(e.idempotencyKey);
|
|
69
|
+
});
|
|
70
|
+
assertEquals(applied, ["c-1"], "the effect is applied exactly once");
|
|
71
|
+
assertEquals(outcome.skipped.length, 1, "the duplicate is skipped");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a second replay of the same tail is a total no-op — idempotent resume", async () => {
|
|
75
|
+
const fence = memFence();
|
|
76
|
+
const tail = [push("sha-1"), comment("c-1")];
|
|
77
|
+
const applyCount = { n: 0 };
|
|
78
|
+
await fenceReplay(tail, fence, () => {
|
|
79
|
+
applyCount.n++;
|
|
80
|
+
});
|
|
81
|
+
assertEquals(applyCount.n, 2, "first replay applies both");
|
|
82
|
+
await fenceReplay(tail, fence, () => {
|
|
83
|
+
applyCount.n++;
|
|
84
|
+
});
|
|
85
|
+
assertEquals(applyCount.n, 2, "second replay applies nothing — the fence holds across resumes");
|
|
86
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// nano-workforce — the effect ledger + fence for durable agent-session resume (issue #324, ADR 0062
|
|
2
|
+
// Slice 4/5, the WORLD half).
|
|
3
|
+
//
|
|
4
|
+
// Durable resume reconstructs the git working tree by INVERTING the forward op: the round's outbound
|
|
5
|
+
// `git push` becomes an inbound `git fetch && git checkout <sha>` (see ./checkpoint.ts). But a round
|
|
6
|
+
// also performs OTHER irreversible actions that are NOT captured by the pushed tree — a PR comment, a
|
|
7
|
+
// `gh merge`. Replaying blindly on a resume would DUPLICATE them (a second identical review comment,
|
|
8
|
+
// a re-attempted merge). The fence stops that: every irreversible action is recorded in an EFFECT
|
|
9
|
+
// LEDGER with an idempotency key (the effect's natural identity — a commit SHA, a comment id, a merge
|
|
10
|
+
// key). On restore we replay the post-checkpoint effect tail THROUGH the fence, so an already-applied
|
|
11
|
+
// effect is SKIPPED, not repeated.
|
|
12
|
+
//
|
|
13
|
+
// This module is PURE (no I/O): the durable ledger lives in ./store.ts, the git inversion in
|
|
14
|
+
// ./git.ts, and the orchestration in ./checkpoint.ts. Keeping the fence a pure fold makes the
|
|
15
|
+
// "no duplicate effect on replay" invariant unit-testable without a database or a real git tree.
|
|
16
|
+
|
|
17
|
+
/** The classes of irreversible action the world-restore fence guards. Each maps to a natural
|
|
18
|
+
* idempotency key: a `push` to its commit SHA, a `pr-comment` to the comment id, a `merge` to the
|
|
19
|
+
* merge key. Anything reversible (a scratch file write reconstructed by the checkout) is NOT an
|
|
20
|
+
* effect — only actions whose re-execution would be observable to the outside world belong here.
|
|
21
|
+
*
|
|
22
|
+
* The runtime tuple is the ONE canonical list (derivation over duplication): {@link EffectKind} is
|
|
23
|
+
* derived from it, and a contract boundary that must validate an externally-supplied kind (e.g. the
|
|
24
|
+
* `worldMarker` the harness reports) checks membership against it rather than re-listing the values. */
|
|
25
|
+
export const EFFECT_KINDS = ["push", "pr-comment", "merge"] as const;
|
|
26
|
+
export type EffectKind = (typeof EFFECT_KINDS)[number];
|
|
27
|
+
|
|
28
|
+
/** True when `kind` is one of the known {@link EFFECT_KINDS}. The guard a contract boundary uses to
|
|
29
|
+
* reject an unexpected effect kind before it enters the durable ledger. */
|
|
30
|
+
export function isEffectKind(kind: unknown): kind is EffectKind {
|
|
31
|
+
return typeof kind === "string" && EFFECT_KINDS.some((k) => k === kind);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when `sha` is a well-formed 40-hex git commit SHA (a full object name). The world-restore
|
|
35
|
+
* marker's `commitSha` is used as an EXACT checkout target (`git fetch && git checkout <sha>`), so a
|
|
36
|
+
* branch name, an abbreviated/short ref, or a whitespace-tainted value could reconstruct to a moved
|
|
37
|
+
* branch tip or fail restore — undermining the "reconstruct the exact tree at <sha>" contract. Both
|
|
38
|
+
* the EMIT boundary (`repoEnvelopeVars`) and the READ boundary (`worldMarkerOf`) validate through
|
|
39
|
+
* this ONE canonical matcher so the two can never drift. */
|
|
40
|
+
export function isCommitSha(sha: unknown): sha is string {
|
|
41
|
+
return typeof sha === "string" && /^[0-9a-f]{40}$/i.test(sha);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One irreversible action in the effect ledger. `idempotencyKey` is the effect's natural identity —
|
|
45
|
+
* the value that makes "did this already happen?" answerable WITHOUT re-doing it. Two ledger entries
|
|
46
|
+
* with the same key denote the same real-world effect, so the fence collapses them to one. */
|
|
47
|
+
export interface Effect {
|
|
48
|
+
readonly kind: EffectKind;
|
|
49
|
+
/** The effect's natural, stable identity (commit SHA / comment id / merge key). The fence key. */
|
|
50
|
+
readonly idempotencyKey: string;
|
|
51
|
+
/** A human audit note (optional) — never load-bearing for fence identity. */
|
|
52
|
+
readonly description?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The durable fence seam the replay folds over. `isApplied` answers "has this effect's idempotency
|
|
56
|
+
* key already landed?" from the ledger; `markApplied` records that an effect has now been realised so
|
|
57
|
+
* a LATER resume skips it too. Both are async so a real SQLite-backed ledger (./store.ts) satisfies
|
|
58
|
+
* them, while an in-memory `Set` satisfies them in tests. */
|
|
59
|
+
export interface Fence {
|
|
60
|
+
isApplied(idempotencyKey: string): boolean | Promise<boolean>;
|
|
61
|
+
markApplied(effect: Effect): void | Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The breakdown of a fenced replay: which tail effects were (re)applied and which the fence skipped
|
|
65
|
+
* because they had already landed. `skipped` being the whole already-applied prefix is what makes a
|
|
66
|
+
* resume idempotent — the acceptance criterion "no duplicate push/comment (fence holds)". */
|
|
67
|
+
export interface FenceOutcome {
|
|
68
|
+
readonly applied: Effect[];
|
|
69
|
+
readonly skipped: Effect[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Replay an ordered effect tail through the fence: for each effect IN ORDER, if the fence already
|
|
74
|
+
* has its idempotency key it is skipped (never re-applied); otherwise `apply` runs the real side
|
|
75
|
+
* effect and the fence records it as applied so no later resume repeats it.
|
|
76
|
+
*
|
|
77
|
+
* The ordering is load-bearing — effects must replay in the sequence they were recorded (a comment
|
|
78
|
+
* that references a push must not run before it) — so this is a strict left-fold, never a parallel
|
|
79
|
+
* map. `apply` is invoked ONLY for genuinely-missing effects, which is the whole point: a replacement
|
|
80
|
+
* activation that crashed AFTER a push but BEFORE its trailing comment replays only the comment.
|
|
81
|
+
*/
|
|
82
|
+
export async function fenceReplay(
|
|
83
|
+
effects: readonly Effect[],
|
|
84
|
+
fence: Fence,
|
|
85
|
+
apply: (effect: Effect) => void | Promise<void>,
|
|
86
|
+
): Promise<FenceOutcome> {
|
|
87
|
+
const applied: Effect[] = [];
|
|
88
|
+
const skipped: Effect[] = [];
|
|
89
|
+
const seenThisReplay = new Set<string>();
|
|
90
|
+
for (const effect of effects) {
|
|
91
|
+
// Guard against a duplicate key WITHIN this tail too (a malformed ledger), not just against the
|
|
92
|
+
// durable fence — two entries with one key are one effect, so the second is always a skip.
|
|
93
|
+
if (seenThisReplay.has(effect.idempotencyKey) || (await fence.isApplied(effect.idempotencyKey))) {
|
|
94
|
+
skipped.push(effect);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
await apply(effect);
|
|
98
|
+
await fence.markApplied(effect);
|
|
99
|
+
seenThisReplay.add(effect.idempotencyKey);
|
|
100
|
+
applied.push(effect);
|
|
101
|
+
}
|
|
102
|
+
return { applied, skipped };
|
|
103
|
+
}
|
package/app/world/git.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// nano-workforce — the git seam for world-restore (issue #324, ADR 0062 Slice 4/5, the WORLD half).
|
|
2
|
+
//
|
|
3
|
+
// World-restore INVERTS the round's forward `git push`: on a replacement activation the outbound push
|
|
4
|
+
// of a SHA becomes an INBOUND `git fetch && git checkout <sha>` that reconstructs the exact working
|
|
5
|
+
// tree on a fresh worktree. This module is the thin, INJECTABLE git port that inversion runs over —
|
|
6
|
+
// `restoreWorld` (./checkpoint.ts) depends on the `GitRunner` interface, never on `child_process`
|
|
7
|
+
// directly, so the restore orchestration is unit-testable against a fake runner (no real repo).
|
|
8
|
+
//
|
|
9
|
+
// The production runner shells out to the host `git` with an argument VECTOR (no shell), mirroring
|
|
10
|
+
// `app/github.ts` `runGh` — a `pr_key`/SHA from the datastore is passed as an argv element and can
|
|
11
|
+
// never inject a command.
|
|
12
|
+
|
|
13
|
+
/** The minimal git surface world-restore needs — the inbound half of the push→pull inversion. */
|
|
14
|
+
export interface GitRunner {
|
|
15
|
+
/** Fetch refs (and their objects) from `remote` so a subsequently-named SHA is reachable locally.
|
|
16
|
+
* The forward op pushed to this remote; fetching is its inverse. */
|
|
17
|
+
fetch(remote?: string): Promise<void>;
|
|
18
|
+
/** Check the working tree out to an exact commit SHA — the reconstruction step. Detaches HEAD at
|
|
19
|
+
* `<sha>`, which is precisely the durable resume boundary the round pushed. */
|
|
20
|
+
checkout(ref: string): Promise<void>;
|
|
21
|
+
/** Resolve a ref to its commit SHA (e.g. to confirm HEAD landed on the expected checkpoint). */
|
|
22
|
+
revParse(ref: string): Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Run the host `git` with the given args in `cwd` (no shell — args are an argv vector, so a
|
|
26
|
+
* datastore-sourced SHA cannot inject a command). Resolves stdout, rejects on non-zero exit with
|
|
27
|
+
* stderr as the message. Mirrors `app/github.ts` `runGh`. */
|
|
28
|
+
async function runGit(args: string[], cwd?: string): Promise<string> {
|
|
29
|
+
const { execFile } = await import("node:child_process");
|
|
30
|
+
return await new Promise<string>((resolve, reject) => {
|
|
31
|
+
execFile("git", args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
32
|
+
if (err) reject(new Error(String(stderr || "").trim() || err.message));
|
|
33
|
+
else resolve(String(stdout));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The production `GitRunner` over the host `git`, operating in `cwd` (the provisioned worktree).
|
|
39
|
+
* `fetch` requests the objects for a subsequent `checkout <sha>`; a bare SHA (not on a branch tip)
|
|
40
|
+
* is fetchable when the remote allows it, which is the reconstruct-to-exact-SHA case. */
|
|
41
|
+
export function execGitRunner(cwd?: string): GitRunner {
|
|
42
|
+
return {
|
|
43
|
+
async fetch(remote = "origin") {
|
|
44
|
+
await runGit(["fetch", remote], cwd);
|
|
45
|
+
},
|
|
46
|
+
async checkout(ref) {
|
|
47
|
+
await runGit(["checkout", "--detach", ref], cwd);
|
|
48
|
+
},
|
|
49
|
+
async revParse(ref) {
|
|
50
|
+
return (await runGit(["rev-parse", ref], cwd)).trim();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// nano-workforce — the WORLD half of durable agent-session resume (issue #324, ADR 0062 Slice 4/5).
|
|
2
|
+
// Barrel over the world-restore surface: the effect ledger + fence, the durable store, the git
|
|
3
|
+
// inversion seam, and the checkpoint JOIN + RESTORE orchestration.
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
type CheckpointSink,
|
|
7
|
+
type RestoreOptions,
|
|
8
|
+
recordWorldCheckpoint,
|
|
9
|
+
restoreWorld,
|
|
10
|
+
type SessionCheckpoint,
|
|
11
|
+
type WorldCheckpointInput,
|
|
12
|
+
type WorldCheckpointResult,
|
|
13
|
+
type WorldRestoreResult,
|
|
14
|
+
} from "./checkpoint.ts";
|
|
15
|
+
export {
|
|
16
|
+
EFFECT_KINDS,
|
|
17
|
+
type Effect,
|
|
18
|
+
type EffectKind,
|
|
19
|
+
type Fence,
|
|
20
|
+
type FenceOutcome,
|
|
21
|
+
fenceReplay,
|
|
22
|
+
isCommitSha,
|
|
23
|
+
isEffectKind,
|
|
24
|
+
} from "./effect-ledger.ts";
|
|
25
|
+
export { execGitRunner, type GitRunner } from "./git.ts";
|
|
26
|
+
export { type LastCheckpoint, type RecordCheckpointInput, WorldStore } from "./store.ts";
|