cueline 0.1.3 → 0.1.4

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 (106) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +3 -3
  3. package/CHANGELOG.md +21 -0
  4. package/README.ja.md +57 -22
  5. package/README.ko.md +57 -22
  6. package/README.md +62 -24
  7. package/README.zh-CN.md +57 -22
  8. package/README.zh-TW.md +60 -23
  9. package/dist/src/api-contracts.d.ts +77 -0
  10. package/dist/src/api-contracts.js +2 -0
  11. package/dist/src/api-contracts.js.map +1 -0
  12. package/dist/src/api-controller-handoff.d.ts +6 -0
  13. package/dist/src/api-controller-handoff.js +253 -0
  14. package/dist/src/api-controller-handoff.js.map +1 -0
  15. package/dist/src/api-runtime-lifecycle.d.ts +22 -0
  16. package/dist/src/api-runtime-lifecycle.js +568 -0
  17. package/dist/src/api-runtime-lifecycle.js.map +1 -0
  18. package/dist/src/api.d.ts +6 -46
  19. package/dist/src/api.js +125 -133
  20. package/dist/src/api.js.map +1 -1
  21. package/dist/src/browser/browser-adapter.d.ts +10 -1
  22. package/dist/src/browser/codex-iab/bootstrap.d.ts +10 -0
  23. package/dist/src/browser/codex-iab/bootstrap.js +49 -0
  24. package/dist/src/browser/codex-iab/bootstrap.js.map +1 -1
  25. package/dist/src/browser/codex-iab/chatgpt-client.js +236 -107
  26. package/dist/src/browser/codex-iab/chatgpt-client.js.map +1 -1
  27. package/dist/src/browser/codex-iab/recovery-evidence.d.ts +6 -0
  28. package/dist/src/browser/codex-iab/recovery-evidence.js +37 -0
  29. package/dist/src/browser/codex-iab/recovery-evidence.js.map +1 -0
  30. package/dist/src/browser/codex-iab/submission-url.d.ts +2 -0
  31. package/dist/src/browser/codex-iab/submission-url.js +53 -0
  32. package/dist/src/browser/codex-iab/submission-url.js.map +1 -0
  33. package/dist/src/cli/main.js +188 -45
  34. package/dist/src/cli/main.js.map +1 -1
  35. package/dist/src/core/controller-abort.d.ts +1 -0
  36. package/dist/src/core/controller-abort.js +11 -0
  37. package/dist/src/core/controller-abort.js.map +1 -0
  38. package/dist/src/core/controller-command-execution.d.ts +9 -0
  39. package/dist/src/core/controller-command-execution.js +299 -0
  40. package/dist/src/core/controller-command-execution.js.map +1 -0
  41. package/dist/src/core/controller-loop.d.ts +3 -43
  42. package/dist/src/core/controller-loop.js +260 -331
  43. package/dist/src/core/controller-loop.js.map +1 -1
  44. package/dist/src/core/controller-turn.d.ts +16 -0
  45. package/dist/src/core/controller-turn.js +309 -0
  46. package/dist/src/core/controller-turn.js.map +1 -0
  47. package/dist/src/core/controller-types.d.ts +58 -0
  48. package/dist/src/core/controller-types.js +2 -0
  49. package/dist/src/core/controller-types.js.map +1 -0
  50. package/dist/src/core/persisted-run.d.ts +4 -0
  51. package/dist/src/core/persisted-run.js +19 -0
  52. package/dist/src/core/persisted-run.js.map +1 -0
  53. package/dist/src/core/process-liveness.d.ts +2 -0
  54. package/dist/src/core/process-liveness.js +44 -0
  55. package/dist/src/core/process-liveness.js.map +1 -0
  56. package/dist/src/core/run-status.d.ts +5 -2
  57. package/dist/src/core/run-status.js +65 -10
  58. package/dist/src/core/run-status.js.map +1 -1
  59. package/dist/src/core/state-machine.d.ts +15 -3
  60. package/dist/src/core/state-machine.js +148 -15
  61. package/dist/src/core/state-machine.js.map +1 -1
  62. package/dist/src/jobs/status.d.ts +1 -1
  63. package/dist/src/jobs/supervisor.js +3 -1
  64. package/dist/src/jobs/supervisor.js.map +1 -1
  65. package/dist/src/router/resolver.d.ts +2 -1
  66. package/dist/src/router/resolver.js +31 -19
  67. package/dist/src/router/resolver.js.map +1 -1
  68. package/dist/src/runners/process-runner.js +59 -6
  69. package/dist/src/runners/process-runner.js.map +1 -1
  70. package/dist/src/state/atomic-write.js +17 -2
  71. package/dist/src/state/atomic-write.js.map +1 -1
  72. package/dist/src/state/cancellation.d.ts +1 -1
  73. package/dist/src/state/cancellation.js +10 -2
  74. package/dist/src/state/cancellation.js.map +1 -1
  75. package/dist/src/state/event-log.d.ts +17 -1
  76. package/dist/src/state/event-log.js +328 -18
  77. package/dist/src/state/event-log.js.map +1 -1
  78. package/dist/src/state/paths.d.ts +1 -0
  79. package/dist/src/state/paths.js +1 -0
  80. package/dist/src/state/paths.js.map +1 -1
  81. package/dist/src/state/runtime-lease.d.ts +25 -0
  82. package/dist/src/state/runtime-lease.js +607 -59
  83. package/dist/src/state/runtime-lease.js.map +1 -1
  84. package/dist/src/state/runtime-retirement.d.ts +16 -0
  85. package/dist/src/state/runtime-retirement.js +95 -0
  86. package/dist/src/state/runtime-retirement.js.map +1 -0
  87. package/dist/src/state/runtime-takeover-intent.d.ts +1 -0
  88. package/dist/src/state/runtime-takeover-intent.js +18 -0
  89. package/dist/src/state/runtime-takeover-intent.js.map +1 -0
  90. package/dist/src/state/store.d.ts +12 -1
  91. package/dist/src/state/store.js +246 -40
  92. package/dist/src/state/store.js.map +1 -1
  93. package/dist/src/version.d.ts +1 -1
  94. package/dist/src/version.js +1 -1
  95. package/docs/architecture.md +16 -11
  96. package/docs/assets/cueline-loop-en.svg +11 -10
  97. package/docs/assets/cueline-loop-ja.svg +11 -10
  98. package/docs/assets/cueline-loop-ko.svg +11 -10
  99. package/docs/assets/cueline-loop-zh-CN.svg +11 -10
  100. package/docs/assets/cueline-loop-zh-TW.svg +11 -10
  101. package/docs/compatibility.md +15 -9
  102. package/docs/controller-protocol.md +7 -5
  103. package/docs/runner-contract.md +19 -5
  104. package/docs/state-and-recovery.md +49 -21
  105. package/package.json +2 -2
  106. package/skills/cueline/SKILL.md +65 -17
