agent-yes 1.204.0 → 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-DqsPHFU9.js → SUPPORTED_CLIS-C8-qh98a.js} +2 -2
- package/dist/SUPPORTED_CLIS-D3XTFfS7.js +9 -0
- package/dist/{agentShare-TJXTia4N.js → agentShare-99IGGOkv.js} +2 -2
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-DAD9Dt9H.js → notifyDaemon-DK1HOMjj.js} +2 -2
- package/dist/{rustBinary-DMx8ljD0.js → rustBinary-Tgn948GR.js} +2 -2
- package/dist/{schedule-DGxbss_D.js → schedule-CBoM67Vy.js} +4 -4
- package/dist/{serve-eqRnhz8Z.js → serve-IvOlaL-7.js} +178 -15
- package/dist/{setup-BgD5RFXj.js → setup-BaKdS-3i.js} +2 -2
- package/dist/{subcommands-B7NvTmdk.js → subcommands-Bs7wJRBB.js} +8 -8
- package/dist/{subcommands-3GIzND1f.js → subcommands-DyDHZ5YI.js} +1 -1
- package/dist/{ts-D9LzuGpU.js → ts-Dm6jjLwC.js} +2 -2
- package/dist/{versionChecker-C8ScoMqV.js → versionChecker-BomfUpSo.js} +2 -2
- package/dist/{ws-BfqEcKyO.js → ws-CSkP2mkD.js} +2 -2
- package/package.json +1 -1
- package/ts/serve.ts +43 -2
- package/ts/serveLock.spec.ts +219 -0
- package/ts/serveLock.ts +172 -0
- package/dist/SUPPORTED_CLIS-BUxPt2CD.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,150 @@ 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
|
+
|
|
45
190
|
//#endregion
|
|
46
191
|
//#region ts/strayServe.ts
|
|
47
192
|
const MANAGEMENT_COMMAND_RE = /\b(?:install|uninstall|status|logs|restart)\b/;
|
|
@@ -908,6 +1053,10 @@ Options:
|
|
|
908
1053
|
type: "boolean",
|
|
909
1054
|
default: false,
|
|
910
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"
|
|
911
1060
|
}).help(false).version(false).exitProcess(false).parseAsync();
|
|
912
1061
|
if (argv.daemon) return cmdServeDaemon("install", rest.filter((a) => a !== "--daemon" && a !== "-d"));
|
|
913
1062
|
const stray = argv._.map(String);
|
|
@@ -929,6 +1078,17 @@ Options:
|
|
|
929
1078
|
const scheme = useHttps ? "https" : "http";
|
|
930
1079
|
const wantWebrtc = argv.webrtc !== void 0 || argv.share !== void 0;
|
|
931
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
|
+
}
|
|
932
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");
|
|
933
1093
|
const token = await loadOrCreateToken(tokenFlag);
|
|
934
1094
|
const titleCache = /* @__PURE__ */ new Map();
|
|
@@ -1497,7 +1657,7 @@ Options:
|
|
|
1497
1657
|
}
|
|
1498
1658
|
});
|
|
1499
1659
|
if (req.method === "GET" && p === "/api/ws") {
|
|
1500
|
-
const ws = await import("./ws-
|
|
1660
|
+
const ws = await import("./ws-CSkP2mkD.js");
|
|
1501
1661
|
try {
|
|
1502
1662
|
await ws.loadProvision();
|
|
1503
1663
|
} catch (e) {
|
|
@@ -1517,7 +1677,7 @@ Options:
|
|
|
1517
1677
|
if (req.method === "GET" && p === "/api/ws/status") {
|
|
1518
1678
|
const dirRaw = url.searchParams.get("path");
|
|
1519
1679
|
if (!dirRaw) return new Response("missing ?path=<workspace dir>", { status: 400 });
|
|
1520
|
-
const ws = await import("./ws-
|
|
1680
|
+
const ws = await import("./ws-CSkP2mkD.js");
|
|
1521
1681
|
let prov;
|
|
1522
1682
|
try {
|
|
1523
1683
|
prov = await ws.loadProvision();
|
|
@@ -2073,7 +2233,7 @@ Options:
|
|
|
2073
2233
|
const perm = body.perm ?? "r";
|
|
2074
2234
|
if (perm !== "r" && perm !== "rw") return new Response(`invalid perm ${perm} (want r or rw)`, { status: 400 });
|
|
2075
2235
|
try {
|
|
2076
|
-
const { createScopedShare } = await import("./agentShare-
|
|
2236
|
+
const { createScopedShare } = await import("./agentShare-99IGGOkv.js");
|
|
2077
2237
|
const share = await createScopedShare({
|
|
2078
2238
|
agent: body.agent,
|
|
2079
2239
|
perm,
|
|
@@ -2088,12 +2248,12 @@ Options:
|
|
|
2088
2248
|
}
|
|
2089
2249
|
}
|
|
2090
2250
|
if (req.method === "GET" && p === "/api/shares") {
|
|
2091
|
-
const { listShares } = await import("./agentShare-
|
|
2251
|
+
const { listShares } = await import("./agentShare-99IGGOkv.js");
|
|
2092
2252
|
return Response.json(listShares());
|
|
2093
2253
|
}
|
|
2094
2254
|
const revokeM = /^\/api\/share\/([^/]+)$/.exec(p);
|
|
2095
2255
|
if (req.method === "DELETE" && revokeM) {
|
|
2096
|
-
const { revokeShare } = await import("./agentShare-
|
|
2256
|
+
const { revokeShare } = await import("./agentShare-99IGGOkv.js");
|
|
2097
2257
|
const ok = revokeShare(decodeURIComponent(revokeM[1]));
|
|
2098
2258
|
return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
|
|
2099
2259
|
}
|
|
@@ -2256,7 +2416,10 @@ Options:
|
|
|
2256
2416
|
await announce(room, link, false);
|
|
2257
2417
|
} catch (e) {
|
|
2258
2418
|
process.stderr.write(`ay serve --webrtc failed: ${e.message}\n`);
|
|
2259
|
-
if (!wantHttp)
|
|
2419
|
+
if (!wantHttp) {
|
|
2420
|
+
await releaseHostLock?.().catch(() => {});
|
|
2421
|
+
return 1;
|
|
2422
|
+
}
|
|
2260
2423
|
}
|
|
2261
2424
|
}
|
|
2262
2425
|
let heartbeat;
|
|
@@ -2275,9 +2438,9 @@ Options:
|
|
|
2275
2438
|
const shutdown = (resolve) => {
|
|
2276
2439
|
if (heartbeat) clearInterval(heartbeat);
|
|
2277
2440
|
closeShare?.();
|
|
2278
|
-
import("./agentShare-
|
|
2441
|
+
import("./agentShare-99IGGOkv.js").then((m) => m.revokeAllShares()).catch(() => {});
|
|
2279
2442
|
server?.stop();
|
|
2280
|
-
resolve();
|
|
2443
|
+
Promise.resolve(releaseHostLock?.()).catch(() => {}).then(resolve);
|
|
2281
2444
|
};
|
|
2282
2445
|
await new Promise((resolve) => {
|
|
2283
2446
|
process.on("SIGINT", () => shutdown(resolve));
|
|
@@ -2288,4 +2451,4 @@ Options:
|
|
|
2288
2451
|
|
|
2289
2452
|
//#endregion
|
|
2290
2453
|
export { cmdServe };
|
|
2291
|
-
//# 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";
|
|
@@ -1221,6 +1222,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1221
1222
|
default: false,
|
|
1222
1223
|
description: "Deprecated no-op — the console can always spawn agents",
|
|
1223
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
|
+
})
|
|
1224
1230
|
.help(false)
|
|
1225
1231
|
.version(false)
|
|
1226
1232
|
.exitProcess(false);
|
|
@@ -1273,6 +1279,31 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1273
1279
|
const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
|
|
1274
1280
|
const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
|
|
1275
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
|
+
|
|
1276
1307
|
if (wantHttp && host !== "127.0.0.1" && host !== "localhost") {
|
|
1277
1308
|
process.stderr.write(
|
|
1278
1309
|
"ay serve: warning: binding to non-loopback — ensure your network is trusted or use Tailscale/VPN\n",
|
|
@@ -3257,7 +3288,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
3257
3288
|
await announce(room, link, false);
|
|
3258
3289
|
} catch (e) {
|
|
3259
3290
|
process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
|
|
3260
|
-
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
|
+
}
|
|
3261
3297
|
}
|
|
3262
3298
|
}
|
|
3263
3299
|
|
|
@@ -3289,7 +3325,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
3289
3325
|
// Close any scoped single-agent share rooms so viewers get an immediate drop.
|
|
3290
3326
|
void import("./agentShare.ts").then((m) => m.revokeAllShares()).catch(() => {});
|
|
3291
3327
|
server?.stop();
|
|
3292
|
-
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);
|
|
3293
3334
|
};
|
|
3294
3335
|
await new Promise<void>((resolve) => {
|
|
3295
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
|
+
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import "./ts-D9LzuGpU.js";
|
|
2
|
-
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import "./versionChecker-C8ScoMqV.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-DqsPHFU9.js";
|
|
8
|
-
|
|
9
|
-
export { SUPPORTED_CLIS };
|