@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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Tests for the world checkpoint JOIN + RESTORE orchestration (issue #324, ADR 0062 Slice 4/5). These
|
|
2
|
+
// prove the two acceptance criteria at the orchestration level:
|
|
3
|
+
// - Divergence guard: mind and world always commit at the SAME checkpoint offset (the join hands
|
|
4
|
+
// the mind the identical checkpoint the world persisted).
|
|
5
|
+
// - Reconstruction + fence: restore INVERTS the push (`git fetch` + `git checkout <sha>`) and
|
|
6
|
+
// fence-replays the effect tail so no already-applied effect is repeated.
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
9
|
+
import { memWorldData } from "../../test/worldDb.ts";
|
|
10
|
+
import { recordWorldCheckpoint, restoreWorld, type SessionCheckpoint } from "./checkpoint.ts";
|
|
11
|
+
import type { Effect } from "./effect-ledger.ts";
|
|
12
|
+
import type { GitRunner } from "./git.ts";
|
|
13
|
+
import { WorldStore } from "./store.ts";
|
|
14
|
+
|
|
15
|
+
const PR = "o/r#1";
|
|
16
|
+
|
|
17
|
+
/** A fake git runner that records the inbound inversion (fetch + checkout) restore performs. */
|
|
18
|
+
function fakeGit(): GitRunner & { fetched: string[]; checkedOut: string[] } {
|
|
19
|
+
const fetched: string[] = [];
|
|
20
|
+
const checkedOut: string[] = [];
|
|
21
|
+
return {
|
|
22
|
+
fetched,
|
|
23
|
+
checkedOut,
|
|
24
|
+
async fetch(remote = "origin") {
|
|
25
|
+
fetched.push(remote);
|
|
26
|
+
},
|
|
27
|
+
async checkout(ref) {
|
|
28
|
+
checkedOut.push(ref);
|
|
29
|
+
},
|
|
30
|
+
async revParse(ref) {
|
|
31
|
+
return ref;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("recordWorldCheckpoint joins the mind to the world at the SAME offset (divergence guard)", async () => {
|
|
37
|
+
const { data } = memWorldData();
|
|
38
|
+
const store = new WorldStore(data);
|
|
39
|
+
const mindSaw: Array<{ offset: number; cp: SessionCheckpoint }> = [];
|
|
40
|
+
// The mind sink (Slice 1 `session.checkpoint`) — capture what offset it committed at by pairing it
|
|
41
|
+
// with the world offset returned. Both halves derive from ONE checkpoint object.
|
|
42
|
+
let lastOffset = -1;
|
|
43
|
+
const sink = (cp: SessionCheckpoint) => {
|
|
44
|
+
mindSaw.push({ offset: lastOffset, cp });
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
for (let round = 1; round <= 3; round++) {
|
|
48
|
+
const res = await recordWorldCheckpoint(store, { prKey: PR, roundNo: round, commitSha: `sha-${round}` }, (cp) => {
|
|
49
|
+
lastOffset = round - 1; // the offset the world just allocated
|
|
50
|
+
sink(cp);
|
|
51
|
+
});
|
|
52
|
+
assertEquals(res.offset, round - 1, "world offset is the per-PR monotonic counter");
|
|
53
|
+
// The mind saw the IDENTICAL checkpoint object the world persisted.
|
|
54
|
+
assertEquals(res.checkpoint.commitSha, `sha-${round}`);
|
|
55
|
+
}
|
|
56
|
+
// Divergence guard: every mind checkpoint's offset equals the world checkpoint offset — they never
|
|
57
|
+
// drift. The last committed offset the mind saw is the store's newest.
|
|
58
|
+
const last = await store.lastCheckpoint(PR);
|
|
59
|
+
assertEquals(last?.offset, 2, "world's newest offset");
|
|
60
|
+
assertEquals(mindSaw.at(-1)?.offset, 2, "mind committed at the SAME offset — no divergence");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("recordWorldCheckpoint derives one checkpoint fed to both the store and the mind sink", async () => {
|
|
64
|
+
const { data } = memWorldData();
|
|
65
|
+
const store = new WorldStore(data);
|
|
66
|
+
const effects: Effect[] = [
|
|
67
|
+
{ kind: "push", idempotencyKey: "sha-1" },
|
|
68
|
+
{ kind: "pr-comment", idempotencyKey: "c-1" },
|
|
69
|
+
];
|
|
70
|
+
let sunk: SessionCheckpoint | null = null;
|
|
71
|
+
const res = await recordWorldCheckpoint(store, { prKey: PR, roundNo: 1, commitSha: "sha-1", effects }, (cp) => {
|
|
72
|
+
sunk = cp;
|
|
73
|
+
});
|
|
74
|
+
assertEquals(sunk, res.checkpoint, "the sink received the exact object the world persisted");
|
|
75
|
+
const tail = await store.effectTail(PR, res.offset);
|
|
76
|
+
assertEquals(tail.map((e) => e.idempotencyKey), ["sha-1", "c-1"], "the same effect ledger is durable");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("recordWorldCheckpoint works without a sink (world half lands before the mind backend)", async () => {
|
|
80
|
+
const { data } = memWorldData();
|
|
81
|
+
const store = new WorldStore(data);
|
|
82
|
+
const res = await recordWorldCheckpoint(store, { prKey: PR, roundNo: 1, commitSha: "sha-1" });
|
|
83
|
+
assertEquals(res.offset, 0, "the world checkpoint is recorded even with no mind sink");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("restoreWorld inverts the push: git fetch + checkout <commitSha> to reconstruct the tree", async () => {
|
|
87
|
+
const { data } = memWorldData();
|
|
88
|
+
const store = new WorldStore(data);
|
|
89
|
+
await recordWorldCheckpoint(store, { prKey: PR, roundNo: 1, commitSha: "sha-a" });
|
|
90
|
+
await recordWorldCheckpoint(store, { prKey: PR, roundNo: 2, commitSha: "sha-b" });
|
|
91
|
+
const git = fakeGit();
|
|
92
|
+
const res = await restoreWorld(git, store, PR);
|
|
93
|
+
assertEquals(git.fetched, ["origin"], "fetch runs first so the SHA is reachable");
|
|
94
|
+
assertEquals(git.checkedOut, ["sha-b"], "the tree is reconstructed to the NEWEST push-checkpoint SHA");
|
|
95
|
+
assertEquals(res?.offset, 1, "restore reports the checkpoint offset it landed on");
|
|
96
|
+
assertEquals(res?.commitSha, "sha-b");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("restoreWorld fence-replays the tail: an already-applied effect is NOT repeated (fence holds)", async () => {
|
|
100
|
+
const { data } = memWorldData();
|
|
101
|
+
const store = new WorldStore(data);
|
|
102
|
+
// The round pushed AND commented; both effects are recorded applied on the forward path.
|
|
103
|
+
await recordWorldCheckpoint(store, {
|
|
104
|
+
prKey: PR,
|
|
105
|
+
roundNo: 1,
|
|
106
|
+
commitSha: "sha-a",
|
|
107
|
+
effects: [
|
|
108
|
+
{ kind: "push", idempotencyKey: "sha-a" },
|
|
109
|
+
{ kind: "pr-comment", idempotencyKey: "c-1" },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
const git = fakeGit();
|
|
113
|
+
const reapplied: string[] = [];
|
|
114
|
+
const res = await restoreWorld(git, store, PR, { apply: (e) => reapplied.push(e.idempotencyKey) });
|
|
115
|
+
assertEquals(reapplied, [], "no effect is re-applied — the fence skips both");
|
|
116
|
+
assertEquals(res?.skipped.map((e) => e.idempotencyKey), ["sha-a", "c-1"], "both are reported skipped");
|
|
117
|
+
assertEquals(res?.applied.length, 0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("restoreWorld re-applies only a genuinely-pending tail effect (crash before it landed)", async () => {
|
|
121
|
+
const { data } = memWorldData();
|
|
122
|
+
const store = new WorldStore(data);
|
|
123
|
+
// The push landed (applied) but its trailing comment was only RECORDED as pending (applied=false)
|
|
124
|
+
// before the worker crashed. Restore must re-apply exactly the comment.
|
|
125
|
+
await recordWorldCheckpoint(store, { prKey: PR, roundNo: 1, commitSha: "sha-a", effects: [{ kind: "push", idempotencyKey: "sha-a" }] });
|
|
126
|
+
// Record the pending comment at the NEXT offset (offset 1) via a second store call with
|
|
127
|
+
// applied=false — `recordCheckpoint` always allocates the next monotonic offset, so this becomes
|
|
128
|
+
// the newest checkpoint whose tail is the still-pending comment.
|
|
129
|
+
await store.recordCheckpoint({
|
|
130
|
+
prKey: PR,
|
|
131
|
+
roundNo: 1,
|
|
132
|
+
commitSha: "sha-a2",
|
|
133
|
+
effects: [{ kind: "pr-comment", idempotencyKey: "c-1" }],
|
|
134
|
+
applied: false,
|
|
135
|
+
});
|
|
136
|
+
const git = fakeGit();
|
|
137
|
+
const reapplied: string[] = [];
|
|
138
|
+
const res = await restoreWorld(git, store, PR, { apply: (e) => reapplied.push(e.idempotencyKey) });
|
|
139
|
+
// The newest checkpoint is offset 1 (commit sha-a2), whose tail is the pending comment.
|
|
140
|
+
assertEquals(res?.commitSha, "sha-a2");
|
|
141
|
+
assertEquals(reapplied, ["c-1"], "the pending comment is re-applied exactly once");
|
|
142
|
+
// A SECOND restore is now a no-op — the fence recorded the comment applied.
|
|
143
|
+
const reapplied2: string[] = [];
|
|
144
|
+
await restoreWorld(git, store, PR, { apply: (e) => reapplied2.push(e.idempotencyKey) });
|
|
145
|
+
assertEquals(reapplied2, [], "the second resume repeats nothing — idempotent");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("restoreWorld THROWS rather than silently losing a genuinely-pending tail effect when no executor is supplied", async () => {
|
|
149
|
+
const { data } = memWorldData();
|
|
150
|
+
const store = new WorldStore(data);
|
|
151
|
+
await recordWorldCheckpoint(store, { prKey: PR, roundNo: 1, commitSha: "sha-a", effects: [{ kind: "push", idempotencyKey: "sha-a" }] });
|
|
152
|
+
// A trailing comment recorded PENDING (applied=false): the crash landed before it executed.
|
|
153
|
+
await store.recordCheckpoint({ prKey: PR, roundNo: 1, commitSha: "sha-a2", effects: [{ kind: "pr-comment", idempotencyKey: "c-1" }], applied: false });
|
|
154
|
+
const git = fakeGit();
|
|
155
|
+
// With no `apply` executor, marking the pending effect applied would SILENTLY DROP it — so restore
|
|
156
|
+
// must throw instead of advancing the fence past an un-executed effect.
|
|
157
|
+
await assertRejects(() => restoreWorld(git, store, PR), Error, "genuinely pending");
|
|
158
|
+
// The fence was NOT advanced: a later restore WITH an executor still re-applies the comment exactly
|
|
159
|
+
// once (the effect survived the guarded restore rather than being lost).
|
|
160
|
+
const reapplied: string[] = [];
|
|
161
|
+
const res = await restoreWorld(git, store, PR, { apply: (e) => reapplied.push(e.idempotencyKey) });
|
|
162
|
+
assertEquals(reapplied, ["c-1"], "the pending effect survived and is re-applied once");
|
|
163
|
+
assertEquals(res?.applied.map((e) => e.idempotencyKey), ["c-1"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("restoreWorld with no executor is fine when every tail effect is already applied (the guard never fires)", async () => {
|
|
167
|
+
const { data } = memWorldData();
|
|
168
|
+
const store = new WorldStore(data);
|
|
169
|
+
// Both effects recorded applied on the forward path — nothing pending, so no executor is needed.
|
|
170
|
+
await recordWorldCheckpoint(store, {
|
|
171
|
+
prKey: PR,
|
|
172
|
+
roundNo: 1,
|
|
173
|
+
commitSha: "sha-a",
|
|
174
|
+
effects: [
|
|
175
|
+
{ kind: "push", idempotencyKey: "sha-a" },
|
|
176
|
+
{ kind: "pr-comment", idempotencyKey: "c-1" },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
const git = fakeGit();
|
|
180
|
+
const res = await restoreWorld(git, store, PR);
|
|
181
|
+
assertEquals(res?.applied, [], "nothing is applied");
|
|
182
|
+
assertEquals(res?.skipped.map((e) => e.idempotencyKey), ["sha-a", "c-1"], "both are skipped by the fence");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("restoreWorld returns null when the PR has no push-checkpoint (nothing to reconstruct)", async () => {
|
|
186
|
+
const { data } = memWorldData();
|
|
187
|
+
const store = new WorldStore(data);
|
|
188
|
+
const git = fakeGit();
|
|
189
|
+
const res = await restoreWorld(git, store, PR);
|
|
190
|
+
assertEquals(res, null, "no checkpoint → null; the caller keeps the freshly-provisioned worktree");
|
|
191
|
+
assertEquals(git.fetched, [], "no git operation runs when there is nothing to restore");
|
|
192
|
+
assert(git.checkedOut.length === 0);
|
|
193
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// nano-workforce — world checkpoint JOIN + RESTORE (issue #324, ADR 0062 Slice 4/5, the WORLD half).
|
|
2
|
+
//
|
|
3
|
+
// This is the orchestration that ties the durable store (./store.ts), the effect fence
|
|
4
|
+
// (./effect-ledger.ts) and the git inversion (./git.ts) into the two operations durable resume needs:
|
|
5
|
+
//
|
|
6
|
+
// 1. recordWorldCheckpoint — the JOIN. At each push the app records the world marker `{commitSha,
|
|
7
|
+
// effects}` AND calls the mind's `session.checkpoint(commitSha, effectLedger)` (Slice 1) with the
|
|
8
|
+
// SAME derived checkpoint, so mind + world commit at ONE turn boundary (the shared offset). This
|
|
9
|
+
// closes the divergence failure: the harness can never think it hasn't pushed when the push
|
|
10
|
+
// landed, or vice-versa, because a single derivation feeds both sides.
|
|
11
|
+
//
|
|
12
|
+
// 2. restoreWorld — the INVERSION. On a re-lease the round's outbound `git push` becomes an inbound
|
|
13
|
+
// `git fetch && git checkout <commitSha>` that reconstructs the exact tree on a fresh worktree,
|
|
14
|
+
// then the post-checkpoint effect tail is fence-replayed so an already-applied effect is skipped.
|
|
15
|
+
// This runs BEFORE the harness mind is resumed (world first, then mind — the tree the replayed
|
|
16
|
+
// conversation refers to must already exist).
|
|
17
|
+
import { type Effect, type FenceOutcome, fenceReplay } from "./effect-ledger.ts";
|
|
18
|
+
import type { GitRunner } from "./git.ts";
|
|
19
|
+
import type { WorldStore } from "./store.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The mind/world checkpoint contract shape (ADR 0062 §2). Slice 1 owns the harness-side
|
|
23
|
+
* `session.checkpoint(commitSha, effectLedger)`; this is the one wire shape both halves derive from,
|
|
24
|
+
* so the world marker recorded here and the mind checkpoint joined to it are the SAME object — a
|
|
25
|
+
* single source of truth for the resume boundary.
|
|
26
|
+
*/
|
|
27
|
+
export interface SessionCheckpoint {
|
|
28
|
+
/** The pushed SHA the working tree is reconstructed to on resume (the durable resume boundary). */
|
|
29
|
+
readonly commitSha: string;
|
|
30
|
+
/** The irreversible effects performed up to this checkpoint, each carrying its fence idempotency
|
|
31
|
+
* key — replayed through the fence on restore so none is repeated. */
|
|
32
|
+
readonly effectLedger: readonly Effect[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The mind-side checkpoint sink — Slice 1's `session.checkpoint(...)`. `recordWorldCheckpoint` calls
|
|
36
|
+
* it with the SAME `SessionCheckpoint` it persists, so the two halves never diverge. Optional so the
|
|
37
|
+
* world half can be exercised (and land) before the mind backend is wired in. */
|
|
38
|
+
export type CheckpointSink = (checkpoint: SessionCheckpoint) => void | Promise<void>;
|
|
39
|
+
|
|
40
|
+
/** The inputs for a world checkpoint at a push. */
|
|
41
|
+
export interface WorldCheckpointInput {
|
|
42
|
+
readonly prKey: string;
|
|
43
|
+
readonly roundNo: number;
|
|
44
|
+
readonly commitSha: string;
|
|
45
|
+
/** The round's irreversible effects, in order. Defaults to a single `push` effect keyed by the
|
|
46
|
+
* commit SHA. */
|
|
47
|
+
readonly effects?: readonly Effect[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The result of recording a world checkpoint: the shared offset (mind + world commit at it) and the
|
|
51
|
+
* derived checkpoint both halves saw. */
|
|
52
|
+
export interface WorldCheckpointResult {
|
|
53
|
+
readonly offset: number;
|
|
54
|
+
readonly checkpoint: SessionCheckpoint;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Record a world checkpoint at a push and JOIN it to the mind checkpoint. Derives ONE
|
|
59
|
+
* {@link SessionCheckpoint} from the world marker, persists it in the durable store (allocating the
|
|
60
|
+
* per-PR monotonic offset), and — when a `sink` is supplied — passes the SAME object to the mind's
|
|
61
|
+
* `session.checkpoint`. Returning the offset lets a caller assert the mind and world committed at one
|
|
62
|
+
* boundary (the divergence guard).
|
|
63
|
+
*/
|
|
64
|
+
export async function recordWorldCheckpoint(
|
|
65
|
+
store: WorldStore,
|
|
66
|
+
input: WorldCheckpointInput,
|
|
67
|
+
sink?: CheckpointSink,
|
|
68
|
+
): Promise<WorldCheckpointResult> {
|
|
69
|
+
const effects: readonly Effect[] = input.effects ?? [{ kind: "push", idempotencyKey: input.commitSha }];
|
|
70
|
+
const checkpoint: SessionCheckpoint = { commitSha: input.commitSha, effectLedger: effects };
|
|
71
|
+
const offset = await store.recordCheckpoint({
|
|
72
|
+
prKey: input.prKey,
|
|
73
|
+
roundNo: input.roundNo,
|
|
74
|
+
commitSha: input.commitSha,
|
|
75
|
+
effects,
|
|
76
|
+
});
|
|
77
|
+
// The JOIN: hand the mind the identical checkpoint the world just persisted, at the same boundary.
|
|
78
|
+
// Advisory — a sink failure must not undo the durable world record (the world is authoritative for
|
|
79
|
+
// the tree), so let it throw to the caller rather than swallowing a half-committed join here.
|
|
80
|
+
if (sink) await sink(checkpoint);
|
|
81
|
+
return { offset, checkpoint };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The outcome of a world restore: the checkpoint the tree was reconstructed to, plus which tail
|
|
85
|
+
* effects were (re)applied vs. skipped by the fence. `null` when the PR has no push-checkpoint yet
|
|
86
|
+
* (nothing to reconstruct). */
|
|
87
|
+
export interface WorldRestoreResult extends FenceOutcome {
|
|
88
|
+
readonly offset: number;
|
|
89
|
+
readonly commitSha: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Options for a world restore. */
|
|
93
|
+
export interface RestoreOptions {
|
|
94
|
+
/** The remote to fetch the checkpoint SHA from (default `origin`). */
|
|
95
|
+
readonly remote?: string;
|
|
96
|
+
/** Runs a fenced tail effect (a `pr-comment`/`merge` that must be re-attempted because it did not
|
|
97
|
+
* land before the crash). Omit ONLY when the tail cannot contain a genuinely-pending effect (every
|
|
98
|
+
* effect already applied): with no executor a genuinely-pending effect would be silently dropped,
|
|
99
|
+
* so `restoreWorld` instead THROWS on one rather than marking it applied without executing it. An
|
|
100
|
+
* already-applied effect is skipped regardless of this option. */
|
|
101
|
+
readonly apply?: (effect: Effect) => void | Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Restore the world on a re-lease by INVERTING the forward push: `git fetch` + `git checkout
|
|
106
|
+
* <commitSha>` reconstructs the exact tree at the last push-checkpoint on the fresh worktree, then
|
|
107
|
+
* the post-checkpoint effect tail is fence-replayed so an already-applied effect is SKIPPED, not
|
|
108
|
+
* repeated. Returns the checkpoint restored to (or `null` when the PR never pushed).
|
|
109
|
+
*
|
|
110
|
+
* Call this BEFORE resuming the harness mind — the conversation being replayed refers to a tree that
|
|
111
|
+
* must already exist.
|
|
112
|
+
*/
|
|
113
|
+
export async function restoreWorld(
|
|
114
|
+
git: GitRunner,
|
|
115
|
+
store: WorldStore,
|
|
116
|
+
prKey: string,
|
|
117
|
+
opts: RestoreOptions = {},
|
|
118
|
+
): Promise<WorldRestoreResult | null> {
|
|
119
|
+
const last = await store.lastCheckpoint(prKey);
|
|
120
|
+
if (!last) return null;
|
|
121
|
+
// The inversion: the round pushed <commitSha> outbound; restore fetches it back and checks the
|
|
122
|
+
// working tree out to it. Fetch first so the (possibly non-branch-tip) SHA is reachable locally.
|
|
123
|
+
await git.fetch(opts.remote ?? "origin");
|
|
124
|
+
await git.checkout(last.commitSha);
|
|
125
|
+
// Fence-replay the effect tail: already-applied effects skip (idempotent — "no duplicate
|
|
126
|
+
// push/comment"). A genuinely-pending effect is re-applied via `opts.apply`. When no executor was
|
|
127
|
+
// supplied, DON'T advance the fence for a pending effect — marking it applied without running it
|
|
128
|
+
// would silently lose the effect. Instead throw, converting that silent-loss class into a loud,
|
|
129
|
+
// caught failure. `fenceReplay` invokes `apply` ONLY for genuinely-missing effects, so this guard
|
|
130
|
+
// never fires when every tail effect is already applied (the safe no-executor case).
|
|
131
|
+
const tail = await store.effectTail(prKey, last.offset);
|
|
132
|
+
const fence = store.fenceFor(prKey, last.offset);
|
|
133
|
+
const apply =
|
|
134
|
+
opts.apply ??
|
|
135
|
+
((effect: Effect): never => {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`restoreWorld(${prKey}): no \`apply\` executor supplied, but effect ${effect.kind}:${effect.idempotencyKey} is genuinely pending — refusing to mark it applied without executing it (silent effect loss). Pass \`opts.apply\` to re-apply pending tail effects.`,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
const outcome = await fenceReplay(tail, fence, apply);
|
|
141
|
+
return { offset: last.offset, commitSha: last.commitSha, ...outcome };
|
|
142
|
+
}
|
|
@@ -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";
|