@botiverse/k-carrier 0.1.8 → 0.2.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.
Files changed (62) hide show
  1. package/NOTICE +5 -2
  2. package/README.md +72 -26
  3. package/core/src/artifact/download.ts +23 -1
  4. package/core/src/artifact/gzip.ts +26 -0
  5. package/core/src/artifact/source.ts +2 -0
  6. package/core/src/{createUpgrader.ts → createRunner.ts} +13 -15
  7. package/core/src/index.ts +11 -3
  8. package/core/src/launcher/launch.ts +15 -0
  9. package/core/src/launcher/supervise.ts +170 -0
  10. package/core/src/lifecycle/commandHost.ts +111 -0
  11. package/core/src/lifecycle/hostAdapter.ts +28 -16
  12. package/core/src/operation.ts +47 -25
  13. package/core/src/operationLifecycle.ts +2 -7
  14. package/core/src/platform/ops.ts +7 -0
  15. package/core/src/platform/posix.ts +26 -6
  16. package/core/src/platform/windows.ts +8 -2
  17. package/core/src/protocol/runner.ts +81 -0
  18. package/core/src/provenance/journal.ts +1 -1
  19. package/core/src/runner/cli.ts +27 -0
  20. package/core/src/runner/execute.ts +68 -0
  21. package/core/src/txn/engine.ts +41 -85
  22. package/core/src/txn/fileEffects.ts +15 -1
  23. package/core/src/txn/hostCallBudget.ts +4 -1
  24. package/core/src/txn/hostCallUncertain.ts +2 -0
  25. package/core/src/txn/lock.ts +81 -37
  26. package/core/src/txn/state.ts +1 -1
  27. package/core/src/upgrade/drive.ts +31 -2
  28. package/core/src/upgrade/outcome.ts +1 -1
  29. package/core/src/upgrade/recover.ts +22 -1
  30. package/core/src/upgrade/retire.ts +1 -1
  31. package/core/src/upgrader.ts +4 -9
  32. package/docs/design.md +173 -0
  33. package/docs/guide.md +196 -0
  34. package/docs/harness-design.md +75 -170
  35. package/docs/integration.md +221 -364
  36. package/docs/prior-art/design-influences.md +26 -0
  37. package/docs/prior-art/external-runner-research.md +49 -0
  38. package/docs/reference.md +209 -0
  39. package/docs/test-plan.md +89 -92
  40. package/harness/src/adapter/releaseKnob.ts +1 -1
  41. package/harness/src/adapter/serviceChecks.ts +5 -5
  42. package/harness/src/artifact/m1.ts +8 -8
  43. package/harness/src/artifact/m1Resume.ts +2 -2
  44. package/harness/src/artifact/m3.ts +25 -104
  45. package/harness/src/artifact/m3Hosts.ts +9 -61
  46. package/harness/src/artifact/m4.ts +3 -3
  47. package/harness/src/artifact/m5.ts +5 -5
  48. package/harness/src/artifact/m6.ts +6 -6
  49. package/harness/src/artifact/m6Status.ts +1 -1
  50. package/harness/src/examples/checks.ts +10 -13
  51. package/harness/src/fixtures/cliToolSource.ts +166 -0
  52. package/harness/src/fixtures/externalCrashAdapter.ts +19 -0
  53. package/harness/src/fixtures/managedHost.ts +100 -0
  54. package/harness/src/fixtures/serviceSource.ts +181 -0
  55. package/harness/src/fixtures/supervisedAdapter.ts +57 -0
  56. package/harness/src/scenario/processScan.ts +3 -1
  57. package/harness/src/scenario/sandbox.ts +2 -2
  58. package/harness/src/teeth/artifact.ts +3 -3
  59. package/harness/src/teeth/examples.ts +1 -1
  60. package/package.json +5 -3
  61. package/docs/design-v1.md +0 -246
  62. package/docs/prior-art.md +0 -150
@@ -7,6 +7,8 @@
7
7
  * - Crash anywhere -> recover() lands on stable-running or completes the
8
8
  * transition, decided by journal replay. Never dual-run, never bricked.
9
9
  * - Promote only after the caller-supplied predicate evaluation passed.
