@sema-agent/core 5.53.0 → 5.54.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.
@@ -3,6 +3,7 @@ import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, assertBackgr
3
3
  import { SharedLedgerTable } from "./shared-ledger.js";
4
4
  import { assertAdoptionBootGate } from "./adoption/marker.js";
5
5
  const agentLedgers = new SharedLedgerTable({
6
+ label: "background-agent ledger",
6
7
  keyOf: (r) => FileBackgroundAgentStore.key(r.handle, r.scope),
7
8
  apply: (rows, ev) => {
8
9
  if (ev.t === "delete") {
@@ -11,7 +11,9 @@ export declare class FileCheckpointStore implements CheckpointStore {
11
11
  /** design/173 §2.3 — honest declaration on the restart-survival axis the vocabulary claims: rows
12
12
  * live on disk (fsync'd append log) and survive a process restart. Multi-replica coordination is
13
13
  * NOT claimed by this axis (see {@link StoreDurability}) — this backend is deliberately
14
- * single-instance-per-data-dir (the boot lock refuses a second process); Pg/TiDB own that. */
14
+ * single-instance-per-data-dir, and now SELF-ENFORCING about it: the constructor takes the ledger
15
+ * directory's writer fence and refuses a second OS process by name (see the class header). Serving
16
+ * many writers at once remains Pg/TiDB's job — this refuses the second one, it does not coordinate it. */
15
17
  readonly durability: "durable";
16
18
  /** Honest declaration on the fidelity axis: the ledger is JSONL, so what survives the restart this
17
19
  * backend promises is the JSON PROJECTION of the row — a `Date` replays as its ISO string, a
@@ -62,7 +64,9 @@ export declare class FileCheckpointStore implements CheckpointStore {
62
64
  /** Test/inspection helper: number of stored checkpoints. */
63
65
  get size(): number;
64
66
  /**
65
- * Release the append handle (best-effort). The boot LOCK is released by the backend factory.
67
+ * Release the append handle (best-effort). The LAST holder over the directory also drops its
68
+ * cross-process writer fence, so a successor process can open the same data root; the data-dir-wide
69
+ * `root/LOCK` (a different fence) stays the backend factory's to release.
66
70
  *
67
71
  * RB-134: refcounted, and the LAST holder REVOKES the directory's authority. Adding the shared table
68
72
  * without this would have repeated RB-73's mistake exactly — a cache with no invalidation: after a
@@ -56,6 +56,7 @@ function applyCheckpointEvent(cps, ev) {
56
56
  }
57
57
  }
58
58
  const checkpointLedgers = new SharedLedgerTable({
59
+ label: "checkpoint ledger",
59
60
  keyOf: (cp) => cp.token,
60
61
  apply: applyCheckpointEvent,
61
62
  });
@@ -151,27 +151,168 @@ export declare class AppendLog {
151
151
  closeForSwap(): void;
152
152
  private releaseFd;
153
153
  }
154
+ /** Why a {@link BootLock}/{@link acquireStoreDirLock} acquisition was refused, as a branchable code. */
155
+ export type FileStoreLockErrorCode =
156
+ /** A LIVE process (this one or another) already holds the directory. Steady state: it ends when that
157
+ * process ends. */
158
+ "store.dir_in_use"
159
+ /** Another process is mid-RECLAIM of a lock left by a crashed owner. Transient by construction — the
160
+ * claimant either finishes (and then owns the directory) or dies (and its gate is pruned in turn), so
161
+ * the honest advice here is "retry", NOT the "stop the other process" that `dir_in_use` earns. */
162
+ | "store.dir_claiming"
163
+ /** The lock is in a state only a human should resolve — no parseable owner, or buried under nested
164
+ * reclaim gates from repeated crashes. Never pruned blindly; the message names what to remove. */
165
+ | "store.lock_unreadable";
154
166
  /**
155
- * The SINGLE coarse boot guard (§2.4): an `O_EXCL` PID file at `root/LOCK` that forbids two processes
156
- * sharing a data dir. A second instance fails fast ("another instance owns this data dir"). A STALE lock
157
- * (the writing PID is dead) is pruned and re-acquired `proper-lockfile`/CC `concurrentSessions` parity.
167
+ * The structured refusal of a file-store directory fence. Carried as a class (not a bare `Error`) so a
168
+ * host can branch on {@link code} "someone else owns this data dir" is an operator-actionable
169
+ * condition (stop the other process / pick another root / move to a SQL backend), and telling it apart
170
+ * from a genuine I/O failure by string-matching a message is exactly the fragility the codes exist to
171
+ * remove. The messages are unchanged from the pre-code era, so existing text assertions still hold.
172
+ */
173
+ export declare class FileStoreLockError extends Error {
174
+ readonly code: FileStoreLockErrorCode;
175
+ /** The lock file whose acquisition was refused. */
176
+ readonly lockPath: string;
177
+ /** The live owner's pid when the lock named one (absent for an unreadable lock or a lost race). */
178
+ readonly ownerPid?: number | undefined;
179
+ readonly name = "FileStoreLockError";
180
+ constructor(code: FileStoreLockErrorCode, message: string,
181
+ /** The lock file whose acquisition was refused. */
182
+ lockPath: string,
183
+ /** The live owner's pid when the lock named one (absent for an unreadable lock or a lost race). */
184
+ ownerPid?: number | undefined);
185
+ }
186
+ /**
187
+ * The coarse boot guard (§2.4): an `O_EXCL` PID file (`root/LOCK` for the whole data dir; one per store
188
+ * ledger directory via {@link acquireStoreDirLock}) that forbids two processes sharing it. A second
189
+ * instance fails fast ("another instance owns this data dir"). A STALE lock (the writing PID is dead) is
190
+ * pruned and re-acquired — `proper-lockfile`/CC `concurrentSessions` parity.
158
191
  *
159
- * This is the ONLY legitimate file lock in the backend: the once-only CAS is in-process (one event loop +
160
- * a per-token async mutex), so there is NO per-operation flock this fence just guarantees the
161
- * in-process model's premise (a single writer to the dir) holds. Cross-process CORRECT concurrency is the
162
- * Pg/TiDB backend's job, by design.
192
+ * This is the family's ONE lock MECHANISM (the per-operation CAS stays in-process: one event loop + a
193
+ * per-token async mutex, so there is NO per-operation flock). A holder of one of these fences guarantees
194
+ * the in-process model's premise a single writer to the directory — actually holds. Cross-process
195
+ * CORRECT concurrency (many writers at once) is still the Pg/TiDB backend's job, by design: this fence
196
+ * refuses the second writer, it does not coordinate it.
163
197
  */
164
198
  export declare class BootLock {
165
199
  private readonly lockPath;
166
200
  private held;
201
+ /** The verdict of the ONE release this holding gets, replayed to any later caller (see {@link release}). */
202
+ private spentVerdict;
167
203
  constructor(lockPath: string);
168
- /** Acquire the lock or throw. Prunes a stale lock whose recorded PID is not running. */
204
+ /** Acquire the lock or throw. Reclaims a lock whose recorded PID is not running (see below). */
169
205
  acquire(): void;
206
+ /**
207
+ * @param depth how many stale reclaim gates deep we already are (see the gate below). Bounded so a
208
+ * pathological chain demands an operator instead of recursing without end.
209
+ */
210
+ private acquireWithin;
211
+ /** Publish our lock, or say out loud that a fresh contender got there first. */
212
+ private publishOrLoseRace;
213
+ /** Take back a lock THIS process published and could not remove on release (never someone else's). */
214
+ private pruneOwnAbandoned;
170
215
  private writeLock;
171
- private readLockPid;
172
- /** Release the lock (only if we hold it). Best-effort; never throws. */
216
+ /**
217
+ * Key for the {@link abandonedLocks} registry: the RESOLVED path, deliberately NOT case-folded. Folding
218
+ * (as the store-table key does) would alias `/data/foo/LOCK` and `/data/Foo/LOCK` — two genuinely
219
+ * different files on a case-sensitive filesystem — and this registry authorizes a DELETE. Missing an
220
+ * entry costs a loud refusal; a false hit costs someone else's lock.
221
+ */
222
+ private get abandonKey();
223
+ /**
224
+ * Read the lock as one of three ANSWERS, never as one "undefined" that means all of them: absent (nobody
225
+ * holds it), a named owner, or unreadable. Collapsing the last two is what let a release that could not
226
+ * even READ the file report the lock as gone — after which the bookkeeping was dropped while the file
227
+ * still named this process, and the directory was refused for the rest of the process's life.
228
+ */
229
+ private inspectLock;
230
+ /**
231
+ * Release the lock (only if we hold it), ONCE. Best-effort; never throws.
232
+ *
233
+ * @returns whether the lock is now GONE — removed, or provably somebody else's. A `false` means the file
234
+ * is still there and this process cannot prove it is not the owner of record, which the caller must not
235
+ * treat as released: every later acquisition would read a live owner (us) and refuse a directory nobody is
236
+ * using. The path is remembered as ABANDONED so a later acquisition in this process can take it back
237
+ * rather than refuse a directory nobody is using — a lock carrying our own pid can only be ours
238
+ * ({@link pruneOwnAbandoned} removes and republishes it; {@link resumeAbandoned} re-arms a holder over
239
+ * the file as it stands).
240
+ *
241
+ * ONE-SHOT, and that is load-bearing. This object stops being a holder the moment it is ASKED to release
242
+ * — before the filesystem is touched — and a second call REPEATS the first verdict instead of acting on
243
+ * it again. A lock file is a bare pid, so two BootLocks over one path in this process are byte-identical
244
+ * on disk: after a FAILED release the path can legitimately be re-published (a later acquisition here
245
+ * finishing our cleanup), and a stale object that "removed its own lock" a second time would be deleting
246
+ * the SUCCESSOR's fence while both sides believed they held the directory — the double-writer this whole
247
+ * mechanism exists to refuse, minted from bookkeeping instead of from a race. So a second release answers
248
+ * the first question again and touches nothing.
249
+ */
250
+ release(): boolean;
251
+ /**
252
+ * The filesystem half of {@link release}: remove the lock while the file still names US, and report what
253
+ * the directory looks like AFTERWARDS (never what we intended).
254
+ *
255
+ * "Still names us" is a READ, and `unlink` cannot be made conditional on content — so, exactly as in
256
+ * {@link resumeAbandoned}, an external removal landing between the two lets this delete a file that is no
257
+ * longer the one we read. That is the advisory-pid-file mechanism's exposure, identical for every holder
258
+ * and every moment of a holding, not something this ordering introduces; the re-inspection below is why
259
+ * the ANSWER stays honest even then.
260
+ */
261
+ private removeOwnLock;
262
+ /**
263
+ * Resume holding a lock THIS process published and could not remove on release — the state a zero-ref
264
+ * entry in {@link acquireStoreDirLock}'s table is in, and the only way a joiner may ride that entry.
265
+ *
266
+ * Pure INSPECTION of the disk: it publishes nothing and removes nothing. The claim is honoured only
267
+ * when the file still there is the one we left (our own pid) AND no publish on this path has spent the
268
+ * abandoned note since — any successful publish (ours, or a reclaim) makes some OTHER object the holder,
269
+ * and re-arming here would put two holders on one path, exactly what one-shot {@link release} closes. A
270
+ * `false` says the bookkeeping can no longer be trusted and the caller must go back to the disk: the
271
+ * file may have been cleared (an operator, a tmp cleaner) and the directory since taken by another
272
+ * OS process.
273
+ *
274
+ * A successful resume SPENDS the note, for the same reason a successful publish does: the note is what
275
+ * authorizes {@link pruneOwnAbandoned} to DELETE this file, and while somebody holds the path that
276
+ * authorization must not exist — a second acquisition here is a genuine conflict that has to be refused
277
+ * by name, not a leftover to be cleaned up over a live holder. A later failed release writes it again.
278
+ *
279
+ * What a `true` establishes, exactly: the file was the one we left AT THE MOMENT WE LOOKED. It is not an
280
+ * atomic handoff and cannot be — this fence is an advisory pid FILE, so nothing stops an operator (or a
281
+ * tmp cleaner) from removing it the instant after any check, here or hours into an ordinary holding, and
282
+ * a foreign process publishing over the gap. That exposure belongs to the mechanism and is the same for
283
+ * a fence nobody ever released; what this check removes is the far wider window in which the removal
284
+ * already happened and the table would have kept vouching for it anyway. The branch where the file is
285
+ * ALREADY gone is not re-armed here at all: it falls through to a real acquisition, whose `O_EXCL`
286
+ * publish is the atomic test-and-set, and a foreign owner there is refused by name.
287
+ */
288
+ resumeAbandoned(): boolean;
289
+ }
290
+ /** One holder's share of a directory fence. Releasing is idempotent and only the LAST share unlinks. */
291
+ export interface StoreDirLockLease {
292
+ /** The lock file this share is counted against (inspection/tests). */
293
+ readonly lockPath: string;
294
+ /** Drop this share. The lock file survives until every share over the directory is released. */
173
295
  release(): void;
174
296
  }
297
+ /**
298
+ * Take (or JOIN) the cross-process writer fence for one store directory.
299
+ *
300
+ * The file family's premise everywhere is "one writer per data directory"; before this, the ONLY thing
301
+ * enforcing it was the `root/LOCK` a `FileStorageBackend` takes — which a store constructed directly
302
+ * (every one of them is a root export) never sees, and which never covered a store the backend does not
303
+ * construct at all. A second OS process then replayed a PRIVATE view of the same ledger: both sides win
304
+ * a once-only CAS, and the one that never loaded the other's rows ERASES them at its next compaction.
305
+ * So the fence belongs to the directory that holds the authority, taken by whoever opens it.
306
+ *
307
+ * Semantics: exclusive across PROCESSES (a live foreign owner ⇒ {@link FileStoreLockError} naming the
308
+ * pid; a dead owner's lock is pruned as stale), refcount-JOINED inside this process (siblings over one
309
+ * directory share the fence, and the last {@link StoreDirLockLease.release} unlinks it). `dir` must
310
+ * already exist (the caller's `ensureDir`), since the fence lives inside it.
311
+ */
312
+ export declare function acquireStoreDirLock(dir: string, opts: {
313
+ label: string;
314
+ fileName?: string;
315
+ }): StoreDirLockLease;
175
316
  /**
176
317
  * design/84 Seam B (TOC profile) — a per-scope CONSOLIDATION lock factory for the file backend's
177
318
  * {@link import("../../core/consolidate-scope.js").ConsolidateScopeDeps.acquire} injection point. A single
@@ -255,71 +255,247 @@ function truncateTornTail(path) {
255
255
  const lastNl = raw.lastIndexOf("\n");
256
256
  truncateSync(path, lastNl === -1 ? 0 : Buffer.byteLength(raw.slice(0, lastNl + 1), "utf8"));
257
257
  }
258
+ export class FileStoreLockError extends Error {
259
+ code;
260
+ lockPath;
261
+ ownerPid;
262
+ name = "FileStoreLockError";
263
+ constructor(code, message, lockPath, ownerPid) {
264
+ super(message);
265
+ this.code = code;
266
+ this.lockPath = lockPath;
267
+ this.ownerPid = ownerPid;
268
+ }
269
+ }
270
+ const MAX_RECLAIM_DEPTH = 8;
271
+ const MAX_ACQUIRE_ATTEMPTS = 4;
272
+ const abandonedLocks = new Set();
258
273
  export class BootLock {
259
274
  lockPath;
260
275
  held = false;
276
+ spentVerdict;
261
277
  constructor(lockPath) {
262
278
  this.lockPath = lockPath;
263
279
  }
264
280
  acquire() {
265
- try {
266
- this.writeLock();
267
- this.held = true;
268
- return;
269
- }
270
- catch (err) {
271
- if (err.code !== "EEXIST")
281
+ this.acquireWithin(0);
282
+ }
283
+ acquireWithin(depth) {
284
+ for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
285
+ try {
286
+ this.writeLock();
287
+ this.held = true;
288
+ abandonedLocks.delete(this.abandonKey);
289
+ return;
290
+ }
291
+ catch (err) {
292
+ if (err.code !== "EEXIST")
293
+ throw err;
294
+ }
295
+ const owner = this.inspectLock();
296
+ if (owner.state === "absent")
297
+ continue;
298
+ if (owner.state === "unreadable") {
299
+ throw new FileStoreLockError("store.lock_unreadable", `file store: lock file at ${this.lockPath} is unreadable/corrupt; remove it manually if it is stale`, this.lockPath);
300
+ }
301
+ if (owner.pid === process.pid && abandonedLocks.has(this.abandonKey)) {
302
+ this.pruneOwnAbandoned();
303
+ return;
304
+ }
305
+ if (isProcessRunning(owner.pid)) {
306
+ throw new FileStoreLockError("store.dir_in_use", `file store: another instance (pid ${owner.pid}) owns this data dir (${dirnameOf(this.lockPath)})`, this.lockPath, owner.pid);
307
+ }
308
+ if (depth >= MAX_RECLAIM_DEPTH) {
309
+ throw new FileStoreLockError("store.lock_unreadable", `file store: lock file at ${this.lockPath} is buried under ${depth} nested stale reclaim gates ` +
310
+ `(repeated crashes mid-reclaim); remove the ${basenameOf(this.lockPath)}.claim.* files manually`, this.lockPath);
311
+ }
312
+ const gate = new BootLock(`${this.lockPath}.claim.${owner.pid}`);
313
+ try {
314
+ gate.acquireWithin(depth + 1);
315
+ }
316
+ catch (err) {
317
+ if (err instanceof FileStoreLockError && err.code === "store.dir_in_use" && err.ownerPid !== undefined) {
318
+ throw new FileStoreLockError("store.dir_claiming", `file store: another instance${err.ownerPid !== undefined ? ` (pid ${err.ownerPid})` : ""} is claiming ` +
319
+ `this data dir (${dirnameOf(this.lockPath)}) from a stale lock`, this.lockPath, err.ownerPid);
320
+ }
272
321
  throw err;
322
+ }
323
+ try {
324
+ const under = this.inspectLock();
325
+ if (under.state === "owner" && under.pid === owner.pid) {
326
+ try {
327
+ unlinkSync(this.lockPath);
328
+ }
329
+ catch {
330
+ }
331
+ }
332
+ else if (under.state !== "absent") {
333
+ continue;
334
+ }
335
+ this.publishOrLoseRace();
336
+ return;
337
+ }
338
+ finally {
339
+ gate.release();
340
+ }
273
341
  }
274
- const ownerPid = this.readLockPid();
275
- if (ownerPid === undefined) {
276
- throw new Error(`file store: lock file at ${this.lockPath} is unreadable/corrupt; remove it manually if it is stale`);
277
- }
278
- if (isProcessRunning(ownerPid)) {
279
- throw new Error(`file store: another instance (pid ${ownerPid}) owns this data dir (${dirnameOf(this.lockPath)})`);
280
- }
281
- try {
282
- unlinkSync(this.lockPath);
283
- }
284
- catch {
285
- }
342
+ throw new FileStoreLockError("store.dir_in_use", `file store: ownership of this data dir (${dirnameOf(this.lockPath)}) kept changing under us ` +
343
+ `(${MAX_ACQUIRE_ATTEMPTS} attempts); another instance is starting and stopping on the same directory`, this.lockPath);
344
+ }
345
+ publishOrLoseRace() {
286
346
  try {
287
347
  this.writeLock();
288
348
  this.held = true;
349
+ abandonedLocks.delete(this.abandonKey);
289
350
  }
290
351
  catch (err) {
291
352
  if (err.code === "EEXIST") {
292
- throw new Error(`file store: another instance won the lock race for this data dir`);
353
+ throw new FileStoreLockError("store.dir_in_use", `file store: another instance won the lock race for this data dir`, this.lockPath);
293
354
  }
294
355
  throw err;
295
356
  }
296
357
  }
358
+ pruneOwnAbandoned() {
359
+ try {
360
+ unlinkSync(this.lockPath);
361
+ }
362
+ catch (err) {
363
+ throw new FileStoreLockError("store.lock_unreadable", `file store: this process still owns the lock file at ${this.lockPath} from an earlier release that ` +
364
+ `could not remove it, and removing it now failed too (${String(err.message)}); ` +
365
+ `remove it manually`, this.lockPath, process.pid);
366
+ }
367
+ abandonedLocks.delete(this.abandonKey);
368
+ this.publishOrLoseRace();
369
+ }
297
370
  writeLock() {
298
371
  writeThenLink(this.lockPath, String(process.pid));
299
372
  }
300
- readLockPid() {
373
+ get abandonKey() {
374
+ return resolvePath(this.lockPath);
375
+ }
376
+ inspectLock() {
377
+ let raw;
301
378
  try {
302
- const raw = readFileSync(this.lockPath, "utf8").trim();
303
- if (!/^[1-9][0-9]*$/.test(raw))
304
- return undefined;
305
- const pid = Number.parseInt(raw, 10);
306
- return Number.isInteger(pid) && pid > 0 ? pid : undefined;
379
+ raw = readFileSync(this.lockPath, "utf8").trim();
307
380
  }
308
- catch {
309
- return undefined;
381
+ catch (err) {
382
+ return err.code === "ENOENT" ? { state: "absent" } : { state: "unreadable" };
310
383
  }
384
+ if (!/^[1-9][0-9]*$/.test(raw))
385
+ return { state: "unreadable" };
386
+ const pid = Number.parseInt(raw, 10);
387
+ return Number.isInteger(pid) && pid > 0 ? { state: "owner", pid } : { state: "unreadable" };
311
388
  }
312
389
  release() {
313
- if (!this.held)
314
- return;
390
+ if (!this.held) {
391
+ return this.spentVerdict ?? true;
392
+ }
315
393
  this.held = false;
394
+ this.spentVerdict = this.removeOwnLock();
395
+ return this.spentVerdict;
396
+ }
397
+ removeOwnLock() {
398
+ const before = this.inspectLock();
399
+ if (before.state === "absent" || (before.state === "owner" && before.pid !== process.pid)) {
400
+ abandonedLocks.delete(this.abandonKey);
401
+ return true;
402
+ }
403
+ if (before.state === "unreadable") {
404
+ abandonedLocks.add(this.abandonKey);
405
+ return false;
406
+ }
316
407
  try {
317
- if (this.readLockPid() === process.pid)
318
- unlinkSync(this.lockPath);
408
+ unlinkSync(this.lockPath);
319
409
  }
320
410
  catch {
321
411
  }
412
+ const after = this.inspectLock();
413
+ const gone = after.state === "absent" || (after.state === "owner" && after.pid !== process.pid);
414
+ if (gone)
415
+ abandonedLocks.delete(this.abandonKey);
416
+ else
417
+ abandonedLocks.add(this.abandonKey);
418
+ return gone;
419
+ }
420
+ resumeAbandoned() {
421
+ if (this.held)
422
+ return true;
423
+ if (!abandonedLocks.has(this.abandonKey))
424
+ return false;
425
+ const owner = this.inspectLock();
426
+ if (owner.state !== "owner" || owner.pid !== process.pid)
427
+ return false;
428
+ this.held = true;
429
+ this.spentVerdict = undefined;
430
+ abandonedLocks.delete(this.abandonKey);
431
+ return true;
432
+ }
433
+ }
434
+ const heldStoreDirLocks = new Map();
435
+ export function acquireStoreDirLock(dir, opts) {
436
+ const fileName = opts.fileName ?? "LOCK";
437
+ const lockPath = join(dir, fileName);
438
+ const key = `${fileName}@${canonicalStoreKey(dir)}`;
439
+ const lease = (entry) => {
440
+ let released = false;
441
+ return {
442
+ lockPath,
443
+ release: () => {
444
+ if (released)
445
+ return;
446
+ released = true;
447
+ if (heldStoreDirLocks.get(key) !== entry)
448
+ return;
449
+ entry.refs -= 1;
450
+ if (entry.refs > 0)
451
+ return;
452
+ if (entry.lock.release()) {
453
+ heldStoreDirLocks.delete(key);
454
+ return;
455
+ }
456
+ console.warn(`[sema] file store: the ${opts.label} directory fence at ${lockPath} could not be handed back — the ` +
457
+ `removal could not be proven, so a lock file naming this live process (pid ${process.pid}) may still ` +
458
+ `be there. While it is, another process opening that directory is refused by name; the next open of ` +
459
+ `it in THIS process finishes the cleanup or refuses by name. After this process exits the file is ` +
460
+ `reclaimed as a stale lock ONLY if it is readable and its directory writable — otherwise it has to ` +
461
+ `be removed by hand.`);
462
+ },
463
+ };
464
+ };
465
+ const existing = heldStoreDirLocks.get(key);
466
+ if (existing !== undefined) {
467
+ if (existing.refs > 0 || existing.lock.resumeAbandoned()) {
468
+ existing.refs += 1;
469
+ return lease(existing);
470
+ }
471
+ heldStoreDirLocks.delete(key);
472
+ }
473
+ const lock = new BootLock(lockPath);
474
+ try {
475
+ lock.acquire();
476
+ }
477
+ catch (err) {
478
+ if (err instanceof FileStoreLockError) {
479
+ const who = err.ownerPid !== undefined ? ` (pid ${err.ownerPid})` : "";
480
+ const single = `This backend is SINGLE-WRITER per directory: a second process replays a private view (both sides can ` +
481
+ `win a once-only decision) and its next compaction erases the records it never loaded.`;
482
+ throw new FileStoreLockError(err.code, err.code === "store.lock_unreadable"
483
+ ?
484
+ `file store: the ${opts.label} directory (${dir}) cannot be opened — ${err.message}`
485
+ : err.code === "store.dir_claiming"
486
+ ?
487
+ `file store: another OS process${who} is claiming this data dir — it is reclaiming the ${opts.label} ` +
488
+ `directory (${dir}) from a lock a crashed owner left behind. ${single} Retry in a moment: either that ` +
489
+ `process finishes and owns the directory, or it dies and the next start reclaims it.`
490
+ : `file store: another OS process${who} is using the ${opts.label} directory (${dir}). ${single} Stop the ` +
491
+ `other process, point this one at a different data root, or use the pg/tidb backend for real ` +
492
+ `multi-process concurrency.`, err.code === "store.lock_unreadable" ? err.lockPath : lockPath, err.ownerPid);
493
+ }
494
+ throw err;
322
495
  }
496
+ const entry = { lock, refs: 1 };
497
+ heldStoreDirLocks.set(key, entry);
498
+ return lease(entry);
323
499
  }
324
500
  export function createFileConsolidationLock(lockDir) {
325
501
  return (scope) => {
@@ -22,6 +22,7 @@ export { FileWorkflowJournalStore, MAX_JOURNAL_RESULT_BYTES, oversizeJournalResu
22
22
  export { FileUsageWindowStore } from "./usage-window-store.js";
23
23
  export { FileStrategyStore, type FileStrategyStoreOptions } from "./strategy-store.js";
24
24
  export { resolveDataRoot, sanitizeScope, sanitizePathComponent, createFileConsolidationLock } from "./fs-atomic.js";
25
+ export { FileStoreLockError, type FileStoreLockErrorCode } from "./fs-atomic.js";
25
26
  export { atomicWriteFile, writeThenLink, ensureDir, readJsonlRecords, AppendLog } from "./fs-atomic.js";
26
27
  export { AdoptionError, assertAdoptionBootGate, readRootAdoptionFile, writeRootAdoptionFile, ROOT_ADOPTION_FILE, type AdoptionErrorCode, type AdoptionSource, type AdoptionReport, type AdoptionReceipt, type AdoptionLegReport, type AffectedDeploymentConfig, type RootAdoptionFile, } from "./adoption/marker.js";
27
28
  export { adoptLocalDataRoot, ackAdoptionConfig, witnessAdoptionConfig, listAdoptionQuarantine, readAdoptionStatus, type AdoptionStatus, type AdoptLocalDataRootOptions, type AdoptLocalDataRootResult, type AdoptionCarriageLeg, type AdoptionCarriageLegContext, type AdoptionConfigWitnessReceipt, } from "./adoption/adopt.js";
@@ -45,9 +46,13 @@ export { adoptLocalDataRoot, ackAdoptionConfig, witnessAdoptionConfig, listAdopt
45
46
  *
46
47
  * **Single-instance-per-data-dir (§2.4 / §7 decision 4):** the constructor takes a coarse boot `flock` on
47
48
  * `root/LOCK`. A second `FileStorageBackend` over the same dir FAILS FAST ("another instance owns this data
48
- * dir"); a stale lock from a dead PID is pruned. This is the ONLY file lock here — the once-only checkpoint
49
- * CAS is in-process (one event loop), so there is no per-operation lock. Cross-process correct concurrency
50
- * is the Pg/TiDB backend's job, by design.
49
+ * dir"); a stale lock from a dead PID is pruned. The once-only checkpoint CAS is in-process (one event
50
+ * loop), so there is still no per-operation lock. What this backend's lock does NOT cover — and never did,
51
+ * despite what the store docs used to say — is a store constructed DIRECTLY off the root export (or one
52
+ * this backend does not construct at all, like `FileWorkflowRunStore`); those now take their own
53
+ * per-directory writer fence at construction ({@link import("./fs-atomic.js").acquireStoreDirLock}), so
54
+ * the single-writer premise holds however the stores were assembled. Serving many writers CONCURRENTLY
55
+ * (rather than refusing the second) is the Pg/TiDB backend's job, by design.
51
56
  */
52
57
  export interface FileStorageBackendOptions {
53
58
  /** Data root. Default `$AGENT_DATA_DIR ?? ~/.ai-agent`, NFC-normalized + realpath-canonicalized. */
@@ -165,6 +170,24 @@ export declare class FileStorageBackend {
165
170
  private readonly fileSessions;
166
171
  private readonly fileWorkflowJournal;
167
172
  constructor(opts?: FileStorageBackendOptions);
173
+ /**
174
+ * The data-dir boot lock could not be handed back — a root that turned read-only under a running
175
+ * process, an unreadable lock file. Teardown here is best-effort by contract, but best-effort must not
176
+ * mean SILENT: a lock file naming a LIVE pid refuses this data root to every other process, and only
177
+ * somebody saying so out loud makes that operator-actionable. (The release's own answer is the seat for
178
+ * this — a discarded boolean was the backend never learning that the fence it took at construction was
179
+ * never given back.)
180
+ *
181
+ * The wording states what a FAILED release actually establishes and no more: the removal could not be
182
+ * proven. A repeat teardown replays that same verdict without re-reading the directory (the release is
183
+ * one-shot), so this must not assert the file's CURRENT contents — only the consequence while it is
184
+ * there, and the ways out. In particular it must NOT promise that exiting clears it: the two conditions
185
+ * that produce this verdict are an UNREADABLE lock (never pruned by anyone — a stale-lock reclaim has to
186
+ * read the pid it is reclaiming) and a directory the removal cannot write; neither is undone by the
187
+ * owner exiting, and "it will be cleaned up automatically" is the one sentence that turns a five-second
188
+ * `chmod` into an outage.
189
+ */
190
+ private discloseRetainedBootLock;
168
191
  /** Release file handles + the boot lock (best-effort). Call on shutdown so a successor can acquire. */
169
192
  dispose(): Promise<void>;
170
193
  }
@@ -25,6 +25,7 @@ export { FileWorkflowJournalStore, MAX_JOURNAL_RESULT_BYTES, oversizeJournalResu
25
25
  export { FileUsageWindowStore } from "./usage-window-store.js";
26
26
  export { FileStrategyStore } from "./strategy-store.js";
27
27
  export { resolveDataRoot, sanitizeScope, sanitizePathComponent, createFileConsolidationLock } from "./fs-atomic.js";
28
+ export { FileStoreLockError } from "./fs-atomic.js";
28
29
  export { atomicWriteFile, writeThenLink, ensureDir, readJsonlRecords, AppendLog } from "./fs-atomic.js";
29
30
  export { AdoptionError, assertAdoptionBootGate, readRootAdoptionFile, writeRootAdoptionFile, ROOT_ADOPTION_FILE, } from "./adoption/marker.js";
30
31
  export { adoptLocalDataRoot, ackAdoptionConfig, witnessAdoptionConfig, listAdoptionQuarantine, readAdoptionStatus, } from "./adoption/adopt.js";
@@ -56,6 +57,7 @@ export class FileStorageBackend {
56
57
  assertAdoptionBootGate(this.root, "FileStorageBackend");
57
58
  this.lock = new BootLock(join(this.root, "LOCK"));
58
59
  this.lock.acquire();
60
+ const built = [];
59
61
  try {
60
62
  const corruptRead = opts.onCorruptRead !== undefined ? { onCorruptRead: opts.onCorruptRead } : undefined;
61
63
  const repo = new FileSessionRepo(this.root, corruptRead);
@@ -63,18 +65,23 @@ export class FileStorageBackend {
63
65
  this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget", durability: "durable", placements: { subagent: { durability: "durable" } } });
64
66
  this.sessionStore = this.ttl;
65
67
  this.fileCheckpoints = new FileCheckpointStore(this.root, opts.checkpoint);
68
+ built.push(() => this.fileCheckpoints.close());
66
69
  this.checkpointStore = this.fileCheckpoints;
67
70
  this.fileMemory = new FileMemoryStore(this.root, opts.embedder ? { embedder: opts.embedder } : {});
71
+ built.push(() => this.fileMemory.close());
68
72
  this.memoryStore = guardedMemoryStore(this.fileMemory, opts.utilityGate);
69
73
  this.toolResultStore = new FileToolResultStore(this.root);
70
74
  this.sessionPolicyStore = new FileSessionPolicyStore(this.root, corruptRead);
71
75
  this.fileSnapshotStore = new FileFileSnapshotStore(this.root, opts.snapshotBounds, corruptRead);
72
76
  this.fileWorkflowJournal = new FileWorkflowJournalStore(this.root);
77
+ built.push(() => this.fileWorkflowJournal.dispose());
73
78
  this.workflowJournalStore = this.fileWorkflowJournal;
74
79
  this.usageWindowStore = new FileUsageWindowStore(this.root);
75
80
  this.fileAgentRows = new FileBackgroundAgentStore(this.root);
81
+ built.push(() => this.fileAgentRows.close());
76
82
  this.backgroundAgentStore = this.fileAgentRows;
77
83
  this.fileMailbox = new FileMailboxStore(this.root, corruptRead ?? {});
84
+ built.push(() => this.fileMailbox.close());
78
85
  this.mailboxStore = this.fileMailbox;
79
86
  this.rosterStore = new FileRosterStore(join(this.root, "roster.json"), corruptRead ?? {});
80
87
  const strategiesRoot = join(this.root, "strategies");
@@ -89,10 +96,25 @@ export class FileStorageBackend {
89
96
  this.consolidationLock = createFileConsolidationLock(join(this.root, "consolidation-locks"));
90
97
  }
91
98
  catch (err) {
92
- this.lock.release();
99
+ for (const undo of built.reverse()) {
100
+ try {
101
+ undo();
102
+ }
103
+ catch {
104
+ }
105
+ }
106
+ if (!this.lock.release())
107
+ this.discloseRetainedBootLock();
93
108
  throw err;
94
109
  }
95
110
  }
111
+ discloseRetainedBootLock() {
112
+ console.warn(`[sema] file store: this backend could not hand back its boot lock at ${join(this.root, "LOCK")} — the ` +
113
+ `removal could not be proven, so a lock file naming this live process (pid ${process.pid}) may still be ` +
114
+ `there. While it is, another process opening this data root is refused by name; a later open of this root ` +
115
+ `in THIS process finishes the cleanup or refuses by name. After this process exits the file is reclaimed ` +
116
+ `as a stale lock ONLY if it is readable and its directory writable — otherwise it has to be removed by hand.`);
117
+ }
96
118
  async dispose() {
97
119
  try {
98
120
  await this.ttl.dispose();
@@ -122,6 +144,7 @@ export class FileStorageBackend {
122
144
  }
123
145
  catch {
124
146
  }
125
- this.lock.release();
147
+ if (!this.lock.release())
148
+ this.discloseRetainedBootLock();
126
149
  }
127
150
  }