asylumwarden 0.0.0 → 0.1.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.
@@ -0,0 +1,551 @@
1
+ // Lease placement — CONTRACT §6, DESIGN-INTERNALS §src/lease.
2
+ //
3
+ // Module law (pinned):
4
+ // - Every entry point validates BEFORE any mutation; rejections throw
5
+ // WardenError and leave disk state byte-identical (fail-closed).
6
+ // - Write order on success: owner.json → cell state (atomic) → lease history
7
+ // line (best-effort audit) → ledger append.
8
+ // - Modules append only SUCCESS events; rejected-attempt ledger lines are the
9
+ // CLI layer's job (CONTRACT §8.3 lives under §8 "CLI").
10
+ // - `failCell` lives in src/receipt (pinned split); this module exports
11
+ // claim/renew/release/requeue only.
12
+ // - No lateral imports (layering): receipt.mjs re-implements its own lease
13
+ // loader rather than importing this file.
14
+ // - ctx.nowFn() (optional, ISO string) is the only clock source: tests inject
15
+ // it; production falls back to fsutil.nowIso (Date).
16
+
17
+ import fs from "node:fs/promises";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+
21
+ import { fail, violation } from "../kernel/errors.mjs";
22
+ import { ensureDir, fileExists, nowIso, readJsonCapped, atomicWriteJson } from "../kernel/fsutil.mjs";
23
+ import { withExclusiveLock } from "../kernel/lock.mjs";
24
+ import { readCell, writeCell, removeStateFile, SAFE_ID_RE } from "../kernel/state.mjs";
25
+
26
+ const TTL_MIN = 30;
27
+ const TTL_MAX = 86400;
28
+ const MAX_HOLD_FLOOR_SECONDS = 1800; // max(2*ttl, 30m) — CONTRACT §6
29
+
30
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
31
+ const LEASE_KEYS = new Set([
32
+ "actor",
33
+ "pid",
34
+ "host",
35
+ "epoch",
36
+ "acquired_at",
37
+ "heartbeat_at",
38
+ "expires_at",
39
+ "max_hold_until",
40
+ "ttl_seconds",
41
+ ]);
42
+
43
+ // ---------------------------------------------------------------- primitives
44
+
45
+ function nowIsoOf(ctx) {
46
+ return (ctx?.nowFn ?? nowIso)();
47
+ }
48
+
49
+ function msOf(iso, what) {
50
+ const ms = Date.parse(iso);
51
+ if (!Number.isFinite(ms)) fail("STATE-CORRUPT", `unparseable timestamp in ${what}: ${iso}`);
52
+ return ms;
53
+ }
54
+
55
+ function addSecondsMs(ms, seconds) {
56
+ return new Date(ms + seconds * 1000).toISOString();
57
+ }
58
+
59
+ function maxHoldSeconds(ttlSeconds) {
60
+ return Math.max(2 * ttlSeconds, MAX_HOLD_FLOOR_SECONDS);
61
+ }
62
+
63
+ function isIsoString(value) {
64
+ return typeof value === "string" && ISO_RE.test(value) && Number.isFinite(Date.parse(value));
65
+ }
66
+
67
+ // Lease JSON shape is a contract value (CONTRACT §6): exact key set, no extra
68
+ // keys. Any deviation is STATE-CORRUPT (fail closed, exit 2).
69
+ function validateLeaseShape(owner, filePath) {
70
+ if (owner === null || typeof owner !== "object" || Array.isArray(owner)) {
71
+ fail("STATE-CORRUPT", `lease is not an object: ${filePath}`);
72
+ }
73
+ const keys = Object.getOwnPropertyNames(owner);
74
+ for (const key of keys) {
75
+ if (!LEASE_KEYS.has(key)) {
76
+ fail("STATE-CORRUPT", `lease has unknown key "${key}": ${filePath}`, [
77
+ violation(filePath, "lease-unknown-key", key),
78
+ ]);
79
+ }
80
+ }
81
+ for (const key of LEASE_KEYS) {
82
+ if (!Object.hasOwn(owner, key)) {
83
+ fail("STATE-CORRUPT", `lease missing key "${key}": ${filePath}`, [
84
+ violation(filePath, "lease-missing-key", key),
85
+ ]);
86
+ }
87
+ }
88
+ if (typeof owner.actor !== "string" || owner.actor.length === 0) {
89
+ fail("STATE-CORRUPT", `lease actor invalid: ${filePath}`);
90
+ }
91
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0) {
92
+ fail("STATE-CORRUPT", `lease pid invalid: ${filePath}`);
93
+ }
94
+ if (typeof owner.host !== "string" || owner.host.length === 0) {
95
+ fail("STATE-CORRUPT", `lease host invalid: ${filePath}`);
96
+ }
97
+ if (!Number.isInteger(owner.epoch) || owner.epoch < 1) {
98
+ fail("STATE-CORRUPT", `lease epoch invalid: ${filePath}`);
99
+ }
100
+ if (!Number.isInteger(owner.ttl_seconds) || owner.ttl_seconds < TTL_MIN || owner.ttl_seconds > TTL_MAX) {
101
+ fail("STATE-CORRUPT", `lease ttl_seconds invalid: ${filePath}`);
102
+ }
103
+ for (const key of ["acquired_at", "heartbeat_at", "expires_at", "max_hold_until"]) {
104
+ if (!isIsoString(owner[key])) {
105
+ fail("STATE-CORRUPT", `lease ${key} is not RFC3339: ${filePath}`);
106
+ }
107
+ }
108
+ }
109
+
110
+ // ------------------------------------------------------------------- helpers
111
+
112
+ function validateTtl(ttlSeconds) {
113
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < TTL_MIN || ttlSeconds > TTL_MAX) {
114
+ fail("USAGE", `ttl_seconds must be an integer in ${TTL_MIN}..${TTL_MAX}, got ${String(ttlSeconds)}`, [
115
+ violation("ttl_seconds", "range", String(ttlSeconds)),
116
+ ]);
117
+ }
118
+ }
119
+
120
+ function validateSafeId(name, value) {
121
+ if (typeof value !== "string" || !SAFE_ID_RE.test(value)) {
122
+ fail("USAGE", `${name} must be a SafeId, got ${String(value)}`, [
123
+ violation(name, "safe-id", String(value)),
124
+ ]);
125
+ }
126
+ }
127
+
128
+ // Wave/cell existence gate, before any lease error (DESIGN-INTERNALS §src/lease).
129
+ async function loadCellOrFail(ctx, wave, cell) {
130
+ validateSafeId("wave", wave);
131
+ validateSafeId("cell", cell);
132
+ if (!(await fileExists(ctx.sl.waveDir(wave)))) {
133
+ fail("WAVE-MISSING", `wave not found: ${wave}`, [violation("wave", "wave-missing", wave)]);
134
+ }
135
+ try {
136
+ return await readCell(ctx.sl, wave, cell);
137
+ } catch (error) {
138
+ if (error && error.code === "ENOENT") {
139
+ fail("CELL-MISSING", `cell not found: ${wave}/${cell}`, [
140
+ violation("cell", "cell-missing", `${wave}/${cell}`),
141
+ ]);
142
+ }
143
+ throw error; // STATE-CORRUPT etc. propagate unchanged (fail closed)
144
+ }
145
+ }
146
+
147
+ async function loadLeaseOrNull(ctx, wave, cell) {
148
+ const filePath = ctx.sl.ownerFile(wave, cell);
149
+ let owner;
150
+ try {
151
+ ({ value: owner } = await readJsonCapped(filePath, undefined, "STATE-CORRUPT"));
152
+ } catch (error) {
153
+ if (error && error.code === "ENOENT") return null;
154
+ throw error;
155
+ }
156
+ validateLeaseShape(owner, filePath);
157
+ return owner;
158
+ }
159
+
160
+ function cellEpochOf(cell, filePath) {
161
+ if (!Number.isInteger(cell.epoch) || cell.epoch < 0) {
162
+ fail("STATE-CORRUPT", `cell epoch invalid: ${filePath}`, [
163
+ violation("epoch", "cell-state", String(cell.epoch)),
164
+ ]);
165
+ }
166
+ return cell.epoch;
167
+ }
168
+
169
+ function newOwner({ actor, ttlSeconds, epoch, nowMs }) {
170
+ return {
171
+ actor,
172
+ pid: process.pid,
173
+ host: os.hostname(),
174
+ epoch,
175
+ acquired_at: new Date(nowMs).toISOString(),
176
+ heartbeat_at: new Date(nowMs).toISOString(),
177
+ expires_at: addSecondsMs(nowMs, ttlSeconds),
178
+ max_hold_until: addSecondsMs(nowMs, maxHoldSeconds(ttlSeconds)),
179
+ ttl_seconds: ttlSeconds,
180
+ };
181
+ }
182
+
183
+ // Best-effort audit line into leases/<id>/history.jsonl (no schema, no
184
+ // failure propagation — the ledger is the authoritative record).
185
+ async function appendHistory(ctx, wave, cell, line) {
186
+ try {
187
+ const filePath = ctx.sl.leaseHistory(wave, cell);
188
+ await ensureDir(path.dirname(filePath));
189
+ await fs.appendFile(filePath, `${JSON.stringify(line)}\n`, "utf8");
190
+ } catch {
191
+ // audit-only file: swallow. §6 design intent; ledger stays authoritative.
192
+ }
193
+ }
194
+
195
+ async function commitAcquire(ctx, { wave, cell, actor, owner, cellRow, event, payload, historyLine }) {
196
+ // Pinned write order: owner first, then cell, then history (best-effort),
197
+ // then ledger. A crash between owner and cell leaves a lease on a stale
198
+ // cell state; claim/renew surface that as STATE-CORRUPT, never open.
199
+ await atomicWriteJson(ctx.sl.ownerFile(wave, cell), owner);
200
+ await writeCell(ctx.sl, wave, cell, {
201
+ ...cellRow,
202
+ state: "claimed",
203
+ epoch: owner.epoch,
204
+ updated_at: owner.acquired_at,
205
+ });
206
+ if (historyLine) await appendHistory(ctx, wave, cell, historyLine);
207
+ await ctx.ledger.append(event, { wave, cell, actor, payload });
208
+ return owner;
209
+ }
210
+
211
+ // CONTRACT §3 end: role snapshot freshness. Missing role file is treated as
212
+ // no snapshot (documented choice: a deleted role file cannot be proven
213
+ // stale, and RESTART-REQUIRED is retryable noise for a cell that no longer
214
+ // carries a role); any other stat failure cannot prove freshness and fails
215
+ // closed as RESTART-REQUIRED (retryable).
216
+ async function checkRoleSnapshot(ctx, cellRow) {
217
+ if (typeof cellRow.role_file !== "string" || cellRow.role_file.length === 0) return;
218
+ if (typeof cellRow.session_started_at !== "string" || cellRow.session_started_at.length === 0) return;
219
+ const startedMs = msOf(cellRow.session_started_at, "cell.session_started_at");
220
+ let mtimeMs;
221
+ try {
222
+ ({ mtimeMs } = await fs.stat(cellRow.role_file));
223
+ } catch (error) {
224
+ if (error && error.code === "ENOENT") return; // no snapshot → nothing to enforce
225
+ fail("RESTART-REQUIRED", `cannot stat role_file ${cellRow.role_file}: ${error.message}`, [
226
+ violation("role_file", "role-check-unknown", error.message),
227
+ ]);
228
+ }
229
+ if (mtimeMs > startedMs) {
230
+ fail("RESTART-REQUIRED", `role file ${cellRow.role_file} changed after session start`, [
231
+ violation("role_file", "role-stale", new Date(mtimeMs).toISOString()),
232
+ ]);
233
+ }
234
+ }
235
+
236
+ // --------------------------------------------------------------------- claim
237
+
238
+ /**
239
+ * claim(ctx, {wave, cell, actor, ttlSeconds=1800, steal=false}) → owner
240
+ * CONTRACT §6 semantics; see module header for ordering law.
241
+ */
242
+ async function claimLocked(ctx, { wave, cell, actor, ttlSeconds = 1800, steal = false }) {
243
+ // Pure input checks first — no I/O, no mutation.
244
+ validateTtl(ttlSeconds);
245
+ if (typeof actor !== "string" || actor.length === 0) {
246
+ fail("USAGE", "claim requires a non-empty actor");
247
+ }
248
+ const cellRow = await loadCellOrFail(ctx, wave, cell);
249
+ const cellPath = ctx.sl.cellFile(wave, cell);
250
+
251
+ if (cellRow.state === "done" || cellRow.state === "failed") {
252
+ fail("CELL-STATE", `cell ${wave}/${cell} is ${cellRow.state}; cannot claim`, [
253
+ violation("state", "cell-state", cellRow.state),
254
+ ]);
255
+ }
256
+ if (cellRow.state !== "pending" && cellRow.state !== "claimed") {
257
+ fail("STATE-CORRUPT", `cell ${wave}/${cell} has unknown state ${String(cellRow.state)}`);
258
+ }
259
+
260
+ // Deps gate applies to fresh claims (pending cells). A claimed cell is past
261
+ // its dep gate; reclaim/steal must not re-litigate deps (§6).
262
+ if (cellRow.state === "pending") {
263
+ // Kernel null-prototype note: readJsonCapped arrays have no
264
+ // Symbol.iterator — iterate disk-read arrays by index only.
265
+ const deps = Array.isArray(cellRow.deps) ? cellRow.deps : [];
266
+ const blocked = [];
267
+ for (let i = 0; i < deps.length; i += 1) {
268
+ const dep = deps[i];
269
+ let depRow;
270
+ try {
271
+ depRow = await readCell(ctx.sl, wave, dep);
272
+ } catch (error) {
273
+ if (error && error.code === "ENOENT") {
274
+ fail("STATE-CORRUPT", `dep cell missing from wave: ${wave}/${dep}`, [
275
+ violation(`deps.${dep}`, "dep-missing", "cell file absent"),
276
+ ]);
277
+ }
278
+ throw error;
279
+ }
280
+ if (depRow.state !== "done") blocked.push({ dep, state: depRow.state });
281
+ }
282
+ if (blocked.length > 0) {
283
+ fail(
284
+ "CELL-STATE",
285
+ `cell ${wave}/${cell} blocked by unfinished deps: ${blocked.map((b) => `${b.dep}(${b.state})`).join(", ")}`,
286
+ blocked.map((b) => violation(`deps.${b.dep}`, "dep-not-done", b.state)),
287
+ );
288
+ }
289
+ }
290
+
291
+ await checkRoleSnapshot(ctx, cellRow);
292
+
293
+ const nowIsoStr = nowIsoOf(ctx);
294
+ const nowMs = msOf(nowIsoStr, "now");
295
+ const owner = await loadLeaseOrNull(ctx, wave, cell);
296
+ const cellEpoch = cellEpochOf(cellRow, cellPath);
297
+
298
+ let newEpoch;
299
+ let event = "claim";
300
+ let payload = { epoch: 0, ttl_seconds: ttlSeconds };
301
+ let historyLine = null;
302
+
303
+ if (owner !== null) {
304
+ const expired = nowMs > msOf(owner.expires_at, "lease.expires_at");
305
+ // Takeover is time-gated ONLY (CONTRACT §6, amended pre-1.0): the pid in
306
+ // a lease is forensic. Short-lived CLI invocations die right after claim,
307
+ // so pid-liveness would make every lease instantly "dead" and void
308
+ // LEASE-HELD entirely. Crash or not, occupancy is bounded by expires_at
309
+ // (reclaim) and max_hold_until (steal) — the contract's blocking bound.
310
+ if (steal) {
311
+ // CONTRACT §6 pins the steal gate to max_hold_until of the current
312
+ // lease — nothing else. Even an expired-but-within-max-hold lease is
313
+ // STEAL-DENIED on the steal path; the reclaim path is the non-steal
314
+ // door for that window. An expired lease with steal=true behaves like
315
+ // reclaim, per the pinned task flow.
316
+ if (expired) {
317
+ newEpoch = Math.max(cellEpoch, owner.epoch) + 1;
318
+ payload = { epoch: newEpoch, ttl_seconds: ttlSeconds, reclaim: "expired" };
319
+ historyLine = { ts: nowIsoStr, event: "claim", actor, epoch: newEpoch, reclaim: payload.reclaim };
320
+ } else if (nowMs > msOf(owner.max_hold_until, "lease.max_hold_until")) {
321
+ // Live lease past its max hold: steal at epoch+1 with evidence.
322
+ newEpoch = Math.max(cellEpoch, owner.epoch) + 1;
323
+ const prior = { actor: owner.actor, epoch: owner.epoch, max_hold_until: owner.max_hold_until };
324
+ const evidence = { now: nowIsoStr, host: os.hostname(), pid: process.pid };
325
+ event = "steal";
326
+ payload = { prior, evidence };
327
+ historyLine = { ts: nowIsoStr, event: "steal", actor, epoch: newEpoch, prior, evidence };
328
+ } else {
329
+ fail("STEAL-DENIED", `cell ${wave}/${cell} held until ${owner.max_hold_until}; steal window not open`, [
330
+ violation("max_hold_until", "steal-window", owner.max_hold_until),
331
+ ]);
332
+ }
333
+ } else if (expired) {
334
+ newEpoch = Math.max(cellEpoch, owner.epoch) + 1;
335
+ payload = { epoch: newEpoch, ttl_seconds: ttlSeconds, reclaim: "expired" };
336
+ historyLine = { ts: nowIsoStr, event: "claim", actor, epoch: newEpoch, reclaim: payload.reclaim };
337
+ } else {
338
+ fail("LEASE-HELD", `cell ${wave}/${cell} leased to ${owner.actor} (epoch ${owner.epoch}) until ${owner.expires_at}`, [
339
+ violation("lease", "lease-held", `${owner.actor}@${owner.host}:${owner.pid}`),
340
+ ]);
341
+ }
342
+ } else {
343
+ if (cellRow.state === "claimed") {
344
+ // Cell claims a holder but no lease file exists: the invariant
345
+ // "claimed ⇒ owner.json present" is broken (crashed release).
346
+ // Fail closed for human repair — never auto-heal into a new epoch.
347
+ fail("STATE-CORRUPT", `cell ${wave}/${cell} is claimed but owner.json is missing`, [
348
+ violation("lease", "claimed-without-lease", cellPath),
349
+ ]);
350
+ }
351
+ newEpoch = cellEpoch + 1; // first claim: 0 → 1; after release: preserved epoch + 1
352
+ payload = { epoch: newEpoch, ttl_seconds: ttlSeconds };
353
+ }
354
+
355
+ const ownerRow = newOwner({ actor, ttlSeconds, epoch: newEpoch, nowMs });
356
+ return commitAcquire(ctx, {
357
+ wave,
358
+ cell,
359
+ actor,
360
+ owner: ownerRow,
361
+ cellRow,
362
+ event,
363
+ payload,
364
+ historyLine,
365
+ });
366
+ }
367
+
368
+ // --------------------------------------------------------------------- renew
369
+
370
+ /**
371
+ * renew(ctx, {wave, cell, actor, epoch, ttlSeconds=1800}) → owner
372
+ * Heartbeat: refreshes heartbeat_at/expires_at/ttl_seconds. acquired_at and
373
+ * max_hold_until are fixed forever (pinned): once now > max_hold_until the
374
+ * lease can never be renewed again in this epoch.
375
+ */
376
+ async function renewLocked(ctx, { wave, cell, actor, epoch, ttlSeconds = 1800 }) {
377
+ validateTtl(ttlSeconds);
378
+ if (typeof actor !== "string" || actor.length === 0) fail("USAGE", "renew requires a non-empty actor");
379
+ const cellRow = await loadCellOrFail(ctx, wave, cell);
380
+ const cellPath = ctx.sl.cellFile(wave, cell);
381
+ if (cellRow.state !== "claimed") {
382
+ fail("CELL-STATE", `cell ${wave}/${cell} is ${String(cellRow.state)}, not claimed`, [
383
+ violation("state", "cell-state", String(cellRow.state)),
384
+ ]);
385
+ }
386
+ const owner = await loadLeaseOrNull(ctx, wave, cell);
387
+ if (owner === null) {
388
+ // Cell is claimed but the lease file is gone — broken invariant.
389
+ fail("STATE-CORRUPT", `cell ${wave}/${cell} is claimed but owner.json is missing`, [
390
+ violation("lease", "claimed-without-lease", cellPath),
391
+ ]);
392
+ }
393
+ if (owner.actor !== actor) {
394
+ fail("ACTOR-MISMATCH", `lease held by ${owner.actor}, renew attempted as ${actor}`, [
395
+ violation("actor", "actor-mismatch", owner.actor),
396
+ ]);
397
+ }
398
+ if (owner.epoch !== epoch) {
399
+ fail("EPOCH-MISMATCH", `lease epoch is ${owner.epoch}, renew attempted with ${epoch}`, [
400
+ violation("epoch", "epoch-mismatch", String(owner.epoch)),
401
+ ]);
402
+ }
403
+ const nowIsoStr = nowIsoOf(ctx);
404
+ const nowMs = msOf(nowIsoStr, "now");
405
+ if (nowMs > msOf(owner.expires_at, "lease.expires_at")) {
406
+ fail("LEASE-EXPIRED", `lease expired at ${owner.expires_at}`, [
407
+ violation("expires_at", "lease-expired", owner.expires_at),
408
+ ]);
409
+ }
410
+ if (nowMs > msOf(owner.max_hold_until, "lease.max_hold_until")) {
411
+ fail("LEASE-MAX-HOLD", `lease past max_hold_until ${owner.max_hold_until}; renewal impossible forever in this epoch`, [
412
+ violation("max_hold_until", "lease-max-hold", owner.max_hold_until),
413
+ ]);
414
+ }
415
+ const updated = {
416
+ ...owner,
417
+ heartbeat_at: nowIsoStr,
418
+ expires_at: addSecondsMs(nowMs, ttlSeconds),
419
+ ttl_seconds: ttlSeconds,
420
+ };
421
+ validateLeaseShape(updated, ctx.sl.ownerFile(wave, cell));
422
+ await atomicWriteJson(ctx.sl.ownerFile(wave, cell), updated);
423
+ await writeCell(ctx.sl, wave, cell, { ...cellRow, updated_at: nowIsoStr });
424
+ await ctx.ledger.append("renew", { wave, cell, actor, payload: { epoch } });
425
+ return updated;
426
+ }
427
+
428
+ // ------------------------------------------------------------------- release
429
+
430
+ /**
431
+ * release(ctx, {wave, cell, actor, epoch}) → {released: true}
432
+ * Pinned flow: actor+epoch check (actor first) → remove owner.json →
433
+ * cell → pending keeping epoch (epoch numbers are never reused) → release
434
+ * event. No cell-state precheck beyond wave/cell existence: the lease is the
435
+ * authority for release, so a crashed release (cell pending, lease present)
436
+ * is self-healing. Consequence (contract §6/§10 tension): a failed cell with
437
+ * a live lease can be cleared with release (failed → pending), making the
438
+ * requeue "no lease may exist" guard reachable without state surgery.
439
+ */
440
+ async function releaseLocked(ctx, { wave, cell, actor, epoch }) {
441
+ if (typeof actor !== "string" || actor.length === 0) fail("USAGE", "release requires a non-empty actor");
442
+ const cellRow = await loadCellOrFail(ctx, wave, cell);
443
+ const owner = await loadLeaseOrNull(ctx, wave, cell);
444
+ if (owner === null) {
445
+ if (cellRow.state === "claimed") {
446
+ fail("STATE-CORRUPT", `cell ${wave}/${cell} is claimed but owner.json is missing`, [
447
+ violation("lease", "claimed-without-lease", ctx.sl.cellFile(wave, cell)),
448
+ ]);
449
+ }
450
+ fail("CELL-STATE", `no lease to release on ${wave}/${cell}`, [violation("lease", "no-lease", "absent")]);
451
+ }
452
+ if (owner.actor !== actor) {
453
+ fail("ACTOR-MISMATCH", `lease held by ${owner.actor}, release attempted as ${actor}`, [
454
+ violation("actor", "actor-mismatch", owner.actor),
455
+ ]);
456
+ }
457
+ if (owner.epoch !== epoch) {
458
+ fail("EPOCH-MISMATCH", `lease epoch is ${owner.epoch}, release attempted with ${epoch}`, [
459
+ violation("epoch", "epoch-mismatch", String(owner.epoch)),
460
+ ]);
461
+ }
462
+ const nowIsoStr = nowIsoOf(ctx);
463
+ // Lease-file change first (DESIGN-INTERNALS write-order law), then cell.
464
+ await removeStateFile(ctx.sl.ownerFile(wave, cell));
465
+ await writeCell(ctx.sl, wave, cell, { ...cellRow, state: "pending", epoch: owner.epoch, updated_at: nowIsoStr });
466
+ await appendHistory(ctx, wave, cell, { ts: nowIsoStr, event: "release", actor, epoch: owner.epoch });
467
+ await ctx.ledger.append("release", { wave, cell, actor, payload: { epoch: owner.epoch } });
468
+ return { released: true };
469
+ }
470
+
471
+ // ------------------------------------------------------------------- requeue
472
+
473
+ /**
474
+ * requeue(ctx, {wave, cell}) → cell
475
+ * Administrative reset: failed → pending only. No lease may exist
476
+ * (CELL-STATE otherwise). Epoch is preserved; the next claim takes epoch+1.
477
+ */
478
+ async function requeueLocked(ctx, { wave, cell }) {
479
+ const cellRow = await loadCellOrFail(ctx, wave, cell);
480
+ if (cellRow.state !== "failed") {
481
+ fail("CELL-STATE", `requeue requires state failed, found ${String(cellRow.state)}`, [
482
+ violation("state", "cell-state", String(cellRow.state)),
483
+ ]);
484
+ }
485
+ if (await fileExists(ctx.sl.ownerFile(wave, cell))) {
486
+ fail("CELL-STATE", `requeue refused: lease exists on ${wave}/${cell}`, [
487
+ violation("lease", "lease-exists", ctx.sl.ownerFile(wave, cell)),
488
+ ]);
489
+ }
490
+ const nowIsoStr = nowIsoOf(ctx);
491
+ await writeCell(ctx.sl, wave, cell, { ...cellRow, state: "pending", updated_at: nowIsoStr });
492
+ await ctx.ledger.append("requeue", { wave, cell, actor: null, payload: { epoch: cellRow.epoch } });
493
+ return { ...cellRow, state: "pending", updated_at: nowIsoStr };
494
+ }
495
+
496
+ // Per-cell serialization (E2E race defect class, T8 row 1): the lease
497
+ // check-to-write sequence must be atomic across processes, or two claims
498
+ // both pass the liveness check and race into owner.json (multi-winner +
499
+ // atomic-write read-back false alarms). Lock is held for the milliseconds
500
+ // of file work; contention past the wait deadline fails closed as
501
+ // LEASE-BUSY (retryable, exit 1).
502
+
503
+ // Wave/cell existence is proven BEFORE locking: acquiring the lock runs
504
+ // ensureDir, and letting it mint directories for unknown waves would flip
505
+ // WAVE-MISSING into CELL-MISSING inside claimLocked's checks.
506
+ async function requireWaveCell(ctx, wave, cell) {
507
+ validateSafeId("wave", wave);
508
+ validateSafeId("cell", cell);
509
+ const fs = await import("node:fs/promises");
510
+ try {
511
+ await fs.stat(ctx.sl.metaFile(wave));
512
+ } catch {
513
+ // plan always writes meta.json; the cells-dir alternative keeps
514
+ // hand-seeded wave fixtures working. Absent both = no wave.
515
+ try {
516
+ await fs.stat(ctx.sl.cellsDir(wave));
517
+ } catch {
518
+ fail("WAVE-MISSING", `no such wave: ${wave}`, [violation("wave", "wave-missing", wave)]);
519
+ }
520
+ }
521
+ try {
522
+ await fs.stat(ctx.sl.cellFile(wave, cell));
523
+ } catch {
524
+ fail("CELL-MISSING", `cell not found: ${wave}/${cell}`, [violation("cell", "cell-missing", `${wave}/${cell}`)]);
525
+ }
526
+ }
527
+
528
+ async function withCellLock(ctx, wave, cell, fn) {
529
+ await requireWaveCell(ctx, wave, cell);
530
+ return withExclusiveLock(
531
+ ctx.sl.leaseLock(wave, cell),
532
+ { busyCode: "LEASE-BUSY", busyMessage: `lease mutation busy for ${wave}/${cell}` },
533
+ fn,
534
+ );
535
+ }
536
+
537
+ export async function claim(ctx, args) {
538
+ return withCellLock(ctx, args.wave, args.cell, () => claimLocked(ctx, args));
539
+ }
540
+
541
+ export async function renew(ctx, args) {
542
+ return withCellLock(ctx, args.wave, args.cell, () => renewLocked(ctx, args));
543
+ }
544
+
545
+ export async function release(ctx, args) {
546
+ return withCellLock(ctx, args.wave, args.cell, () => releaseLocked(ctx, args));
547
+ }
548
+
549
+ export async function requeue(ctx, args) {
550
+ return withCellLock(ctx, args.wave, args.cell, () => requeueLocked(ctx, args));
551
+ }