10
+ * - Readback is bound to a NEW incarnation: the startId probed before
11
+ * handover is journaled, and evidence carrying it again is refused.
10
12
  * - Rollback is always available until promote; its reason is journaled.
11
13
  */
12
14
  import type { HostAdapter, ProcessEvidence } from "../lifecycle/hostAdapter.ts";
@@ -14,7 +16,7 @@ import type { TxnEffects } from "./effects.ts";
14
16
  import type { JournalEntry, TxnPhase } from "./state.ts";
15
17
  import { STATE_FORMAT_VERSION } from "./state.ts";
16
18
  import type { Clock } from "../clock.ts";
17
- import { HostCallTimeout, DEFAULT_HOST_CALL_BUDGET_MS } from "./hostCallBudget.ts";
19
+ import { HostCallTimeout, HostCallUncertain, DEFAULT_HOST_CALL_BUDGET_MS } from "./hostCallBudget.ts";
18
20
 
19
21
 
20
22
  export interface EngineDeps {
@@ -53,6 +55,8 @@ export class UpgradeEngine {
53
55
  private seq = 0;
54
56
 
55
57
  constructor(deps: EngineDeps) {
58
+ const budget = deps.hostCallBudgetMs ?? DEFAULT_HOST_CALL_BUDGET_MS;
59
+ if (!Number.isSafeInteger(budget) || budget <= 0 || budget > 2_147_483_647) throw new Error("invalid host call budget");
56
60
  this.deps = deps;
57
61
  }
58
62
 
@@ -71,6 +75,7 @@ export class UpgradeEngine {
71
75
  * Must be called before upgrade() on every process start.
72
76
  */
73
77
  async recover(): Promise<void> {
78
+ if (this.deps.host.fence) await this.withBudget("fence", () => this.deps.host.fence!());
74
79
  const entries = await this.deps.effects.journal.readAll();
75
80
  const last = entries.at(-1);
76
81
  this.seq = (last?.seq ?? -1) + 1;
@@ -89,16 +94,16 @@ export class UpgradeEngine {
89
94
  // resume is part of the terminal action too. A crash after promote()
90
95
  // but before resume() used to leave the service alive and its hosted
91
96
  // work permanently parked; DST found this exact effect boundary.
92
- await this.deps.host.resume();
97
+ await this.withBudget("resume", () => this.deps.host.resume());
93
98
  return;
94
99
  case "rolled-back":
95
100
  // The terminal journal entry is WAL intent, not proof that the host
96
101
  // restore ran. Redo the whole idempotent rollback action: a crash
97
102
  // immediately after journaling `rolled-back` may still have the
98
103
  // experiment process live and workloads parked.
99
- await this.deps.host.stop("experiment");
100
- await this.deps.host.start("stable");
101
- await this.deps.host.resume();
104
+ await this.withBudget("stop", () => this.deps.host.stop("experiment"));
105
+ await this.withBudget("start", () => this.deps.host.start("stable"));
106
+ await this.withBudget("resume", () => this.deps.host.resume());
102
107
  await this.deps.effects.slots.clearExperiment();
103
108
  return;
104
109
  case "staged":
@@ -108,15 +113,8 @@ export class UpgradeEngine {
108
113
  case "handing-over":
109
114
  case "running-experiment":
110
115
  case "readback": {
111
- // We may have died with the experiment (partially) live -- or the
112
- // handover succeeded and killed the process that was driving it.
113
- // EVIDENCE decides, never a "this restart was planned" flag: a flag is
114
- // a claim the crash path could make just as easily.
115
- if (version !== null && (await this.handoverSucceeded(last, version))) {
116
- await this.finishHandover(version);
117
- return;
118
- }
119
- // Fail closed: stop whatever runs, restore stable, resume workloads.
116
+ // Runner death before the commit intent always undoes the attempt.
117
+ // A healthy candidate does not authorize a successor to commit it.
120
118
  await this.rollbackTo(`crash during ${last.intent}` + (version ? ` (experiment ${version})` : ""));
121
119
  return;
122
120
  }
@@ -134,20 +132,23 @@ export class UpgradeEngine {
134
132
  const versions = await this.deps.effects.slots.slotVersions();
135
133
  if (versions.stable === target.version) return { result: "up-to-date" };
136
134
 
135
+ // Record the incarnation this upgrade replaces, before anything on disk
136
+ // changes. A failed probe provides no usable baseline; it does not prove
137
+ // the service is stopped. The controller must still confirm stop before
138
+ // start. An uncertain probe is let out, as for the readback probe below.
139
+ let prior: ProcessEvidence | null = null;
140
+ try {
141
+ prior = await this.withBudget("healthProbe", () => this.deps.host.healthProbe());
142
+ } catch (err) {
143
+ if (err instanceof HostCallUncertain) throw err;
144
+ prior = null;
145
+ }
146
+
137
147
  await this.journal("staged", { version: target.version });
138
148
  await this.deps.effects.slots.stageExperiment(target);
139
149
 
140
- // Who is handing over. Recorded BEFORE the handover because on some hosts
141
- // this very process does not survive it: a service that is replaced by
142
- // exiting (its supervisor respawns it from the new bytes) dies here on the
143
- // SUCCESS path, and the successor -- not us -- finishes the transaction.
144
- // Without this identity the successor cannot tell "the handover worked"
145
- // from "we crashed mid-handover", because both leave the same journal.
146
- const priorStartId = await this.probeStartId();
147
- await this.journal("handing-over", {
148
- version: target.version,
149
- ...(priorStartId === null ? {} : { priorStartId }),
150
- });
150
+ // The external runner survives normal service replacement.
151
+ await this.journal("handing-over", { version: target.version, ...(prior ? { priorStartId: prior.startId } : {}) });
151
152
  await this.withBudget("quiesce", () => this.deps.host.quiesce());
152
153
  await this.withBudget("stop", () => this.deps.host.stop("stable"));
153
154
  await this.withBudget("start", () => this.deps.host.start("experiment"));
@@ -164,69 +165,36 @@ export class UpgradeEngine {
164
165
  // how a stuck upgrade becomes two live incarnations. Let it out; the
165
166
  // journal keeps the in-flight phase and the next start resolves it from
166
167
  // evidence.
167
- if (err instanceof HostCallTimeout) throw err;
168
+ if (err instanceof HostCallUncertain) throw err;
168
169
  return this.rollbackOutcome(`experiment probe failed: ${(err as Error).message}`);
169
170
  }
170
171
 
171
172
  await this.journal("readback", { version: target.version });
172
- const refusal = await this.deps.evaluatePredicates(evidence, target.version);
173
+ // The same startId after stop/start means the old process answered: it
174
+ // was never stopped, or the probe served cached evidence. A version
175
+ // string alone cannot tell those apart; the incarnation identity can.
176
+ if (prior !== null && evidence.startId === prior.startId) {
177
+ return this.rollbackOutcome(
178
+ `live process is still the pre-upgrade incarnation (startId ${evidence.startId}); the old service was not replaced`,
179
+ );
180
+ }
181
+ const refusal = await this.withBudget("readback", () => this.deps.evaluatePredicates(evidence, target.version));
173
182
  if (refusal !== null) {
174
183
  return this.rollbackOutcome(`predicates refused: ${refusal}`);
175
184
  }
176
185
 
177
186
  await this.journal("promoted", { version: target.version });
178
187
  await this.deps.effects.slots.promoteExperiment();
179
- await this.deps.host.resume();
188
+ await this.withBudget("resume", () => this.deps.host.resume());
180
189
  return { result: "promoted", version: target.version };
181
190
  }
182
191
 
183
- /**
184
- * Did the handover actually happen? True only when a live process reports
185
- * the EXPERIMENT version from a DIFFERENT incarnation than the one that
186
- * journaled the handover. Same incarnation answering => nothing was
187
- * replaced, whatever it claims about its version.
188
- */
189
- private async handoverSucceeded(last: JournalEntry, experimentVersion: string): Promise<boolean> {
190
- const priorStartId = last.detail.priorStartId;
191
- // No recorded identity (older journal, or a host whose probe was
192
- // unavailable) => we cannot prove a successor exists => fail closed.
193
- if (priorStartId === undefined) return false;
194
- let evidence: ProcessEvidence;
195
- try {
196
- evidence = await this.withBudget("healthProbe", () => this.deps.host.healthProbe());
197
- } catch {
198
- return false; // nothing alive to vouch for the handover
199
- }
200
- return evidence.version === experimentVersion && evidence.startId !== priorStartId;
201
- }
202
-
203
- /** Finish a transaction whose handover outlived the process that began it. */
204
- private async finishHandover(experimentVersion: string): Promise<void> {
205
- let evidence: ProcessEvidence;
206
- try {
207
- evidence = await this.withBudget("healthProbe", () => this.deps.host.healthProbe());
208
- } catch (err) {
209
- if (err instanceof HostCallTimeout) throw err; // same rule: do not act on a wedged host
210
- await this.rollbackTo(`successor probe failed: ${(err as Error).message}`);
211
- return;
212
- }
213
- await this.journal("readback", { version: experimentVersion });
214
- const refusal = await this.deps.evaluatePredicates(evidence, experimentVersion);
215
- if (refusal !== null) {
216
- await this.rollbackTo(`predicates refused after handover: ${refusal}`);
217
- return;
218
- }
219
- await this.journal("promoted", { version: experimentVersion });
220
- await this.deps.effects.slots.promoteExperiment();
221
- await this.deps.host.resume();
222
- }
223
-
224
192
  /**
225
193
  * Bound a host call. On expiry we do NOT pretend the call failed cleanly:
226
194
  * a pending promise cannot be cancelled in JS, so the host may still be
227
195
  * mid-operation. The transaction gives up instead of issuing further host
228
196
  * calls it cannot reason about, and the journal records why. Recovery is
229
- * the next process start, which probes for evidence and decides from facts.
197
+ * the next process start, which replays the durable commit or restores stable.
230
198
  */
231
199
  private async withBudget<T>(label: string, call: () => Promise<T>): Promise<T> {
232
200
  const budgetMs = this.deps.hostCallBudgetMs ?? DEFAULT_HOST_CALL_BUDGET_MS;
@@ -245,18 +213,6 @@ export class UpgradeEngine {
245
213
  }
246
214
  }
247
215
 
248
- /** Best-effort identity of the live process; null when nothing answers. */
249
- private async probeStartId(): Promise<string | null> {
250
- try {
251
- // Budgeted like every other host call: found by the wedged-host test,
252
- // which hung HERE -- an unbounded probe before the handover is the same
253
- // trap one line earlier.
254
- return (await this.withBudget("healthProbe", () => this.deps.host.healthProbe())).startId;
255
- } catch {
256
- return null;
257
- }
258
- }
259
-
260
216
  private async rollbackOutcome(reason: string): Promise<EngineOutcome> {
261
217
  await this.rollbackTo(reason);
262
218
  return { result: "rolled-back", reason };
@@ -266,9 +222,9 @@ export class UpgradeEngine {
266
222
  await this.journal("rolled-back", { reason });
267
223
  if (!opts.skipHostRestart) {
268
224
  // Stop whatever may be running (either slot), restore stable, resume.
269
- await this.deps.host.stop("experiment");
270
- await this.deps.host.start("stable");
271
- await this.deps.host.resume();
225
+ await this.withBudget("stop", () => this.deps.host.stop("experiment"));
226
+ await this.withBudget("start", () => this.deps.host.start("stable"));
227
+ await this.withBudget("resume", () => this.deps.host.resume());
272
228
  }
273
229
  await this.deps.effects.slots.clearExperiment();
274
230
  }
@@ -38,6 +38,9 @@ export function fileJournalStore(stateDir: string): JournalStore {
38
38
  } finally {
39
39
  await fh.close();
40
40
  }
41
+ // The first append creates the file; its directory entry is only
42
+ // durable once the parent is fsync'd. Cheap enough to do every time.
43
+ await platformOpsFor().syncDirectory(stateDir);
41
44
  },
42
45
  async readAll(): Promise<JournalEntry[]> {
43
46
  let text: string;
@@ -80,6 +83,13 @@ export function fileSlotStore(stateDir: string): SlotStore {
80
83
  // bytesRef is a path to the verified bytes the caller downloaded.
81
84
  await fs.copyFile(artifact.bytesRef, path.join(staging, ARTIFACT_FILE));
82
85
  await fs.writeFile(path.join(staging, VERSION_FILE), artifact.version);
86
+ // Flush copied bytes, version metadata and their names before publication.
87
+ // Syncing only the parent after rename does not persist file contents.
88
+ for (const name of [ARTIFACT_FILE, VERSION_FILE]) {
89
+ const handle = await fs.open(path.join(staging, name), "r+");
90
+ try { await handle.sync(); } finally { await handle.close(); }
91
+ }
92
+ await platformOpsFor().syncDirectory(staging);
83
93
  // Publish the slot atomically: a half-written experiment must never be
84
94
  // visible as a stageable slot.
85
95
  await fs.rm(dir, { recursive: true, force: true });
@@ -95,7 +105,11 @@ export function fileSlotStore(stateDir: string): SlotStore {
95
105
  const stable = slotDir(stateDir, "stable");
96
106
  if ((await readVersion("experiment")) === null) return; // idempotent redo
97
107
  await fs.rm(`${stable}.old`, { recursive: true, force: true });
98
- await platformOpsFor().renamePath(stable, `${stable}.old`).catch(() => {});
108
+ const stableExists = await fs.stat(stable).then(() => true, (error: NodeJS.ErrnoException) => {
109
+ if (error.code === "ENOENT") return false; // replay after the first rename
110
+ throw error;
111
+ });
112
+ if (stableExists) await platformOpsFor().renamePath(stable, `${stable}.old`);
99
113
  await platformOpsFor().renamePath(experiment, stable);
100
114
  await fs.rm(`${stable}.old`, { recursive: true, force: true });
101
115
  },
@@ -15,8 +15,11 @@
15
15
  */
16
16
  export const DEFAULT_HOST_CALL_BUDGET_MS = 120_000;
17
17
 
18
+ import { HostCallUncertain } from "./hostCallUncertain.ts";
19
+ export { HostCallUncertain } from "./hostCallUncertain.ts";
20
+
18
21
  /** A host call exceeded its budget: the host is wedged, not merely failing. */
19
- export class HostCallTimeout extends Error {
22
+ export class HostCallTimeout extends HostCallUncertain {
20
23
  readonly call: string;
21
24
  readonly budgetMs: number;
22
25
  constructor(call: string, budgetMs: number) {
@@ -0,0 +1,2 @@
1
+ /** An effect may still execute; retain transaction ownership until worker exit. */
2
+ export class HostCallUncertain extends Error {}
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { promises as fs } from "node:fs";
19
19
  import path from "node:path";
20
+ import { randomUUID } from "node:crypto";
20
21
  import { platformOpsFor } from "../platform/index.ts";
21
22
 
22
23
  /** Bounded retries: an unclaimable lock is a typed failure, never a hang. */
@@ -53,46 +54,89 @@ export async function acquireUpgradeLock(stateDir: string, nowMs: number): Promi
53
54
  await fs.mkdir(stateDir, { recursive: true });
54
55
  const ops = platformOpsFor();
55
56
 
56
- const record: LockRecord = { pid: process.pid, acquiredAtMs: nowMs };
57
- let attempts = 0;
58
- for (;;) {
59
- try {
60
- // wx: fails if the file exists — the atomic "claim it" primitive.
61
- const fh = await fs.open(lockPath, "wx");
57
+ // Unique contender directories are visible atomically, before inspecting the
58
+ // legacy lock file. Never unlink a live contender or reuse its path. This
59
+ // closes both the empty-file publication window and double stale-unlink race.
60
+ const claims = path.join(stateDir, "upgrade.lock.claims");
61
+ await fs.mkdir(claims, { recursive: true });
62
+ const name = `${process.pid}-${randomUUID()}`;
63
+ const claim = path.join(claims, name);
64
+ await fs.mkdir(claim);
65
+ const removeClaim = async (): Promise<void> => {
66
+ try { await fs.rmdir(claim); }
67
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
68
+ };
69
+ try {
70
+ for (const other of await fs.readdir(claims)) {
71
+ if (other === name) continue;
72
+ const pid = Number(other.split("-")[0]);
73
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("UPGRADE_LOCK_UNREADABLE");
74
+ if (ops.isProcessAlive(pid)) throw new UpgradeLockError(pid);
75
+ await fs.rmdir(path.join(claims, other)).catch((error: NodeJS.ErrnoException) => {
76
+ if (error.code !== "ENOENT") throw error;
77
+ });
78
+ }
79
+ } catch (error) {
80
+ await removeClaim();
81
+ throw error;
82
+ }
83
+ let acquired = false;
84
+ try {
85
+ const record: LockRecord = { pid: process.pid, acquiredAtMs: nowMs };
86
+ let attempts = 0;
87
+ for (;;) {
62
88
  try {
63
- await fh.writeFile(JSON.stringify(record));
64
- } finally {
65
- await fh.close();
66
- }
67
- return {
68
- async release() {
69
- await fs.rm(lockPath, { force: true });
70
- },
71
- };
72
- } catch (err) {
73
- if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
74
- attempts += 1;
75
- if (attempts > MAX_ATTEMPTS) {
76
- // Never spin forever: a lock we can neither claim nor clear is a
77
- // typed failure, not a hang. (Hangs hide bugs; failures report them.)
78
- throw new Error(
79
- `[UPGRADE_LOCK_UNRESOLVABLE] could not acquire or clear ${lockPath} after ${MAX_ATTEMPTS} attempts`,
80
- { cause: err },
81
- );
82
- }
83
- const holder = await readHolder(lockPath);
84
- if (holder === "vanished") continue; // gone between open and read: retry
85
- // A non-positive pid is never a real holder — and must NEVER reach
86
- // process.kill, where pid<=0 addresses process GROUPS or every process.
87
- if (holder !== "unreadable" && holder.pid > 0 && ops.isProcessAlive(holder.pid)) {
88
- throw new UpgradeLockError(holder.pid);
89
+ // wx: fails if the file exists — the atomic "claim it" primitive.
90
+ const fh = await fs.open(lockPath, "wx");
91
+ try {
92
+ await fh.writeFile(JSON.stringify(record));
93
+ } finally {
94
+ await fh.close();
95
+ }
96
+ acquired = true;
97
+ let released = false;
98
+ return {
99
+ async release() {
100
+ if (released) return;
101
+ // Quarantine can move this entire state directory. A replacement
102
+ // directory at the same pathname belongs to a different owner.
103
+ try { await fs.stat(claim); }
104
+ catch (error) {
105
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
106
+ released = true;
107
+ return;
108
+ }
109
+ await fs.rm(lockPath, { force: true });
110
+ await removeClaim();
111
+ released = true;
112
+ },
113
+ };
114
+ } catch (err) {
115
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
116
+ attempts += 1;
117
+ if (attempts > MAX_ATTEMPTS) {
118
+ // Never spin forever: a lock we can neither claim nor clear is a
119
+ // typed failure, not a hang. (Hangs hide bugs; failures report them.)
120
+ throw new Error(
121
+ `[UPGRADE_LOCK_UNRESOLVABLE] could not acquire or clear ${lockPath} after ${MAX_ATTEMPTS} attempts`,
122
+ { cause: err },
123
+ );
124
+ }
125
+ const holder = await readHolder(lockPath);
126
+ if (holder === "vanished") continue; // gone between open and read: retry
127
+ // A non-positive pid is never a real holder — and must NEVER reach
128
+ // process.kill, where pid<=0 addresses process GROUPS or every process.
129
+ if (holder !== "unreadable" && holder.pid > 0 && ops.isProcessAlive(holder.pid)) {
130
+ throw new UpgradeLockError(holder.pid);
131
+ }
132
+ // Holder is gone: its transaction died mid-flight. Recovery (journal
133
+ // replay) will decide what to do with the state; clear the lock and
134
+ // take it. The contender guard above serializes stale-file removal.
135
+ await fs.rm(lockPath, { force: true });
89
136
  }
90
- // Holder is gone: its transaction died mid-flight. Recovery (journal
91
- // replay) will decide what to do with the state; clear the lock and
92
- // take it. Removing a specific stale file is safe to race — whoever
93
- // wins the next `wx` owns the lock.
94
- await fs.rm(lockPath, { force: true });
95
137
  }
138
+ } finally {
139
+ if (!acquired) await removeClaim();
96
140
  }
97
141
  }
98
142
 
@@ -124,7 +124,7 @@ export type TxnStateInput =
124
124
  *
125
125
  * The one place an inconsistent persisted world can slip in is the runtime
126
126
  * boundary that reads the journal and slots but only knows the phase at
127
- * runtime (see `createUpgrader.readState`); that boundary must decide the
127
+ * runtime (see `createRunner.readState`); that boundary must decide the
128
128
  * phase before calling this, failing closed if a phase is in flight but the
129
129
  * experiment slot is empty.
130
130
  */
@@ -1,3 +1,4 @@
1
+ import { HostCallUncertain } from "../txn/hostCallBudget.ts";
1
2
  import * as path from "node:path";
2
3
  import type { Release } from "../artifact/source.ts";
3
4
  import { downloadVerified } from "../artifact/download.ts";
@@ -14,7 +15,7 @@ import type { Clock } from "../clock.ts";
14
15
  import type { UpgradeProgress } from "../progress.ts";
15
16
  import type { ProcessEvidence } from "../lifecycle/hostAdapter.ts";
16
17
  import type { PredicateResult, ConvergenceReport } from "../converge/predicates.ts";
17
- import type { OperationDescriptor } from "../operation.ts";
18
+ import { OperationReplay, loadArchivedOperation, type OperationDescriptor } from "../operation.ts";
18
19
  import type { OperationLifecycle } from "../operationLifecycle.ts";
19
20
  import type {
20
21
  NotificationEvent,
@@ -69,9 +70,28 @@ export async function driveUpgrade(
69
70
  }
70
71
 
71
72
  const lock = await acquireUpgradeLock(deps.stateDir, deps.clock.nowMs());
73
+ let releaseLock = true;
72
74
  try {
75
+ deps.operation.reset();
76
+ // Inspect under the transaction lock, before recovery or any new host action.
77
+ const latest = await deps.operation.read();
78
+ if (latest.kind === "unreadable") throw new Error(latest.reason);
79
+ const archived = request.operation ? await loadArchivedOperation(deps.stateDir, request.operation.id) : { kind: "genesis" as const };
80
+ if (archived.kind === "unreadable") throw new Error(archived.reason);
81
+ const prior = archived.kind === "observed" ? archived : latest;
82
+ if (request.operation && prior.kind === "observed" && prior.operation.id === request.operation.id) {
83
+ if (prior.operation.targetVersion !== request.targetVersionHint) {
84
+ throw new Error("OPERATION_ID_CONFLICT: request id is already bound to another version");
85
+ }
86
+ if (prior.operation.outcome !== null) throw new OperationReplay(prior.operation);
87
+ }
73
88
  await deps.engine.recover();
74
89
  await deps.operation.settleRecovery();
90
+ const recovered = await deps.operation.read();
91
+ if (request.operation && recovered.kind === "observed" &&
92
+ recovered.operation.id === request.operation.id && recovered.operation.outcome !== null) {
93
+ throw new OperationReplay(recovered.operation);
94
+ }
75
95
  const current = await deps.readStableVersion();
76
96
  await deps.operation.begin(
77
97
  request.operation ?? null,
@@ -97,6 +117,10 @@ export async function driveUpgrade(
97
117
  });
98
118
  throw error;
99
119
  }
120
+ if (release !== null && request.targetVersionHint !== undefined && release.version !== request.targetVersionHint) {
121
+ await deps.operation.transition({ phase: "failed", outcome: "failed", reason: "target-version-mismatch" });
122
+ throw new Error("PINNED_VERSION_MISMATCH: release source returned a different target");
123
+ }
100
124
  if (release === null) {
101
125
  await deps.operation.transition({ phase: "up-to-date", outcome: "up-to-date" });
102
126
  return { result: "up-to-date" };
@@ -182,7 +206,12 @@ export async function driveUpgrade(
182
206
  break;
183
207
  }
184
208
  return finished.outcome;
209
+ } catch (error) {
210
+ // A timed-out promise may still execute. Keep ownership until this worker
211
+ // exits; its successor must fence external controller effects as well.
212
+ if (error instanceof HostCallUncertain) releaseLock = false;
213
+ throw error;
185
214
  } finally {
186
- await lock.release();
215
+ if (releaseLock) await lock.release();
187
216
  }
188
217
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * finishUpgradeOutcome — the engine outcome becomes the facade outcome:
3
3
  * report the stage, notify the sink, build the convergence report
4
- * (promoted only), and return. Split out of createUpgrader so that file
4
+ * (promoted only), and return. Split out of createRunner so that file
5
5
  * stays under the line budget; the semantics are unchanged from the inline
6
6
  * version.
7
7
  */
@@ -1,3 +1,5 @@
1
+ import { HostCallUncertain } from "../txn/hostCallBudget.ts";
2
+ import { loadOperation, loadArchivedOperation, OperationReplay } from "../operation.ts";
1
3
  import type { Clock } from "../clock.ts";
2
4
  import type { UpgradeEngine } from "../txn/engine.ts";
3
5
  import { acquireUpgradeLock } from "../txn/lock.ts";
@@ -8,12 +10,31 @@ export async function recoverUpgrade(
8
10
  clock: Clock,
9
11
  engine: UpgradeEngine,
10
12
  afterRecover?: () => Promise<void>,
13
+ expected?: { id: string; targetVersion: string },
11
14
  ): Promise<void> {
12
15
  const lock = await acquireUpgradeLock(stateDir, clock.nowMs());
16
+ let release = true;
13
17
  try {
18
+ const current = await loadOperation(stateDir);
19
+ if (current.kind === "unreadable") throw new Error(current.reason);
20
+ if (expected) {
21
+ const archived = await loadArchivedOperation(stateDir, expected.id);
22
+ if (archived.kind === "unreadable") throw new Error(archived.reason);
23
+ const prior = archived.kind === "observed" ? archived : current;
24
+ if (prior.kind !== "observed" || prior.operation.id !== expected.id) {
25
+ throw new Error("RECOVERY_OPERATION_NOT_FOUND");
26
+ }
27
+ if (prior.operation.targetVersion !== expected.targetVersion) throw new Error("OPERATION_ID_CONFLICT");
28
+ if (prior.operation.outcome !== null) throw new OperationReplay(prior.operation);
29
+ }
14
30
  await engine.recover();
15
31
  await afterRecover?.();
32
+ } catch (error) {
33
+ // A timed-out promise may still execute. Keep ownership until this worker
34
+ // exits; its successor must fence external controller effects as well.
35
+ if (error instanceof HostCallUncertain) release = false;
36
+ throw error;
16
37
  } finally {
17
- await lock.release();
38
+ if (release) await lock.release();
18
39
  }
19
40
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * retireReason — the fail-closed retirement gate (M5, extracted from
3
- * createUpgrader for the line budget). The legacy lifecycle manager may be
3
+ * createRunner for the line budget). The legacy lifecycle manager may be
4
4
  * retired ONLY after host_lifecycle_converged passed on the last promote;
5
5
  * before that, retirement is refused with a typed HOLD — removing the old
6
6
  * supervisor without a converged replacement leaves the machine with
@@ -19,11 +19,9 @@ export interface ProvenanceIdentity {
19
19
  }
20
20
 
21
21
  /**
22
- * Upgrader is the single facade an application calls. Every entrypoint the
23
- * app exposes (daemon-internal auto-update, `myapp self upgrade`, install
24
- * script, remote drive) constructs the SAME Upgrader one canonical
25
- * executor, so there is no entrypoint that "swaps bytes but skips
26
- * convergence" (the class of bug this framework exists to kill).
22
+ * Upgrader is the transaction facade constructed by createRunner
23
+ * inside the disposable runner. Application entry points submit runner
24
+ * requests; they do not construct an in-process upgrade engine.
27
25
  */
28
26
  export interface Upgrader {
29
27
  /**
@@ -35,7 +33,7 @@ export interface Upgrader {
35
33
  * recorded by K. Hosts should run this from a coordinator that survives
36
34
  * service replacement, because recovery may stop and restart the service.
37
35
  */
38
- recover(): Promise<void>;
36
+ recover(expected?: { id: string; targetVersion: string }): Promise<void>;
39
37
 
40
38
  /**
41
39
  * Ask the release source whether this install should move, without moving
@@ -105,9 +103,6 @@ export interface Upgrader {
105
103
  /** K's single durable operation receipt; hosts project it, never mirror it. */
106
104
  operation(): Promise<OperationRead>;
107
105
 
108
- /** Mark one exact terminal operation delivered by the host transport. */
109
- acknowledgeOperation(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
110
-
111
106
  /** Atomically move quiesced K state to an audit-only fresh-install backup. */
112
107
  quarantineState(options: QuarantineOptions): Promise<QuarantineResult>;
113
108
  }