@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 CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.101.1](https://github.com/nanobpm/nano-workforce/compare/v0.101.0...v0.101.1) (2026-08-19)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **merge-loop:** abandon a closed-not-merged PR instead of escalating ([#342](https://github.com/nanobpm/nano-workforce/issues/342)) ([#343](https://github.com/nanobpm/nano-workforce/issues/343)) ([10ea6be](https://github.com/nanobpm/nano-workforce/commit/10ea6be0e23c9315dfb71a6c94958826a1795a8c)), closes [#350](https://github.com/nanobpm/nano-workforce/issues/350)
7
+
8
+ # [0.101.0](https://github.com/nanobpm/nano-workforce/compare/v0.100.0...v0.101.0) (2026-08-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * **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)
14
+
1
15
  # [0.100.0](https://github.com/nanobpm/nano-workforce/compare/v0.99.1...v0.100.0) (2026-08-19)
2
16
 
3
17
 
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 = {
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead } from "./github.ts";
6
+ import { BaseBranchMustExistError, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type PrState } from "./github.ts";
7
7
 
8
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
9
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -428,3 +428,38 @@ test("ensurePromotionPr: creates when none exists, then reuses on a re-run (idem
428
428
  assertEquals(second?.number, 500);
429
429
  assertEquals(state.creates.length, 1);
430
430
  });
431
+ // The shared PR-liveness gate (#342) maps live GitHub state onto one of {open, merged, closed,
432
+ // unknown} so neither durable loop (merge/convergence) can escalate against a non-open PR. A closed
433
+ // PR is terminal (abandon), a merged PR completes, and a null read stays conservative (unknown →
434
+ // proceed as before). `merged` wins over `state` because a merged PR also reports state="closed".
435
+ function prState(over: Partial<PrState>): PrState {
436
+ return {
437
+ merged: false,
438
+ state: "open",
439
+ mergeStateStatus: "CLEAN",
440
+ failingChecks: 0,
441
+ failingCheckNames: [],
442
+ presentCheckNames: [],
443
+ totalChecks: 0,
444
+ isDraft: false,
445
+ headRefOid: null,
446
+ ...over,
447
+ };
448
+ }
449
+
450
+ test("classifyPrLiveness: an open PR proceeds", () => {
451
+ assertEquals(classifyPrLiveness(prState({ state: "open" })), "open");
452
+ });
453
+
454
+ test("classifyPrLiveness: a merged PR completes (merged wins over a closed state)", () => {
455
+ assertEquals(classifyPrLiveness(prState({ merged: true, state: "closed" })), "merged");
456
+ assertEquals(classifyPrLiveness(prState({ merged: true, state: "merged" })), "merged");
457
+ });
458
+
459
+ test("classifyPrLiveness: a closed-not-merged PR is terminal (abandon)", () => {
460
+ assertEquals(classifyPrLiveness(prState({ merged: false, state: "closed" })), "closed");
461
+ });
462
+
463
+ test("classifyPrLiveness: a null read (transport hiccup) is unknown — never abandons blind", () => {
464
+ assertEquals(classifyPrLiveness(null), "unknown");
465
+ });
package/app/github.ts CHANGED
@@ -484,6 +484,11 @@ export function coalesceTitle(...candidates: (string | null | undefined)[]): str
484
484
  * failing gates (empty in token mode) so the CI-fix agent knows what to make green. */
