History

add scope-aware daemon pointer and automatic port fallback

Rhys Sullivan committed 6 months ago

Commit 0adad791 parent 25151836 files · +636 −94Browse files

Unified · Split

6 files
  1. README.md +1 −0
  2. apps/cli/src/daemon-state.ts +219 −0
  3. apps/cli/src/daemon.ts +80 −0
  4. apps/cli/src/main.ts +220 −92
  5. tests/daemon-bootstrap.test.ts +28 −0
  6. tests/daemon-state.test.ts +88 −2
README.md+1−0
@@ -89,6 +89,7 @@ executor tools invoke gmail.send --input '{"to":"alice@example.com","subject":"H · 88 unmodified lines
8989 ```
9090
9191 `executor call`, `executor resume`, and `executor tools ...` commands auto-start a local daemon if needed.
92+If the default port is busy, the CLI will pick an available local port and track it automatically.
9293
9394 If an execution pauses for auth or approval, resume it:
9495
apps/cli/src/daemon-state.ts+219−0
@@ -16,6 +16,23 @@ export interface DaemonRecord { · 15 unmodified lines
1616 readonly scopeDir: string | null;
1717 }
1818
19+export interface DaemonPointer {
20+ readonly version: 1;
21+ readonly hostname: string;
22+ readonly port: number;
23+ readonly pid: number;
24+ readonly startedAt: string;
25+ readonly scopeId: string;
26+ readonly scopeDir: string | null;
27+ readonly token: string;
28+}
29+
30+export interface DaemonStartLock {
31+ readonly path: string;
32+ readonly hostname: string;
33+ readonly scopeId: string;
34+}
35+
1936 // ---------------------------------------------------------------------------
2037 // Host normalization
2138 // ---------------------------------------------------------------------------
@@ -27,6 +44,14 @@ export const canonicalDaemonHost = (hostname: string): string => { · 5 unmodified lines
2744 return LOCAL_HOST_ALIASES.has(normalized) ? "localhost" : normalized;
2845 };
2946
47+export const currentDaemonScopeId = (): string => {
48+ const explicitScope = process.env.EXECUTOR_SCOPE_DIR?.trim();
49+ if (explicitScope && explicitScope.length > 0) {
50+ return `scope:${explicitScope}`;
51+ }
52+ return `cwd:${process.cwd()}`;
53+};
54+
3055 // ---------------------------------------------------------------------------
3156 // Paths
3257 // ---------------------------------------------------------------------------
@@ -35,12 +60,22 @@ const resolveDaemonDataDir = (path: Path.Path): string => · 2 unmodified lines
3560 process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor");
3661
3762 const sanitizeHostForPath = (hostname: string): string => hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");
63+const sanitizeScopeForPath = (scopeId: string): string => scopeId.replaceAll(/[^a-z0-9.-]+/gi, "_");
3864
3965 const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => {
4066 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
4167 return path.join(resolveDaemonDataDir(path), `daemon-${host}-${input.port}.json`);
4268 };
4369
70+const daemonPointerPath = (path: Path.Path, input: { hostname: string; scopeId: string }): string => {
71+ const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
72+ const scope = sanitizeScopeForPath(input.scopeId);
73+ return path.join(resolveDaemonDataDir(path), `daemon-active-${host}-${scope}.json`);
74+};
75+
76+const daemonStartLockPath = (path: Path.Path, input: { hostname: string; scopeId: string }): string =>
77+ `${daemonPointerPath(path, input)}.lock`;
78+
4479 // ---------------------------------------------------------------------------
4580 // Persistence
4681 // ---------------------------------------------------------------------------
@@ -111,6 +146,48 @@ const parseRecord = (raw: string): DaemonRecord | null => { · 64 unmodified lines
111146 };
112147 };
113148
149+const parsePointer = (raw: string): DaemonPointer | null => {
150+ let parsed: unknown;
151+ try {
152+ parsed = JSON.parse(raw);
153+ } catch {
154+ return null;
155+ }
156+
157+ if (
158+ typeof parsed !== "object" ||
159+ parsed === null ||
160+ !("version" in parsed) ||
161+ (parsed as { version?: unknown }).version !== 1
162+ ) {
163+ return null;
164+ }
165+
166+ const r = parsed as Record<string, unknown>;
167+ if (
168+ typeof r.hostname !== "string" ||
169+ typeof r.port !== "number" ||
170+ typeof r.pid !== "number" ||
171+ typeof r.startedAt !== "string" ||
172+ typeof r.scopeId !== "string" ||
173+ !(typeof r.scopeDir === "string" || r.scopeDir === null) ||
174+ typeof r.token !== "string"
175+ ) {
176+ return null;
177+ }
178+
179+ return {
180+ version: 1,
181+ hostname: canonicalDaemonHost(r.hostname),
182+ port: r.port,
183+ pid: r.pid,
184+ startedAt: r.startedAt,
185+ scopeId: r.scopeId,
186+ scopeDir: r.scopeDir,
187+ token: r.token,
188+ };
189+};
190+
114191 export const readDaemonRecord = (input: {
115192 hostname: string;
116193 port: number;
@@ -135,6 +212,148 @@ export const removeDaemonRecord = (input: { · 18 unmodified lines
135212 yield* fs.remove(daemonRecordPath(path, input), { force: true });
136213 });
137214
215+export const writeDaemonPointer = (input: {
216+ hostname: string;
217+ port: number;
218+ pid: number;
219+ scopeId: string;
220+ scopeDir: string | null;
221+ token: string;
222+}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
223+ Effect.gen(function* () {
224+ const fs = yield* FileSystem.FileSystem;
225+ const path = yield* Path.Path;
226+ const dataDir = resolveDaemonDataDir(path);
227+ yield* fs.makeDirectory(dataDir, { recursive: true });
228+
229+ const payload: DaemonPointer = {
230+ version: 1,
231+ hostname: canonicalDaemonHost(input.hostname),
232+ port: input.port,
233+ pid: input.pid,
234+ startedAt: new Date().toISOString(),
235+ scopeId: input.scopeId,
236+ scopeDir: input.scopeDir,
237+ token: input.token,
238+ };
239+
240+ yield* fs.writeFileString(
241+ daemonPointerPath(path, { hostname: input.hostname, scopeId: input.scopeId }),
242+ `${JSON.stringify(payload, null, 2)}\n`,
243+ );
244+ });
245+
246+export const readDaemonPointer = (input: {
247+ hostname: string;
248+ scopeId: string;
249+}): Effect.Effect<DaemonPointer | null, never, FileSystem.FileSystem | Path.Path> =>
250+ Effect.gen(function* () {
251+ const fs = yield* FileSystem.FileSystem;
252+ const path = yield* Path.Path;
253+ const raw = yield* fs
254+ .readFileString(daemonPointerPath(path, input))
255+ .pipe(Effect.catchAll(() => Effect.succeed(null)));
256+ if (raw === null) return null;
257+ return parsePointer(raw);
258+ });
259+
260+export const removeDaemonPointer = (input: {
261+ hostname: string;
262+ scopeId: string;
263+}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
264+ Effect.gen(function* () {
265+ const fs = yield* FileSystem.FileSystem;
266+ const path = yield* Path.Path;
267+ yield* fs.remove(daemonPointerPath(path, input), { force: true });
268+ });
269+
270+const parseLockPid = (raw: string): number | null => {
271+ let parsed: unknown;
272+ try {
273+ parsed = JSON.parse(raw);
274+ } catch {
275+ return null;
276+ }
277+
278+ if (
279+ typeof parsed !== "object" ||
280+ parsed === null ||
281+ typeof (parsed as Record<string, unknown>).pid !== "number"
282+ ) {
283+ return null;
284+ }
285+
286+ return (parsed as Record<string, number>).pid;
287+};
288+
289+export const acquireDaemonStartLock = (input: {
290+ hostname: string;
291+ scopeId: string;
292+}): Effect.Effect<DaemonStartLock, Error, FileSystem.FileSystem | Path.Path> =>
293+ Effect.gen(function* () {
294+ const fs = yield* FileSystem.FileSystem;
295+ const path = yield* Path.Path;
296+ const dataDir = resolveDaemonDataDir(path);
297+ yield* fs.makeDirectory(dataDir, { recursive: true });
298+
299+ const lockPath = daemonStartLockPath(path, input);
300+ const lockPayload = JSON.stringify(
301+ {
302+ pid: process.pid,
303+ hostname: canonicalDaemonHost(input.hostname),
304+ scopeId: input.scopeId,
305+ startedAt: new Date().toISOString(),
306+ },
307+ null,
308+ 2,
309+ );
310+
311+ const tryAcquire = () =>
312+ fs.writeFileString(lockPath, `${lockPayload}\n`, { flag: "wx" }).pipe(
313+ Effect.as(true),
314+ Effect.catchAll(() => Effect.succeed(false)),
315+ );
316+
317+ if (yield* tryAcquire()) {
318+ return {
319+ path: lockPath,
320+ hostname: canonicalDaemonHost(input.hostname),
321+ scopeId: input.scopeId,
322+ };
323+ }
324+
325+ const existingRaw = yield* fs.readFileString(lockPath).pipe(Effect.catchAll(() => Effect.succeed(null)));
326+ if (existingRaw !== null) {
327+ const existingPid = parseLockPid(existingRaw);
328+ if (existingPid !== null && !isPidAlive(existingPid)) {
329+ yield* fs.remove(lockPath, { force: true });
330+ if (yield* tryAcquire()) {
331+ return {
332+ path: lockPath,
333+ hostname: canonicalDaemonHost(input.hostname),
334+ scopeId: input.scopeId,
335+ };
336+ }
337+ }
338+ }
339+
340+ return yield* Effect.fail(
341+ new Error(
342+ `Another daemon startup is already in progress for ${canonicalDaemonHost(input.hostname)} (${input.scopeId}).`,
343+ ),
344+ );
345+ });
346+
347+export const releaseDaemonStartLock = (input: DaemonStartLock): Effect.Effect<
348+ void,
349+ PlatformError,
350+ FileSystem.FileSystem | Path.Path
351+> =>
352+ Effect.gen(function* () {
353+ const fs = yield* FileSystem.FileSystem;
354+ yield* fs.remove(input.path, { force: true });
355+ });
356+
138357 // ---------------------------------------------------------------------------
139358 // Process helpers
140359 // ---------------------------------------------------------------------------
apps/cli/src/daemon.ts+80−0
@@ -1,4 +1,5 @@
11 import { spawn } from "node:child_process";
2+import { createServer } from "node:net";
23 import * as Clock from "effect/Clock";
34 import * as Effect from "effect/Effect";
45
@@ -142,3 +143,82 @@ export const waitForUnreachable = <E, R>(input: { · 137 unmodified lines
142143 timeoutMs: input.timeoutMs,
143144 intervalMs: input.intervalMs,
144145 });
146+
147+const toProbeHost = (hostname: string): string => {
148+ const normalized = hostname.trim().toLowerCase();
149+ if (normalized === "localhost" || normalized === "0.0.0.0") {
150+ return "127.0.0.1";
151+ }
152+ return hostname;
153+};
154+
155+const isPortAvailable = (input: { hostname: string; port: number }): Effect.Effect<boolean, Error> =>
156+ Effect.tryPromise({
157+ try: () =>
158+ new Promise<boolean>((resolve) => {
159+ const server = createServer() as any;
160+ const cleanup = () => {
161+ if (typeof server.removeAllListeners === "function") {
162+ server.removeAllListeners();
163+ }
164+ };
165+
166+ server.once("error", () => {
167+ cleanup();
168+ resolve(false);
169+ });
170+
171+ server.once("listening", () => {
172+ cleanup();
173+ server.close(() => resolve(true));
174+ });
175+
176+ server.listen({ port: input.port, host: toProbeHost(input.hostname) });
177+ }),
178+ catch: (cause) =>
179+ cause instanceof Error
180+ ? cause
181+ : new Error(`Failed probing port availability: ${String(cause)}`),
182+ });
183+
184+const pickEphemeralPort = (hostname: string): Effect.Effect<number, Error> =>
185+ Effect.tryPromise({
186+ try: () =>
187+ new Promise<number>((resolve, reject) => {
188+ const server = createServer() as any;
189+
190+ server.once("error", (error: unknown) => {
191+ reject(error);
192+ });
193+
194+ server.once("listening", () => {
195+ const address = server.address();
196+ const port = typeof address === "object" && address !== null ? address.port : 0;
197+ server.close(() => resolve(port));
198+ });
199+
200+ server.listen({ port: 0, host: toProbeHost(hostname) });
201+ }),
202+ catch: (cause) =>
203+ cause instanceof Error ? cause : new Error(`Failed selecting ephemeral port: ${String(cause)}`),
204+ });
205+
206+export const chooseDaemonPort = (input: {
207+ preferredPort: number;
208+ hostname: string;
209+}): Effect.Effect<number, Error> =>
210+ Effect.gen(function* () {
211+ const preferredAvailable = yield* isPortAvailable({
212+ hostname: input.hostname,
213+ port: input.preferredPort,
214+ });
215+ if (preferredAvailable) return input.preferredPort;
216+
217+ const fallbackPort = yield* pickEphemeralPort(input.hostname);
218+ if (!Number.isFinite(fallbackPort) || fallbackPort <= 0 || fallbackPort > 65535) {
219+ return yield* Effect.fail(
220+ new Error(`Could not find an available daemon port for host ${input.hostname}`),
221+ );
222+ }
223+ return fallbackPort;
224+ });
apps/cli/src/main.ts+220−92
@@ -1,4 +1,5 @@
11 // Ensure binaries next to the executor (e.g. secure-exec-v8) are on $PATH
2+import { randomUUID } from "node:crypto";
23 import { dirname, join, resolve } from "node:path";
34 const execDir = dirname(process.execPath);
45 if (process.env.PATH && !process.env.PATH.includes(execDir)) {
@@ -33,7 +34,7 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { · 28 unmodified lines
3334
3435 import { Command, Options, Args } from "@effect/cli";
3536 import { BunContext, BunRuntime } from "@effect/platform-bun";
36−import { FetchHttpClient, FileSystem, HttpApiClient } from "@effect/platform";
37+import { FetchHttpClient, FileSystem, HttpApiClient, Path as PlatformPath } from "@effect/platform";
3738 import * as Effect from "effect/Effect";
3839 import * as Option from "effect/Option";
3940 import * as Cause from "effect/Cause";
@@ -43,6 +44,7 @@ import { startServer, runMcpStdioServer, getExecutor } from "@executor/local"; · 3 unmodified lines
4344 import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
4445 import {
4546 buildDaemonSpawnSpec,
47+ chooseDaemonPort,
4648 canAutoStartLocalDaemonForHost,
4749 parseDaemonBaseUrl,
4850 spawnDetached,
@@ -50,11 +52,17 @@ import { · 1 unmodified line
5052 waitForUnreachable,
5153 } from "./daemon";
5254 import {
55+ acquireDaemonStartLock,
5356 canonicalDaemonHost,
57+ currentDaemonScopeId,
5458 isPidAlive,
59+ readDaemonPointer,
5560 readDaemonRecord,
61+ releaseDaemonStartLock,
62+ removeDaemonPointer,
5663 removeDaemonRecord,
5764 terminatePid,
65+ writeDaemonPointer,
5866 writeDaemonRecord,
5967 } from "./daemon-state";
6068 import {
@@ -102,9 +110,25 @@ const waitForShutdownSignal = () => · 41 unmodified lines
102110 // Background server management
103111 // ---------------------------------------------------------------------------
104112
113+const isRecord = (value: unknown): value is Record<string, unknown> =>
114+ typeof value === "object" && value !== null && !Array.isArray(value);
115+
105116 const isServerReachable = (baseUrl: string): Effect.Effect<boolean> =>
106117 Effect.tryPromise(() => fetch(`${baseUrl}/api/scope`, { signal: AbortSignal.timeout(2000) })).pipe(
107− Effect.map((res) => res.ok),
118+ Effect.flatMap((res) => {
119+ if (!res.ok) return Effect.succeed(false);
120+ return Effect.tryPromise(() => res.json()).pipe(
121+ Effect.map((payload) => {
122+ if (!isRecord(payload)) return false;
123+ return (
124+ typeof payload.id === "string" &&
125+ typeof payload.name === "string" &&
126+ typeof payload.dir === "string"
127+ );
128+ }),
129+ Effect.catchAll(() => Effect.succeed(false)),
130+ );
131+ }),
108132 Effect.catchAll(() => Effect.succeed(false)),
109133 );
110134
@@ -112,116 +136,191 @@ const script = process.argv[1]; · 1 unmodified line
112136 const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js");
113137 const cliPrefix = isDevMode ? `bun run ${script}` : "executor";
114138
115−const ensureDaemon = (baseUrl: string) =>
139+const toError = (cause: unknown): Error =>
140+ cause instanceof Error ? cause : new Error(String(cause));
141+
142+const parseDaemonUrl = (baseUrl: string) =>
143+ Effect.try({
144+ try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),
145+ catch: (cause) =>
146+ cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),
147+ });
148+
149+const daemonBaseUrl = (hostname: string, port: number): string =>
150+ `http://${canonicalDaemonHost(hostname)}:${port}`;
151+
152+const cleanupPointer = (input: { hostname: string; scopeId: string; port: number }) =>
116153 Effect.gen(function* () {
117− if (yield* isServerReachable(baseUrl)) return;
154+ yield* removeDaemonPointer({ hostname: input.hostname, scopeId: input.scopeId }).pipe(Effect.ignore);
155+ yield* removeDaemonRecord({ hostname: input.hostname, port: input.port }).pipe(Effect.ignore);
156+ });
118157
119− const parsed = yield* Effect.try({
120− try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),
121− catch: (cause) =>
122− cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),
123− });
158+const resolveDaemonTarget = (baseUrl: string) =>
159+ Effect.gen(function* () {
160+ const parsed = yield* parseDaemonUrl(baseUrl);
161+ const host = canonicalDaemonHost(parsed.hostname);
162+ const scopeId = currentDaemonScopeId();
163+ const pointer = yield* readDaemonPointer({ hostname: host, scopeId });
164+
165+ if (pointer) {
166+ const pointerUrl = daemonBaseUrl(pointer.hostname, pointer.port);
167+ if (isPidAlive(pointer.pid) && (yield* isServerReachable(pointerUrl))) {
168+ return {
169+ baseUrl: pointerUrl,
170+ hostname: pointer.hostname,
171+ port: pointer.port,
172+ scopeId,
173+ };
174+ }
175+
176+ yield* cleanupPointer({ hostname: pointer.hostname, scopeId, port: pointer.port });
177+ }
178+
179+ return {
180+ baseUrl: daemonBaseUrl(host, parsed.port),
181+ hostname: host,
182+ port: parsed.port,
183+ scopeId,
184+ };
185+ });
186+
187+const ensureDaemon = (
188+ baseUrl: string,
189+): Effect.Effect<string, Error, FileSystem.FileSystem | PlatformPath.Path> =>
190+ Effect.gen(function* () {
191+ const resolvedTarget = yield* resolveDaemonTarget(baseUrl);
192+ if (yield* isServerReachable(resolvedTarget.baseUrl)) {
193+ return resolvedTarget.baseUrl;
194+ }
195+
196+ const parsed = yield* parseDaemonUrl(baseUrl);
197+ const scopeId = currentDaemonScopeId();
198+ const host = canonicalDaemonHost(parsed.hostname);
124199
125− if (!canAutoStartLocalDaemonForHost(parsed.hostname)) {
200+ if (!canAutoStartLocalDaemonForHost(host)) {
126201 return yield* Effect.fail(
127202 new Error(
128203 [
129204 `Executor daemon is not reachable at ${baseUrl}.`,
130205 "Auto-start is only supported for local hosts.",
131− `Start it manually: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${parsed.hostname}`,
206+ `Start it manually: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${host}`,
132207 ].join("\n"),
133208 ),
134209 );
135210 }
136211
137− const spec = yield* Effect.try({
138− try: () =>
139− buildDaemonSpawnSpec({
140− port: parsed.port,
141− hostname: parsed.hostname,
142− isDevMode,
143− scriptPath: script,
144− executablePath: process.execPath,
145− }),
146− catch: (cause) =>
147− cause instanceof Error ? cause : new Error(`Failed to build daemon command: ${String(cause)}`),
148− });
212+ const lock = yield* acquireDaemonStartLock({ hostname: host, scopeId });
149213
150− console.error(`Starting daemon on ${parsed.hostname}:${parsed.port}...`);
151− yield* spawnDetached({
152− command: spec.command,
153− args: spec.args,
154− env: process.env,
155− });
214+ try {
215+ const existing = yield* resolveDaemonTarget(baseUrl);
216+ if (yield* isServerReachable(existing.baseUrl)) {
217+ return existing.baseUrl;
218+ }
156219
157− const ready = yield* waitForReachable({
158− check: isServerReachable(baseUrl),
159− timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
160− intervalMs: DAEMON_BOOT_POLL_MS,
161− });
220+ const selectedPort = yield* chooseDaemonPort({
221+ preferredPort: parsed.port,
222+ hostname: host,
223+ });
162224
163− if (!ready) {
164− return yield* Effect.fail(
165− new Error(
166− [
167− `Daemon did not become reachable at ${baseUrl} within ${DAEMON_BOOT_TIMEOUT_MS}ms.`,
168− `Run in foreground to inspect logs: ${cliPrefix} daemon run --port ${parsed.port} --hostname ${parsed.hostname}`,
169− ].join("\n"),
170− ),
171− );
225+ if (selectedPort !== parsed.port) {
226+ console.error(
227+ `Port ${parsed.port} is in use. Starting daemon on available port ${selectedPort} instead.`,
228+ );
229+ }
230+
231+ const spec = yield* Effect.try({
232+ try: () =>
233+ buildDaemonSpawnSpec({
234+ port: selectedPort,
235+ hostname: host,
236+ isDevMode,
237+ scriptPath: script,
238+ executablePath: process.execPath,
239+ }),
240+ catch: (cause) =>
241+ cause instanceof Error ? cause : new Error(`Failed to build daemon command: ${String(cause)}`),
242+ });
243+
244+ const startBaseUrl = daemonBaseUrl(host, selectedPort);
245+ console.error(`Starting daemon on ${host}:${selectedPort}...`);
246+ yield* spawnDetached({
247+ command: spec.command,
248+ args: spec.args,
249+ env: process.env,
250+ });
251+
252+ const ready = yield* waitForReachable({
253+ check: isServerReachable(startBaseUrl),
254+ timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
255+ intervalMs: DAEMON_BOOT_POLL_MS,
256+ });
257+
258+ if (!ready) {
259+ return yield* Effect.fail(
260+ new Error(
261+ [
262+ `Daemon did not become reachable at ${startBaseUrl} within ${DAEMON_BOOT_TIMEOUT_MS}ms.`,
263+ `Run in foreground to inspect logs: ${cliPrefix} daemon run --port ${selectedPort} --hostname ${host}`,
264+ ].join("\n"),
265+ ),
266+ );
267+ }
268+
269+ return startBaseUrl;
270+ } finally {
271+ yield* releaseDaemonStartLock(lock).pipe(Effect.ignore);
172272 }
173− });
273+ }).pipe(Effect.mapError(toError));
174274
175−const stopDaemon = (baseUrl: string) =>
275+const stopDaemon = (
276+ baseUrl: string,
277+): Effect.Effect<void, Error, FileSystem.FileSystem | PlatformPath.Path> =>
176278 Effect.gen(function* () {
177− const parsed = yield* Effect.try({
178− try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),
179− catch: (cause) =>
180− cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),
181− });
182−
183− const host = canonicalDaemonHost(parsed.hostname);
184− const record = yield* readDaemonRecord({ hostname: host, port: parsed.port });
185− const reachable = yield* isServerReachable(baseUrl);
279+ const target = yield* resolveDaemonTarget(baseUrl);
280+ const host = canonicalDaemonHost(target.hostname);
281+ const scopeId = target.scopeId;
282+ const record = yield* readDaemonRecord({ hostname: host, port: target.port });
283+ const reachable = yield* isServerReachable(target.baseUrl);
186284
187285 if (!record) {
188286 if (reachable) {
189287 return yield* Effect.fail(
190288 new Error(
191289 [
192− `Executor is reachable at ${baseUrl} but no daemon record exists.`,
290+ `Executor is reachable at ${target.baseUrl} but no daemon record exists.`,
193291 "It may not be managed by this CLI process.",
194292 "Stop it from the terminal/session where it was started.",
195293 ].join("\n"),
196294 ),
197295 );
198296 }
199− console.log(`No daemon running at ${baseUrl}.`);
297+ console.log(`No daemon running at ${target.baseUrl}.`);
200298 return;
201299 }
202300
203301 if (!isPidAlive(record.pid)) {
204− yield* removeDaemonRecord({ hostname: host, port: parsed.port });
302+ yield* removeDaemonRecord({ hostname: host, port: target.port });
303+ yield* removeDaemonPointer({ hostname: host, scopeId }).pipe(Effect.ignore);
205304 if (reachable) {
206305 return yield* Effect.fail(
207306 new Error(
208307 [
209− `Daemon record for ${baseUrl} points to dead pid ${record.pid}, but endpoint is still reachable.`,
308+ `Daemon record for ${target.baseUrl} points to dead pid ${record.pid}, but endpoint is still reachable.`,
210309 "Refusing to stop an unknown process without ownership metadata.",
211310 ].join("\n"),
212311 ),
213312 );
214313 }
215− console.log(`No daemon running at ${baseUrl} (removed stale record for pid ${record.pid}).`);
314+ console.log(`No daemon running at ${target.baseUrl} (removed stale record for pid ${record.pid}).`);
216315 return;
217316 }
218317
219− console.log(`Stopping daemon at ${baseUrl} (pid ${record.pid})...`);
318+ console.log(`Stopping daemon at ${target.baseUrl} (pid ${record.pid})...`);
220319
221320 yield* terminatePid(record.pid);
222321
223322 const stopped = yield* waitForUnreachable({
224− check: isServerReachable(baseUrl),
323+ check: isServerReachable(target.baseUrl),
225324 timeoutMs: DAEMON_STOP_TIMEOUT_MS,
226325 intervalMs: DAEMON_BOOT_POLL_MS,
227326 });
@@ -230,16 +329,17 @@ const stopDaemon = (baseUrl: string) => · 2 unmodified lines
230329 return yield* Effect.fail(
231330 new Error(
232331 [
233− `Daemon at ${baseUrl} did not stop within ${DAEMON_STOP_TIMEOUT_MS}ms.`,
332+ `Daemon at ${target.baseUrl} did not stop within ${DAEMON_STOP_TIMEOUT_MS}ms.`,
234333 "Try terminating the process manually.",
235334 ].join("\n"),
236335 ),
237336 );
238337 }
239338
240− yield* removeDaemonRecord({ hostname: host, port: parsed.port });
241− console.log(`Daemon stopped at ${baseUrl}.`);
242− });
339+ yield* removeDaemonRecord({ hostname: host, port: target.port });
340+ yield* removeDaemonPointer({ hostname: host, scopeId }).pipe(Effect.ignore);
341+ console.log(`Daemon stopped at ${target.baseUrl}.`);
342+ }).pipe(Effect.mapError(toError));
243343
244344 type ExecuteCodeOutcome =
245345 | {
@@ -252,16 +352,13 @@ type ExecuteCodeOutcome = · 6 unmodified lines
252352 readonly executionId: string | undefined;
253353 };
254354
255−const toError = (cause: unknown): Error =>
256− cause instanceof Error ? cause : new Error(String(cause));
257−
258355 const executeCode = (input: {
259356 baseUrl: string;
260357 code: string;
261−}): Effect.Effect<ExecuteCodeOutcome, Error> =>
358+}): Effect.Effect<ExecuteCodeOutcome, Error, FileSystem.FileSystem | PlatformPath.Path> =>
262359 Effect.gen(function* () {
263− yield* ensureDaemon(input.baseUrl);
264− const client = yield* makeApiClient(input.baseUrl);
360+ const daemonUrl = yield* ensureDaemon(input.baseUrl);
361+ const client = yield* makeApiClient(daemonUrl);
265362 const response = yield* client.executions.execute({
266363 payload: {
267364 code: input.code,
@@ -361,6 +458,26 @@ const runDaemonSession = (input: { · 93 unmodified lines
361458 allowedHosts: ReadonlyArray<string>;
362459 }) =>
363460 Effect.gen(function* () {
461+ const daemonHost = canonicalDaemonHost(input.hostname);
462+ const scopeId = currentDaemonScopeId();
463+ const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId });
464+
465+ if (existing) {
466+ const existingUrl = daemonBaseUrl(existing.hostname, existing.port);
467+ if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) {
468+ return yield* Effect.fail(
469+ new Error(
470+ [
471+ `A daemon is already running for scope ${scopeId} on ${daemonHost}.`,
472+ `Existing daemon: ${existingUrl} (pid ${existing.pid}).`,
473+ `Stop it first: ${cliPrefix} daemon stop`,
474+ ].join("\n"),
475+ ),
476+ );
477+ }
478+ yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });
479+ }
480+
364481 const server = yield* Effect.promise(() =>
365482 startServer({
366483 port: input.port,
@@ -370,8 +487,8 @@ const runDaemonSession = (input: { · 3 unmodified lines
370487 }),
371488 );
372489
373− const daemonHost = canonicalDaemonHost(input.hostname);
374490 const daemonPort = server.port;
491+ const token = randomUUID();
375492
376493 yield* writeDaemonRecord({
377494 hostname: daemonHost,
@@ -379,6 +496,14 @@ const runDaemonSession = (input: { · 1 unmodified line
379496 pid: process.pid,
380497 scopeDir: process.env.EXECUTOR_SCOPE_DIR ?? null,
381498 });
499+ yield* writeDaemonPointer({
500+ hostname: daemonHost,
501+ port: daemonPort,
502+ pid: process.pid,
503+ scopeId,
504+ scopeDir: process.env.EXECUTOR_SCOPE_DIR ?? null,
505+ token,
506+ });
382507
383508 console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`);
384509
@@ -387,6 +512,7 @@ const runDaemonSession = (input: { · 2 unmodified lines
387512 } finally {
388513 yield* Effect.promise(() => server.stop());
389514 yield* removeDaemonRecord({ hostname: daemonHost, port: daemonPort });
515+ yield* removeDaemonPointer({ hostname: daemonHost, scopeId }).pipe(Effect.ignore);
390516 }
391517 });
392518
@@ -469,9 +595,9 @@ const callCommand = Command.make( · 76 unmodified lines
469595 Effect.gen(function* () {
470596 applyScope(scope);
471597 const resolvedCode = yield* readCode({ code, file, stdin });
472− yield* ensureDaemon(baseUrl);
598+ const daemonUrl = yield* ensureDaemon(baseUrl);
473599
474− const client = yield* makeApiClient(baseUrl);
600+ const client = yield* makeApiClient(daemonUrl);
475601 const result = yield* client.executions.execute({ payload: { code: resolvedCode } });
476602
477603 if (result.status === "completed") {
@@ -507,12 +633,12 @@ const resumeCommand = Command.make( · 29 unmodified lines
507633 ({ executionId, action, content, baseUrl, scope }) =>
508634 Effect.gen(function* () {
509635 applyScope(scope);
510− yield* ensureDaemon(baseUrl);
636+ const daemonUrl = yield* ensureDaemon(baseUrl);
511637
512638 const parsedContent = Option.getOrUndefined(content);
513639 const contentObj = parsedContent ? JSON.parse(parsedContent) : undefined;
514640
515− const client = yield* makeApiClient(baseUrl);
641+ const client = yield* makeApiClient(daemonUrl);
516642 const result = yield* client.executions.resume({
517643 path: { executionId },
518644 payload: { action: action as "accept" | "decline" | "cancel", content: contentObj },
@@ -711,41 +837,43 @@ const daemonStatusCommand = Command.make( · 192 unmodified lines
711837 },
712838 ({ baseUrl }) =>
713839 Effect.gen(function* () {
714− const parsed = yield* Effect.try({
715− try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),
716− catch: (cause) =>
717− cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),
718− });
719− const host = canonicalDaemonHost(parsed.hostname);
840+ const target = yield* resolveDaemonTarget(baseUrl);
841+ const host = canonicalDaemonHost(target.hostname);
720842
721843 const [record, reachable] = yield* Effect.all([
722− readDaemonRecord({ hostname: host, port: parsed.port }),
723− isServerReachable(baseUrl),
844+ readDaemonRecord({ hostname: host, port: target.port }),
845+ isServerReachable(target.baseUrl),
724846 ]);
725847
726848 if (!record) {
727849 if (reachable) {
728− console.log(`Daemon reachable at ${baseUrl} (no local ownership record).`);
850+ console.log(`Daemon reachable at ${target.baseUrl} (no local ownership record).`);
729851 } else {
730− console.log(`Daemon not running at ${baseUrl}.`);
852+ console.log(`Daemon not running at ${target.baseUrl}.`);
731853 }
732854 return;
733855 }
734856
735857 if (!isPidAlive(record.pid)) {
736858 if (!reachable) {
737− yield* removeDaemonRecord({ hostname: host, port: parsed.port });
738− console.log(`Daemon not running at ${baseUrl} (removed stale record for pid ${record.pid}).`);
859+ yield* removeDaemonRecord({ hostname: host, port: target.port });
860+ yield* removeDaemonPointer({ hostname: host, scopeId: target.scopeId }).pipe(Effect.ignore);
861+ console.log(
862+ `Daemon not running at ${target.baseUrl} (removed stale record for pid ${record.pid}).`,
863+ );
739864 return;
740865 }
741866 console.log(
742− `Daemon reachable at ${baseUrl}, but recorded pid ${record.pid} is not alive (ownership mismatch).`,
867+ `Daemon reachable at ${target.baseUrl}, but recorded pid ${record.pid} is not alive (ownership mismatch).`,
743868 );
744869 return;
745870 }
746871
747872 const state = reachable ? "running" : "unreachable";
748− console.log(`Daemon ${state} at ${baseUrl} (pid ${record.pid}).`);
873+ console.log(`Daemon ${state} at ${target.baseUrl} (pid ${record.pid}).`);
874+ if (target.baseUrl !== baseUrl) {
875+ console.log(`Requested: ${baseUrl}`);
876+ }
749877 if (record.scopeDir) {
750878 console.log(`Scope: ${record.scopeDir}`);
751879 }
@@ -770,8 +898,8 @@ const daemonRestartCommand = Command.make( · 18 unmodified lines
770898 Effect.gen(function* () {
771899 applyScope(scope);
772900 yield* stopDaemon(baseUrl);
773− yield* ensureDaemon(baseUrl);
774− console.log(`Daemon restarted at ${baseUrl}.`);
901+ const daemonUrl = yield* ensureDaemon(baseUrl);
902+ console.log(`Daemon restarted at ${daemonUrl}.`);
775903 }),
776904 ).pipe(Command.withDescription("Restart the local daemon"));
777905
tests/daemon-bootstrap.test.ts+28−0
@@ -1,8 +1,11 @@
11 import { describe, expect, it } from "vitest";
2+import { createServer } from "node:net";
3+import * as Effect from "effect/Effect";
24
35 import {
46 buildDaemonSpawnSpec,
57 canAutoStartLocalDaemonForHost,
8+ chooseDaemonPort,
69 parseDaemonBaseUrl,
710 } from "../apps/cli/src/daemon";
811
@@ -83,4 +86,29 @@ describe("daemon bootstrap helpers", () => { · 74 unmodified lines
8386 }),
8487 ).toThrow("Cannot auto-start daemon in dev mode");
8588 });
89+
90+ it("falls back when preferred daemon port is occupied", async () => {
91+ const blocker = createServer();
92+ await new Promise<void>((resolve, reject) => {
93+ blocker.once("error", reject);
94+ blocker.listen({ port: 0, host: "127.0.0.1" }, () => resolve());
95+ });
96+
97+ const occupied = (() => {
98+ const address = blocker.address();
99+ return typeof address === "object" && address !== null ? address.port : 0;
100+ })();
101+
102+ try {
103+ const picked = await Effect.runPromise(
104+ chooseDaemonPort({
105+ preferredPort: occupied,
106+ hostname: "127.0.0.1",
107+ }),
108+ );
109+ expect(picked).not.toBe(occupied);
110+ } finally {
111+ await new Promise<void>((resolve) => blocker.close(() => resolve()));
112+ }
113+ });
86114 });
tests/daemon-state.test.ts+88−2
@@ -9,10 +9,16 @@ import { describe, expect, it } from "@effect/vitest"; · 8 unmodified lines
99 import { Effect, Layer } from "effect";
1010
1111 import {
12+ acquireDaemonStartLock,
1213 canonicalDaemonHost,
14+ currentDaemonScopeId,
1315 isPidAlive,
16+ readDaemonPointer,
1417 readDaemonRecord,
18+ releaseDaemonStartLock,
19+ removeDaemonPointer,
1520 removeDaemonRecord,
21+ writeDaemonPointer,
1622 writeDaemonRecord,
1723 } from "../apps/cli/src/daemon-state";
1824
@@ -31,9 +37,9 @@ const fileSystemLayer = FileSystem.layerNoop({ · 12 unmodified lines
3137 try: () => mkdir(path, { recursive: options?.recursive, mode: options?.mode }),
3238 catch: (cause) => fileSystemError("makeDirectory", cause),
3339 }),
34− writeFileString: (path, data, _options) =>
40+ writeFileString: (path, data, options) =>
3541 Effect.tryPromise({
36− try: () => writeFile(path, data, "utf8"),
42+ try: () => writeFile(path, data, { encoding: "utf8", flag: options?.flag }),
3743 catch: (cause) => fileSystemError("writeFileString", cause),
3844 }),
3945 readFileString: (path, encoding = "utf8") =>
@@ -104,6 +110,86 @@ describe("daemon state", () => { · 64 unmodified lines
104110 ),
105111 );
106112
113+ it.effect("writes, reads, and removes daemon pointers", () =>
114+ withDaemonDataDir(
115+ Effect.gen(function* () {
116+ yield* writeDaemonPointer({
117+ hostname: "127.0.0.1",
118+ port: 5799,
119+ pid: 24680,
120+ scopeId: "scope:/tmp/project",
121+ scopeDir: "/tmp/project",
122+ token: "tok_123",
123+ });
124+
125+ const stored = yield* readDaemonPointer({
126+ hostname: "localhost",
127+ scopeId: "scope:/tmp/project",
128+ });
129+ expect(stored).toEqual({
130+ version: 1,
131+ hostname: "localhost",
132+ port: 5799,
133+ pid: 24680,
134+ startedAt: expect.any(String),
135+ scopeId: "scope:/tmp/project",
136+ scopeDir: "/tmp/project",
137+ token: "tok_123",
138+ });
139+
140+ yield* removeDaemonPointer({
141+ hostname: "localhost",
142+ scopeId: "scope:/tmp/project",
143+ });
144+ const after = yield* readDaemonPointer({
145+ hostname: "localhost",
146+ scopeId: "scope:/tmp/project",
147+ });
148+ expect(after).toBeNull();
149+ }),
150+ ),
151+ );
152+
153+ it.effect("serializes daemon startup with lock files", () =>
154+ withDaemonDataDir(
155+ Effect.gen(function* () {
156+ const lock = yield* acquireDaemonStartLock({
157+ hostname: "localhost",
158+ scopeId: "scope:/tmp/project",
159+ });
160+
161+ const second = yield* acquireDaemonStartLock({
162+ hostname: "localhost",
163+ scopeId: "scope:/tmp/project",
164+ }).pipe(Effect.flip);
165+
166+ expect(second.message).toContain("already in progress");
167+
168+ yield* releaseDaemonStartLock(lock);
169+
170+ const third = yield* acquireDaemonStartLock({
171+ hostname: "localhost",
172+ scopeId: "scope:/tmp/project",
173+ });
174+ yield* releaseDaemonStartLock(third);
175+ }),
176+ ),
177+ );
178+
179+ it("derives scope id from EXECUTOR_SCOPE_DIR or cwd", () => {
180+ const prev = process.env.EXECUTOR_SCOPE_DIR;
181+ process.env.EXECUTOR_SCOPE_DIR = "/tmp/explicit-scope";
182+ expect(currentDaemonScopeId()).toBe("scope:/tmp/explicit-scope");
183+
184+ if (prev === undefined) {
185+ delete process.env.EXECUTOR_SCOPE_DIR;
186+ } else {
187+ process.env.EXECUTOR_SCOPE_DIR = prev;
188+ }
189+
190+ expect(currentDaemonScopeId()).toContain("cwd:");
191+ });
192+
107193 it("detects live and invalid pids", () => {
108194 expect(isPidAlive(process.pid)).toBe(true);
109195 expect(isPidAlive(-1)).toBe(false);