@@ -1,16 +1,246 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { open, readFile, unlink } from "node:fs/promises";
2
+ import { link, mkdir, open, readFile, readdir, rmdir, stat, unlink, utimes, } from "node:fs/promises";
3
+ import path from "node:path";
3
4
  import { CueLineError } from "../core/errors.js";
4
5
  import { canonicalJson } from "../core/ids.js";
5
- import { runtimePidTag } from "../core/runtime.js";
6
+ import { runtimePidTag, runtimePlatform } from "../core/runtime.js";
6
7
  import { atomicWriteJson } from "./atomic-write.js";
8
+ import { captureEventLegacyFence, readEvents, } from "./event-log.js";
7
9
  import { runPaths } from "./paths.js";
10
+ import { persistRuntimeOwnerRetirements, readStableRuntimeOwnerRetirementCutoffs, runtimeFenceAuthorityIdentity, } from "./runtime-retirement.js";
11
+ import { persistRuntimeTakeoverIntent } from "./runtime-takeover-intent.js";
8
12
  const LEASE_PROTOCOL = "cueline/runtime-lease/0.1";
13
+ const FENCE_PROTOCOL = "cueline/runtime-fence/0.1";
9
14
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
10
15
  const DEFAULT_STALE_AFTER_MS = 20_000;
16
+ const LEASE_LOCK_TIMEOUT_MS = 10_000;
17
+ const LEASE_LOCK_STALE_MS = 30_000;
18
+ function pidIsDefinitelyDead(pidTag) {
19
+ const pid = Number(pidTag);
20
+ if (!Number.isSafeInteger(pid) || pid < 1)
21
+ return false;
22
+ const native = typeof process === "undefined" ? undefined : process;
23
+ if (typeof native?.kill !== "function")
24
+ return false;
25
+ try {
26
+ native.kill(pid, 0);
27
+ return false;
28
+ }
29
+ catch (error) {
30
+ return (typeof error === "object" &&
31
+ error !== null &&
32
+ "code" in error &&
33
+ error.code === "ESRCH");
34
+ }
35
+ }
36
+ function isAlreadyExists(error) {
37
+ return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
38
+ }
39
+ async function syncDirectory(directory) {
40
+ if (runtimePlatform() === "win32")
41
+ return;
42
+ const handle = await open(directory, "r");
43
+ try {
44
+ await handle.sync();
45
+ }
46
+ finally {
47
+ await handle.close();
48
+ }
49
+ }
50
+ async function createExclusiveJson(target, value) {
51
+ const directory = path.dirname(target);
52
+ await mkdir(directory, { recursive: true, mode: 0o700 });
53
+ const temporary = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`);
54
+ let handle;
55
+ try {
56
+ handle = await open(temporary, "wx", 0o600);
57
+ await handle.writeFile(`${canonicalJson(value)}\n`, "utf8");
58
+ await handle.sync();
59
+ await handle.close();
60
+ handle = undefined;
61
+ await link(temporary, target);
62
+ await syncDirectory(directory);
63
+ }
64
+ finally {
65
+ await handle?.close().catch(() => undefined);
66
+ await unlink(temporary).catch(() => undefined);
67
+ }
68
+ }
69
+ function delay(milliseconds) {
70
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
71
+ }
72
+ async function withLeaseMutationLock(home, runId, operation) {
73
+ const lockDirectory = `${runPaths(home, runId).runtimeLease}.lock`;
74
+ const ownerToken = `owner-${randomUUID()}`;
75
+ const ownerPath = `${lockDirectory}/${ownerToken}`;
76
+ const deadline = Date.now() + LEASE_LOCK_TIMEOUT_MS;
77
+ let reclaimed = false;
78
+ while (true) {
79
+ try {
80
+ await mkdir(lockDirectory, { mode: 0o700 });
81
+ let handle;
82
+ try {
83
+ handle = await open(ownerPath, "wx", 0o600);
84
+ await handle.writeFile(`${canonicalJson({
85
+ protocol: "cueline/runtime-mutation-lock/0.1",
86
+ owner_token: ownerToken,
87
+ pid: runtimePidTag(),
88
+ created_at: new Date().toISOString(),
89
+ })}\n`, "utf8");
90
+ await handle.sync();
91
+ }
92
+ finally {
93
+ await handle?.close();
94
+ }
95
+ break;
96
+ }
97
+ catch (error) {
98
+ if (isNotFound(error)) {
99
+ // A concurrent stale-empty-directory reclaimer may remove the
100
+ // directory between mkdir and owner-token creation. No operation has
101
+ // started yet, so retrying acquisition is safe.
102
+ await delay(1);
103
+ continue;
104
+ }
105
+ if (!isAlreadyExists(error))
106
+ throw error;
107
+ const entries = await readdir(lockDirectory).catch(() => []);
108
+ const evidencePaths = entries.length === 0
109
+ ? [lockDirectory]
110
+ : entries.map((entry) => `${lockDirectory}/${entry}`);
111
+ const newestMtime = Math.max(0, ...(await Promise.all(evidencePaths.map((entry) => stat(entry).then((value) => value.mtimeMs).catch(() => 0)))));
112
+ const lockAge = newestMtime === 0 ? 0 : Date.now() - newestMtime;
113
+ if (lockAge > LEASE_LOCK_STALE_MS && entries.length <= 1) {
114
+ let removedExactEvidence = false;
115
+ if (entries.length === 0) {
116
+ removedExactEvidence = await rmdir(lockDirectory).then(() => true, (removeError) => {
117
+ if (isNotFound(removeError))
118
+ return false;
119
+ const code = typeof removeError === "object" && removeError !== null && "code" in removeError
120
+ ? removeError.code
121
+ : undefined;
122
+ if (code === "ENOTEMPTY")
123
+ return false;
124
+ throw removeError;
125
+ });
126
+ }
127
+ else {
128
+ const staleOwnerPath = `${lockDirectory}/${entries[0]}`;
129
+ removedExactEvidence = await unlink(staleOwnerPath).then(() => true, (removeError) => {
130
+ if (isNotFound(removeError))
131
+ return false;
132
+ throw removeError;
133
+ });
134
+ if (removedExactEvidence) {
135
+ await rmdir(lockDirectory).catch((removeError) => {
136
+ const code = typeof removeError === "object" && removeError !== null && "code" in removeError
137
+ ? removeError.code
138
+ : undefined;
139
+ if (!isNotFound(removeError) && code !== "ENOTEMPTY")
140
+ throw removeError;
141
+ });
142
+ }
143
+ }
144
+ if (removedExactEvidence) {
145
+ reclaimed = true;
146
+ continue;
147
+ }
148
+ }
149
+ if (Date.now() >= deadline) {
150
+ throw new CueLineError("RUN_CLAIM_IN_PROGRESS", `CueLine run '${runId}' has another runtime lease mutation in progress.`);
151
+ }
152
+ await delay(10);
153
+ }
154
+ }
155
+ const heartbeat = setInterval(() => {
156
+ const timestamp = new Date();
157
+ void utimes(ownerPath, timestamp, timestamp).catch(() => undefined);
158
+ }, Math.max(1_000, Math.floor(LEASE_LOCK_STALE_MS / 3)));
159
+ heartbeat.unref();
160
+ try {
161
+ const context = await prepareMutationFence(home, runId, reclaimed);
162
+ const result = await operation(context);
163
+ const authoritativeFence = await readFenceRecord(home, runId);
164
+ const contextIsAuthoritative = context.generation === undefined
165
+ ? authoritativeFence === undefined
166
+ : authoritativeFence?.generation === context.generation &&
167
+ (authoritativeFence.lease_source ?? "epoch") === context.leaseSource;
168
+ if (!contextIsAuthoritative) {
169
+ throw new CueLineError("RUNTIME_MUTATION_FENCED", `CueLine run '${runId}' runtime mutation was superseded by a newer fence.`);
170
+ }
171
+ return result;
172
+ }
173
+ finally {
174
+ clearInterval(heartbeat);
175
+ let removedOwnToken = false;
176
+ await unlink(ownerPath).then(() => {
177
+ removedOwnToken = true;
178
+ }, (error) => {
179
+ if (!isNotFound(error))
180
+ throw error;
181
+ });
182
+ if (removedOwnToken) {
183
+ await rmdir(lockDirectory).catch((error) => {
184
+ const code = typeof error === "object" && error !== null && "code" in error
185
+ ? error.code
186
+ : undefined;
187
+ if (!isNotFound(error) && code !== "ENOTEMPTY")
188
+ throw error;
189
+ });
190
+ }
191
+ }
192
+ }
193
+ /**
194
+ * Serializes one authoritative state mutation with lease claim, heartbeat,
195
+ * release, and takeover. The event payload itself still uses immutable
196
+ * lock-free segments; this fence only closes the owner-change commit window.
197
+ */
198
+ export async function withRuntimeLeaseMutation(home, runId, operation) {
199
+ return withLeaseMutationLock(home, runId, async () => operation());
200
+ }
201
+ export async function retireDeadRuntimeLease(home, runId, expectedOwnerId) {
202
+ return withLeaseMutationLock(home, runId, async ({ target }) => {
203
+ const current = await readLeaseRecordAt(target, runId);
204
+ if (current === undefined ||
205
+ current.state !== "active" ||
206
+ current.owner_id !== expectedOwnerId ||
207
+ !pidIsDefinitelyDead(current.pid)) {
208
+ return false;
209
+ }
210
+ await persistRuntimeOwnerRetirements(home, runId, current.retired_owners ?? []);
211
+ await unlink(target);
212
+ return true;
213
+ });
214
+ }
11
215
  function isNotFound(error) {
12
216
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
13
217
  }
218
+ function parseRetiredOwners(value) {
219
+ if (value === undefined)
220
+ return [];
221
+ if (!Array.isArray(value))
222
+ throw new Error("RUNTIME_LEASE_INVALID");
223
+ return value.map((candidate) => {
224
+ if (typeof candidate !== "object" ||
225
+ candidate === null ||
226
+ Array.isArray(candidate)) {
227
+ throw new Error("RUNTIME_LEASE_INVALID");
228
+ }
229
+ const record = candidate;
230
+ if (typeof record.owner_id !== "string" ||
231
+ record.owner_id === "" ||
232
+ !Number.isSafeInteger(record.events_after_sequence) ||
233
+ record.events_after_sequence < 0 ||
234
+ typeof record.retired_at !== "string") {
235
+ throw new Error("RUNTIME_LEASE_INVALID");
236
+ }
237
+ return {
238
+ owner_id: record.owner_id,
239
+ events_after_sequence: record.events_after_sequence,
240
+ retired_at: record.retired_at,
241
+ };
242
+ });
243
+ }
14
244
  function parseLease(source, runId) {
15
245
  const value = JSON.parse(source);
16
246
  if (value.protocol !== LEASE_PROTOCOL ||
@@ -22,11 +252,96 @@ function parseLease(source, runId) {
22
252
  typeof value.heartbeat_at !== "string") {
23
253
  throw new Error("RUNTIME_LEASE_INVALID");
24
254
  }
25
- return value;
255
+ return {
256
+ ...value,
257
+ ...(value.retired_owners === undefined
258
+ ? {}
259
+ : { retired_owners: parseRetiredOwners(value.retired_owners) }),
260
+ };
26
261
  }
27
- async function readLeaseRecord(home, runId) {
262
+ function parseFence(source, runId) {
263
+ const value = JSON.parse(source);
264
+ if (value.protocol !== FENCE_PROTOCOL ||
265
+ value.run_id !== runId ||
266
+ typeof value.generation !== "string" ||
267
+ value.generation === "" ||
268
+ typeof value.created_at !== "string" ||
269
+ (value.lease_source !== undefined &&
270
+ value.lease_source !== "legacy" &&
271
+ value.lease_source !== "epoch")) {
272
+ throw new Error("RUNTIME_FENCE_INVALID");
273
+ }
274
+ if (value.legacy_event_fence !== undefined) {
275
+ const candidate = value.legacy_event_fence;
276
+ if (candidate.protocol !== "cueline/event-segment-fence/0.1" ||
277
+ !Number.isSafeInteger(candidate.legacy_event_count) ||
278
+ candidate.legacy_event_count < 0 ||
279
+ !Number.isSafeInteger(candidate.legacy_byte_length) ||
280
+ candidate.legacy_byte_length < 0 ||
281
+ typeof candidate.legacy_sha256 !== "string" ||
282
+ !/^[a-f0-9]{64}$/.test(candidate.legacy_sha256)) {
283
+ throw new Error("RUNTIME_FENCE_INVALID");
284
+ }
285
+ }
286
+ return {
287
+ ...value,
288
+ lease_source: value.lease_source ?? "epoch",
289
+ };
290
+ }
291
+ function runtimeFencePath(home, runId) {
292
+ return `${runPaths(home, runId).runtimeLease}.fence`;
293
+ }
294
+ function runtimeLeaseEpochDirectory(home, runId) {
295
+ return `${runPaths(home, runId).runtimeLease}.epochs`;
296
+ }
297
+ function runtimeLeaseEpochPath(home, runId, generation) {
298
+ return `${runtimeLeaseEpochDirectory(home, runId)}/${generation}.json`;
299
+ }
300
+ function runtimeFenceLeaseTarget(home, runId, fence) {
301
+ return (fence.lease_source ?? "epoch") === "legacy"
302
+ ? runPaths(home, runId).runtimeLease
303
+ : runtimeLeaseEpochPath(home, runId, fence.generation);
304
+ }
305
+ export async function readRuntimeOwnerRetirementCutoffs(home, runId) {
306
+ return readStableRuntimeOwnerRetirementCutoffs(home, runId, () => readRetirementLeaseSnapshot(home, runId));
307
+ }
308
+ async function readRetirementLeaseSnapshot(home, runId) {
309
+ for (let attempt = 0; attempt < 4; attempt += 1) {
310
+ const before = await readFenceRecord(home, runId);
311
+ const target = before === undefined
312
+ ? runPaths(home, runId).runtimeLease
313
+ : runtimeFenceLeaseTarget(home, runId, before);
314
+ let record;
315
+ let invalid = false;
316
+ try {
317
+ record = await readLeaseRecordAt(target, runId);
318
+ }
319
+ catch {
320
+ invalid = true;
321
+ }
322
+ const after = await readFenceRecord(home, runId);
323
+ const beforeIdentity = runtimeFenceAuthorityIdentity(before);
324
+ const afterIdentity = runtimeFenceAuthorityIdentity(after);
325
+ if (beforeIdentity !== afterIdentity)
326
+ continue;
327
+ if (invalid) {
328
+ return { identity: `${afterIdentity}:invalid`, retirements: [], missing: false };
329
+ }
330
+ if (record === undefined) {
331
+ return { identity: `${afterIdentity}:missing`, retirements: [], missing: true };
332
+ }
333
+ const retirements = record.retired_owners ?? [];
334
+ return {
335
+ identity: `${afterIdentity}:${record.owner_id}:${record.state}:${canonicalJson(retirements)}`,
336
+ retirements,
337
+ missing: false,
338
+ };
339
+ }
340
+ throw new Error("RUNTIME_FENCE_UNSTABLE");
341
+ }
342
+ async function readFenceRecord(home, runId) {
28
343
  try {
29
- return parseLease(await readFile(runPaths(home, runId).runtimeLease, "utf8"), runId);
344
+ return parseFence(await readFile(runtimeFencePath(home, runId), "utf8"), runId);
30
345
  }
31
346
  catch (error) {
32
347
  if (isNotFound(error))
@@ -34,6 +349,128 @@ async function readLeaseRecord(home, runId) {
34
349
  throw error;
35
350
  }
36
351
  }
352
+ async function readLeaseRecordAt(target, runId) {
353
+ try {
354
+ return parseLease(await readFile(target, "utf8"), runId);
355
+ }
356
+ catch (error) {
357
+ if (isNotFound(error))
358
+ return undefined;
359
+ throw error;
360
+ }
361
+ }
362
+ async function readLeaseRecord(home, runId) {
363
+ for (let attempt = 0; attempt < 4; attempt += 1) {
364
+ const before = await readFenceRecord(home, runId);
365
+ const target = before === undefined
366
+ ? runPaths(home, runId).runtimeLease
367
+ : runtimeFenceLeaseTarget(home, runId, before);
368
+ const record = await readLeaseRecordAt(target, runId);
369
+ const after = await readFenceRecord(home, runId);
370
+ if (runtimeFenceAuthorityIdentity(before) === runtimeFenceAuthorityIdentity(after)) {
371
+ return record;
372
+ }
373
+ }
374
+ throw new Error("RUNTIME_FENCE_UNSTABLE");
375
+ }
376
+ async function prepareMutationFence(home, runId, rotate) {
377
+ const currentFence = await readFenceRecord(home, runId);
378
+ if (!rotate) {
379
+ if (currentFence === undefined) {
380
+ return {
381
+ generation: undefined,
382
+ target: runPaths(home, runId).runtimeLease,
383
+ leaseSource: "legacy",
384
+ };
385
+ }
386
+ return {
387
+ generation: currentFence.generation,
388
+ target: runtimeFenceLeaseTarget(home, runId, currentFence),
389
+ leaseSource: currentFence.lease_source ?? "epoch",
390
+ ...(currentFence.legacy_event_fence === undefined
391
+ ? {}
392
+ : { legacyEventFence: currentFence.legacy_event_fence }),
393
+ };
394
+ }
395
+ const sourceTarget = currentFence === undefined
396
+ ? runPaths(home, runId).runtimeLease
397
+ : runtimeFenceLeaseTarget(home, runId, currentFence);
398
+ const currentRecord = await readLeaseRecordAt(sourceTarget, runId);
399
+ const generation = randomUUID();
400
+ const sourceKind = currentFence?.lease_source ?? "legacy";
401
+ const target = sourceKind === "legacy"
402
+ ? runPaths(home, runId).runtimeLease
403
+ : runtimeLeaseEpochPath(home, runId, generation);
404
+ if (sourceKind === "epoch") {
405
+ await mkdir(runtimeLeaseEpochDirectory(home, runId), { recursive: true, mode: 0o700 });
406
+ if (currentRecord !== undefined)
407
+ await atomicWriteJson(target, currentRecord);
408
+ }
409
+ const replacementFence = {
410
+ protocol: FENCE_PROTOCOL,
411
+ run_id: runId,
412
+ generation,
413
+ created_at: new Date().toISOString(),
414
+ lease_source: sourceKind,
415
+ ...(currentFence?.legacy_event_fence === undefined
416
+ ? {}
417
+ : { legacy_event_fence: currentFence.legacy_event_fence }),
418
+ };
419
+ if (currentFence === undefined) {
420
+ await createExclusiveJson(runtimeFencePath(home, runId), replacementFence);
421
+ }
422
+ else {
423
+ await atomicWriteJson(runtimeFencePath(home, runId), replacementFence);
424
+ }
425
+ return {
426
+ generation,
427
+ target,
428
+ leaseSource: sourceKind,
429
+ ...(currentFence?.legacy_event_fence === undefined
430
+ ? {}
431
+ : { legacyEventFence: currentFence.legacy_event_fence }),
432
+ };
433
+ }
434
+ async function commitLegacyLeaseReplacement(home, runId, context, record, legacyEventFence) {
435
+ if (context.leaseSource !== "legacy") {
436
+ throw new Error("RUNTIME_LEGACY_REPLACEMENT_CONTEXT_INVALID");
437
+ }
438
+ const generation = context.generation ?? randomUUID();
439
+ const target = runtimeLeaseEpochPath(home, runId, generation);
440
+ await mkdir(runtimeLeaseEpochDirectory(home, runId), { recursive: true, mode: 0o700 });
441
+ await atomicWriteJson(target, record);
442
+ const fence = {
443
+ protocol: FENCE_PROTOCOL,
444
+ run_id: runId,
445
+ generation,
446
+ created_at: new Date().toISOString(),
447
+ lease_source: "epoch",
448
+ legacy_event_fence: legacyEventFence,
449
+ };
450
+ try {
451
+ if (context.generation === undefined) {
452
+ await createExclusiveJson(runtimeFencePath(home, runId), fence);
453
+ }
454
+ else {
455
+ const current = await readFenceRecord(home, runId);
456
+ if (current?.generation !== context.generation ||
457
+ (current.lease_source ?? "epoch") !== "legacy") {
458
+ throw new CueLineError("RUNTIME_MUTATION_FENCED", `CueLine run '${runId}' runtime mutation was superseded before legacy replacement.`);
459
+ }
460
+ await atomicWriteJson(runtimeFencePath(home, runId), fence);
461
+ }
462
+ }
463
+ catch (error) {
464
+ if (isAlreadyExists(error)) {
465
+ throw new CueLineError("RUNTIME_MUTATION_FENCED", `CueLine run '${runId}' runtime mutation lost the legacy authority commit race.`, { cause: error });
466
+ }
467
+ throw error;
468
+ }
469
+ context.generation = generation;
470
+ context.target = target;
471
+ context.leaseSource = "epoch";
472
+ context.legacyEventFence = legacyEventFence;
473
+ }
37
474
  export async function readRuntimeLease(home, runId, options = {}) {
38
475
  let record;
39
476
  try {
@@ -66,17 +503,11 @@ export async function readRuntimeLease(home, runId, options = {}) {
66
503
  };
67
504
  }
68
505
  async function createExclusiveLease(target, record) {
69
- let handle;
70
- try {
71
- handle = await open(target, "wx", 0o600);
72
- await handle.writeFile(`${canonicalJson(record)}\n`, "utf8");
73
- await handle.sync();
74
- }
75
- finally {
76
- await handle?.close();
77
- }
506
+ await createExclusiveJson(target, record);
78
507
  }
79
508
  export class RuntimeLease {
509
+ #home;
510
+ #runId;
80
511
  #target;
81
512
  #now;
82
513
  #heartbeatIntervalMs;
@@ -84,7 +515,10 @@ export class RuntimeLease {
84
515
  #timer;
85
516
  #writeChain = Promise.resolve();
86
517
  #heartbeatError;
87
- constructor(target, record, now, heartbeatIntervalMs) {
518
+ #loss = new AbortController();
519
+ constructor(home, runId, target, record, now, heartbeatIntervalMs) {
520
+ this.#home = home;
521
+ this.#runId = runId;
88
522
  this.#target = target;
89
523
  this.#record = record;
90
524
  this.#now = now;
@@ -92,22 +526,7 @@ export class RuntimeLease {
92
526
  }
93
527
  static async claim(options) {
94
528
  const now = options.now ?? (() => new Date());
95
- const observation = await readRuntimeLease(options.home, options.runId, {
96
- now,
97
- ...(options.staleAfterMs === undefined ? {} : { staleAfterMs: options.staleAfterMs }),
98
- });
99
- if (observation.ownership === "active") {
100
- throw new CueLineError("RUN_ALREADY_ACTIVE", `CueLine run '${options.runId}' already has an active runtime lease.`);
101
- }
102
- if (observation.ownership === "stale") {
103
- throw new CueLineError("RUN_STALE_REQUIRES_TAKEOVER", `CueLine run '${options.runId}' has a stale runtime lease; explicit recovery is required.`);
104
- }
105
- if (observation.ownership === "invalid") {
106
- throw new CueLineError("RUNTIME_LEASE_INVALID", `CueLine run '${options.runId}' has an unreadable runtime lease.`);
107
- }
108
- const target = runPaths(options.home, options.runId).runtimeLease;
109
- if (observation.ownership === "released")
110
- await unlink(target).catch(() => undefined);
529
+ let target = "";
111
530
  const timestamp = now().toISOString();
112
531
  const record = {
113
532
  protocol: LEASE_PROTOCOL,
@@ -118,39 +537,169 @@ export class RuntimeLease {
118
537
  claimed_at: timestamp,
119
538
  heartbeat_at: timestamp,
120
539
  };
121
- try {
122
- await createExclusiveLease(target, record);
123
- }
124
- catch (error) {
125
- if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
126
- throw new CueLineError("RUN_ALREADY_ACTIVE", `CueLine run '${options.runId}' was claimed by another runtime.`, { cause: error });
540
+ await withLeaseMutationLock(options.home, options.runId, async (context) => {
541
+ target = context.target;
542
+ let current;
543
+ try {
544
+ current = await readLeaseRecordAt(target, options.runId);
127
545
  }
128
- throw error;
129
- }
130
- const lease = new RuntimeLease(target, record, now, options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS);
546
+ catch (error) {
547
+ throw new CueLineError("RUNTIME_LEASE_INVALID", `CueLine run '${options.runId}' has an unreadable runtime lease.`, { cause: error });
548
+ }
549
+ if (current?.state === "active") {
550
+ const ageMs = Math.max(0, now().getTime() - Date.parse(current.heartbeat_at));
551
+ const stale = !Number.isFinite(ageMs) ||
552
+ ageMs > (options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS);
553
+ throw new CueLineError(stale ? "RUN_STALE_REQUIRES_TAKEOVER" : "RUN_ALREADY_ACTIVE", stale
554
+ ? `CueLine run '${options.runId}' has a stale runtime lease; explicit recovery is required.`
555
+ : `CueLine run '${options.runId}' already has an active runtime lease.`);
556
+ }
557
+ if ((current?.retired_owners?.length ?? 0) > 0) {
558
+ record.retired_owners = [...current.retired_owners];
559
+ }
560
+ if (current?.state === "released") {
561
+ await persistRuntimeOwnerRetirements(options.home, options.runId, current.retired_owners ?? []);
562
+ if (context.leaseSource === "epoch")
563
+ await unlink(target);
564
+ }
565
+ if (context.leaseSource === "legacy") {
566
+ const legacyEventFence = await captureEventLegacyFence(runPaths(options.home, options.runId).events);
567
+ await commitLegacyLeaseReplacement(options.home, options.runId, context, record, legacyEventFence);
568
+ target = context.target;
569
+ return;
570
+ }
571
+ try {
572
+ await createExclusiveLease(target, record);
573
+ }
574
+ catch (error) {
575
+ if (isAlreadyExists(error)) {
576
+ throw new CueLineError("RUN_ALREADY_ACTIVE", `CueLine run '${options.runId}' was claimed by another runtime.`, { cause: error });
577
+ }
578
+ throw error;
579
+ }
580
+ });
581
+ const lease = new RuntimeLease(options.home, options.runId, target, record, now, options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS);
131
582
  lease.startHeartbeat();
132
583
  return lease;
133
584
  }
585
+ /**
586
+ * Replaces one exact stale lease with a fresh owner in one mutation-lock
587
+ * transaction. There is no ownerless interval. A reclaimed mutation lock
588
+ * rotates the authoritative epoch first, so a paused previous writer can
589
+ * only modify its fenced-off epoch when it resumes.
590
+ */
591
+ static async takeoverStale(options) {
592
+ const now = options.now ?? (() => new Date());
593
+ let target = "";
594
+ const takeoverAt = now();
595
+ const timestamp = takeoverAt.toISOString();
596
+ const record = {
597
+ protocol: LEASE_PROTOCOL,
598
+ run_id: options.runId,
599
+ owner_id: randomUUID(),
600
+ pid: runtimePidTag(),
601
+ state: "active",
602
+ claimed_at: timestamp,
603
+ heartbeat_at: timestamp,
604
+ };
605
+ await withLeaseMutationLock(options.home, options.runId, async (context) => {
606
+ target = context.target;
607
+ let current;
608
+ try {
609
+ current = await readLeaseRecordAt(target, options.runId);
610
+ }
611
+ catch (error) {
612
+ throw new CueLineError("RUNTIME_LEASE_INVALID", `CueLine run '${options.runId}' has an unreadable runtime lease.`, { cause: error });
613
+ }
614
+ await persistRuntimeTakeoverIntent(options.home, options.runId, options.expectedOwnerId, options.expectedHeartbeatAt, timestamp);
615
+ if (current === undefined ||
616
+ current.state !== "active" ||
617
+ current.owner_id !== options.expectedOwnerId ||
618
+ current.heartbeat_at !== options.expectedHeartbeatAt) {
619
+ throw new CueLineError("RUNTIME_TAKEOVER_RACE", `CueLine run '${options.runId}' changed before the stale owner could be replaced.`);
620
+ }
621
+ const ageMs = Math.max(0, takeoverAt.getTime() - Date.parse(current.heartbeat_at));
622
+ if (Number.isFinite(ageMs) &&
623
+ ageMs <= (options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS)) {
624
+ throw new CueLineError("RUNTIME_TAKEOVER_ACTIVE_REFUSED", `CueLine run '${options.runId}' still has a fresh runtime heartbeat.`);
625
+ }
626
+ await options.beforeReplace?.();
627
+ const eventsPath = runPaths(options.home, options.runId).events;
628
+ const legacyEventFence = context.legacyEventFence ?? (await captureEventLegacyFence(eventsPath));
629
+ const events = await readEvents(eventsPath, { legacyFence: legacyEventFence });
630
+ const retirement = {
631
+ owner_id: current.owner_id,
632
+ events_after_sequence: events.at(-1)?.sequence ?? 0,
633
+ retired_at: timestamp,
634
+ };
635
+ record.retired_owners = [...(current.retired_owners ?? []), retirement];
636
+ if (context.leaseSource === "legacy") {
637
+ await commitLegacyLeaseReplacement(options.home, options.runId, context, record, legacyEventFence);
638
+ target = context.target;
639
+ }
640
+ else {
641
+ if (context.legacyEventFence === undefined) {
642
+ const currentFence = await readFenceRecord(options.home, options.runId);
643
+ if (currentFence === undefined || currentFence.generation !== context.generation) {
644
+ throw new CueLineError("RUNTIME_MUTATION_FENCED", `CueLine run '${options.runId}' runtime mutation was superseded before takeover.`);
645
+ }
646
+ await atomicWriteJson(runtimeFencePath(options.home, options.runId), {
647
+ ...currentFence,
648
+ lease_source: "epoch",
649
+ legacy_event_fence: legacyEventFence,
650
+ });
651
+ context.legacyEventFence = legacyEventFence;
652
+ }
653
+ await atomicWriteJson(target, record);
654
+ }
655
+ await options.afterReplace?.();
656
+ });
657
+ const lease = new RuntimeLease(options.home, options.runId, target, record, now, options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS);
658
+ lease.startHeartbeat();
659
+ return lease;
660
+ }
661
+ get signal() {
662
+ return this.#loss.signal;
663
+ }
664
+ get ownerId() {
665
+ return this.#record.owner_id;
666
+ }
134
667
  startHeartbeat() {
135
668
  this.#timer = setInterval(() => {
669
+ if (this.#heartbeatError !== undefined)
670
+ return;
136
671
  this.#writeChain = this.#writeChain.then(async () => {
672
+ if (this.#heartbeatError !== undefined)
673
+ return;
137
674
  try {
138
675
  await this.heartbeat();
139
676
  }
140
677
  catch (error) {
678
+ if (this.#heartbeatError !== undefined)
679
+ return;
141
680
  this.#heartbeatError = error;
681
+ if (this.#timer !== undefined) {
682
+ clearInterval(this.#timer);
683
+ this.#timer = undefined;
684
+ }
685
+ this.#loss.abort(new CueLineError("RUNTIME_LEASE_HEARTBEAT_FAILED", `CueLine run '${this.#record.run_id}' runtime lease heartbeat failed.`, { cause: error }));
142
686
  }
143
687
  });
144
688
  }, this.#heartbeatIntervalMs);
145
689
  this.#timer.unref();
146
690
  }
147
691
  async heartbeat() {
148
- const current = parseLease(await readFile(this.#target, "utf8"), this.#record.run_id);
149
- if (!current || current.owner_id !== this.#record.owner_id || current.state !== "active") {
150
- throw new CueLineError("RUNTIME_LEASE_LOST", `CueLine run '${this.#record.run_id}' runtime lease ownership changed.`);
151
- }
152
- this.#record = { ...this.#record, heartbeat_at: this.#now().toISOString() };
153
- await atomicWriteJson(this.#target, this.#record);
692
+ await withLeaseMutationLock(this.#home, this.#runId, async (context) => {
693
+ const current = await readLeaseRecordAt(context.target, this.#record.run_id);
694
+ if (current === undefined ||
695
+ current.owner_id !== this.#record.owner_id ||
696
+ current.state !== "active") {
697
+ throw new CueLineError("RUNTIME_LEASE_LOST", `CueLine run '${this.#record.run_id}' runtime lease ownership changed.`);
698
+ }
699
+ this.#target = context.target;
700
+ this.#record = { ...this.#record, heartbeat_at: this.#now().toISOString() };
701
+ await atomicWriteJson(this.#target, this.#record);
702
+ });
154
703
  }
155
704
  assertHealthy() {
156
705
  if (this.#heartbeatError === undefined)
@@ -161,19 +710,18 @@ export class RuntimeLease {
161
710
  if (this.#timer !== undefined)
162
711
  clearInterval(this.#timer);
163
712
  await this.#writeChain;
164
- const current = await readFile(this.#target, "utf8")
165
- .then((source) => parseLease(source, this.#record.run_id))
166
- .catch(() => undefined);
167
- if (!current || current.owner_id !== this.#record.owner_id)
168
- return;
169
- const timestamp = this.#now().toISOString();
170
- this.#record = {
171
- ...this.#record,
172
- state: "released",
173
- heartbeat_at: timestamp,
174
- released_at: timestamp,
175
- };
176
- await atomicWriteJson(this.#target, this.#record);
713
+ await withLeaseMutationLock(this.#home, this.#runId, async (context) => {
714
+ const current = await readLeaseRecordAt(context.target, this.#record.run_id)
715
+ .catch(() => undefined);
716
+ if (!current || current.owner_id !== this.#record.owner_id)
717
+ return;
718
+ this.#target = context.target;
719
+ await persistRuntimeOwnerRetirements(this.#home, this.#runId, current.retired_owners ?? []);
720
+ await unlink(this.#target).catch((error) => {
721
+ if (!isNotFound(error))
722
+ throw error;
723
+ });
724
+ });
177
725
  }
178
726
  }
179
727
  //# sourceMappingURL=runtime-lease.js.map