485
485
  export interface PrState {
486
486
  merged: boolean;
487
+ /** GitHub's high-level PR lifecycle state, normalised to `"open" | "closed" | "merged"`. A PR
488
+ * closed *without* merging reports `"closed"` (GitHub also reports a merged PR as `"closed"` on
489
+ * the REST list, but `merged` disambiguates it). Lets a caller gate on PR liveness — see
490
+ * `classifyPrLiveness` — so neither loop escalates against a non-open PR (#342). */
491
+ state: "open" | "closed" | "merged";
487
492
  mergeStateStatus: string;
488
493
  failingChecks: number;
489
494
  failingCheckNames: string[];
@@ -584,8 +589,10 @@ export async function fetchPrState(
584
589
  };
585
590
  const rollup = j.statusCheckRollup ?? [];
586
591
  const names = failingCheckNames(rollup);
592
+ const merged = j.state === "MERGED" || !!j.mergedAt;
587
593
  return {
588
- merged: j.state === "MERGED" || !!j.mergedAt,
594
+ merged,
595
+ state: merged ? "merged" : (j.state ?? "").toUpperCase() === "CLOSED" ? "closed" : "open",
589
596
  mergeStateStatus: (j.mergeStateStatus || "UNKNOWN").toUpperCase(),
590
597
  failingChecks: names.length,
591
598
  failingCheckNames: names,
@@ -604,14 +611,19 @@ export async function fetchPrState(
604
611
  const j = (await r.json()) as {
605
612
  merged?: boolean;
606
613
  merged_at?: string | null;
614
+ state?: string;
607
615
  mergeable_state?: string;
608
616
  draft?: boolean;
609
617
  head?: { sha?: string | null };
610
618
  };
619
+ const restMerged = !!j.merged || !!j.merged_at;
611
620
  return {
612
621
  // The single-PR GET returns a `merged` boolean (unlike the list endpoint); we also honour
613
622
  // `merged_at` so this mirrors the gh branch's `state === "MERGED" || mergedAt` rule.
614
- merged: !!j.merged || !!j.merged_at,
623
+ merged: restMerged,
624
+ // REST reports a merged PR as `state:"closed"` too, so `merged` disambiguates: a `closed` PR
625
+ // here is genuinely closed WITHOUT merging (e.g. superseded) — the #342 abandon case.
626
+ state: restMerged ? "merged" : (j.state ?? "").toLowerCase() === "closed" ? "closed" : "open",
615
627
  mergeStateStatus: normalizeMergeState(j.mergeable_state ?? "unknown"),
616
628
  failingChecks: -1, // REST here doesn't enumerate checks → classifier treats BLOCKED as "wait"
617
629
  failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode
@@ -622,6 +634,27 @@ export async function fetchPrState(
622
634
  };
623
635
  }
624
636
 
637
+ /** Map a PR's live GitHub state to one **liveness** verdict shared by both durable loops (merge +
638
+ * convergence), so neither can ever escalate against a non-open PR (#342):
639
+ *
640
+ * • `open` — proceed with the normal protocol.
641
+ * • `merged` — already landed (out-of-band); complete the loop as merged.
642
+ * • `closed` — closed on GitHub WITHOUT merging (e.g. superseded); the PR can never merge, so
643
+ * the loop must **abandon** (terminate) it — NOT escalate a merge no human can
644
+ * complete. This is terminal state, not a human decision.
645
+ * • `unknown` — a transport hiccup left us without live state (`fetchPrState` returned null);
646
+ * stay conservative and fall through to the normal path rather than abandoning a
647
+ * PR we could not read.
648
+ *
649
+ * Deriving all three from one source keeps a single canonical liveness gate instead of each loop
650
+ * re-implementing `pre?.merged`/closed checks against drifting field names. */
651
+ export function classifyPrLiveness(pre: PrState | null): "open" | "merged" | "closed" | "unknown" {
652
+ if (!pre) return "unknown";
653
+ if (pre.merged) return "merged";
654
+ if (pre.state === "closed") return "closed";
655
+ return "open";
656
+ }
657
+
625
658
  /** The changed file paths of a PR (for the D2 conflict-scan, #58). `gh` returns them directly;
626
659
  * the token transport pages `/pulls/{n}/files` (100/page, capped). Returns `null` when no
627
660
  * transport is usable (idle), an empty array for a PR with no files. */
@@ -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
+ });
@@ -11,6 +11,7 @@ import { queuedVerdict } from "./service.ts";
11
11
  function st(over: Partial<PrState>): PrState {
12
12
  return {
13
13
  merged: false,
14
+ state: "open",
14
15
  mergeStateStatus: "CLEAN",
15
16
  failingChecks: 0,
16
17
  failingCheckNames: [],
@@ -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
@@ -25,6 +25,7 @@ import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
25
25
  import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
26
26
  import {
27
27
  classifyMergeability,
28
+ classifyPrLiveness,
28
29
  coalesceTitle,
29
30
  ensureFreshHeadRun,
30
31
  ensurePromotionPr,
@@ -81,6 +82,7 @@ import {
81
82
  } from "./userTasks.ts";
82
83
  import { deriveWaitGate } from "./waitGate.ts";
83
84
  import { waveMergeTargets } from "./waves.ts";
85
+ import { isCommitSha, WorldStore } from "./world/index.ts";
84
86
 
85
87
  /** The BPMN process that drives review convergence (`resources/processes/convergence-loop.bpmn`). */
86
88
  export const PROCESS_ID = "convergence-loop";
@@ -373,8 +375,20 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
373
375
  * fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
374
376
  * it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
375
377
  * 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
- export function repoEnvelopeVars(repo: string, ref: string | null, baseRef: string | null = null): Record<string, unknown> {
378
+ * that base reachable for the diff.
379
+ *
380
+ * World-restore (issue #324, ADR 0062 Slice 4/5): when a PR already has a durable push-checkpoint,
381
+ * `commitSha` is emitted so a REPLACEMENT activation (a fresh worktree after a lease loss)
382
+ * reconstructs the working tree to the EXACT pushed SHA — the inversion of the round's outbound
383
+ * `git push` into an inbound `git fetch && git checkout <sha>` — rather than to a branch tip that may
384
+ * have moved. Omitted (no key) when the PR has no checkpoint yet, so a first activation clones the
385
+ * head branch normally. */
386
+ export function repoEnvelopeVars(
387
+ repo: string,
388
+ ref: string | null,
389
+ baseRef: string | null = null,
390
+ commitSha: string | null = null,
391
+ ): Record<string, unknown> {
378
392
  if (!ref) return {};
379
393
  // Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
380
394
  // `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
@@ -399,11 +413,33 @@ export function repoEnvelopeVars(repo: string, ref: string | null, baseRef: stri
399
413
  // The base branch this PR targets — emitted so the harness fetches its tip alongside the
400
414
  // single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
401
415
  ...(baseRef ? { baseRef } : {}),
416
+ // World-restore (issue #324): the last pushed SHA a replacement activation reconstructs the
417
+ // working tree to (inverting the round's push into a fetch+checkout). Only emitted when it is
418
+ // a well-formed 40-hex commit SHA: `commitSha` is forwarded to the harness as an EXACT
419
+ // checkout target, so a non-SHA ref or a whitespace-tainted value could reconstruct to an
420
+ // unintended ref (a moved branch tip) or fail provisioning. A malformed value degrades to
421
+ // omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
422
+ // when the PR has no durable push-checkpoint yet.
423
+ ...(isCommitSha(commitSha) ? { commitSha } : {}),
402
424
  },
403
425
  },
404
426
  };
405
427
  }
406
428
 
429
+ /** The last durable push-checkpoint SHA for a PR (issue #324, ADR 0062 Slice 4/5), or `null` when it
430
+ * has none yet. Threaded into `repoEnvelopeVars` so a replacement activation reconstructs the exact
431
+ * pushed tree. Best-effort: any store read failure (a legacy DB predating migration 049, an in-flight
432
+ * desync) degrades to `null` — the harness then clones the head branch tip, the pre-#324 behaviour —
433
+ * rather than blocking a submit/merge on the world store. */
434
+ async function lastPushedSha(data: DataLayer, prKey: string): Promise<string | null> {
435
+ try {
436
+ return (await new WorldStore(data).lastCheckpoint(prKey))?.commitSha ?? null;
437
+ } catch (err) {
438
+ console.warn(`[world] ${prKey} last-checkpoint read: ${err}`);
439
+ return null;
440
+ }
441
+ }
442
+
407
443
  /** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
408
444
  * `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
409
445
  * recorded as the PR's merge-stage dependency set. */
@@ -508,6 +544,11 @@ export async function submitPr(
508
544
  });
509
545
  }
510
546
  const abUrl = abandonUrl(abandonToken);
547
+ // World-restore (issue #324, ADR 0062 Slice 4/5): a re-run of convergence for a PR that already
548
+ // pushed is a resume — carry its last durable push-checkpoint so a replacement activation on a
549
+ // fresh worktree reconstructs the tree to the EXACT pushed SHA. Absent (null) on a first submit,
550
+ // which leaves the envelope unchanged.
551
+ const worldSha = await lastPushedSha(data, parsed.prKey);
511
552
  const { processInstanceKey } = await engine.createInstance({
512
553
  processDefinitionId: PROCESS_ID,
513
554
  variables: {
@@ -533,7 +574,7 @@ export async function submitPr(
533
574
  // Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
534
575
  // harness clones an isolated workspace checked out on the PR head branch. Spread last so an
535
576
  // unresolved head (`{}`) leaves the other vars untouched.
536
- ...repoEnvelopeVars(parsed.repo, headRef, baseRef),
577
+ ...repoEnvelopeVars(parsed.repo, headRef, baseRef, worldSha),
537
578
  },
538
579
  });
539
580
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -580,6 +621,9 @@ export async function startMerge(
580
621
  if (!headRef) {
581
622
  console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
582
623
  }
624
+ // World-restore (issue #324): the merge stage runs on the same durable working tree; carry the
625
+ // last push-checkpoint so a replacement fix-ci/rebase activation reconstructs the exact SHA.
626
+ const worldSha = await lastPushedSha(data, pr.prKey);
583
627
  const { processInstanceKey } = await engine.createInstance({
584
628
  processDefinitionId: MERGE_PROCESS_ID,
585
629
  variables: {
@@ -601,7 +645,7 @@ export async function startMerge(
601
645
  abandonBrief: renderAbandonBrief(abUrl),
602
646
  // Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
603
647
  // fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
604
- ...repoEnvelopeVars(pr.repo, headRef, baseRef),
648
+ ...repoEnvelopeVars(pr.repo, headRef, baseRef, worldSha),
605
649
  },
606
650
  });
607
651
  if (processInstanceKey != null) {
@@ -882,7 +926,8 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
882
926
  try {
883
927
  const st = await fetchPrState(repo, number, token);
884
928
  if (st === null) continue; // no transport → skip this PR (others may still advance)
885
- if (st.merged) {
929
+ const liveness = classifyPrLiveness(st);
930
+ if (liveness === "merged") {
886
931
  // Landed out-of-band (a maintainer clicked Merge, a mergify queue merged it, etc.). The
887
932
  // instance is parked at `wait-mergeable`, which subscribes to `merge-ready` — NOT
888
933
  // `merge-landed` (that catch, `wait-landed`, only exists later, after we enqueue). Publishing
@@ -898,6 +943,21 @@ async function pollMerges(data: DataLayer, engine: EngineClient, token: string)
898
943
  console.log(`[poller] already merged -> ${prKey}`);
899
944
  continue;
900
945
  }
946
+ if (liveness === "closed") {
947
+ // Closed on GitHub WITHOUT merging (e.g. superseded by a newer PR — #350). The PR can never
948
+ // land, so it must NOT be classified as blocked/conflict and escalated (that orphans the
949
+ // process on a dead PR, #342). Route it through the same canonical `merge-ready` → `ready` →
950
+ // `attempt-merge` path as the merged case; the merge worker's closed short-circuit records a
951
+ // terminal `abandoned` audit row and drives the loop down its terminate/abandon end event.
952
+ // One canonical abandon implementation lives in the worker — the poller only routes to it.
953
+ await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
954
+ name: "merge-ready",
955
+ correlationKey: prKey,
956
+ variables: { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
957
+ });
958
+ console.log(`[poller] closed without merging -> ${prKey}`);
959
+ continue;
960
+ }
901
961
  const verdict = classifyMergeability(st);
902
962
  if (verdict === "waiting") {
903
963
  // Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh