@awebai/oats 0.22.17 → 0.22.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/oats.mjs +341 -21
- package/docs/configuration.md +65 -0
- package/docs/design/2026-09-07-mobile-agent-management-proposal.md +228 -0
- package/docs/design/launch-configurations.md +164 -0
- package/docs/desktop-cli-api.md +67 -1
- package/docs/desktop-instance-start.md +39 -3
- package/docs/oats-config.schema.json +28 -0
- package/docs/release-notes/v0.22.18.md +101 -0
- package/docs/release-notes/v0.22.19.md +115 -0
- package/docs/souls-and-instances.md +1 -0
- package/lib/core.mjs +836 -137
- package/lib/servers.mjs +89 -4
- package/package.json +1 -1
- package/packages/record/bin/capture.mjs +49 -6
- package/packages/record/lib/capture-lock.mjs +68 -5
package/lib/servers.mjs
CHANGED
|
@@ -252,7 +252,8 @@ export function checkRemoteSupport(remote, target, oatsArgs, roster) {
|
|
|
252
252
|
// capability-defined agent, or one with no live instance) has no default
|
|
253
253
|
// this side can establish: the remote kernel validates its own default at
|
|
254
254
|
// spawn, and nothing is asserted here about it.
|
|
255
|
-
const
|
|
255
|
+
const namedConfig = oatsArgs.includes("--launch-config");
|
|
256
|
+
const runtime = flagOf("--runtime") || (namedConfig ? undefined : soul?.runtime);
|
|
256
257
|
// What the message may claim depends on what was established: an
|
|
257
258
|
// advertising remote said what it supports; a silent one (before 0.22.2)
|
|
258
259
|
// said nothing, and only pi and claude are assumed of it.
|
|
@@ -260,6 +261,7 @@ export function checkRemoteSupport(remote, target, oatsArgs, roster) {
|
|
|
260
261
|
? `it advertises runtimes ${remote.runtimes.join(", ")}${remote.sessionBackends.length ? `, session backends ${remote.sessionBackends.join(", ")}` : ", no session backend choice"}${remote.launchOptions.length ? `, launch options ${remote.launchOptions.join(", ")}` : ", no launch options"}`
|
|
261
262
|
: `it does not advertise what it supports (kernels before 0.22.2 do not), so only pi and claude on tmux with no launch options are assumed of it; upgrade it there to use more`;
|
|
262
263
|
const refuse = (what) => { throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost}: ${what} was not established as supported there (${supports})`); };
|
|
264
|
+
if (namedConfig && !remote.features.includes("launch-config")) refuse("a named launch configuration (the host must advertise launch-config)");
|
|
263
265
|
if (runtime && !remote.runtimes.includes(runtime)) refuse(`runtime ${runtime}${flagOf("--runtime") ? "" : ` (the default of soul ${agent} there)`}`);
|
|
264
266
|
// A wake schedule at spawn is saved by the host: it must advertise schedules.
|
|
265
267
|
if (["--wake-json", "--wake-every", "--wake-cron"].some((f) => oatsArgs.includes(f)) && !remote.features.includes("schedule")) refuse("a wake schedule at spawn (the host must advertise the schedule feature)");
|
|
@@ -656,17 +658,100 @@ const OPERATIONS_COMMANDS = new Set(["inspect", "operation", "use", "soul"]);
|
|
|
656
658
|
/** `session start` on the execution host for a remote instance: the same
|
|
657
659
|
* route resolution as inspect, refused before any mutation when the remote
|
|
658
660
|
* kernel does not advertise session-start, the envelope relayed as is. */
|
|
659
|
-
export function startRemote(serverId,
|
|
660
|
-
|
|
661
|
+
export function startRemote(serverId, choices = {}, io = {}) {
|
|
662
|
+
return launchRemoteSession(serverId, "start", choices, io);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function restartRemote(serverId, choices = {}, io = {}) {
|
|
666
|
+
return launchRemoteSession(serverId, "restart", choices, io);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function remoteLaunchArgs({ launchConfig, runtime, model, yolo } = {}) {
|
|
670
|
+
const args = [];
|
|
671
|
+
if (launchConfig !== undefined) {
|
|
672
|
+
if (typeof launchConfig !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(launchConfig)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
|
|
673
|
+
args.push("--launch-config", launchConfig);
|
|
674
|
+
}
|
|
675
|
+
if (runtime !== undefined) {
|
|
676
|
+
if (!["pi", "claude", "codex"].includes(runtime)) throw serverError("E_BAD_ARGS", "runtime must be pi, claude or codex");
|
|
677
|
+
args.push("--runtime", runtime);
|
|
678
|
+
}
|
|
679
|
+
if (model !== undefined && model !== null && model !== "") {
|
|
680
|
+
if (typeof model !== "string" || model.startsWith("-") || model.includes("\0")) throw serverError("E_BAD_ARGS", "invalid model name");
|
|
681
|
+
args.push("--model", model);
|
|
682
|
+
}
|
|
683
|
+
if (yolo !== undefined) {
|
|
684
|
+
if (typeof yolo !== "boolean") throw serverError("E_BAD_ARGS", "yolo must be true or false");
|
|
685
|
+
args.push(yolo ? "--yolo" : "--no-yolo");
|
|
686
|
+
}
|
|
687
|
+
return args;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function requireRemoteFeature(remote, target, feature) {
|
|
691
|
+
if (!remote.features.includes(feature)) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise ${feature}; upgrade it there; nothing was sent`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function launchRemoteSession(serverId, action, choices, io) {
|
|
695
|
+
const { instance, home, launchConfig, runtime, yolo } = choices;
|
|
696
|
+
const choiceArgs = remoteLaunchArgs(choices);
|
|
697
|
+
const route = resolveRoute(serverId, { instance, home }, `session ${action}`);
|
|
661
698
|
const remote = requireSessionRemote(route.target, io);
|
|
662
699
|
if (!remote.features.includes("session-start")) {
|
|
663
700
|
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-start (kernels from ${SESSION_START_REMOTE_VERSION} do); upgrade it there, or start the instance on that host`);
|
|
664
701
|
}
|
|
665
|
-
|
|
702
|
+
if (action === "restart") requireRemoteFeature(remote, route.target, "session-restart");
|
|
703
|
+
if (launchConfig !== undefined || runtime !== undefined || yolo !== undefined) requireRemoteFeature(remote, route.target, "launch-config");
|
|
704
|
+
const args = ["session", action, "--home", route.home, ...choiceArgs, "--json"];
|
|
666
705
|
const { envelope, stderr } = runRemote(route.target, args, io);
|
|
667
706
|
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance || envelope.result.instance } } : envelope, stderr, route };
|
|
668
707
|
}
|
|
669
708
|
|
|
709
|
+
/** Scope operations follow the registration; existing-home operations follow
|
|
710
|
+
* its saved route. Definitions travel on stdin, never as remote file paths or
|
|
711
|
+
* shell arguments. The host resolves environment references and validates the
|
|
712
|
+
* definition; this client never substitutes its own environment. */
|
|
713
|
+
export function launchConfigRemote(serverId, options = {}, io = {}) {
|
|
714
|
+
const { action, name, context, instance, home, soul, agentsRoot, definition, keepEnv } = options;
|
|
715
|
+
if (!["list", "set", "remove", "preview"].includes(action)) throw serverError("E_BAD_ARGS", "unknown launch configuration action");
|
|
716
|
+
const write = action === "set" || action === "remove";
|
|
717
|
+
const homeSelected = home !== undefined || instance !== undefined;
|
|
718
|
+
if (homeSelected && (context !== undefined || soul !== undefined || agentsRoot !== undefined)) throw serverError("E_BAD_ARGS", "select one home or configuration scope");
|
|
719
|
+
if (write && (homeSelected || soul !== undefined || agentsRoot !== undefined)) throw serverError("E_BAD_ARGS", "edit a launch configuration with --dir, not a home or soul");
|
|
720
|
+
if (action === "preview" && !homeSelected && !soul) throw serverError("E_BAD_ARGS", "select a home or soul to preview");
|
|
721
|
+
const args = ["launch-config", action];
|
|
722
|
+
if (write) {
|
|
723
|
+
if (typeof name !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
|
|
724
|
+
args.push(name);
|
|
725
|
+
}
|
|
726
|
+
let input;
|
|
727
|
+
if (action === "set") {
|
|
728
|
+
if (!definition || typeof definition !== "object" || Array.isArray(definition)) throw serverError("E_BAD_ARGS", "specify a launch configuration object");
|
|
729
|
+
if (keepEnv !== undefined && typeof keepEnv !== "boolean") throw serverError("E_BAD_ARGS", "keepEnv must be true or false");
|
|
730
|
+
if (keepEnv && Object.hasOwn(definition, "env")) throw serverError("E_BAD_ARGS", "omit env when preserving the saved environment");
|
|
731
|
+
input = Buffer.from(JSON.stringify(definition));
|
|
732
|
+
args.push("--file", "-");
|
|
733
|
+
if (keepEnv) args.push("--keep-env");
|
|
734
|
+
}
|
|
735
|
+
const add = (flag, value, absolute = false) => {
|
|
736
|
+
if (typeof value !== "string" || !value || value.startsWith("-") || value.includes("\0") || (absolute && !value.startsWith("/"))) throw serverError("E_BAD_ARGS", `invalid ${flag}`);
|
|
737
|
+
args.push(flag, value);
|
|
738
|
+
};
|
|
739
|
+
// Validate explicit selectors before even probing the host.
|
|
740
|
+
if (context !== undefined) add("--dir", context, true);
|
|
741
|
+
if (soul !== undefined) add("--soul", soul);
|
|
742
|
+
if (agentsRoot !== undefined) add("--agents-root", agentsRoot, true);
|
|
743
|
+
if (action === "preview") args.push(...remoteLaunchArgs(options));
|
|
744
|
+
const route = homeSelected ? resolveRoute(serverId, { instance, home }, "launch-config") : undefined;
|
|
745
|
+
const target = route?.target || targetOf(io.server || getServer(serverId));
|
|
746
|
+
if (route) add("--home", route.home, true);
|
|
747
|
+
else if (context === undefined) args.push("--dir", target.workspace);
|
|
748
|
+
// stdin belongs only to the set operation, never its preceding version probe.
|
|
749
|
+
const remote = checkRemote(target, { ...io, input: undefined });
|
|
750
|
+
requireRemoteFeature(remote, target, "launch-config");
|
|
751
|
+
const { envelope, stderr } = runRemote(target, [...args, "--json"], { ...io, input });
|
|
752
|
+
return { envelope, stderr, target, ...(route ? { route } : {}) };
|
|
753
|
+
}
|
|
754
|
+
|
|
670
755
|
/** `oats schedule ...` on the execution host: schedules are host-owned, so
|
|
671
756
|
* every subcommand runs in the server's registered workspace; refused
|
|
672
757
|
* before any remote mutation when the remote kernel does not advertise
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.19",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -160,9 +160,30 @@ function log(...parts) {
|
|
|
160
160
|
|
|
161
161
|
/** Take the root's single-run lock, or say who holds it. A hook-triggered
|
|
162
162
|
* pass that finds it held exits 0: the holder's pass, or the next one,
|
|
163
|
-
* reconciles the same sessions.
|
|
163
|
+
* reconciles the same sessions.
|
|
164
|
+
*
|
|
165
|
+
* `fn` must return or throw, never call process.exit: the lock is released
|
|
166
|
+
* in the finally, and a release that did not happen is said so on stderr
|
|
167
|
+
* with the operator recovery and a nonzero exit status. Likewise an owner
|
|
168
|
+
* record that could not be written: the lock never silently outlives the
|
|
169
|
+
* pass that created it. */
|
|
164
170
|
function withCaptureLock(fn) {
|
|
165
|
-
|
|
171
|
+
let lock;
|
|
172
|
+
try {
|
|
173
|
+
lock = acquireCaptureLock(root);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err.lockCleanup) {
|
|
176
|
+
const c = err.lockCleanup;
|
|
177
|
+
const outcome = c.removed ? "the initializing lock was removed"
|
|
178
|
+
: c.reason === "gone" ? "the initializing lock was already gone (removed by another party)"
|
|
179
|
+
: c.reason === "replaced" ? (c.owner
|
|
180
|
+
? `the lock now belongs to pid ${c.owner.pid} (started ${c.owner.startedAt || "?"}) and was left alone`
|
|
181
|
+
: "the lock directory changed or its identity could not be verified, no owner record was readable, and it was left alone")
|
|
182
|
+
: `the initializing lock could NOT be removed${c.error ? ` (${c.error})` : ""}; ${c.recovery}`;
|
|
183
|
+
console.error(`capture: could not write the owner record of ${c.path}: ${err.message}; ${outcome}`);
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
166
187
|
if (lock.held) {
|
|
167
188
|
// Never quiet: a stale lock after a killed pass needs the operator, and
|
|
168
189
|
// the line says exactly what to check and what to remove.
|
|
@@ -170,13 +191,29 @@ function withCaptureLock(fn) {
|
|
|
170
191
|
if (lock.held.liveness === "alive") log(line); else console.error(line);
|
|
171
192
|
return { appended: 0, skipped: true };
|
|
172
193
|
}
|
|
173
|
-
try {
|
|
194
|
+
try {
|
|
195
|
+
return fn();
|
|
196
|
+
} finally {
|
|
197
|
+
const r = lock.release();
|
|
198
|
+
if (!r.released) {
|
|
199
|
+
// Say what was observed: gone, unreadable (unknown), another owner, or
|
|
200
|
+
// our own lock that would not go away; never a guess about liveness.
|
|
201
|
+
const detail = r.reason === "gone" ? "it was already removed by another party (an operator recovery?); nothing to release"
|
|
202
|
+
: r.reason === "not-owner" ? `it now belongs to pid ${r.owner.pid} (started ${r.owner.startedAt || "?"}); left alone`
|
|
203
|
+
: r.reason === "unknown-owner" ? `its owner record is missing or unreadable, so it may be an operator removal in progress or a newer pass initializing; left alone; ${r.recovery}`
|
|
204
|
+
: `${r.error ? `${r.error}; ` : ""}${r.recovery}`;
|
|
205
|
+
console.error(`capture: did not release ${lock.path} (${r.reason}): ${detail}`);
|
|
206
|
+
process.exitCode = 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
174
209
|
}
|
|
175
210
|
|
|
176
211
|
function pass() {
|
|
212
|
+
// The privacy loader fails closed by exiting; it runs before the lock is
|
|
213
|
+
// taken so that exit never leaves the lock behind.
|
|
214
|
+
const ignore = loadIgnoreOrExit(root);
|
|
177
215
|
return withCaptureLock(() => {
|
|
178
216
|
const out = { appended: 0 };
|
|
179
|
-
const ignore = loadIgnoreOrExit(root);
|
|
180
217
|
if (!args["aw-only"]) {
|
|
181
218
|
for (const r of captureAllSessions(store, { owner, ignore })) {
|
|
182
219
|
out.appended += r.appended;
|
|
@@ -307,11 +344,17 @@ if (args.home) {
|
|
|
307
344
|
});
|
|
308
345
|
}
|
|
309
346
|
console.log(JSON.stringify({ home: args.home, owner, appended, sessions, ...(unattributed.length ? { unattributed } : {}) }, null, 2));
|
|
310
|
-
process.exit(0);
|
|
347
|
+
process.exit(process.exitCode ?? 0); // nonzero when the lock release had to be reported
|
|
311
348
|
}
|
|
312
349
|
|
|
313
350
|
warnOnStrangerOwner();
|
|
314
|
-
|
|
351
|
+
try {
|
|
352
|
+
pass();
|
|
353
|
+
} catch (err) {
|
|
354
|
+
// The lock's finally has run by now; one line, then the status.
|
|
355
|
+
console.error(`capture pass failed: ${err.message}`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
315
358
|
|
|
316
359
|
if (args.watch) {
|
|
317
360
|
const roots = [
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// message says exactly that. (A reclaim protocol was reviewed and rejected:
|
|
13
13
|
// rename is not compare-and-swap, and stealing from a stalled live
|
|
14
14
|
// initializer under memory pressure is the failure we are preventing.)
|
|
15
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
16
17
|
import { join } from "node:path";
|
|
17
18
|
|
|
18
19
|
export function captureLockPath(root) { return join(root, ".capture.lock"); }
|
|
@@ -41,8 +42,28 @@ export function recoveryInstruction(dir, owner, liveness) {
|
|
|
41
42
|
|
|
42
43
|
/** Try to take the root's capture lock. Returns { path, release } when
|
|
43
44
|
* taken, or { path, held: { pid, startedAt, liveness, recovery } } when any
|
|
44
|
-
* lock exists. Never removes a lock it did not create.
|
|
45
|
-
|
|
45
|
+
* lock exists. Never removes a lock it did not create.
|
|
46
|
+
*
|
|
47
|
+
* Two failure points are reported rather than left behind. If the owner
|
|
48
|
+
* record cannot be written after THIS call created the directory (a full
|
|
49
|
+
* disk, say), the directory is removed only while it is still the very
|
|
50
|
+
* directory this call created (same inode) and carries no other owner's
|
|
51
|
+
* record; a replacement that appeared meanwhile (operator recovery, then a
|
|
52
|
+
* newer pass) is left alone. The original error is rethrown with
|
|
53
|
+
* `lockCleanup: { path, removed, reason?, owner?, error?, recovery? }`
|
|
54
|
+
* saying what happened. And `release()` never throws: it answers
|
|
55
|
+
* `{ released: true }` only when the lock is verifiably gone, otherwise
|
|
56
|
+
* `{ released: false, reason: "gone" | "unknown-owner" | "not-owner" |
|
|
57
|
+
* "remove-failed", ... }` with the actual observation, never a guess.
|
|
58
|
+
*
|
|
59
|
+
* The owner record carries a per-acquisition nonce, so a release kept from
|
|
60
|
+
* an earlier acquisition cannot erase a later one by the same pid (an
|
|
61
|
+
* operator recovery followed by a new pass in the same long-lived process).
|
|
62
|
+
* That is ownership checking; no lock is ever reclaimed.
|
|
63
|
+
*
|
|
64
|
+
* `io` exists for fault injection in tests only. */
|
|
65
|
+
export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, liveness = holderLiveness, io = {} } = {}) {
|
|
66
|
+
const fs = { writeFileSync, rmSync, openSync, closeSync, ...io };
|
|
46
67
|
const dir = captureLockPath(root);
|
|
47
68
|
mkdirSync(root, { recursive: true }); // the store creates the root lazily; the lock may come first
|
|
48
69
|
try {
|
|
@@ -53,12 +74,54 @@ export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, li
|
|
|
53
74
|
const live = owner ? (owner.pid === pid ? "alive" : liveness(owner.pid)) : "unknown";
|
|
54
75
|
return { path: dir, held: { pid: owner?.pid, startedAt: owner?.startedAt, liveness: live, recovery: recoveryInstruction(dir, owner, live) } };
|
|
55
76
|
}
|
|
56
|
-
|
|
77
|
+
const nonce = randomBytes(8).toString("hex");
|
|
78
|
+
let directoryFd, identity;
|
|
79
|
+
try {
|
|
80
|
+
// Keep the directory alive until initialization or its cleanup finishes.
|
|
81
|
+
// Otherwise Linux can reuse its inode immediately after an unlink, making
|
|
82
|
+
// a record-less replacement look like the directory we created.
|
|
83
|
+
directoryFd = fs.openSync(dir, "r");
|
|
84
|
+
identity = fstatSync(directoryFd);
|
|
85
|
+
fs.writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid, nonce, startedAt: new Date(now()).toISOString() }));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
// Ownership was proven by the mkdir, not by the moment of cleanup: the
|
|
88
|
+
// directory is removed only if it is still ours (same inode) and holds
|
|
89
|
+
// no other pass's record. Anything else is reported, not deleted.
|
|
90
|
+
let cleanupError, reason;
|
|
91
|
+
const cur = readOwner(dir);
|
|
92
|
+
const same = (() => { try { const current = lstatSync(dir); return identity && current.dev === identity.dev && current.ino === identity.ino; } catch { return false; } })();
|
|
93
|
+
if (!existsSync(dir)) reason = "gone";
|
|
94
|
+
else if (!identity) reason = "unverified";
|
|
95
|
+
else if (!same || (cur && (cur.pid !== pid || cur.nonce !== nonce))) reason = "replaced";
|
|
96
|
+
else { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e2) { cleanupError = e2; } }
|
|
97
|
+
const removed = reason === undefined && !existsSync(dir);
|
|
98
|
+
err.lockCleanup = {
|
|
99
|
+
path: dir,
|
|
100
|
+
removed,
|
|
101
|
+
...(reason ? { reason } : {}),
|
|
102
|
+
...(reason === "replaced" && cur ? { owner: cur } : {}),
|
|
103
|
+
...(cleanupError ? { error: cleanupError.message } : {}),
|
|
104
|
+
...(removed || reason === "gone" || reason === "replaced" ? {} : { recovery: recoveryInstruction(dir, undefined, "unknown") }),
|
|
105
|
+
};
|
|
106
|
+
throw err;
|
|
107
|
+
} finally {
|
|
108
|
+
if (directoryFd !== undefined) fs.closeSync(directoryFd);
|
|
109
|
+
}
|
|
57
110
|
return {
|
|
58
111
|
path: dir,
|
|
59
112
|
release: () => {
|
|
113
|
+
if (!existsSync(dir)) return { released: false, reason: "gone" };
|
|
60
114
|
const cur = readOwner(dir);
|
|
61
|
-
if (cur
|
|
115
|
+
if (!cur) return { released: false, reason: "unknown-owner", recovery: recoveryInstruction(dir, undefined, "unknown") };
|
|
116
|
+
if (cur.pid !== pid || cur.nonce !== nonce) return { released: false, reason: "not-owner", owner: cur };
|
|
117
|
+
let error;
|
|
118
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { error = e; }
|
|
119
|
+
if (!existsSync(dir)) return { released: true };
|
|
120
|
+
// Our own lock could not be removed. We are the holder and, as far as
|
|
121
|
+
// this process can tell, alive; the operator gets a conditional line.
|
|
122
|
+
const live = pid === process.pid ? "alive" : liveness(pid);
|
|
123
|
+
const recovery = `${dir} is still held by pid ${pid} (this pass, ${live} when it reported this); its removal failed${error ? ` (${error.message})` : ""}; once that process has exited (ps -p ${pid}), remove the lock with: rm -r -- ${shellQuote(dir)} and rerun`;
|
|
124
|
+
return { released: false, reason: "remove-failed", liveness: live, ...(error ? { error: error.message } : {}), recovery };
|
|
62
125
|
},
|
|
63
126
|
};
|
|
64
127
|
}
|