@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.101.0](https://github.com/nanobpm/nano-workforce/compare/v0.100.0...v0.101.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **world:** durable world-restore — c8ctl working-tree reconstruction + effect fence ([#324](https://github.com/nanobpm/nano-workforce/issues/324)) ([#337](https://github.com/nanobpm/nano-workforce/issues/337)) ([d2f7655](https://github.com/nanobpm/nano-workforce/commit/d2f76557027eddeed062208ebaeb8136f7b9b922)), closes [#nextSeqOn](https://github.com/nanobpm/nano-workforce/issues/nextSeqOn) [#nextSeqOn](https://github.com/nanobpm/nano-workforce/issues/nextSeqOn) [#nextSeqOn](https://github.com/nanobpm/nano-workforce/issues/nextSeqOn) [#appendEffect](https://github.com/nanobpm/nano-workforce/issues/appendEffect) [#isFenceCollision](https://github.com/nanobpm/nano-workforce/issues/isFenceCollision) [#appendEffect](https://github.com/nanobpm/nano-workforce/issues/appendEffect) [#reconcileApplied](https://github.com/nanobpm/nano-workforce/issues/reconcileApplied) [#nextSeqOn](https://github.com/nanobpm/nano-workforce/issues/nextSeqOn)
|
|
7
|
+
|
|
1
8
|
# [0.100.0](https://github.com/nanobpm/nano-workforce/compare/v0.99.1...v0.100.0) (2026-08-19)
|
|
2
9
|
|
|
3
10
|
|
package/app/contracts.ts
CHANGED
|
@@ -354,9 +354,9 @@ export const WIRE_CONTRACTS = {
|
|
|
354
354
|
name: "io.nanobpm.agentTask.repository",
|
|
355
355
|
owner: "app/service.ts",
|
|
356
356
|
semantics:
|
|
357
|
-
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`) and the c8ctl worker harness consumes to provision an isolated clone on the PR head branch. Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
357
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`) and the c8ctl worker harness consumes to provision an isolated clone on the PR head branch. Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `commitSha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
|
|
358
358
|
shape:
|
|
359
|
-
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string }',
|
|
359
|
+
'{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string }',
|
|
360
360
|
},
|
|
361
361
|
"epicSet.submit": {
|
|
362
362
|
category: "wire",
|
|
@@ -367,6 +367,14 @@ export const WIRE_CONTRACTS = {
|
|
|
367
367
|
shape:
|
|
368
368
|
'{ epics: Array<{ issue|url: string, baseBranch: string, allowSharedBase?: boolean, confirmDefaultBase?: boolean }>, deps?: Array<{ consumer: string, producer: string, package: string, capabilityRef: string }> }',
|
|
369
369
|
},
|
|
370
|
+
"world.checkpoint": {
|
|
371
|
+
category: "wire",
|
|
372
|
+
name: "world.checkpoint",
|
|
373
|
+
owner: "app/world/checkpoint.ts",
|
|
374
|
+
semantics:
|
|
375
|
+
"The mind/world checkpoint JOIN shape (issue #324, ADR 0062 Slice 4/5, the WORLD half). At each push the app derives ONE `{commitSha, effectLedger}` and records it in the durable world store (`world_checkpoints`/`world_effects`) AND passes the SAME object to the mind's `session.checkpoint(commitSha, effectLedger)` (Slice 1, `@nanobpm/agentic/session`), so mind + world commit at the SAME per-PR monotonic offset — closing the divergence failure (harness thinks it hasn't pushed but the push landed, or vice-versa). `effectLedger` entries carry a fence idempotency key (push→commit SHA, PR comment→comment id, `gh merge`→merge key); on a re-lease `restoreWorld` inverts the push (`git fetch && git checkout <commitSha>`) then fence-replays the tail so an already-applied effect is skipped, not repeated. Consume this ONE shape from app/world — do not re-declare a synonym.",
|
|
376
|
+
shape: '{ commitSha: string, effectLedger: Array<{ kind: "push"|"pr-comment"|"merge", idempotencyKey: string, description?: string }> }',
|
|
377
|
+
},
|
|
370
378
|
} as const satisfies Record<string, WireContract>;
|
|
371
379
|
|
|
372
380
|
export const TYPE_CONTRACTS = {
|
|
@@ -386,6 +394,14 @@ export const TYPE_CONTRACTS = {
|
|
|
386
394
|
"One INTER-epic dependency edge (issue #292): dependent epic `plan_key` waits for producer epic `depends_on_plan_key`, gated by the producer's `{ package, capability_ref }` capability descriptor. This ONE row shape backs BOTH the durable `plan_deps` table (materialized by planner lowering S3) AND its FK-free admission-staging twin `admitted_plan_deps` (staged by the S2 door). Set admission (S2), planner lowering (S3), and operator visibility (S4) all import it from app/plan.ts — no re-declared synonym.",
|
|
387
395
|
module: "app/plan.ts",
|
|
388
396
|
},
|
|
397
|
+
SessionCheckpoint: {
|
|
398
|
+
category: "type",
|
|
399
|
+
name: "SessionCheckpoint",
|
|
400
|
+
owner: "app/world/checkpoint.ts",
|
|
401
|
+
semantics:
|
|
402
|
+
"The mind/world checkpoint contract shape (issue #324, ADR 0062 Slice 4/5). `{ commitSha, effectLedger }` — the ONE type both the world marker (recorded in `world_checkpoints`/`world_effects`) and the mind checkpoint (Slice 1's `session.checkpoint`) derive from, so a single derivation feeds both halves and they cannot diverge. Its `effectLedger` is `Effect[]` (the fence-keyed irreversible-action ledger). The world half imports it from app/world; when Slice 1's harness-side `@nanobpm/agentic/session` lands it MUST reuse this shape, not re-declare a synonym.",
|
|
403
|
+
module: "app/world/checkpoint.ts",
|
|
404
|
+
},
|
|
389
405
|
} as const satisfies Record<string, TypeContract>;
|
|
390
406
|
|
|
391
407
|
export const CAPABILITY_URL_CONTRACTS = {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Regression guard for migration 049 (issue #324, ADR 0062 Slice 4/5, the WORLD half): the durable
|
|
2
|
+
// constraints that MAKE world-restore correct. The `UNIQUE(pr_key, idempotency_key)` on
|
|
3
|
+
// `world_effects` IS the fence — an effect recorded once cannot be double-applied — and the
|
|
4
|
+
// `UNIQUE(pr_key, checkpoint_offset)` on `world_checkpoints` guarantees one world per turn boundary.
|
|
5
|
+
// The tables are deliberately FK-FREE (like the 045 admission-staging twins): a checkpoint may be
|
|
6
|
+
// recorded for an in-flight PR whose `pull_requests` row a store desync momentarily lost.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
12
|
+
|
|
13
|
+
function migratedDb(): DatabaseSync {
|
|
14
|
+
const db = new DatabaseSync(":memory:");
|
|
15
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
16
|
+
// Deliberately NO `pull_requests` table — the world tables must apply and accept rows with no PR
|
|
17
|
+
// parent (FK-free by design).
|
|
18
|
+
const sql = readFileSync(fileURLToPath(new URL("../db/migrations/049_world_checkpoint.sql", import.meta.url)), "utf8");
|
|
19
|
+
db.exec(sql);
|
|
20
|
+
return db;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const insertCheckpoint = (db: DatabaseSync, prKey: string, offset: number, sha: string) =>
|
|
24
|
+
db
|
|
25
|
+
.prepare(
|
|
26
|
+
`INSERT INTO world_checkpoints (pr_key, round_no, checkpoint_offset, commit_sha, created_at)
|
|
27
|
+
VALUES (?, 1, ?, ?, 't')`,
|
|
28
|
+
)
|
|
29
|
+
.run(prKey, offset, sha);
|
|
30
|
+
|
|
31
|
+
const insertEffect = (db: DatabaseSync, prKey: string, key: string) =>
|
|
32
|
+
db
|
|
33
|
+
.prepare(
|
|
34
|
+
`INSERT INTO world_effects (pr_key, checkpoint_offset, seq, kind, idempotency_key, applied, created_at)
|
|
35
|
+
VALUES (?, 0, 0, 'push', ?, 1, 't')`,
|
|
36
|
+
)
|
|
37
|
+
.run(prKey, key);
|
|
38
|
+
|
|
39
|
+
test("migration 049 applies cleanly with NO pull_requests table (FK-free) and records a checkpoint", () => {
|
|
40
|
+
const db = migratedDb();
|
|
41
|
+
insertCheckpoint(db, "o/r#2", 0, "sha-a"); // no PR parent — must NOT FK-fail
|
|
42
|
+
const row = db.prepare("SELECT pr_key, commit_sha FROM world_checkpoints WHERE pr_key = ?").get("o/r#2") as {
|
|
43
|
+
pr_key: string;
|
|
44
|
+
commit_sha: string;
|
|
45
|
+
};
|
|
46
|
+
assertEquals(row.pr_key, "o/r#2");
|
|
47
|
+
assertEquals(row.commit_sha, "sha-a");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("world_checkpoints UNIQUE(pr_key, checkpoint_offset): one world per turn boundary", () => {
|
|
51
|
+
const db = migratedDb();
|
|
52
|
+
insertCheckpoint(db, "o/r#1", 0, "sha-a");
|
|
53
|
+
assertThrows(
|
|
54
|
+
() => insertCheckpoint(db, "o/r#1", 0, "sha-b"),
|
|
55
|
+
undefined,
|
|
56
|
+
"UNIQUE constraint failed",
|
|
57
|
+
);
|
|
58
|
+
// A different offset for the same PR, and the same offset for a different PR, are both fine.
|
|
59
|
+
insertCheckpoint(db, "o/r#1", 1, "sha-b");
|
|
60
|
+
insertCheckpoint(db, "o/r#2", 0, "sha-c");
|
|
61
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM world_checkpoints").get() as { c: number }).c), 3);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("world_effects UNIQUE(pr_key, idempotency_key) IS the fence: one real effect → one row", () => {
|
|
65
|
+
const db = migratedDb();
|
|
66
|
+
insertEffect(db, "o/r#1", "sha-a");
|
|
67
|
+
assertThrows(
|
|
68
|
+
() => insertEffect(db, "o/r#1", "sha-a"),
|
|
69
|
+
undefined,
|
|
70
|
+
"UNIQUE constraint failed",
|
|
71
|
+
);
|
|
72
|
+
// The SAME key under a DIFFERENT PR is a different effect — allowed.
|
|
73
|
+
insertEffect(db, "o/r#2", "sha-a");
|
|
74
|
+
const rows = db.prepare("SELECT pr_key FROM world_effects WHERE idempotency_key = ?").all("sha-a");
|
|
75
|
+
assertEquals(rows.length, 2, "the fence is scoped per PR");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("world_effects.applied defaults to 0 (a pending tail entry) unless set", () => {
|
|
79
|
+
const db = migratedDb();
|
|
80
|
+
db.prepare(
|
|
81
|
+
`INSERT INTO world_effects (pr_key, checkpoint_offset, seq, kind, idempotency_key, created_at)
|
|
82
|
+
VALUES ('o/r#1', 0, 0, 'pr-comment', 'c-1', 't')`,
|
|
83
|
+
).run();
|
|
84
|
+
const row = db.prepare("SELECT applied FROM world_effects WHERE idempotency_key = 'c-1'").get() as { applied: number };
|
|
85
|
+
assertEquals(row.applied, 0, "an effect recorded before it is performed is pending by default");
|
|
86
|
+
assert(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("world_effects CHECK(applied IN (0,1)): the fence's boolean domain is pinned at the schema", () => {
|
|
90
|
+
const db = migratedDb();
|
|
91
|
+
// The fence reads `applied` as "already realised?" — a stray value (a future writer bug or a
|
|
92
|
+
// corrupt row on this externalised durability boundary) must be rejected, not silently mis-skip
|
|
93
|
+
// or re-apply an effect on replay.
|
|
94
|
+
assertThrows(
|
|
95
|
+
() =>
|
|
96
|
+
db
|
|
97
|
+
.prepare(
|
|
98
|
+
`INSERT INTO world_effects (pr_key, checkpoint_offset, seq, kind, idempotency_key, applied, created_at)
|
|
99
|
+
VALUES ('o/r#1', 0, 0, 'push', 'sha-bad', 2, 't')`,
|
|
100
|
+
)
|
|
101
|
+
.run(),
|
|
102
|
+
undefined,
|
|
103
|
+
"CHECK constraint failed",
|
|
104
|
+
);
|
|
105
|
+
// The two legal values both insert fine.
|
|
106
|
+
insertEffect(db, "o/r#1", "sha-applied"); // applied = 1
|
|
107
|
+
db.prepare(
|
|
108
|
+
`INSERT INTO world_effects (pr_key, checkpoint_offset, seq, kind, idempotency_key, applied, created_at)
|
|
109
|
+
VALUES ('o/r#1', 0, 1, 'push', 'sha-pending', 0, 't')`,
|
|
110
|
+
).run();
|
|
111
|
+
assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM world_effects").get() as { c: number }).c), 2);
|
|
112
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -503,6 +503,22 @@ test("repoEnvelopeVars emits nothing for a malformed repo (not owner/repo)", ()
|
|
|
503
503
|
}
|
|
504
504
|
});
|
|
505
505
|
|
|
506
|
+
test("repoEnvelopeVars emits commitSha only for a well-formed 40-hex SHA (world-restore, #324)", () => {
|
|
507
|
+
const sha = "77ee0993cc6ad4493da0f7551212ef16722135db";
|
|
508
|
+
const env = (repoEnvelopeVars("owner/repo", "feat/x", "main", sha) as any)["io.nanobpm.agentTask"];
|
|
509
|
+
assertEquals(env.repository.commitSha, sha, "a valid 40-hex SHA is threaded through as the exact checkout target");
|
|
510
|
+
// A non-SHA ref, an abbreviated SHA, or a whitespace-tainted value is dropped (no `commitSha` key):
|
|
511
|
+
// it is forwarded to the harness as an EXACT checkout target, so a bad value could reconstruct to a
|
|
512
|
+
// moved branch tip or fail provisioning. Omission degrades to the pre-#324 head-branch-tip clone.
|
|
513
|
+
for (const bad of ["main", "feat/x", "77ee099", `${sha} `, ` ${sha}`, `${sha}\n`, "z".repeat(40), `${sha}0`, ""]) {
|
|
514
|
+
const r = (repoEnvelopeVars("owner/repo", "feat/x", "main", bad) as any)["io.nanobpm.agentTask"].repository;
|
|
515
|
+
assertEquals("commitSha" in r, false, `expected no commitSha for "${JSON.stringify(bad)}"`);
|
|
516
|
+
}
|
|
517
|
+
// Omitted entirely when there is no checkpoint SHA at all (the common first-activation case).
|
|
518
|
+
const none = (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"].repository;
|
|
519
|
+
assertEquals("commitSha" in none, false);
|
|
520
|
+
});
|
|
521
|
+
|
|
506
522
|
// `parsePr` is total on any input: it is called unguarded from several workers (progress-check,
|
|
507
523
|
// persist-round, persist-escalation, record-dependency) with a process variable that a regression
|
|
508
524
|
// — or an older in-flight instance — could carry as a non-string. `.trim()` on a non-string throws,
|
package/app/service.ts
CHANGED
|
@@ -81,6 +81,7 @@ import {
|
|
|
81
81
|
} from "./userTasks.ts";
|
|
82
82
|
import { deriveWaitGate } from "./waitGate.ts";
|
|
83
83
|
import { waveMergeTargets } from "./waves.ts";
|
|
84
|
+
import { isCommitSha, WorldStore } from "./world/index.ts";
|
|
84
85
|
|
|
85
86
|
/** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
|
|
86
87
|
export const PROCESS_ID = "convergence-loop";
|
|
@@ -373,8 +374,20 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
|
373
374
|
* fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
|
|
374
375
|
* it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
|
|
375
376
|
* is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
|
|
376
|
-
* that base reachable for the diff.
|
|
377
|
-
|
|
377
|
+
* that base reachable for the diff.
|
|
378
|
+
*
|
|
379
|
+
* World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
|
|
380
|
+
* `commitSha` is emitted so a REPLACEMENT activation (a fresh worktree after a lease loss)
|
|
381
|
+
* reconstructs the working tree to the EXACT pushed SHA — the inversion of the round's outbound
|
|
382
|
+
* `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a branch tip that may
|
|
383
|
+
* have moved. Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
|
|
384
|
+
* head branch normally. */
|
|
385
|
+
export function repoEnvelopeVars(
|
|
386
|
+
repo: string,
|
|
387
|
+
ref: string | null,
|
|
388
|
+
baseRef: string | null = null,
|
|
389
|
+
commitSha: string | null = null,
|
|
390
|
+
): Record<string, unknown> {
|
|
378
391
|
if (!ref) return {};
|
|
379
392
|
// Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
|
|
380
393
|
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
@@ -399,11 +412,33 @@ export function repoEnvelopeVars(repo: string, ref: string | null, baseRef: stri
|
|
|
399
412
|
// The base branch this PR targets — emitted so the harness fetches its tip alongside the
|
|
400
413
|
// single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
|
|
401
414
|
...(baseRef ? { baseRef } : {}),
|
|
415
|
+
// World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
|
|
416
|
+
// working tree to (inverting the round's push into a fetch+checkout). Only emitted when it is
|
|
417
|
+
// a well-formed 40-hex commit SHA: `commitSha` is forwarded to the harness as an EXACT
|
|
418
|
+
// checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to an
|
|
419
|
+
// unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
|
|
420
|
+
// omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
|
|
421
|
+
// when the PR has no durable push-checkpoint yet.
|
|
422
|
+
...(isCommitSha(commitSha) ? { commitSha } : {}),
|
|
402
423
|
},
|
|
403
424
|
},
|
|
404
425
|
};
|
|
405
426
|
}
|
|
406
427
|
|
|
428
|
+
/** The last durable push-checkpoint SHA for a PR (issue #324, ADR 0062 Slice 4/5), or `null` when it
|
|
429
|
+
* has none yet. Threaded into `repoEnvelopeVars` so a replacement activation reconstructs the exact
|
|
430
|
+
* pushed tree. Best-effort: any store read failure (a legacy DB predating migration 049, an in-flight
|
|
431
|
+
* desync) degrades to `null` — the harness then clones the head branch tip, the pre-#324 behaviour —
|
|
432
|
+
* rather than blocking a submit/merge on the world store. */
|
|
433
|
+
async function lastPushedSha(data: DataLayer, prKey: string): Promise<string | null> {
|
|
434
|
+
try {
|
|
435
|
+
return (await new WorldStore(data).lastCheckpoint(prKey))?.commitSha ?? null;
|
|
436
|
+
} catch (err) {
|
|
437
|
+
console.warn(`[world] ${prKey} last-checkpoint read: ${err}`);
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
407
442
|
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
408
443
|
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
409
444
|
* recorded as the PR's merge-stage dependency set. */
|
|
@@ -508,6 +543,11 @@ export async function submitPr(
|
|
|
508
543
|
});
|
|
509
544
|
}
|
|
510
545
|
const abUrl = abandonUrl(abandonToken);
|
|
546
|
+
// World-restore (issue #324, ADR 0062 Slice 4/5): a re-run of convergence for a PR that already
|
|
547
|
+
// pushed is a resume — carry its last durable push-checkpoint so a replacement activation on a
|
|
548
|
+
// fresh worktree reconstructs the tree to the EXACT pushed SHA. Absent (null) on a first submit,
|
|
549
|
+
// which leaves the envelope unchanged.
|
|
550
|
+
const worldSha = await lastPushedSha(data, parsed.prKey);
|
|
511
551
|
const { processInstanceKey } = await engine.createInstance({
|
|
512
552
|
processDefinitionId: PROCESS_ID,
|
|
513
553
|
variables: {
|
|
@@ -533,7 +573,7 @@ export async function submitPr(
|
|
|
533
573
|
// Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
|
|
534
574
|
// harness clones an isolated workspace checked out on the PR head branch. Spread last so an
|
|
535
575
|
// unresolved head (`{}`) leaves the other vars untouched.
|
|
536
|
-
...repoEnvelopeVars(parsed.repo, headRef, baseRef),
|
|
576
|
+
...repoEnvelopeVars(parsed.repo, headRef, baseRef, worldSha),
|
|
537
577
|
},
|
|
538
578
|
});
|
|
539
579
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -580,6 +620,9 @@ export async function startMerge(
|
|
|
580
620
|
if (!headRef) {
|
|
581
621
|
console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
|
|
582
622
|
}
|
|
623
|
+
// World-restore (issue #324): the merge stage runs on the same durable working tree; carry the
|
|
624
|
+
// last push-checkpoint so a replacement fix-ci/rebase activation reconstructs the exact SHA.
|
|
625
|
+
const worldSha = await lastPushedSha(data, pr.prKey);
|
|
583
626
|
const { processInstanceKey } = await engine.createInstance({
|
|
584
627
|
processDefinitionId: MERGE_PROCESS_ID,
|
|
585
628
|
variables: {
|
|
@@ -601,7 +644,7 @@ export async function startMerge(
|
|
|
601
644
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
602
645
|
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
603
646
|
// fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
|
|
604
|
-
...repoEnvelopeVars(pr.repo, headRef, baseRef),
|
|
647
|
+
...repoEnvelopeVars(pr.repo, headRef, baseRef, worldSha),
|
|
605
648
|
},
|
|
606
649
|
});
|
|
607
650
|
if (processInstanceKey != null) {
|
|
@@ -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
|
+
}
|