agent-yes 1.203.1 → 1.205.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/dist/{SUPPORTED_CLIS-Cu4aeSNQ.js → SUPPORTED_CLIS-C8-qh98a.js} +2 -2
- package/dist/SUPPORTED_CLIS-D3XTFfS7.js +9 -0
- package/dist/{agentShare-D6AxUbf4.js → agentShare-99IGGOkv.js} +2 -2
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-KLsuGTfs.js → notifyDaemon-DK1HOMjj.js} +2 -2
- package/dist/{rustBinary-Cb0U4_4s.js → rustBinary-Tgn948GR.js} +2 -2
- package/dist/{schedule-hC-cPypM.js → schedule-CBoM67Vy.js} +4 -4
- package/dist/{serve-CFTBba3Y.js → serve-IvOlaL-7.js} +233 -15
- package/dist/{setup-CyFHjoSl.js → setup-BaKdS-3i.js} +2 -2
- package/dist/{subcommands-B0s3sP4A.js → subcommands-Bs7wJRBB.js} +8 -8
- package/dist/{subcommands-DdAdp0Sp.js → subcommands-DyDHZ5YI.js} +1 -1
- package/dist/{ts-C7l18XR8.js → ts-Dm6jjLwC.js} +2 -2
- package/dist/{versionChecker-CHLdtpqI.js → versionChecker-BomfUpSo.js} +2 -2
- package/dist/{ws-B1eASck9.js → ws-CSkP2mkD.js} +2 -2
- package/package.json +1 -1
- package/ts/serve.ts +57 -2
- package/ts/serveLock.spec.ts +219 -0
- package/ts/serveLock.ts +172 -0
- package/ts/strayServe.spec.ts +50 -0
- package/ts/strayServe.ts +56 -0
- package/dist/SUPPORTED_CLIS-Bb6ATd-k.js +0 -9
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { t as CLIS_CONFIG } from "./ts-
|
|
1
|
+
import { t as CLIS_CONFIG } from "./ts-Dm6jjLwC.js";
|
|
2
2
|
|
|
3
3
|
//#region ts/SUPPORTED_CLIS.ts
|
|
4
4
|
const SUPPORTED_CLIS = Object.keys(CLIS_CONFIG);
|
|
5
5
|
|
|
6
6
|
//#endregion
|
|
7
7
|
export { SUPPORTED_CLIS as t };
|
|
8
|
-
//# sourceMappingURL=SUPPORTED_CLIS-
|
|
8
|
+
//# sourceMappingURL=SUPPORTED_CLIS-C8-qh98a.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import "./ts-Dm6jjLwC.js";
|
|
2
|
+
import "./logger-CDIsZ-Pp.js";
|
|
3
|
+
import "./versionChecker-BomfUpSo.js";
|
|
4
|
+
import "./pidStore-BIvsBQ8X.js";
|
|
5
|
+
import "./globalPidIndex-CoNr7tS8.js";
|
|
6
|
+
import "./messageLog-CxrKJj77.js";
|
|
7
|
+
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-C8-qh98a.js";
|
|
8
|
+
|
|
9
|
+
export { SUPPORTED_CLIS };
|
|
@@ -2,7 +2,7 @@ import "./logger-CDIsZ-Pp.js";
|
|
|
2
2
|
import "./globalPidIndex-CoNr7tS8.js";
|
|
3
3
|
import "./messageLog-CxrKJj77.js";
|
|
4
4
|
import "./configShared-aKTg-sa5.js";
|
|
5
|
-
import { M as resolveOne } from "./subcommands-
|
|
5
|
+
import { M as resolveOne } from "./subcommands-Bs7wJRBB.js";
|
|
6
6
|
import "./e2e-jb0Hp43q.js";
|
|
7
7
|
import "./webrtcLink-B7REGtK2.js";
|
|
8
8
|
import "./remotes-Cim0dBU7.js";
|
|
@@ -229,4 +229,4 @@ function transformEvent(rawEvent, agentId, forwarded) {
|
|
|
229
229
|
|
|
230
230
|
//#endregion
|
|
231
231
|
export { createScopedShare, listShares, revokeAllShares, revokeShare };
|
|
232
|
-
//# sourceMappingURL=agentShare-
|
|
232
|
+
//# sourceMappingURL=agentShare-99IGGOkv.js.map
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { t as invokedCliName } from "./invokedCli-uqM2YYA7.js";
|
|
3
3
|
import { n as logger } from "./logger-CDIsZ-Pp.js";
|
|
4
|
-
import { i as versionString, n as displayVersion, t as checkAndAutoUpdate } from "./versionChecker-
|
|
5
|
-
import { n as getRustBinary } from "./rustBinary-
|
|
4
|
+
import { i as versionString, n as displayVersion, t as checkAndAutoUpdate } from "./versionChecker-BomfUpSo.js";
|
|
5
|
+
import { n as getRustBinary } from "./rustBinary-Tgn948GR.js";
|
|
6
6
|
import { argv } from "process";
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
8
|
import ms from "ms";
|
|
@@ -285,7 +285,7 @@ function buildRustArgs(argv, cliFromScript, supportedClis) {
|
|
|
285
285
|
const rawArg = process.argv[2];
|
|
286
286
|
const managerCommands = !invokedCliName(process.argv);
|
|
287
287
|
const isHelpFlag = rawArg === "-h" || rawArg === "--help";
|
|
288
|
-
const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-
|
|
288
|
+
const { isSubcommand, runSubcommand, cmdHelp } = await import("./subcommands-DyDHZ5YI.js");
|
|
289
289
|
if (isHelpFlag && process.argv.length === 3) {
|
|
290
290
|
await cmdHelp(managerCommands);
|
|
291
291
|
process.exit(0);
|
|
@@ -327,7 +327,7 @@ if (config.useRust) {
|
|
|
327
327
|
}
|
|
328
328
|
}
|
|
329
329
|
if (rustBinary) {
|
|
330
|
-
const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-
|
|
330
|
+
const { SUPPORTED_CLIS } = await import("./SUPPORTED_CLIS-D3XTFfS7.js");
|
|
331
331
|
const rustArgs = buildRustArgs(process.argv, config.cli, SUPPORTED_CLIS);
|
|
332
332
|
if (config.verbose) {
|
|
333
333
|
console.log(`[rust] Using binary: ${rustBinary}`);
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-
|
|
1
|
+
import { a as removeControlCharacters, i as AgentContext, n as agentYes, r as config, t as CLIS_CONFIG } from "./ts-Dm6jjLwC.js";
|
|
2
2
|
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import "./versionChecker-
|
|
3
|
+
import "./versionChecker-BomfUpSo.js";
|
|
4
4
|
import "./pidStore-BIvsBQ8X.js";
|
|
5
5
|
import "./globalPidIndex-CoNr7tS8.js";
|
|
6
6
|
import "./messageLog-CxrKJj77.js";
|
|
@@ -2,7 +2,7 @@ import { n as logger } from "./logger-CDIsZ-Pp.js";
|
|
|
2
2
|
import "./globalPidIndex-CoNr7tS8.js";
|
|
3
3
|
import "./messageLog-CxrKJj77.js";
|
|
4
4
|
import "./configShared-aKTg-sa5.js";
|
|
5
|
-
import { $ as
|
|
5
|
+
import { $ as daemonLockOwnerPath, G as gcInboxes, J as listInboxParents, K as hostId, Q as daemonLockDir, S as listRecords, W as appendEvent, X as readInbox, Y as liveWatchers, Z as shouldStealLock, _ as isPidAlive, c as deriveLiveState, et as notifyDir, k as renderLogTailLines } from "./subcommands-Bs7wJRBB.js";
|
|
6
6
|
import "./e2e-jb0Hp43q.js";
|
|
7
7
|
import "./webrtcLink-B7REGtK2.js";
|
|
8
8
|
import "./remotes-Cim0dBU7.js";
|
|
@@ -589,4 +589,4 @@ async function ensureDaemon() {
|
|
|
589
589
|
|
|
590
590
|
//#endregion
|
|
591
591
|
export { daemonStatus, ensureDaemon, requestDaemonStop, runDaemon };
|
|
592
|
-
//# sourceMappingURL=notifyDaemon-
|
|
592
|
+
//# sourceMappingURL=notifyDaemon-DK1HOMjj.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as getInstalledPackage } from "./versionChecker-
|
|
1
|
+
import { r as getInstalledPackage } from "./versionChecker-BomfUpSo.js";
|
|
2
2
|
import { execFileSync } from "child_process";
|
|
3
3
|
import { existsSync, mkdirSync, unlinkSync } from "fs";
|
|
4
4
|
import { chmod, copyFile } from "fs/promises";
|
|
@@ -225,4 +225,4 @@ async function getRustBinary(options = {}) {
|
|
|
225
225
|
|
|
226
226
|
//#endregion
|
|
227
227
|
export { getRustBinary as n, findSpawnHiddenLauncher as t };
|
|
228
|
-
//# sourceMappingURL=rustBinary-
|
|
228
|
+
//# sourceMappingURL=rustBinary-Tgn948GR.js.map
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import "./ts-
|
|
1
|
+
import "./ts-Dm6jjLwC.js";
|
|
2
2
|
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import "./versionChecker-
|
|
3
|
+
import "./versionChecker-BomfUpSo.js";
|
|
4
4
|
import "./pidStore-BIvsBQ8X.js";
|
|
5
5
|
import "./globalPidIndex-CoNr7tS8.js";
|
|
6
6
|
import "./messageLog-CxrKJj77.js";
|
|
7
|
-
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-
|
|
7
|
+
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-C8-qh98a.js";
|
|
8
8
|
import { d as resolveSpawnCwd } from "./workspaceConfig-CjaRvTjf.js";
|
|
9
9
|
import { n as liveEnv, t as ensureNodeRuntime } from "./nodeRuntime-CwNJuwH5.js";
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
@@ -151,4 +151,4 @@ async function cmdSchedule(rest) {
|
|
|
151
151
|
|
|
152
152
|
//#endregion
|
|
153
153
|
export { cmdSchedule };
|
|
154
|
-
//# sourceMappingURL=schedule-
|
|
154
|
+
//# sourceMappingURL=schedule-CBoM67Vy.js.map
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import "./ts-
|
|
1
|
+
import "./ts-Dm6jjLwC.js";
|
|
2
2
|
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import { r as getInstalledPackage } from "./versionChecker-
|
|
4
|
-
import { t as findSpawnHiddenLauncher } from "./rustBinary-
|
|
3
|
+
import { r as getInstalledPackage } from "./versionChecker-BomfUpSo.js";
|
|
4
|
+
import { t as findSpawnHiddenLauncher } from "./rustBinary-Tgn948GR.js";
|
|
5
|
+
import { t as agentYesHome$1 } from "./agentYesHome-CtHb5b71.js";
|
|
5
6
|
import "./pidStore-BIvsBQ8X.js";
|
|
6
7
|
import { a as updateGlobalPidStatus } from "./globalPidIndex-CoNr7tS8.js";
|
|
7
8
|
import { r as recordInbox } from "./messageLog-CxrKJj77.js";
|
|
8
9
|
import { t as pgidForWrapper } from "./reaper-BUHCyxdF.js";
|
|
9
10
|
import "./configShared-aKTg-sa5.js";
|
|
10
|
-
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-
|
|
11
|
-
import { A as renderRawLog, D as recentMessageEdges, E as readPtysize, L as snapshotStatus, M as resolveOne, O as recentReadEdges, S as listRecords, T as readNotes, U as writeToIpc, b as isUserTyping,
|
|
11
|
+
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-C8-qh98a.js";
|
|
12
|
+
import { A as renderRawLog, D as recentMessageEdges, E as readPtysize, L as snapshotStatus, M as resolveOne, O as recentReadEdges, S as listRecords, T as readNotes, U as writeToIpc, b as isUserTyping, f as extractNeedsInput, k as renderLogTailLines, l as deriveLiveStatus, o as controlCodeFromName, p as extractTaskCounts, q as isTransientLockMkdirError, tt as TYPING_BADGE, u as extractBadges } from "./subcommands-Bs7wJRBB.js";
|
|
12
13
|
import "./e2e-jb0Hp43q.js";
|
|
13
14
|
import "./webrtcLink-B7REGtK2.js";
|
|
14
15
|
import "./remotes-Cim0dBU7.js";
|
|
@@ -16,7 +17,7 @@ import { a as getSpawnHook, c as hasProvisionHook, d as resolveSpawnCwd, i as ge
|
|
|
16
17
|
import { n as liveEnv, t as ensureNodeRuntime } from "./nodeRuntime-CwNJuwH5.js";
|
|
17
18
|
import { r as spawnRejectionReason } from "./spawnGate-CXjEz6Pf.js";
|
|
18
19
|
import yargs from "yargs";
|
|
19
|
-
import { mkdir, open, readFile, stat, unlink, writeFile } from "fs/promises";
|
|
20
|
+
import { mkdir, open, readFile, rename, rm, stat, unlink, writeFile } from "fs/promises";
|
|
20
21
|
import { arch, cpus, freemem, homedir, hostname, loadavg, platform, totalmem, uptime, userInfo } from "os";
|
|
21
22
|
import path from "path";
|
|
22
23
|
import { execFileSync } from "node:child_process";
|
|
@@ -42,6 +43,197 @@ import { existsSync, renameSync, watch, writeFileSync } from "node:fs";
|
|
|
42
43
|
*/
|
|
43
44
|
const isTerminalReply = (s) => /^(?:\x1b\[(?:\??\d+(?:;\d+)*R|\?[\d;]*c|>[\d;]*c|\d*n))+$/.test(s);
|
|
44
45
|
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region ts/serveLock.ts
|
|
48
|
+
/**
|
|
49
|
+
* Singleton lock for the WebRTC host role: one `ay serve` host per
|
|
50
|
+
* ~/.agent-yes. Two hosts sharing the persisted room (~/.agent-yes/.share-room)
|
|
51
|
+
* fight over every viewer connection — the exact outage seen live: an orphaned
|
|
52
|
+
* `ay serve --webrtc` from a previous manager era plus a manual run left the
|
|
53
|
+
* managed daemon crash-looping (12 watchdog restarts) and the share link
|
|
54
|
+
* unloadable. The lock makes the loser fail FAST with a pointer to the owner
|
|
55
|
+
* instead of silently contending.
|
|
56
|
+
*
|
|
57
|
+
* mkdir-based (atomic on every platform), owner JSON alongside, heartbeat
|
|
58
|
+
* refresh while held. A stale owner — dead pid, or a heartbeat older than
|
|
59
|
+
* SERVE_LOCK_STALE_MS (a SIGKILLed host can't clean up) — is stolen, so a
|
|
60
|
+
* crashed host never wedges the next start.
|
|
61
|
+
*/
|
|
62
|
+
const SERVE_LOCK_STALE_MS = 2e4;
|
|
63
|
+
const SERVE_LOCK_BEAT_MS = 5e3;
|
|
64
|
+
const SERVE_LOCK_GRACE_MS = 12e3;
|
|
65
|
+
function lockDir() {
|
|
66
|
+
return path.join(agentYesHome$1(), "webrtc-host.lock");
|
|
67
|
+
}
|
|
68
|
+
function ownerPath() {
|
|
69
|
+
return path.join(lockDir(), "owner.json");
|
|
70
|
+
}
|
|
71
|
+
function pidAlive(pid) {
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return e.code === "EPERM";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function readOwner() {
|
|
80
|
+
try {
|
|
81
|
+
const o = JSON.parse(await readFile(ownerPath(), "utf-8"));
|
|
82
|
+
return typeof o?.pid === "number" ? o : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Pure staleness decision, exported for tests. */
|
|
88
|
+
function isOwnerStale(owner, now, alive, staleMs = SERVE_LOCK_STALE_MS) {
|
|
89
|
+
if (!owner) return true;
|
|
90
|
+
if (!alive(owner.pid)) return true;
|
|
91
|
+
return now - owner.beat_at > staleMs;
|
|
92
|
+
}
|
|
93
|
+
async function stampOwner(startedAt) {
|
|
94
|
+
const tmp = `${ownerPath()}.${process.pid}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
await writeFile(tmp, JSON.stringify({
|
|
97
|
+
pid: process.pid,
|
|
98
|
+
started_at: startedAt,
|
|
99
|
+
beat_at: Date.now()
|
|
100
|
+
}));
|
|
101
|
+
await rename(tmp, ownerPath());
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Acquire the host lock, retrying for up to `graceMs`. Returns fast on a live
|
|
110
|
+
* owner once the grace expires (never blocks a daemon boot forever). With
|
|
111
|
+
* `takeover`, a live owner is SIGTERM'd (then SIGKILL'd) and the lock stolen.
|
|
112
|
+
*/
|
|
113
|
+
async function acquireWebrtcHostLock(opts) {
|
|
114
|
+
const graceMs = opts?.graceMs ?? SERVE_LOCK_GRACE_MS;
|
|
115
|
+
const staleMs = opts?.staleMs ?? SERVE_LOCK_STALE_MS;
|
|
116
|
+
const beatMs = opts?.beatMs ?? SERVE_LOCK_BEAT_MS;
|
|
117
|
+
const startedAt = Date.now();
|
|
118
|
+
const deadline = startedAt + graceMs;
|
|
119
|
+
let tookOver = false;
|
|
120
|
+
for (;;) try {
|
|
121
|
+
await mkdir(lockDir(), { recursive: false });
|
|
122
|
+
if (!await stampOwner(startedAt)) {
|
|
123
|
+
await rm(lockDir(), {
|
|
124
|
+
recursive: true,
|
|
125
|
+
force: true
|
|
126
|
+
}).catch(() => {});
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
owner: null
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const beat = setInterval(() => {
|
|
133
|
+
(async () => {
|
|
134
|
+
if ((await readOwner())?.pid !== process.pid) {
|
|
135
|
+
clearInterval(beat);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await stampOwner(startedAt);
|
|
139
|
+
})();
|
|
140
|
+
}, beatMs);
|
|
141
|
+
if (typeof beat.unref === "function") beat.unref();
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
release: async () => {
|
|
145
|
+
clearInterval(beat);
|
|
146
|
+
if ((await readOwner())?.pid === process.pid) await rm(lockDir(), {
|
|
147
|
+
recursive: true,
|
|
148
|
+
force: true
|
|
149
|
+
}).catch(() => {});
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
} catch (e) {
|
|
153
|
+
const code = e.code;
|
|
154
|
+
if (!isTransientLockMkdirError(code)) throw e;
|
|
155
|
+
if (code !== "EEXIST") {
|
|
156
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const owner = await readOwner();
|
|
160
|
+
if (isOwnerStale(owner, Date.now(), pidAlive, staleMs)) {
|
|
161
|
+
await rm(lockDir(), {
|
|
162
|
+
recursive: true,
|
|
163
|
+
force: true
|
|
164
|
+
}).catch(() => {});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (opts?.takeover && owner && !tookOver) {
|
|
168
|
+
tookOver = true;
|
|
169
|
+
try {
|
|
170
|
+
process.kill(owner.pid, "SIGTERM");
|
|
171
|
+
} catch {}
|
|
172
|
+
await new Promise((r) => setTimeout(r, opts?.takeoverWaitMs ?? 2e3));
|
|
173
|
+
if (pidAlive(owner.pid)) try {
|
|
174
|
+
process.kill(owner.pid, "SIGKILL");
|
|
175
|
+
} catch {}
|
|
176
|
+
await rm(lockDir(), {
|
|
177
|
+
recursive: true,
|
|
178
|
+
force: true
|
|
179
|
+
}).catch(() => {});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (Date.now() >= deadline) return {
|
|
183
|
+
ok: false,
|
|
184
|
+
owner
|
|
185
|
+
};
|
|
186
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region ts/strayServe.ts
|
|
192
|
+
const MANAGEMENT_COMMAND_RE = /\b(?:install|uninstall|status|logs|restart)\b/;
|
|
193
|
+
function isServeCommand(command) {
|
|
194
|
+
return /\bay(?:\.js)?\s+serve\b/.test(command) || /\bagent-yes(?:\.js)?\s+serve\b/.test(command) || /\bagent-yes\.js\s+serve\b/.test(command);
|
|
195
|
+
}
|
|
196
|
+
function parseStrayServeProcesses(psOutput, opts) {
|
|
197
|
+
const ignoredPids = new Set([opts.selfPid, opts.parentPid]);
|
|
198
|
+
const processes = [];
|
|
199
|
+
for (const line of psOutput.split(/\r?\n/)) {
|
|
200
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
|
201
|
+
if (!match) continue;
|
|
202
|
+
const pid = Number(match[1]);
|
|
203
|
+
const command = match[2];
|
|
204
|
+
if (!Number.isFinite(pid) || ignoredPids.has(pid)) continue;
|
|
205
|
+
if (!isServeCommand(command)) continue;
|
|
206
|
+
if (MANAGEMENT_COMMAND_RE.test(command)) continue;
|
|
207
|
+
processes.push({
|
|
208
|
+
pid,
|
|
209
|
+
command
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return processes;
|
|
213
|
+
}
|
|
214
|
+
async function listStrayServeProcesses() {
|
|
215
|
+
if (process.platform === "win32") return [];
|
|
216
|
+
try {
|
|
217
|
+
const ps = Bun.spawn([
|
|
218
|
+
"ps",
|
|
219
|
+
"-axo",
|
|
220
|
+
"pid=,command="
|
|
221
|
+
], {
|
|
222
|
+
stdout: "pipe",
|
|
223
|
+
stderr: "ignore",
|
|
224
|
+
env: liveEnv()
|
|
225
|
+
});
|
|
226
|
+
const output = await new Response(ps.stdout).text();
|
|
227
|
+
if (await ps.exited !== 0) return [];
|
|
228
|
+
return parseStrayServeProcesses(output, {
|
|
229
|
+
selfPid: process.pid,
|
|
230
|
+
parentPid: process.ppid
|
|
231
|
+
});
|
|
232
|
+
} catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
45
237
|
//#endregion
|
|
46
238
|
//#region ts/serve.ts
|
|
47
239
|
const DEFAULT_PORT = 7432;
|
|
@@ -520,6 +712,12 @@ function portFromArgs(args) {
|
|
|
520
712
|
const m = /--port[=\s](\d+)/.exec(args.join(" "));
|
|
521
713
|
return m ? Number(m[1]) : DEFAULT_PORT;
|
|
522
714
|
}
|
|
715
|
+
async function warnStrayServeProcesses() {
|
|
716
|
+
const stray = await listStrayServeProcesses();
|
|
717
|
+
if (stray.length === 0) return;
|
|
718
|
+
const pids = stray.map((p) => p.pid);
|
|
719
|
+
process.stderr.write(`warning: ${stray.length} unmanaged ay serve process(es) running (pid ${pids.join(", ")}) — they will fight the daemon for the WebRTC room; stop them: kill ${pids.join(" ")}\n`);
|
|
720
|
+
}
|
|
523
721
|
function explicitWebrtcUrl(args) {
|
|
524
722
|
for (let i = 0; i < args.length; i++) {
|
|
525
723
|
const a = args[i];
|
|
@@ -598,6 +796,7 @@ async function cmdServeDaemon(sub, args) {
|
|
|
598
796
|
}
|
|
599
797
|
}
|
|
600
798
|
if (removed === 0 && failed === 0) process.stdout.write(`no '${DAEMON_NAME}' daemon registered with any manager\n`);
|
|
799
|
+
await warnStrayServeProcesses();
|
|
601
800
|
return failed ? 1 : 0;
|
|
602
801
|
}
|
|
603
802
|
let mgr = resolveDaemonManager();
|
|
@@ -653,6 +852,7 @@ async function cmdServeDaemon(sub, args) {
|
|
|
653
852
|
DAEMON_NAME
|
|
654
853
|
]);
|
|
655
854
|
}
|
|
855
|
+
await warnStrayServeProcesses();
|
|
656
856
|
const serveArgv = ayServeArgv(effArgs);
|
|
657
857
|
const spawnHidden = findSpawnHiddenLauncher();
|
|
658
858
|
const managedArgv = spawnHidden ? [spawnHidden, ...serveArgv] : serveArgv;
|
|
@@ -853,6 +1053,10 @@ Options:
|
|
|
853
1053
|
type: "boolean",
|
|
854
1054
|
default: false,
|
|
855
1055
|
description: "Deprecated no-op — the console can always spawn agents"
|
|
1056
|
+
}).option("takeover", {
|
|
1057
|
+
type: "boolean",
|
|
1058
|
+
default: false,
|
|
1059
|
+
description: "If another ay serve already hosts this home's WebRTC room, stop it and take the room"
|
|
856
1060
|
}).help(false).version(false).exitProcess(false).parseAsync();
|
|
857
1061
|
if (argv.daemon) return cmdServeDaemon("install", rest.filter((a) => a !== "--daemon" && a !== "-d"));
|
|
858
1062
|
const stray = argv._.map(String);
|
|
@@ -874,6 +1078,17 @@ Options:
|
|
|
874
1078
|
const scheme = useHttps ? "https" : "http";
|
|
875
1079
|
const wantWebrtc = argv.webrtc !== void 0 || argv.share !== void 0;
|
|
876
1080
|
const wantHttp = argv.http === true || argv.share !== void 0 || argv.webrtc === void 0;
|
|
1081
|
+
let releaseHostLock = null;
|
|
1082
|
+
if (wantWebrtc && process.env.AGENT_YES_NO_SERVE_LOCK !== "1") {
|
|
1083
|
+
const lock = await acquireWebrtcHostLock({ takeover: argv.takeover === true });
|
|
1084
|
+
if (!lock.ok) {
|
|
1085
|
+
const o = lock.owner;
|
|
1086
|
+
const since = o ? new Date(o.started_at).toLocaleString() : "unknown";
|
|
1087
|
+
process.stderr.write(`ay serve: another instance${o ? ` (pid ${o.pid}, started ${since})` : ""} already hosts this home's WebRTC room\n ay serve status # inspect it\n ay serve --webrtc --takeover # stop it and take the room\n AGENT_YES_NO_SERVE_LOCK=1 # opt out (multi-room setups only)\n`);
|
|
1088
|
+
return 1;
|
|
1089
|
+
}
|
|
1090
|
+
releaseHostLock = lock.release;
|
|
1091
|
+
}
|
|
877
1092
|
if (wantHttp && host !== "127.0.0.1" && host !== "localhost") process.stderr.write("ay serve: warning: binding to non-loopback — ensure your network is trusted or use Tailscale/VPN\n");
|
|
878
1093
|
const token = await loadOrCreateToken(tokenFlag);
|
|
879
1094
|
const titleCache = /* @__PURE__ */ new Map();
|
|
@@ -1442,7 +1657,7 @@ Options:
|
|
|
1442
1657
|
}
|
|
1443
1658
|
});
|
|
1444
1659
|
if (req.method === "GET" && p === "/api/ws") {
|
|
1445
|
-
const ws = await import("./ws-
|
|
1660
|
+
const ws = await import("./ws-CSkP2mkD.js");
|
|
1446
1661
|
try {
|
|
1447
1662
|
await ws.loadProvision();
|
|
1448
1663
|
} catch (e) {
|
|
@@ -1462,7 +1677,7 @@ Options:
|
|
|
1462
1677
|
if (req.method === "GET" && p === "/api/ws/status") {
|
|
1463
1678
|
const dirRaw = url.searchParams.get("path");
|
|
1464
1679
|
if (!dirRaw) return new Response("missing ?path=<workspace dir>", { status: 400 });
|
|
1465
|
-
const ws = await import("./ws-
|
|
1680
|
+
const ws = await import("./ws-CSkP2mkD.js");
|
|
1466
1681
|
let prov;
|
|
1467
1682
|
try {
|
|
1468
1683
|
prov = await ws.loadProvision();
|
|
@@ -2018,7 +2233,7 @@ Options:
|
|
|
2018
2233
|
const perm = body.perm ?? "r";
|
|
2019
2234
|
if (perm !== "r" && perm !== "rw") return new Response(`invalid perm ${perm} (want r or rw)`, { status: 400 });
|
|
2020
2235
|
try {
|
|
2021
|
-
const { createScopedShare } = await import("./agentShare-
|
|
2236
|
+
const { createScopedShare } = await import("./agentShare-99IGGOkv.js");
|
|
2022
2237
|
const share = await createScopedShare({
|
|
2023
2238
|
agent: body.agent,
|
|
2024
2239
|
perm,
|
|
@@ -2033,12 +2248,12 @@ Options:
|
|
|
2033
2248
|
}
|
|
2034
2249
|
}
|
|
2035
2250
|
if (req.method === "GET" && p === "/api/shares") {
|
|
2036
|
-
const { listShares } = await import("./agentShare-
|
|
2251
|
+
const { listShares } = await import("./agentShare-99IGGOkv.js");
|
|
2037
2252
|
return Response.json(listShares());
|
|
2038
2253
|
}
|
|
2039
2254
|
const revokeM = /^\/api\/share\/([^/]+)$/.exec(p);
|
|
2040
2255
|
if (req.method === "DELETE" && revokeM) {
|
|
2041
|
-
const { revokeShare } = await import("./agentShare-
|
|
2256
|
+
const { revokeShare } = await import("./agentShare-99IGGOkv.js");
|
|
2042
2257
|
const ok = revokeShare(decodeURIComponent(revokeM[1]));
|
|
2043
2258
|
return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
|
|
2044
2259
|
}
|
|
@@ -2201,7 +2416,10 @@ Options:
|
|
|
2201
2416
|
await announce(room, link, false);
|
|
2202
2417
|
} catch (e) {
|
|
2203
2418
|
process.stderr.write(`ay serve --webrtc failed: ${e.message}\n`);
|
|
2204
|
-
if (!wantHttp)
|
|
2419
|
+
if (!wantHttp) {
|
|
2420
|
+
await releaseHostLock?.().catch(() => {});
|
|
2421
|
+
return 1;
|
|
2422
|
+
}
|
|
2205
2423
|
}
|
|
2206
2424
|
}
|
|
2207
2425
|
let heartbeat;
|
|
@@ -2220,9 +2438,9 @@ Options:
|
|
|
2220
2438
|
const shutdown = (resolve) => {
|
|
2221
2439
|
if (heartbeat) clearInterval(heartbeat);
|
|
2222
2440
|
closeShare?.();
|
|
2223
|
-
import("./agentShare-
|
|
2441
|
+
import("./agentShare-99IGGOkv.js").then((m) => m.revokeAllShares()).catch(() => {});
|
|
2224
2442
|
server?.stop();
|
|
2225
|
-
resolve();
|
|
2443
|
+
Promise.resolve(releaseHostLock?.()).catch(() => {}).then(resolve);
|
|
2226
2444
|
};
|
|
2227
2445
|
await new Promise((resolve) => {
|
|
2228
2446
|
process.on("SIGINT", () => shutdown(resolve));
|
|
@@ -2233,4 +2451,4 @@ Options:
|
|
|
2233
2451
|
|
|
2234
2452
|
//#endregion
|
|
2235
2453
|
export { cmdServe };
|
|
2236
|
-
//# sourceMappingURL=serve-
|
|
2454
|
+
//# sourceMappingURL=serve-IvOlaL-7.js.map
|
|
@@ -32,7 +32,7 @@ async function cmdSetup(rest) {
|
|
|
32
32
|
if (!existsSync(abs)) process.stderr.write(` note: that directory doesn't exist yet — create it, or agents spawned there will fail\n`);
|
|
33
33
|
if (noShare) return 0;
|
|
34
34
|
process.stdout.write(`\nsharing this machine to agent-yes.com…\n`);
|
|
35
|
-
const { cmdServe } = await import("./serve-
|
|
35
|
+
const { cmdServe } = await import("./serve-IvOlaL-7.js");
|
|
36
36
|
return cmdServe([
|
|
37
37
|
"install",
|
|
38
38
|
"--share",
|
|
@@ -42,4 +42,4 @@ async function cmdSetup(rest) {
|
|
|
42
42
|
|
|
43
43
|
//#endregion
|
|
44
44
|
export { cmdSetup };
|
|
45
|
-
//# sourceMappingURL=setup-
|
|
45
|
+
//# sourceMappingURL=setup-BaKdS-3i.js.map
|
|
@@ -1237,19 +1237,19 @@ async function runSubcommand(argv) {
|
|
|
1237
1237
|
case "restart": return await cmdRestart(rest);
|
|
1238
1238
|
case "note": return await cmdNote(rest);
|
|
1239
1239
|
case "serve": {
|
|
1240
|
-
const { cmdServe } = await import("./serve-
|
|
1240
|
+
const { cmdServe } = await import("./serve-IvOlaL-7.js");
|
|
1241
1241
|
return cmdServe(rest);
|
|
1242
1242
|
}
|
|
1243
1243
|
case "setup": {
|
|
1244
|
-
const { cmdSetup } = await import("./setup-
|
|
1244
|
+
const { cmdSetup } = await import("./setup-BaKdS-3i.js");
|
|
1245
1245
|
return cmdSetup(rest);
|
|
1246
1246
|
}
|
|
1247
1247
|
case "ws": {
|
|
1248
|
-
const { cmdWs } = await import("./ws-
|
|
1248
|
+
const { cmdWs } = await import("./ws-CSkP2mkD.js");
|
|
1249
1249
|
return cmdWs(rest);
|
|
1250
1250
|
}
|
|
1251
1251
|
case "schedule": {
|
|
1252
|
-
const { cmdSchedule } = await import("./schedule-
|
|
1252
|
+
const { cmdSchedule } = await import("./schedule-CBoM67Vy.js");
|
|
1253
1253
|
return cmdSchedule(rest);
|
|
1254
1254
|
}
|
|
1255
1255
|
case "remote": {
|
|
@@ -4269,7 +4269,7 @@ async function cmdNotify(rest) {
|
|
|
4269
4269
|
}
|
|
4270
4270
|
const ensure = async () => {
|
|
4271
4271
|
if (!argv["ensure-daemon"]) return;
|
|
4272
|
-
const { ensureDaemon } = await import("./notifyDaemon-
|
|
4272
|
+
const { ensureDaemon } = await import("./notifyDaemon-DK1HOMjj.js");
|
|
4273
4273
|
await ensureDaemon().catch(() => null);
|
|
4274
4274
|
};
|
|
4275
4275
|
await heartbeatWatcher(parent, selfStartedAt);
|
|
@@ -4343,7 +4343,7 @@ async function cmdNotifyCursor(args) {
|
|
|
4343
4343
|
}
|
|
4344
4344
|
async function cmdNotifyd(rest) {
|
|
4345
4345
|
const sub = rest[0] ?? "status";
|
|
4346
|
-
const daemon = await import("./notifyDaemon-
|
|
4346
|
+
const daemon = await import("./notifyDaemon-DK1HOMjj.js");
|
|
4347
4347
|
switch (sub) {
|
|
4348
4348
|
case "run": return daemon.runDaemon();
|
|
4349
4349
|
case "once": return daemon.runDaemon({ once: true });
|
|
@@ -4369,5 +4369,5 @@ async function cmdNotifyd(rest) {
|
|
|
4369
4369
|
}
|
|
4370
4370
|
|
|
4371
4371
|
//#endregion
|
|
4372
|
-
export {
|
|
4373
|
-
//# sourceMappingURL=subcommands-
|
|
4372
|
+
export { daemonLockOwnerPath as $, renderRawLog as A, submitAndConfirm as B, matchKeyword as C, recentMessageEdges as D, readPtysize as E, restartHintLines as F, gcInboxes as G, writeKeysPaced as H, runSubcommand as I, listInboxParents as J, hostId as K, snapshotStatus as L, resolveOne as M, resolveReadWindow as N, recentReadEdges as O, resolveResumeArgs as P, daemonLockDir as Q, stdinActivityPath as R, listRecords as S, readNotes as T, writeToIpc as U, waitForLogQuiet as V, appendEvent as W, readInbox as X, liveWatchers as Y, shouldStealLock as Z, isPidAlive as _, cmdHelp as a, isUserTyping as b, deriveLiveState as c, extractMenu as d, notifyDir as et, extractNeedsInput as f, isExitRequest as g, isAgentStuck as h, backoffWhileTyping as i, renderRawLogLines as j, renderLogTailLines as k, deriveLiveStatus as l, finalizedLines as m, READ_PAGE_DEFAULT as n, controlCodeFromName as o, extractTaskCounts as p, isTransientLockMkdirError as q, TYPING_WINDOW_MS as r, cursorAbs as s, GRACEFUL_EXIT_COMMANDS as t, TYPING_BADGE as tt, extractBadges as u, isSlashCommand as v, menuSelectKeys as w, lastStdinAt as x, isSubcommand as y, stopTipForCli as z };
|
|
4373
|
+
//# sourceMappingURL=subcommands-Bs7wJRBB.js.map
|
|
@@ -2,7 +2,7 @@ import "./logger-CDIsZ-Pp.js";
|
|
|
2
2
|
import "./globalPidIndex-CoNr7tS8.js";
|
|
3
3
|
import "./messageLog-CxrKJj77.js";
|
|
4
4
|
import "./configShared-aKTg-sa5.js";
|
|
5
|
-
import { A as renderRawLog, B as submitAndConfirm, C as matchKeyword, D as recentMessageEdges, E as readPtysize, F as restartHintLines, H as writeKeysPaced, I as runSubcommand, L as snapshotStatus, M as resolveOne, N as resolveReadWindow, O as recentReadEdges, P as resolveResumeArgs, R as stdinActivityPath, S as listRecords, T as readNotes, U as writeToIpc, V as waitForLogQuiet, _ as isPidAlive, a as cmdHelp, b as isUserTyping, c as deriveLiveState, d as extractMenu, f as extractNeedsInput, g as isExitRequest, h as isAgentStuck, i as backoffWhileTyping, j as renderRawLogLines, k as renderLogTailLines, l as deriveLiveStatus, m as finalizedLines, n as READ_PAGE_DEFAULT, o as controlCodeFromName, p as extractTaskCounts, r as TYPING_WINDOW_MS, s as cursorAbs, t as GRACEFUL_EXIT_COMMANDS, u as extractBadges, v as isSlashCommand, w as menuSelectKeys, x as lastStdinAt, y as isSubcommand, z as stopTipForCli } from "./subcommands-
|
|
5
|
+
import { A as renderRawLog, B as submitAndConfirm, C as matchKeyword, D as recentMessageEdges, E as readPtysize, F as restartHintLines, H as writeKeysPaced, I as runSubcommand, L as snapshotStatus, M as resolveOne, N as resolveReadWindow, O as recentReadEdges, P as resolveResumeArgs, R as stdinActivityPath, S as listRecords, T as readNotes, U as writeToIpc, V as waitForLogQuiet, _ as isPidAlive, a as cmdHelp, b as isUserTyping, c as deriveLiveState, d as extractMenu, f as extractNeedsInput, g as isExitRequest, h as isAgentStuck, i as backoffWhileTyping, j as renderRawLogLines, k as renderLogTailLines, l as deriveLiveStatus, m as finalizedLines, n as READ_PAGE_DEFAULT, o as controlCodeFromName, p as extractTaskCounts, r as TYPING_WINDOW_MS, s as cursorAbs, t as GRACEFUL_EXIT_COMMANDS, u as extractBadges, v as isSlashCommand, w as menuSelectKeys, x as lastStdinAt, y as isSubcommand, z as stopTipForCli } from "./subcommands-Bs7wJRBB.js";
|
|
6
6
|
import "./e2e-jb0Hp43q.js";
|
|
7
7
|
import "./webrtcLink-B7REGtK2.js";
|
|
8
8
|
import "./remotes-Cim0dBU7.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as logger, t as addTransport } from "./logger-CDIsZ-Pp.js";
|
|
2
|
-
import { r as getInstalledPackage } from "./versionChecker-
|
|
2
|
+
import { r as getInstalledPackage } from "./versionChecker-BomfUpSo.js";
|
|
3
3
|
import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
|
|
4
4
|
import { i as shouldUseLock, r as releaseLock, t as acquireLock } from "./runningLock-CNMl13dC.js";
|
|
5
5
|
import { t as PidStore } from "./pidStore-BIvsBQ8X.js";
|
|
@@ -1901,4 +1901,4 @@ function sleep(ms) {
|
|
|
1901
1901
|
|
|
1902
1902
|
//#endregion
|
|
1903
1903
|
export { removeControlCharacters as a, AgentContext as i, agentYes as n, config as r, CLIS_CONFIG as t };
|
|
1904
|
-
//# sourceMappingURL=ts-
|
|
1904
|
+
//# sourceMappingURL=ts-Dm6jjLwC.js.map
|
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from "url";
|
|
|
7
7
|
|
|
8
8
|
//#region package.json
|
|
9
9
|
var name = "agent-yes";
|
|
10
|
-
var version = "1.
|
|
10
|
+
var version = "1.205.0";
|
|
11
11
|
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region ts/versionChecker.ts
|
|
@@ -215,4 +215,4 @@ async function displayVersion() {
|
|
|
215
215
|
|
|
216
216
|
//#endregion
|
|
217
217
|
export { versionString as i, displayVersion as n, getInstalledPackage as r, checkAndAutoUpdate as t };
|
|
218
|
-
//# sourceMappingURL=versionChecker-
|
|
218
|
+
//# sourceMappingURL=versionChecker-BomfUpSo.js.map
|
|
@@ -2,7 +2,7 @@ import "./logger-CDIsZ-Pp.js";
|
|
|
2
2
|
import "./globalPidIndex-CoNr7tS8.js";
|
|
3
3
|
import "./messageLog-CxrKJj77.js";
|
|
4
4
|
import "./configShared-aKTg-sa5.js";
|
|
5
|
-
import { S as listRecords } from "./subcommands-
|
|
5
|
+
import { S as listRecords } from "./subcommands-Bs7wJRBB.js";
|
|
6
6
|
import "./e2e-jb0Hp43q.js";
|
|
7
7
|
import "./webrtcLink-B7REGtK2.js";
|
|
8
8
|
import "./remotes-Cim0dBU7.js";
|
|
@@ -418,4 +418,4 @@ async function cmdWs(args) {
|
|
|
418
418
|
|
|
419
419
|
//#endregion
|
|
420
420
|
export { WS_JSON_SCHEMA, cmdWs, collectWorkspaces, isPathInside, loadProvision, workspaceStatus };
|
|
421
|
-
//# sourceMappingURL=ws-
|
|
421
|
+
//# sourceMappingURL=ws-CSkP2mkD.js.map
|
package/package.json
CHANGED
package/ts/serve.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import { TYPING_BADGE } from "./badges.ts";
|
|
40
40
|
import { isTerminalReply } from "./terminalReply.ts";
|
|
41
41
|
import { ensureNodeRuntime, liveEnv } from "./nodeRuntime.ts";
|
|
42
|
+
import { acquireWebrtcHostLock, type ServeLockOwner } from "./serveLock.ts";
|
|
42
43
|
import { type MailParty, recordInbox } from "./messageLog.ts";
|
|
43
44
|
import { updateGlobalPidStatus } from "./globalPidIndex.ts";
|
|
44
45
|
import { spawnRejectionReason } from "./spawnGate.ts";
|
|
@@ -46,6 +47,7 @@ import { findSpawnHiddenLauncher } from "./rustBinary.ts";
|
|
|
46
47
|
import { pgidForWrapper } from "./reaper.ts";
|
|
47
48
|
import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
|
|
48
49
|
import { getInstalledPackage } from "./versionChecker.ts";
|
|
50
|
+
import { listStrayServeProcesses } from "./strayServe.ts";
|
|
49
51
|
import {
|
|
50
52
|
getProvisionHook,
|
|
51
53
|
getProvisionRoot,
|
|
@@ -707,6 +709,17 @@ function portFromArgs(args: string[]): number {
|
|
|
707
709
|
return m ? Number(m[1]) : DEFAULT_PORT;
|
|
708
710
|
}
|
|
709
711
|
|
|
712
|
+
async function warnStrayServeProcesses(): Promise<void> {
|
|
713
|
+
const stray = await listStrayServeProcesses();
|
|
714
|
+
if (stray.length === 0) return;
|
|
715
|
+
const pids = stray.map((p) => p.pid);
|
|
716
|
+
process.stderr.write(
|
|
717
|
+
`warning: ${stray.length} unmanaged ay serve process(es) running (pid ${pids.join(
|
|
718
|
+
", ",
|
|
719
|
+
)}) — they will fight the daemon for the WebRTC room; stop them: kill ${pids.join(" ")}\n`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
710
723
|
// An explicit webrtc:// URL passed to --webrtc/--share in the daemon's serve args,
|
|
711
724
|
// or undefined for a bare flag (which mints a persisted room instead). Mirrors how
|
|
712
725
|
// cmdServe resolves argv.webrtc/argv.share, but over the raw arg list install holds
|
|
@@ -785,6 +798,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
785
798
|
}
|
|
786
799
|
if (removed === 0 && failed === 0)
|
|
787
800
|
process.stdout.write(`no '${DAEMON_NAME}' daemon registered with any manager\n`);
|
|
801
|
+
await warnStrayServeProcesses();
|
|
788
802
|
return failed ? 1 : 0;
|
|
789
803
|
}
|
|
790
804
|
|
|
@@ -893,6 +907,7 @@ async function cmdServeDaemon(sub: string, args: string[]): Promise<number> {
|
|
|
893
907
|
await spawnExit([mgr.bin, "stop", DAEMON_NAME]);
|
|
894
908
|
await spawnExit([mgr.bin, "delete", DAEMON_NAME]);
|
|
895
909
|
}
|
|
910
|
+
await warnStrayServeProcesses();
|
|
896
911
|
|
|
897
912
|
// oxmgr takes the command as one string; pm2 takes the binary plus its
|
|
898
913
|
// args after `--`. Both auto-restart on crash by default (pm2) / via the
|
|
@@ -1207,6 +1222,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1207
1222
|
default: false,
|
|
1208
1223
|
description: "Deprecated no-op — the console can always spawn agents",
|
|
1209
1224
|
})
|
|
1225
|
+
.option("takeover", {
|
|
1226
|
+
type: "boolean",
|
|
1227
|
+
default: false,
|
|
1228
|
+
description: "If another ay serve already hosts this home's WebRTC room, stop it and take the room",
|
|
1229
|
+
})
|
|
1210
1230
|
.help(false)
|
|
1211
1231
|
.version(false)
|
|
1212
1232
|
.exitProcess(false);
|
|
@@ -1259,6 +1279,31 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1259
1279
|
const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
|
|
1260
1280
|
const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
|
|
1261
1281
|
|
|
1282
|
+
// Singleton WebRTC host per ~/.agent-yes: a second host joining the SAME
|
|
1283
|
+
// persisted room (~/.agent-yes/.share-room) fights the first over every
|
|
1284
|
+
// viewer connection — seen live as an unloadable share link plus the managed
|
|
1285
|
+
// daemon crash-looping under the health watchdog while an orphaned manual
|
|
1286
|
+
// `ay serve --webrtc` held the room. Fail fast with a pointer to the owner
|
|
1287
|
+
// instead of contending. HTTP-only serves skip this (port collisions already
|
|
1288
|
+
// fail naturally with EADDRINUSE). AGENT_YES_NO_SERVE_LOCK=1 opts out for
|
|
1289
|
+
// exotic multi-room setups (explicit webrtc:// URLs to different rooms).
|
|
1290
|
+
let releaseHostLock: (() => Promise<void>) | null = null;
|
|
1291
|
+
if (wantWebrtc && process.env.AGENT_YES_NO_SERVE_LOCK !== "1") {
|
|
1292
|
+
const lock = await acquireWebrtcHostLock({ takeover: argv.takeover === true });
|
|
1293
|
+
if (!lock.ok) {
|
|
1294
|
+
const o: ServeLockOwner | null = lock.owner;
|
|
1295
|
+
const since = o ? new Date(o.started_at).toLocaleString() : "unknown";
|
|
1296
|
+
process.stderr.write(
|
|
1297
|
+
`ay serve: another instance${o ? ` (pid ${o.pid}, started ${since})` : ""} already hosts this home's WebRTC room\n` +
|
|
1298
|
+
` ay serve status # inspect it\n` +
|
|
1299
|
+
` ay serve --webrtc --takeover # stop it and take the room\n` +
|
|
1300
|
+
` AGENT_YES_NO_SERVE_LOCK=1 # opt out (multi-room setups only)\n`,
|
|
1301
|
+
);
|
|
1302
|
+
return 1;
|
|
1303
|
+
}
|
|
1304
|
+
releaseHostLock = lock.release;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1262
1307
|
if (wantHttp && host !== "127.0.0.1" && host !== "localhost") {
|
|
1263
1308
|
process.stderr.write(
|
|
1264
1309
|
"ay serve: warning: binding to non-loopback — ensure your network is trusted or use Tailscale/VPN\n",
|
|
@@ -3243,7 +3288,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
3243
3288
|
await announce(room, link, false);
|
|
3244
3289
|
} catch (e) {
|
|
3245
3290
|
process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
|
|
3246
|
-
if (!wantHttp)
|
|
3291
|
+
if (!wantHttp) {
|
|
3292
|
+
// Release promptly so the manager's instant respawn doesn't have to
|
|
3293
|
+
// wait out the stale window to reclaim the host lock.
|
|
3294
|
+
await releaseHostLock?.().catch(() => {});
|
|
3295
|
+
return 1; // nothing else is running
|
|
3296
|
+
}
|
|
3247
3297
|
}
|
|
3248
3298
|
}
|
|
3249
3299
|
|
|
@@ -3275,7 +3325,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
3275
3325
|
// Close any scoped single-agent share rooms so viewers get an immediate drop.
|
|
3276
3326
|
void import("./agentShare.ts").then((m) => m.revokeAllShares()).catch(() => {});
|
|
3277
3327
|
server?.stop();
|
|
3278
|
-
resolve()
|
|
3328
|
+
// Gate exit on the lock release: resolve() ends cmdServe and the process,
|
|
3329
|
+
// and an un-awaited rm would race that exit — leaving a lock the next host
|
|
3330
|
+
// (a manager's instant respawn) must wait a full stale window to steal.
|
|
3331
|
+
void Promise.resolve(releaseHostLock?.())
|
|
3332
|
+
.catch(() => {})
|
|
3333
|
+
.then(resolve);
|
|
3279
3334
|
};
|
|
3280
3335
|
await new Promise<void>((resolve) => {
|
|
3281
3336
|
process.on("SIGINT", () => shutdown(resolve));
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from "fs/promises";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
acquireWebrtcHostLock,
|
|
7
|
+
isOwnerStale,
|
|
8
|
+
SERVE_LOCK_STALE_MS,
|
|
9
|
+
type ServeLockOwner,
|
|
10
|
+
} from "./serveLock.ts";
|
|
11
|
+
|
|
12
|
+
// Guards the single-WebRTC-host invariant: two `ay serve --webrtc` in one home
|
|
13
|
+
// fight over the persisted room (live outage: orphan host + crash-looping
|
|
14
|
+
// managed daemon + unloadable share link). The loser must fail FAST with the
|
|
15
|
+
// owner's identity, and a dead/stale owner must never wedge the next start.
|
|
16
|
+
describe("acquireWebrtcHostLock", () => {
|
|
17
|
+
let home: string;
|
|
18
|
+
let savedHome: string | undefined;
|
|
19
|
+
const releases: Array<() => Promise<void>> = [];
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
home = await mkdtemp(path.join(tmpdir(), "ay-serve-lock-"));
|
|
23
|
+
savedHome = process.env.AGENT_YES_HOME;
|
|
24
|
+
process.env.AGENT_YES_HOME = home;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
for (const r of releases.splice(0)) await r().catch(() => {});
|
|
29
|
+
if (savedHome === undefined) delete process.env.AGENT_YES_HOME;
|
|
30
|
+
else process.env.AGENT_YES_HOME = savedHome;
|
|
31
|
+
await rm(home, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("acquires, stamps an owner, and a second acquire fails fast with that owner", async () => {
|
|
35
|
+
const first = await acquireWebrtcHostLock({ graceMs: 0 });
|
|
36
|
+
expect(first.ok).toBe(true);
|
|
37
|
+
if (first.ok) releases.push(first.release);
|
|
38
|
+
|
|
39
|
+
const owner = JSON.parse(
|
|
40
|
+
await readFile(path.join(home, "webrtc-host.lock", "owner.json"), "utf-8"),
|
|
41
|
+
) as ServeLockOwner;
|
|
42
|
+
expect(owner.pid).toBe(process.pid);
|
|
43
|
+
|
|
44
|
+
const second = await acquireWebrtcHostLock({ graceMs: 0 });
|
|
45
|
+
expect(second.ok).toBe(false);
|
|
46
|
+
if (!second.ok) expect(second.owner?.pid).toBe(process.pid);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("release frees the lock for the next acquire", async () => {
|
|
50
|
+
const first = await acquireWebrtcHostLock({ graceMs: 0 });
|
|
51
|
+
expect(first.ok).toBe(true);
|
|
52
|
+
if (first.ok) await first.release();
|
|
53
|
+
|
|
54
|
+
const second = await acquireWebrtcHostLock({ graceMs: 0 });
|
|
55
|
+
expect(second.ok).toBe(true);
|
|
56
|
+
if (second.ok) releases.push(second.release);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("steals a stale lock whose heartbeat went quiet (SIGKILLed host)", async () => {
|
|
60
|
+
// Forge an owner: alive pid but ancient beat — a host whose interval died.
|
|
61
|
+
const lockDir = path.join(home, "webrtc-host.lock");
|
|
62
|
+
const { mkdir, writeFile } = await import("fs/promises");
|
|
63
|
+
await mkdir(lockDir, { recursive: true });
|
|
64
|
+
await writeFile(
|
|
65
|
+
path.join(lockDir, "owner.json"),
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
pid: process.pid,
|
|
68
|
+
started_at: Date.now() - 60_000,
|
|
69
|
+
beat_at: Date.now() - SERVE_LOCK_STALE_MS - 1_000,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
const got = await acquireWebrtcHostLock({ graceMs: 0 });
|
|
73
|
+
expect(got.ok).toBe(true);
|
|
74
|
+
if (got.ok) releases.push(got.release);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("steals a lock whose owner pid is dead", async () => {
|
|
78
|
+
// A freshly-exited child pid is reliably dead. Spawn the current runtime
|
|
79
|
+
// (bun or node — both take -e) so this also runs on Windows, via
|
|
80
|
+
// node:child_process (not Bun.spawn) for the node vitest matrix.
|
|
81
|
+
const { spawnSync } = await import("node:child_process");
|
|
82
|
+
const deadPid = spawnSync(process.execPath, ["-e", "0"]).pid!;
|
|
83
|
+
|
|
84
|
+
const lockDir = path.join(home, "webrtc-host.lock");
|
|
85
|
+
const { mkdir, writeFile } = await import("fs/promises");
|
|
86
|
+
await mkdir(lockDir, { recursive: true });
|
|
87
|
+
await writeFile(
|
|
88
|
+
path.join(lockDir, "owner.json"),
|
|
89
|
+
JSON.stringify({ pid: deadPid, started_at: Date.now(), beat_at: Date.now() }),
|
|
90
|
+
);
|
|
91
|
+
const got = await acquireWebrtcHostLock({ graceMs: 0 });
|
|
92
|
+
expect(got.ok).toBe(true);
|
|
93
|
+
if (got.ok) releases.push(got.release);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("isOwnerStale", () => {
|
|
98
|
+
const now = 1_000_000;
|
|
99
|
+
const owner = (beatAgo: number): ServeLockOwner => ({
|
|
100
|
+
pid: 4242,
|
|
101
|
+
started_at: now - 60_000,
|
|
102
|
+
beat_at: now - beatAgo,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("absent/torn owner is stale (mkdir won but the write died)", () => {
|
|
106
|
+
expect(isOwnerStale(null, now, () => true)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("dead pid is stale regardless of heartbeat", () => {
|
|
110
|
+
expect(isOwnerStale(owner(0), now, () => false)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("live pid with a fresh beat is NOT stale", () => {
|
|
114
|
+
expect(isOwnerStale(owner(SERVE_LOCK_STALE_MS - 1), now, () => true)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("live pid with a quiet heartbeat past the window is stale", () => {
|
|
118
|
+
expect(isOwnerStale(owner(SERVE_LOCK_STALE_MS + 1), now, () => true)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Coverage for the held-lock lifecycle: heartbeat refresh, thief detection,
|
|
123
|
+
// live-owner takeover, and the bounded grace wait.
|
|
124
|
+
describe("acquireWebrtcHostLock — held-lock lifecycle", () => {
|
|
125
|
+
let home: string;
|
|
126
|
+
let savedHome: string | undefined;
|
|
127
|
+
const releases: Array<() => Promise<void>> = [];
|
|
128
|
+
|
|
129
|
+
beforeEach(async () => {
|
|
130
|
+
home = await mkdtemp(path.join(tmpdir(), "ay-serve-lock2-"));
|
|
131
|
+
savedHome = process.env.AGENT_YES_HOME;
|
|
132
|
+
process.env.AGENT_YES_HOME = home;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(async () => {
|
|
136
|
+
for (const r of releases.splice(0)) await r().catch(() => {});
|
|
137
|
+
if (savedHome === undefined) delete process.env.AGENT_YES_HOME;
|
|
138
|
+
else process.env.AGENT_YES_HOME = savedHome;
|
|
139
|
+
await rm(home, { recursive: true, force: true });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const ownerFile = () => path.join(home, "webrtc-host.lock", "owner.json");
|
|
143
|
+
const readOwnerFile = async () => JSON.parse(await readFile(ownerFile(), "utf-8")) as ServeLockOwner;
|
|
144
|
+
|
|
145
|
+
it("heartbeats refresh beat_at while held", async () => {
|
|
146
|
+
const got = await acquireWebrtcHostLock({ graceMs: 0, beatMs: 40 });
|
|
147
|
+
expect(got.ok).toBe(true);
|
|
148
|
+
if (got.ok) releases.push(got.release);
|
|
149
|
+
const before = (await readOwnerFile()).beat_at;
|
|
150
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
151
|
+
const after = (await readOwnerFile()).beat_at;
|
|
152
|
+
expect(after).toBeGreaterThan(before);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("heartbeat stops itself once a thief owns the file (never clobbers the thief)", async () => {
|
|
156
|
+
const got = await acquireWebrtcHostLock({ graceMs: 0, beatMs: 40 });
|
|
157
|
+
expect(got.ok).toBe(true);
|
|
158
|
+
if (got.ok) releases.push(got.release);
|
|
159
|
+
const { writeFile: wf } = await import("fs/promises");
|
|
160
|
+
const thief: ServeLockOwner = { pid: process.pid + 1, started_at: Date.now(), beat_at: 42 };
|
|
161
|
+
await wf(ownerFile(), JSON.stringify(thief));
|
|
162
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
163
|
+
expect((await readOwnerFile()).pid).toBe(thief.pid); // our beat backed off
|
|
164
|
+
expect((await readOwnerFile()).beat_at).toBe(42);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("takeover stops a live owner and takes the room", async () => {
|
|
168
|
+
const { spawn } = await import("node:child_process");
|
|
169
|
+
// Long-lived victim via the current runtime — /bin/sh doesn't exist on win32.
|
|
170
|
+
const victim = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], {
|
|
171
|
+
stdio: "ignore",
|
|
172
|
+
});
|
|
173
|
+
const victimPid = victim.pid!;
|
|
174
|
+
const { mkdir: mkd, writeFile: wf } = await import("fs/promises");
|
|
175
|
+
await mkd(path.join(home, "webrtc-host.lock"), { recursive: true });
|
|
176
|
+
await wf(
|
|
177
|
+
ownerFile(),
|
|
178
|
+
JSON.stringify({ pid: victimPid, started_at: Date.now(), beat_at: Date.now() }),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const got = await acquireWebrtcHostLock({ takeover: true, graceMs: 0, takeoverWaitMs: 100 });
|
|
182
|
+
expect(got.ok).toBe(true);
|
|
183
|
+
if (got.ok) releases.push(got.release);
|
|
184
|
+
expect((await readOwnerFile()).pid).toBe(process.pid);
|
|
185
|
+
// The victim was killed (SIGTERM or the escalation SIGKILL).
|
|
186
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
187
|
+
expect(victim.exitCode !== null || victim.signalCode !== null).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it.skipIf(process.platform === "win32")(
|
|
191
|
+
"escalates to SIGKILL when the owner ignores SIGTERM",
|
|
192
|
+
async () => {
|
|
193
|
+
const { spawn } = await import("node:child_process");
|
|
194
|
+
const stubborn = spawn("/bin/sh", ["-c", "trap '' TERM; sleep 30"], { stdio: "ignore" });
|
|
195
|
+
const pid = stubborn.pid!;
|
|
196
|
+
await new Promise((r) => setTimeout(r, 100)); // let the trap install
|
|
197
|
+
const { mkdir: mkd, writeFile: wf } = await import("fs/promises");
|
|
198
|
+
await mkd(path.join(home, "webrtc-host.lock"), { recursive: true });
|
|
199
|
+
await wf(ownerFile(), JSON.stringify({ pid, started_at: Date.now(), beat_at: Date.now() }));
|
|
200
|
+
|
|
201
|
+
const got = await acquireWebrtcHostLock({ takeover: true, graceMs: 0, takeoverWaitMs: 200 });
|
|
202
|
+
expect(got.ok).toBe(true);
|
|
203
|
+
if (got.ok) releases.push(got.release);
|
|
204
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
205
|
+
expect(stubborn.signalCode).toBe("SIGKILL");
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
it("waits out the grace window against a live owner, then reports it", async () => {
|
|
210
|
+
const holder = await acquireWebrtcHostLock({ graceMs: 0 });
|
|
211
|
+
expect(holder.ok).toBe(true);
|
|
212
|
+
if (holder.ok) releases.push(holder.release);
|
|
213
|
+
const t0 = Date.now();
|
|
214
|
+
const got = await acquireWebrtcHostLock({ graceMs: 300 });
|
|
215
|
+
expect(got.ok).toBe(false);
|
|
216
|
+
if (!got.ok) expect(got.owner?.pid).toBe(process.pid);
|
|
217
|
+
expect(Date.now() - t0).toBeGreaterThanOrEqual(250); // actually waited a beat
|
|
218
|
+
});
|
|
219
|
+
});
|
package/ts/serveLock.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile, rename } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { agentYesHome } from "./agentYesHome.ts";
|
|
4
|
+
import { isTransientLockMkdirError } from "./notifyStore.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Singleton lock for the WebRTC host role: one `ay serve` host per
|
|
8
|
+
* ~/.agent-yes. Two hosts sharing the persisted room (~/.agent-yes/.share-room)
|
|
9
|
+
* fight over every viewer connection — the exact outage seen live: an orphaned
|
|
10
|
+
* `ay serve --webrtc` from a previous manager era plus a manual run left the
|
|
11
|
+
* managed daemon crash-looping (12 watchdog restarts) and the share link
|
|
12
|
+
* unloadable. The lock makes the loser fail FAST with a pointer to the owner
|
|
13
|
+
* instead of silently contending.
|
|
14
|
+
*
|
|
15
|
+
* mkdir-based (atomic on every platform), owner JSON alongside, heartbeat
|
|
16
|
+
* refresh while held. A stale owner — dead pid, or a heartbeat older than
|
|
17
|
+
* SERVE_LOCK_STALE_MS (a SIGKILLed host can't clean up) — is stolen, so a
|
|
18
|
+
* crashed host never wedges the next start.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const SERVE_LOCK_STALE_MS = 20_000;
|
|
22
|
+
export const SERVE_LOCK_BEAT_MS = 5_000;
|
|
23
|
+
// Riding out a manager roll-forward: stop-old/start-new overlap briefly, so a
|
|
24
|
+
// new host retries for a grace window before declaring the lock busy.
|
|
25
|
+
export const SERVE_LOCK_GRACE_MS = 12_000;
|
|
26
|
+
|
|
27
|
+
export type ServeLockOwner = { pid: number; started_at: number; beat_at: number };
|
|
28
|
+
|
|
29
|
+
export type ServeLockResult =
|
|
30
|
+
| { ok: true; release: () => Promise<void> }
|
|
31
|
+
| { ok: false; owner: ServeLockOwner | null };
|
|
32
|
+
|
|
33
|
+
function lockDir(): string {
|
|
34
|
+
return path.join(agentYesHome(), "webrtc-host.lock");
|
|
35
|
+
}
|
|
36
|
+
function ownerPath(): string {
|
|
37
|
+
return path.join(lockDir(), "owner.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pidAlive(pid: number): boolean {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// EPERM = alive but not ours; only ESRCH means gone.
|
|
46
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readOwner(): Promise<ServeLockOwner | null> {
|
|
51
|
+
try {
|
|
52
|
+
const o = JSON.parse(await readFile(ownerPath(), "utf-8")) as ServeLockOwner;
|
|
53
|
+
return typeof o?.pid === "number" ? o : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pure staleness decision, exported for tests. */
|
|
60
|
+
export function isOwnerStale(
|
|
61
|
+
owner: ServeLockOwner | null,
|
|
62
|
+
now: number,
|
|
63
|
+
alive: (pid: number) => boolean,
|
|
64
|
+
staleMs = SERVE_LOCK_STALE_MS,
|
|
65
|
+
): boolean {
|
|
66
|
+
if (!owner) return true; // torn/absent owner: mkdir won but write died
|
|
67
|
+
if (!alive(owner.pid)) return true;
|
|
68
|
+
return now - owner.beat_at > staleMs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Atomic owner stamp (temp + rename) so a concurrent reader never parses a
|
|
72
|
+
// torn file as "no owner" while we in fact hold the lock.
|
|
73
|
+
async function stampOwner(startedAt: number): Promise<boolean> {
|
|
74
|
+
const tmp = `${ownerPath()}.${process.pid}.tmp`;
|
|
75
|
+
try {
|
|
76
|
+
await writeFile(
|
|
77
|
+
tmp,
|
|
78
|
+
JSON.stringify({ pid: process.pid, started_at: startedAt, beat_at: Date.now() }),
|
|
79
|
+
);
|
|
80
|
+
await rename(tmp, ownerPath());
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Acquire the host lock, retrying for up to `graceMs`. Returns fast on a live
|
|
90
|
+
* owner once the grace expires (never blocks a daemon boot forever). With
|
|
91
|
+
* `takeover`, a live owner is SIGTERM'd (then SIGKILL'd) and the lock stolen.
|
|
92
|
+
*/
|
|
93
|
+
export async function acquireWebrtcHostLock(opts?: {
|
|
94
|
+
takeover?: boolean;
|
|
95
|
+
graceMs?: number;
|
|
96
|
+
staleMs?: number;
|
|
97
|
+
/** Heartbeat cadence override — tests only. */
|
|
98
|
+
beatMs?: number;
|
|
99
|
+
/** SIGTERM→SIGKILL escalation wait for --takeover — tests only. */
|
|
100
|
+
takeoverWaitMs?: number;
|
|
101
|
+
}): Promise<ServeLockResult> {
|
|
102
|
+
const graceMs = opts?.graceMs ?? SERVE_LOCK_GRACE_MS;
|
|
103
|
+
const staleMs = opts?.staleMs ?? SERVE_LOCK_STALE_MS;
|
|
104
|
+
const beatMs = opts?.beatMs ?? SERVE_LOCK_BEAT_MS;
|
|
105
|
+
const startedAt = Date.now();
|
|
106
|
+
const deadline = startedAt + graceMs;
|
|
107
|
+
let tookOver = false;
|
|
108
|
+
for (;;) {
|
|
109
|
+
try {
|
|
110
|
+
await mkdir(lockDir(), { recursive: false });
|
|
111
|
+
if (!(await stampOwner(startedAt))) {
|
|
112
|
+
await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
|
|
113
|
+
return { ok: false, owner: null };
|
|
114
|
+
}
|
|
115
|
+
// Heartbeat while held; stops itself if a thief replaced our owner file.
|
|
116
|
+
const beat = setInterval(() => {
|
|
117
|
+
void (async () => {
|
|
118
|
+
const o = await readOwner();
|
|
119
|
+
if (o?.pid !== process.pid) {
|
|
120
|
+
clearInterval(beat);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await stampOwner(startedAt);
|
|
124
|
+
})();
|
|
125
|
+
}, beatMs);
|
|
126
|
+
if (typeof beat.unref === "function") beat.unref();
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
release: async () => {
|
|
130
|
+
clearInterval(beat);
|
|
131
|
+
if ((await readOwner())?.pid === process.pid)
|
|
132
|
+
await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
137
|
+
if (!isTransientLockMkdirError(code)) throw e;
|
|
138
|
+
if (code !== "EEXIST") {
|
|
139
|
+
// win32 mkdir-vs-rm race: dir mid-delete, just retry.
|
|
140
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const owner = await readOwner();
|
|
144
|
+
if (isOwnerStale(owner, Date.now(), pidAlive, staleMs)) {
|
|
145
|
+
await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
|
|
146
|
+
continue; // re-prove via mkdir — two stealers can't both win
|
|
147
|
+
}
|
|
148
|
+
if (opts?.takeover && owner && !tookOver) {
|
|
149
|
+
tookOver = true; // one shot: never kill a second, newer owner
|
|
150
|
+
try {
|
|
151
|
+
process.kill(owner.pid, "SIGTERM");
|
|
152
|
+
} catch {
|
|
153
|
+
/* already gone */
|
|
154
|
+
}
|
|
155
|
+
// Give it a moment to shut down cleanly (it releases the lock), then
|
|
156
|
+
// escalate; the loop's stale check mops up whatever remains.
|
|
157
|
+
await new Promise((r) => setTimeout(r, opts?.takeoverWaitMs ?? 2_000));
|
|
158
|
+
if (pidAlive(owner.pid)) {
|
|
159
|
+
try {
|
|
160
|
+
process.kill(owner.pid, "SIGKILL");
|
|
161
|
+
} catch {
|
|
162
|
+
/* gone */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (Date.now() >= deadline) return { ok: false, owner };
|
|
169
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseStrayServeProcesses } from "./strayServe.ts";
|
|
3
|
+
|
|
4
|
+
describe("parseStrayServeProcesses", () => {
|
|
5
|
+
it("finds foreground ay serve processes", () => {
|
|
6
|
+
const rows = parseStrayServeProcesses(
|
|
7
|
+
[
|
|
8
|
+
" 101 /Users/me/.bun/bin/bun /Users/me/.bun/bin/ay serve --webrtc",
|
|
9
|
+
" 102 bun /repo/dist/agent-yes.js serve --share",
|
|
10
|
+
].join("\n"),
|
|
11
|
+
{ selfPid: 999, parentPid: 998 },
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
expect(rows).toEqual([
|
|
15
|
+
{ pid: 101, command: "/Users/me/.bun/bin/bun /Users/me/.bun/bin/ay serve --webrtc" },
|
|
16
|
+
{ pid: 102, command: "bun /repo/dist/agent-yes.js serve --share" },
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("excludes management commands and this process family", () => {
|
|
21
|
+
const rows = parseStrayServeProcesses(
|
|
22
|
+
[
|
|
23
|
+
" 200 ay serve install --webrtc",
|
|
24
|
+
" 201 ay serve uninstall",
|
|
25
|
+
" 202 ay serve status",
|
|
26
|
+
" 203 ay serve logs",
|
|
27
|
+
" 204 ay serve restart",
|
|
28
|
+
" 300 ay serve --webrtc",
|
|
29
|
+
" 301 ay serve --webrtc",
|
|
30
|
+
" 302 ay serve --webrtc",
|
|
31
|
+
].join("\n"),
|
|
32
|
+
{ selfPid: 300, parentPid: 301 },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(rows).toEqual([{ pid: 302, command: "ay serve --webrtc" }]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("ignores unrelated commands", () => {
|
|
39
|
+
const rows = parseStrayServeProcesses(
|
|
40
|
+
[
|
|
41
|
+
" 401 ay send serve --webrtc",
|
|
42
|
+
" 402 agent-yes status",
|
|
43
|
+
" 403 node ./dist/cli.js share",
|
|
44
|
+
].join("\n"),
|
|
45
|
+
{ selfPid: 999, parentPid: 998 },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(rows).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/ts/strayServe.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { liveEnv } from "./nodeRuntime.ts";
|
|
2
|
+
|
|
3
|
+
export type StrayServeProcess = { pid: number; command: string };
|
|
4
|
+
|
|
5
|
+
const MANAGEMENT_COMMAND_RE = /\b(?:install|uninstall|status|logs|restart)\b/;
|
|
6
|
+
|
|
7
|
+
function isServeCommand(command: string): boolean {
|
|
8
|
+
return (
|
|
9
|
+
/\bay(?:\.js)?\s+serve\b/.test(command) ||
|
|
10
|
+
/\bagent-yes(?:\.js)?\s+serve\b/.test(command) ||
|
|
11
|
+
/\bagent-yes\.js\s+serve\b/.test(command)
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseStrayServeProcesses(
|
|
16
|
+
psOutput: string,
|
|
17
|
+
opts: { selfPid: number; parentPid: number },
|
|
18
|
+
): StrayServeProcess[] {
|
|
19
|
+
const ignoredPids = new Set([opts.selfPid, opts.parentPid]);
|
|
20
|
+
const processes: StrayServeProcess[] = [];
|
|
21
|
+
|
|
22
|
+
for (const line of psOutput.split(/\r?\n/)) {
|
|
23
|
+
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
|
24
|
+
if (!match) continue;
|
|
25
|
+
|
|
26
|
+
const pid = Number(match[1]);
|
|
27
|
+
const command = match[2]!;
|
|
28
|
+
if (!Number.isFinite(pid) || ignoredPids.has(pid)) continue;
|
|
29
|
+
if (!isServeCommand(command)) continue;
|
|
30
|
+
if (MANAGEMENT_COMMAND_RE.test(command)) continue;
|
|
31
|
+
|
|
32
|
+
processes.push({ pid, command });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return processes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function listStrayServeProcesses(): Promise<StrayServeProcess[]> {
|
|
39
|
+
if (process.platform === "win32") return [];
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const ps = Bun.spawn(["ps", "-axo", "pid=,command="], {
|
|
43
|
+
stdout: "pipe",
|
|
44
|
+
stderr: "ignore",
|
|
45
|
+
env: liveEnv(),
|
|
46
|
+
});
|
|
47
|
+
const output = await new Response(ps.stdout).text();
|
|
48
|
+
if ((await ps.exited) !== 0) return [];
|
|
49
|
+
return parseStrayServeProcesses(output, {
|
|
50
|
+
selfPid: process.pid,
|
|
51
|
+
parentPid: process.ppid,
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import "./ts-C7l18XR8.js";
|
|
2
|
-
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import "./versionChecker-CHLdtpqI.js";
|
|
4
|
-
import "./pidStore-BIvsBQ8X.js";
|
|
5
|
-
import "./globalPidIndex-CoNr7tS8.js";
|
|
6
|
-
import "./messageLog-CxrKJj77.js";
|
|
7
|
-
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-Cu4aeSNQ.js";
|
|
8
|
-
|
|
9
|
-
export { SUPPORTED_CLIS };
|