@sema-agent/core 5.52.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.
- package/CHANGELOG.md +99 -0
- package/dist/agents/subagent.d.ts +4 -0
- package/dist/agents/subagent.js +1 -1
- package/dist/brain/anthropic.js +17 -4
- package/dist/core/a2a.js +12 -1
- package/dist/core/cache-break-detector.js +9 -3
- package/dist/core/mcp.d.ts +168 -5
- package/dist/core/mcp.js +200 -21
- package/dist/core/permission-rule-model.d.ts +140 -21
- package/dist/core/permission-rule-model.js +76 -17
- package/dist/core/permission-rule-org.d.ts +4 -3
- package/dist/core/permission-rule-org.js +12 -3
- package/dist/core/protocol-naming.d.ts +25 -2
- package/dist/core/protocol-naming.js +11 -0
- package/dist/core/runner/prepare-safety-scan.js +7 -0
- package/dist/core/runner/prepare-task.js +24 -3
- package/dist/core/runner/runtask.js +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/workflow-types.d.ts +16 -6
- package/dist/orchestration/workflow-types.js +10 -4
- package/dist/orchestration/workflow.js +32 -6
- package/dist/stores/file/background-agent-store.js +1 -0
- package/dist/stores/file/checkpoint-store.d.ts +6 -2
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/fs-atomic.d.ts +151 -10
- package/dist/stores/file/fs-atomic.js +208 -32
- package/dist/stores/file/index.d.ts +26 -3
- package/dist/stores/file/index.js +25 -2
- package/dist/stores/file/shared-ledger.d.ts +40 -5
- package/dist/stores/file/shared-ledger.js +24 -8
- package/dist/stores/file/workflow-run-store.d.ts +8 -1
- package/dist/stores/file/workflow-run-store.js +1 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +71 -0
- package/dist/tools/fs/bash-readonly-classifier.js +58 -47
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +3 -1
|
@@ -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
|
|
156
|
-
*
|
|
157
|
-
* (the
|
|
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
|
|
160
|
-
*
|
|
161
|
-
* in-process model's premise
|
|
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.
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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
|
-
|
|
373
|
+
get abandonKey() {
|
|
374
|
+
return resolvePath(this.lockPath);
|
|
375
|
+
}
|
|
376
|
+
inspectLock() {
|
|
377
|
+
let raw;
|
|
301
378
|
try {
|
|
302
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
49
|
-
*
|
|
50
|
-
* is the
|
|
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
|
-
|
|
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
|
}
|