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.
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/CHANGELOG.md +21 -0
- package/README.ja.md +57 -22
- package/README.ko.md +57 -22
- package/README.md +62 -24
- package/README.zh-CN.md +57 -22
- package/README.zh-TW.md +60 -23
- package/dist/src/api-contracts.d.ts +77 -0
- package/dist/src/api-contracts.js +2 -0
- package/dist/src/api-contracts.js.map +1 -0
- package/dist/src/api-controller-handoff.d.ts +6 -0
- package/dist/src/api-controller-handoff.js +253 -0
- package/dist/src/api-controller-handoff.js.map +1 -0
- package/dist/src/api-runtime-lifecycle.d.ts +22 -0
- package/dist/src/api-runtime-lifecycle.js +568 -0
- package/dist/src/api-runtime-lifecycle.js.map +1 -0
- package/dist/src/api.d.ts +6 -46
- package/dist/src/api.js +125 -133
- package/dist/src/api.js.map +1 -1
- package/dist/src/browser/browser-adapter.d.ts +10 -1
- package/dist/src/browser/codex-iab/bootstrap.d.ts +10 -0
- package/dist/src/browser/codex-iab/bootstrap.js +49 -0
- package/dist/src/browser/codex-iab/bootstrap.js.map +1 -1
- package/dist/src/browser/codex-iab/chatgpt-client.js +236 -107
- package/dist/src/browser/codex-iab/chatgpt-client.js.map +1 -1
- package/dist/src/browser/codex-iab/recovery-evidence.d.ts +6 -0
- package/dist/src/browser/codex-iab/recovery-evidence.js +37 -0
- package/dist/src/browser/codex-iab/recovery-evidence.js.map +1 -0
- package/dist/src/browser/codex-iab/submission-url.d.ts +2 -0
- package/dist/src/browser/codex-iab/submission-url.js +53 -0
- package/dist/src/browser/codex-iab/submission-url.js.map +1 -0
- package/dist/src/cli/main.js +188 -45
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/core/controller-abort.d.ts +1 -0
- package/dist/src/core/controller-abort.js +11 -0
- package/dist/src/core/controller-abort.js.map +1 -0
- package/dist/src/core/controller-command-execution.d.ts +9 -0
- package/dist/src/core/controller-command-execution.js +299 -0
- package/dist/src/core/controller-command-execution.js.map +1 -0
- package/dist/src/core/controller-loop.d.ts +3 -43
- package/dist/src/core/controller-loop.js +260 -331
- package/dist/src/core/controller-loop.js.map +1 -1
- package/dist/src/core/controller-turn.d.ts +16 -0
- package/dist/src/core/controller-turn.js +309 -0
- package/dist/src/core/controller-turn.js.map +1 -0
- package/dist/src/core/controller-types.d.ts +58 -0
- package/dist/src/core/controller-types.js +2 -0
- package/dist/src/core/controller-types.js.map +1 -0
- package/dist/src/core/persisted-run.d.ts +4 -0
- package/dist/src/core/persisted-run.js +19 -0
- package/dist/src/core/persisted-run.js.map +1 -0
- package/dist/src/core/process-liveness.d.ts +2 -0
- package/dist/src/core/process-liveness.js +44 -0
- package/dist/src/core/process-liveness.js.map +1 -0
- package/dist/src/core/run-status.d.ts +5 -2
- package/dist/src/core/run-status.js +65 -10
- package/dist/src/core/run-status.js.map +1 -1
- package/dist/src/core/state-machine.d.ts +15 -3
- package/dist/src/core/state-machine.js +148 -15
- package/dist/src/core/state-machine.js.map +1 -1
- package/dist/src/jobs/status.d.ts +1 -1
- package/dist/src/jobs/supervisor.js +3 -1
- package/dist/src/jobs/supervisor.js.map +1 -1
- package/dist/src/router/resolver.d.ts +2 -1
- package/dist/src/router/resolver.js +31 -19
- package/dist/src/router/resolver.js.map +1 -1
- package/dist/src/runners/process-runner.js +59 -6
- package/dist/src/runners/process-runner.js.map +1 -1
- package/dist/src/state/atomic-write.js +17 -2
- package/dist/src/state/atomic-write.js.map +1 -1
- package/dist/src/state/cancellation.d.ts +1 -1
- package/dist/src/state/cancellation.js +10 -2
- package/dist/src/state/cancellation.js.map +1 -1
- package/dist/src/state/event-log.d.ts +17 -1
- package/dist/src/state/event-log.js +328 -18
- package/dist/src/state/event-log.js.map +1 -1
- package/dist/src/state/paths.d.ts +1 -0
- package/dist/src/state/paths.js +1 -0
- package/dist/src/state/paths.js.map +1 -1
- package/dist/src/state/runtime-lease.d.ts +25 -0
- package/dist/src/state/runtime-lease.js +607 -59
- package/dist/src/state/runtime-lease.js.map +1 -1
- package/dist/src/state/runtime-retirement.d.ts +16 -0
- package/dist/src/state/runtime-retirement.js +95 -0
- package/dist/src/state/runtime-retirement.js.map +1 -0
- package/dist/src/state/runtime-takeover-intent.d.ts +1 -0
- package/dist/src/state/runtime-takeover-intent.js +18 -0
- package/dist/src/state/runtime-takeover-intent.js.map +1 -0
- package/dist/src/state/store.d.ts +12 -1
- package/dist/src/state/store.js +246 -40
- package/dist/src/state/store.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/docs/architecture.md +16 -11
- package/docs/assets/cueline-loop-en.svg +11 -10
- package/docs/assets/cueline-loop-ja.svg +11 -10
- package/docs/assets/cueline-loop-ko.svg +11 -10
- package/docs/assets/cueline-loop-zh-CN.svg +11 -10
- package/docs/assets/cueline-loop-zh-TW.svg +11 -10
- package/docs/compatibility.md +15 -9
- package/docs/controller-protocol.md +7 -5
- package/docs/runner-contract.md +19 -5
- package/docs/state-and-recovery.md +49 -21
- package/package.json +2 -2
- 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
|
|
255
|
+
return {
|
|
256
|
+
...value,
|
|
257
|
+
...(value.retired_owners === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: { retired_owners: parseRetiredOwners(value.retired_owners) }),
|
|
260
|
+
};
|
|
26
261
|
}
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|