@awebai/oats 0.39.4 → 0.40.2
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/bin/oats.mjs +106 -16
- package/docs/capabilities.md +249 -2
- package/docs/desktop-cli-api.md +246 -18
- package/docs/execution-targets.md +34 -29
- package/docs/implementation.md +62 -0
- package/docs/oats-local.schema.json +2 -2
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +5 -5
- package/docs/release-lane.md +8 -4
- package/docs/release-notes/v0.40.0.md +181 -0
- package/docs/release-notes/v0.40.1.md +20 -0
- package/docs/release-notes/v0.40.2.md +120 -0
- package/docs/schedules.md +117 -19
- package/docs/servers.md +3 -1
- package/docs/workspaces.md +1 -1
- package/lib/automations.mjs +4 -1
- package/lib/core.mjs +76 -21
- package/lib/dir-lock.mjs +7 -4
- package/lib/instance-events.mjs +278 -39
- package/lib/schedule-command-child.mjs +88 -0
- package/lib/schedule-command.mjs +26 -0
- package/lib/schedule.mjs +175 -41
- package/lib/servers.mjs +21 -3
- package/lib/session-input.mjs +42 -47
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
package/lib/core.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
33
33
|
} from "node:fs";
|
|
34
34
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
35
|
+
import { homedir } from "node:os";
|
|
35
36
|
import { accessSync, constants as fsConstants } from "node:fs";
|
|
36
37
|
import { recordLocalInput } from "./local-inputs.mjs";
|
|
37
38
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -40,7 +41,7 @@ import { initializeNativeHistory, prepareNativeStart } from "../packages/record/
|
|
|
40
41
|
import { noteRuntimeName } from "./deprecation.mjs";
|
|
41
42
|
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
42
43
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
43
|
-
import { appendEvent } from "./instance-events.mjs";
|
|
44
|
+
import { appendEvent, liveWaiting, recordStartBoundary } from "./instance-events.mjs";
|
|
44
45
|
import { killGroup } from "./process-group.mjs";
|
|
45
46
|
|
|
46
47
|
import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
|
|
@@ -204,7 +205,7 @@ function shIn(cwd, cmdline, timeout = 45000) {
|
|
|
204
205
|
return execSync(cmdline, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
|
|
205
206
|
}
|
|
206
207
|
function shInTry(cwd, cmdline, timeout) { try { return shIn(cwd, cmdline, timeout); } catch { return undefined; } }
|
|
207
|
-
function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
208
|
+
export function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
208
209
|
export function slug(s) {
|
|
209
210
|
const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
210
211
|
return r || "agent";
|
|
@@ -2008,7 +2009,7 @@ export const RELATIONS = ["child", "sibling", "parent", "unrelated"];
|
|
|
2008
2009
|
/** Verify the harness packages that ACTIVE capabilities require for `harness`.
|
|
2009
2010
|
*
|
|
2010
2011
|
* Each comes from a declared harness-package requirement, so the capability has
|
|
2011
|
-
* stated the dependency
|
|
2012
|
+
* stated the dependency. Installing it remains the operator's responsibility.
|
|
2012
2013
|
* We verify PRESENCE and record provenance; we deliberately do NOT resolve the
|
|
2013
2014
|
* extension's entry file. pi owns that resolution — its manifest supports globs
|
|
2014
2015
|
* and exclusions, packages without a `pi` manifest use conventional directories,
|
|
@@ -2050,8 +2051,16 @@ function verifyHarnessPackages(harness, resolved, contextDir, { bin, env } = {})
|
|
|
2050
2051
|
const status = harnessPackageStatus(harness, spec, probeEnv, probeOpts);
|
|
2051
2052
|
const mgr = HARNESS_PACKAGE_MANAGERS[harness];
|
|
2052
2053
|
const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
|
|
2053
|
-
|
|
2054
|
-
|
|
2054
|
+
// Keep the remedy in the same resource directory and selected wrapper as
|
|
2055
|
+
// the probe; quote argv, never turn a path or package selector into shell code.
|
|
2056
|
+
// Pi expands ~ against the launch HOME; relative selectors are anchored
|
|
2057
|
+
// to the probe cwd before the remedy changes to contextDir.
|
|
2058
|
+
const piResourceDir = harness === "pi" ? piAgentDir(probeEnv).replace(/^~(?=$|\/)/, () => probeEnv.HOME || homedir()) : undefined;
|
|
2059
|
+
const resourceEnv = harness === "pi" ? `PI_CODING_AGENT_DIR=${shq(resolve(piResourceDir))} `
|
|
2060
|
+
: harness === "claude" && probeEnv.CLAUDE_CONFIG_DIR ? `CLAUDE_CONFIG_DIR=${shq(resolve(probeEnv.CLAUDE_CONFIG_DIR))} ` : "";
|
|
2061
|
+
const direct = stepList.filter((a) => a.length).map((a) => resourceEnv + [bin || a[0], ...a.slice(1)].map(shq).join(" ")).join(" && ");
|
|
2062
|
+
const remedy = direct ? `ask the operator to run \`cd ${shq(contextDir)} && ${direct}\` with the selected harness's environment; OATS does not install packages during spawn or restart`
|
|
2063
|
+
: `ask the operator to install it with the selected harness's package manager`;
|
|
2055
2064
|
// `ifInstalled: true`: the row constrains a package that may be absent
|
|
2056
2065
|
// (an ambient extension must honour a contract IF it is there); absence
|
|
2057
2066
|
// satisfies it. Without the flag, absence fails as before.
|
|
@@ -2659,6 +2668,12 @@ export async function spawnInstanceAsync(root, agent, o = {}) {
|
|
|
2659
2668
|
}
|
|
2660
2669
|
return step.value;
|
|
2661
2670
|
}
|
|
2671
|
+
/** The producer sets this only when dispatched effects or their compensation
|
|
2672
|
+
* cannot be confirmed. A diagnostic's wording or home path is not evidence. */
|
|
2673
|
+
function unconfirmedSpawn(error) {
|
|
2674
|
+
error.details = { ...error.details, unconfirmed: true };
|
|
2675
|
+
return error;
|
|
2676
|
+
}
|
|
2662
2677
|
function* spawnBody(root, agent, o = {}) {
|
|
2663
2678
|
if (!o.prepared) throw localMissingForSpawn(agent);
|
|
2664
2679
|
const deliver = (r) => r;
|
|
@@ -2747,7 +2762,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2747
2762
|
// Completion custody: the home exists from the first metadata write, but
|
|
2748
2763
|
// the launch/lineage/events that make it a finished spawn may not have
|
|
2749
2764
|
// happened (crash in the interval). Say which, never replay a half-spawn.
|
|
2750
|
-
if (prior.spawnCompleted !== true) throw Object.assign(oatsError("E_SPAWN_INCOMPLETE", `${prior.instance} was created for this key but its spawn did not complete (launch or lineage unfinished); inspect it with oats session inspect --home ${prior.home} — do not spawn again`), { instance: prior.instance, home: prior.home, launched: prior.launched === true ? "unknown" : false });
|
|
2765
|
+
if (prior.spawnCompleted !== true) throw unconfirmedSpawn(Object.assign(oatsError("E_SPAWN_INCOMPLETE", `${prior.instance} was created for this key but its spawn did not complete (launch or lineage unfinished); inspect it with oats session inspect --home ${prior.home} — do not spawn again`), { instance: prior.instance, home: prior.home, launched: prior.launched === true ? "unknown" : false }));
|
|
2751
2766
|
return { ...prior, replayed: true, launch: undefined, command: undefined, wake: prior.wake ?? { requested: null, saved: null, error: null } };
|
|
2752
2767
|
}
|
|
2753
2768
|
}
|
|
@@ -3209,7 +3224,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3209
3224
|
// name taken (M1). Nothing of a classic home exists at this point either.
|
|
3210
3225
|
const rollbackEmptyOrPreparedHome = (e) => {
|
|
3211
3226
|
try { rmSync(home, { recursive: true, force: true }); }
|
|
3212
|
-
catch (x) { e.message += ` — rollback INCOMPLETE, remove ${home} manually: ${x.message}`; }
|
|
3227
|
+
catch (x) { e.message += ` — rollback INCOMPLETE, remove ${home} manually: ${x.message}`; unconfirmedSpawn(e); }
|
|
3213
3228
|
return e;
|
|
3214
3229
|
};
|
|
3215
3230
|
// Workspace model: copy every resolved capability WHOLE into the new home
|
|
@@ -3377,9 +3392,10 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3377
3392
|
if (incomplete.length) {
|
|
3378
3393
|
// Nothing outside the home exists yet (no worktree, no hooks, no window), so
|
|
3379
3394
|
// removing the scaffold is the whole rollback.
|
|
3380
|
-
let removal = "";
|
|
3381
|
-
try { rmSync(home, { recursive: true, force: true }); } catch (e) { removal = ` — rollback INCOMPLETE, remove ${home} manually: ${e.message}`; }
|
|
3382
|
-
|
|
3395
|
+
let removal = "", removalFailed = false;
|
|
3396
|
+
try { rmSync(home, { recursive: true, force: true }); } catch (e) { removalFailed = true; removal = ` — rollback INCOMPLETE, remove ${home} manually: ${e.message}`; }
|
|
3397
|
+
const error = oatsError("E_COMPOSITION_INCOMPLETE", `the instance composition did not materialize completely:\n${incomplete.map((m) => ` ${m}`).join("\n")}${removal}`);
|
|
3398
|
+
throw removalFailed ? unconfirmedSpawn(error) : error;
|
|
3383
3399
|
}
|
|
3384
3400
|
|
|
3385
3401
|
// Work tree.
|
|
@@ -3424,7 +3440,8 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3424
3440
|
}
|
|
3425
3441
|
try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
|
|
3426
3442
|
const note = incomplete.length ? ` — rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}` : "";
|
|
3427
|
-
|
|
3443
|
+
const error = new Error(`git worktree add/canonicalization failed: ${original}${note}`);
|
|
3444
|
+
throw incomplete.length ? unconfirmedSpawn(error) : error;
|
|
3428
3445
|
}
|
|
3429
3446
|
} else if (work === "directory") {
|
|
3430
3447
|
// An owned execution directory, not a link to the source or a fake Git repo.
|
|
@@ -3520,11 +3537,11 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3520
3537
|
outstandingGit.add("worktree");
|
|
3521
3538
|
if (branch) outstandingGit.add("branch");
|
|
3522
3539
|
}
|
|
3523
|
-
return quarantineInstanceHome({
|
|
3540
|
+
return { unconfirmed: true, note: quarantineInstanceHome({
|
|
3524
3541
|
home, instance, agent, soulDir: homeSoulTarget, soulId: preparedSoulId, incomplete, failed, outstandingHooks, outstandingGit,
|
|
3525
3542
|
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
3526
3543
|
launched: true, tmux: spawnTmux, directoryHome: homeReal, recordRetirementBaseline: true,
|
|
3527
|
-
});
|
|
3544
|
+
}) };
|
|
3528
3545
|
}
|
|
3529
3546
|
}
|
|
3530
3547
|
// Once hooks ran, directory execution may already hold authored results.
|
|
@@ -3546,7 +3563,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3546
3563
|
launched: false, directoryPreservation: true, directoryHome: homeReal,
|
|
3547
3564
|
recordRetirementBaseline: true,
|
|
3548
3565
|
});
|
|
3549
|
-
return `${note}${directoryRecoveries.length ? `; prior work recovery: ${directoryRecoveries.join(", ")}` : ""}
|
|
3566
|
+
return { unconfirmed: true, note: `${note}${directoryRecoveries.length ? `; prior work recovery: ${directoryRecoveries.join(", ")}` : ""}` };
|
|
3550
3567
|
};
|
|
3551
3568
|
const preserveDirectory = () => {
|
|
3552
3569
|
if (work !== "directory") return;
|
|
@@ -3630,7 +3647,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3630
3647
|
if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
|
|
3631
3648
|
note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
|
|
3632
3649
|
}
|
|
3633
|
-
return `${note}${directoryRecoveries.length ? `; directory work preserved at ${directoryRecoveries.join(", ")}` : ""}
|
|
3650
|
+
return { unconfirmed: incomplete.length > 0, note: `${note}${directoryRecoveries.length ? `; directory work preserved at ${directoryRecoveries.join(", ")}` : ""}` };
|
|
3634
3651
|
};
|
|
3635
3652
|
|
|
3636
3653
|
try {
|
|
@@ -3849,8 +3866,15 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3849
3866
|
}
|
|
3850
3867
|
return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
|
|
3851
3868
|
} catch (error) {
|
|
3852
|
-
|
|
3853
|
-
|
|
3869
|
+
try {
|
|
3870
|
+
const compensation = compensateSpawn();
|
|
3871
|
+
error.message += compensation.note;
|
|
3872
|
+
if (compensation.unconfirmed) unconfirmedSpawn(error);
|
|
3873
|
+
} catch (cleanupError) {
|
|
3874
|
+
// An interrupted compensation pass cannot establish that effects ended.
|
|
3875
|
+
error.message += ` — rollback INCOMPLETE: cleanup could not be completed: ${cleanupError.message}`;
|
|
3876
|
+
unconfirmedSpawn(error);
|
|
3877
|
+
}
|
|
3854
3878
|
throw error;
|
|
3855
3879
|
}
|
|
3856
3880
|
}
|
|
@@ -3948,7 +3972,18 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
3948
3972
|
...(meta.home !== undefined && meta.home !== home ? { recordedHome: meta.home } : {}),
|
|
3949
3973
|
...(meta.instance !== undefined && meta.instance !== e.name ? { recordedInstance: meta.instance } : {}),
|
|
3950
3974
|
};
|
|
3951
|
-
|
|
3975
|
+
// Feature waiting-on-you: a producer's live claim that the instance is
|
|
3976
|
+
// blocked on a human, read only for a running row (one bounded read of
|
|
3977
|
+
// its home log); null otherwise, and null means unknown. `running` is
|
|
3978
|
+
// the window's presence, which a crashed harness's fallback shell or a
|
|
3979
|
+
// retained dead pane keeps: a row with a claim is shown only when its
|
|
3980
|
+
// session is observed running a harness, as session inspect reports it.
|
|
3981
|
+
let waitingOnYou = liveness.running === true ? liveWaiting(home) : null;
|
|
3982
|
+
if (waitingOnYou) {
|
|
3983
|
+
try { const s = instanceSessionTarget(home); if (!s.target || !harnessRunning(inspectSessionTarget(s.target))) waitingOnYou = null; }
|
|
3984
|
+
catch { waitingOnYou = null; }
|
|
3985
|
+
}
|
|
3986
|
+
return { ...meta, ...claims, home, instance: e.name, ...facts, ...(identity ? { identity } : {}), ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, waitingOnYou, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
|
|
3952
3987
|
|
|
3953
3988
|
});
|
|
3954
3989
|
};
|
|
@@ -4324,6 +4359,12 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
|
4324
4359
|
* otherwise every stop or event write would read as "changed home bytes". */
|
|
4325
4360
|
const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
|
|
4326
4361
|
const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
|
|
4362
|
+
/** Harness project settings in the home are configuration, not work: the
|
|
4363
|
+
* home's `.claude/` is harness layout the kernel already shapes (the skills
|
|
4364
|
+
* alias), and capabilities keep their own entries current in its
|
|
4365
|
+
* settings.json at every launch. A retirement fingerprint ignores exactly
|
|
4366
|
+
* that path, home-relative. */
|
|
4367
|
+
const HARNESS_HOME_SETTINGS = new Set([join(".claude", "settings.json")]);
|
|
4327
4368
|
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
|
|
4328
4369
|
const hash = createHash("sha256");
|
|
4329
4370
|
const rootStat = lstatSync(root);
|
|
@@ -4338,6 +4379,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
4338
4379
|
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
4339
4380
|
if ((!rel && excludeRoot.has(e.name)) || (instanceHome && !rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
|
|
4340
4381
|
const childRel = rel ? join(rel, e.name) : e.name;
|
|
4382
|
+
if (instanceHome && HARNESS_HOME_SETTINGS.has(childRel)) continue;
|
|
4341
4383
|
const path = join(dir, e.name);
|
|
4342
4384
|
const st = lstatSync(path);
|
|
4343
4385
|
hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
|
|
@@ -4565,12 +4607,18 @@ function instanceSessionTarget(home) {
|
|
|
4565
4607
|
}
|
|
4566
4608
|
|
|
4567
4609
|
export function inspectInstanceSession(home) {
|
|
4568
|
-
if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
|
|
4610
|
+
if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped", waitingOnYou: null };
|
|
4569
4611
|
const s = instanceSessionTarget(home);
|
|
4570
|
-
if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
|
|
4571
|
-
|
|
4612
|
+
if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched", waitingOnYou: null };
|
|
4613
|
+
let observed;
|
|
4614
|
+
try { observed = inspectSessionTarget(s.target); }
|
|
4572
4615
|
catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
|
|
4616
|
+
// Feature waiting-on-you: beside `state`, whose enum is unchanged.
|
|
4617
|
+
return { home: s.home, ...observed, waitingOnYou: harnessRunning(observed) ? liveWaiting(s.home) : null };
|
|
4573
4618
|
}
|
|
4619
|
+
/** Whether an observed session runs a harness: present, and neither a
|
|
4620
|
+
* fallback shell nor a dead pane. Only such a session can be waiting on a human. */
|
|
4621
|
+
function harnessRunning(observed) { return observed?.present === true && observed.state !== "shell" && observed.state !== "stopped"; }
|
|
4574
4622
|
|
|
4575
4623
|
/** K3: quiesce one instance's session and RETAIN everything else — home,
|
|
4576
4624
|
* worktree, transcript, launch configuration — so `restart` can bring it back.
|
|
@@ -5125,6 +5173,9 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5125
5173
|
// Reconcile even an exited target: the independent baseline may
|
|
5126
5174
|
// already name it while metadata still names the old allocation.
|
|
5127
5175
|
const meta = readMeta();
|
|
5176
|
+
// The adopted start's session boundary, at its launch time, completed
|
|
5177
|
+
// in whichever log the interrupted start did not record it.
|
|
5178
|
+
recordStartBoundary(realHome, { startId: pending.id, startedAt: pending.startedAt, harness: pending.harness ?? meta.harness ?? null, backend: "tmux", launchConfig: pending.launch?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: "recovered" });
|
|
5128
5179
|
const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
|
|
5129
5180
|
if (st.present && st.state !== "shell") {
|
|
5130
5181
|
if (o.restart) { rmSync(pendingPath, { force: true }); }
|
|
@@ -5305,6 +5356,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5305
5356
|
catch (e) { throw launchFailure(e); }
|
|
5306
5357
|
}
|
|
5307
5358
|
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
5359
|
+
// The session exists: its boundary (lib/instance-events.mjs) is recorded
|
|
5360
|
+
// now, before the metadata, so a start whose metadata write fails still
|
|
5361
|
+
// voids the claims of the session it replaced; its adoption only completes a log it missed.
|
|
5362
|
+
recordStartBoundary(realHome, { startId: id, startedAt, harness: launchPlan?.harness || harness, backend: "tmux", launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: o.restart ? "restart" : "start" });
|
|
5308
5363
|
// Keep launch evidence until the command exits or the target disappears.
|
|
5309
5364
|
// A transient child (for example the native-start recorder) is not proof that startup
|
|
5310
5365
|
// has finished. A later start reconciles the receipt without a watcher.
|
package/lib/dir-lock.mjs
CHANGED
|
@@ -10,9 +10,9 @@ function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) {
|
|
|
10
10
|
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
11
11
|
|
|
12
12
|
/** A mkdir lock that is never reclaimed by another process: an existing
|
|
13
|
-
* lock whose owner is unreadable or dead is refused
|
|
14
|
-
* remove, because the gap between mkdir and owner.json
|
|
15
|
-
* acquirer and a dead-owner reclaim races every other acquirer. The
|
|
13
|
+
* lock whose owner is unreadable or dead is refused after bounded waiting,
|
|
14
|
+
* with the directory to remove, because the gap between mkdir and owner.json
|
|
15
|
+
* belongs to a live acquirer and a dead-owner reclaim races every other acquirer. The
|
|
16
16
|
* holder removes its own lock in finally and on SIGINT/SIGTERM. A lock still
|
|
17
17
|
* held after `retryMs` is refused with `busy(why)`, the caller's own error. */
|
|
18
18
|
export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
|
|
@@ -23,7 +23,10 @@ export function withDirLock(dir, what, fn, { retryMs = 0, busy } = {}) {
|
|
|
23
23
|
catch (e) {
|
|
24
24
|
if (e.code !== "EEXIST") throw e;
|
|
25
25
|
let owner; try { owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { owner = undefined; }
|
|
26
|
-
|
|
26
|
+
// Missing/unreadable owners also occur between mkdir and publication,
|
|
27
|
+
// and while the holder removes its directory. Wait without touching it.
|
|
28
|
+
const remaining = deadline - Date.now();
|
|
29
|
+
if (remaining > 0) { pause(Math.min(50, remaining)); continue; }
|
|
27
30
|
throw busy(!owner ? "its owner is not readable yet or the file is missing" : pidAlive(owner.pid) ? `pid ${owner.pid} holds it` : `its owner pid ${owner.pid} is gone`);
|
|
28
31
|
}
|
|
29
32
|
}
|