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.
- package/README.md +10 -5
- package/adapters/opencode/index.js +282 -0
- package/bin/asylumwarden.mjs +5 -0
- package/package.json +14 -1
- package/schemas/config.schema.json +93 -0
- package/schemas/ledger.schema.json +21 -0
- package/schemas/manifest.schema.json +49 -0
- package/schemas/receipt.schema.json +102 -0
- package/schemas/registry.schema.json +43 -0
- package/src/cli/main.mjs +342 -0
- package/src/config/bootstrap.mjs +36 -0
- package/src/config/load.mjs +531 -0
- package/src/config/schemas.mjs +58 -0
- package/src/context.mjs +50 -0
- package/src/index.mjs +12 -0
- package/src/kernel/canon.mjs +59 -0
- package/src/kernel/errors.mjs +80 -0
- package/src/kernel/fsutil.mjs +147 -0
- package/src/kernel/glob.mjs +70 -0
- package/src/kernel/lock.mjs +104 -0
- package/src/kernel/schemalite.mjs +153 -0
- package/src/kernel/state.mjs +56 -0
- package/src/lease/lease.mjs +551 -0
- package/src/ledger/anchor.mjs +395 -0
- package/src/ledger/chain.mjs +468 -0
- package/src/manifest/manifest.mjs +199 -0
- package/src/manifest/plan.mjs +142 -0
- package/src/receipt/receipt.mjs +555 -0
- package/src/views/views.mjs +148 -0
|
@@ -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
|
+
}
|