From 9f3ec715a891820e0bcf32f8a3bdb07d1927d0ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 21:37:00 +0000 Subject: [PATCH 1/6] test(server): reproduce #33552 agent-device daemon surviving a graceful quit A daemon recorded in T3's agent-device state dir by a previous server is never stopped when the host stops, unless this session readied agent tools. This test fails on main until the host stops such a daemon. Co-Authored-By: Claude Opus 5.5 --- .../server/src/device/LocalDeviceHost.test.ts | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 6b86a4d032610..e85d4e6171a3a 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -40,6 +40,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( stateDir: string, ) => Effect.Effect, avdOutput: ProcessRunner.ProcessRunOutput = processOutput(0), + previousDaemon?: typeof LocalDeviceHost.__testing.AgentDeviceDaemonFile.Type, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -47,6 +48,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const hub = { running: true, spawnFails: false }; const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); const daemonHealthy = yield* Ref.make(true); + const runs: Array = []; for (const [name, version, entry] of [ ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"], ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"], @@ -57,6 +59,10 @@ const agentFixture = Effect.fn("agentFixture")(function* ( yield* fs.writeFileString(entryPath, ""); yield* fs.writeFileString(path.join(installDir, ".install-complete"), version!); } + if (previousDaemon) { + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString(path.join(stateDir, "daemon.json"), JSON.stringify(previousDaemon)); + } const host = yield* LocalDeviceHost.make().pipe( Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)), Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }), @@ -92,10 +98,12 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ), Effect.provideService(ProcessRunner.ProcessRunner, { - run: (input) => - input.args.includes("devices") + run: (input) => { + runs.push(input); + return input.args.includes("devices") ? startup(stateDir) - : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)), + : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); + }, }), Effect.provideService( HttpClient.HttpClient, @@ -114,7 +122,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, stateDir, hub, daemonHealthy }; + return { host, fs, stateDir, hub, daemonHealthy, runs }; }); describe("AgentDevice startup", () => { @@ -276,6 +284,31 @@ describe("AgentDevice startup", () => { expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + // regression: #33552 a daemon left by a previous server survived every later graceful quit + it.effect("stops a daemon recorded by a previous server when the host stops", () => + Effect.gen(function* () { + const { host, stateDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { + httpPort: 12345, + token: "previous-token", + pid: process.pid, + version: AGENT_DEVICE_VERSION, + }, + ); + yield* host.stop; + const daemonCommands = runs.map((run) => run.args).filter((args) => args.includes("daemon")); + expect(daemonCommands).toContainEqual([ + expect.stringMatching(/agent-device\.mjs$/), + "daemon", + "stop", + "--state-dir", + stateDir, + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( From b5aa9301ee1ef2a598eb7421a2a50b1fb244bc51 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 21:42:16 +0000 Subject: [PATCH 2/6] fix(server): quitting stops an agent-device daemon left by an earlier server The local device host only stopped the agent-device daemon when this process had readied agent tools, so a daemon left behind by a killed or crashed server ran forever. Stopping now acts on the daemon recorded in T3's own state dir whenever daemon.json exists, deriving the installed agent-device entry and node runtime at stop time. The stop call is capped at 3 s to stay inside the desktop's quit grace. Fixes pingdotgg/t3code#33552 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/LocalDeviceHost.ts | 57 +++++++++++++---------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 6643e689328d1..2bc9958a2bb11 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -624,28 +624,36 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } }); - const stopAgentDeviceDaemon = ( - agentTool: { readonly entryPath: string; readonly nodePath: string } | null, - ) => - agentTool - ? runner - .run({ - command: agentTool.nodePath, - args: [ - agentTool.entryPath, - "daemon", - "stop", - "--state-dir", - agentDeviceStateDir(path, config.stateDir), - ], - env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, - timeout: Duration.seconds(10), - timeoutBehavior: "timedOutResult", - }) - .pipe(Effect.ignore) - : Effect.void; - - let agentToolRef: { readonly entryPath: string; readonly nodePath: string } | null = null; + // The daemon may predate this process (an earlier server was killed), so derive + // the tool from the install rather than from this session's startup. + const stopAgentDeviceDaemon = Effect.gen(function* () { + if (!(yield* fs.exists(daemonFilePath()))) return; + const installed = yield* isAgentDeviceInstalled(config.baseDir); + if (!installed) return; + const agentTool = yield* ensureAgentDevice(config.baseDir); + const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( + Effect.provideService(HostProcess.Platform, hostPlatform), + ); + yield* runner.run({ + command: nodePath, + args: [ + agentTool.entryPath, + "daemon", + "stop", + "--state-dir", + agentDeviceStateDir(path, config.stateDir), + ], + env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, + // Shutdown must finish inside the desktop's 5 s quit grace. + timeout: Duration.seconds(3), + timeoutBehavior: "timedOutResult", + }); + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.ignore, + ); const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( onPhase: (phase: "installing" | "starting", detail?: string) => Effect.Effect, @@ -760,7 +768,6 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { }), ), ); - agentToolRef = { entryPath: agentTool.entryPath, nodePath: running.hub.nodePath }; yield* onPhase("starting"); const agentDevice = yield* startAgentDeviceDaemon(agentTool, running.hub.nodePath); yield* pruneLocalDeviceTools(config.baseDir, running.hub.nodePath, "agent").pipe( @@ -840,7 +847,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)( Effect.gen(function* () { - yield* stopAgentDeviceDaemon(agentToolRef); + yield* stopAgentDeviceDaemon; yield* Ref.update(runningRef, (running) => running ? { ...running, agentDevice: null } : running, ); @@ -852,7 +859,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const running = yield* Ref.getAndSet(runningRef, null); yield* stopHub(running?.hub); yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - yield* stopAgentDeviceDaemon(agentToolRef); + yield* stopAgentDeviceDaemon; }), ); From 6c4b13cee2acbbea89ce357cca33c8ce8a8b516b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 22:00:28 +0000 Subject: [PATCH 3/6] fix(server): stop a recorded agent-device daemon with the CLI of its own version Reviewers found the round-1 stop skipped a daemon orphaned on an older agent-device pin, called the installer at shutdown, and cut agent-device's own SIGKILL step with a 3 s timeout. Resolve the CLI from the version daemon.json records (falling back to the current pin) without installing, and keep the 10 s budget. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceToolchain.ts | 13 ++++++++++ .../server/src/device/LocalDeviceHost.test.ts | 26 ++++++++++++++++++- apps/server/src/device/LocalDeviceHost.ts | 21 ++++++++------- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 250e6cf14f3be..eb299f9e29cdd 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -209,6 +209,19 @@ export const isDeviceHubInstalled = (baseDir: string) => export const isAgentDeviceInstalled = (baseDir: string) => isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); +/** Entry of the first installed agent-device among `versions`; never installs. */ +export const installedAgentDeviceEntry = Effect.fn("DeviceToolchain.installedAgentDeviceEntry")( + function* (baseDir: string, versions: ReadonlyArray) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const version of versions) { + const paths = toolPaths(path, baseDir, { ...AGENT_DEVICE_SPEC, version }); + if (yield* isInstalled(fs, paths, version)) return paths.entryPath; + } + return null; + }, +); + export const deviceToolVersions = Effect.fn("DeviceToolchain.versions")(function* ( baseDir: string, running: { hub?: string; agent?: string } = {}, diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index e85d4e6171a3a..4f9e9f53ded16 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -122,7 +122,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, stateDir, hub, daemonHealthy, runs }; + return { host, fs, baseDir, stateDir, hub, daemonHealthy, runs }; }); describe("AgentDevice startup", () => { @@ -309,6 +309,30 @@ describe("AgentDevice startup", () => { ]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("stops a previous server's daemon with the agent-device version it recorded", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { host, fs, baseDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" }, + ); + const olderInstall = path.join(baseDir, "tools", "agent-device", "0.0.1"); + const olderEntry = path.join( + olderInstall, + "node_modules", + "agent-device", + "bin", + "agent-device.mjs", + ); + yield* fs.makeDirectory(path.dirname(olderEntry), { recursive: true }); + yield* fs.writeFileString(olderEntry, ""); + yield* fs.writeFileString(path.join(olderInstall, ".install-complete"), "0.0.1"); + yield* host.stop; + expect(runs.find((run) => run.args.includes("daemon"))?.args[0]).toBe(olderEntry); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 2bc9958a2bb11..9f3c38b685eea 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -47,7 +47,9 @@ import { isAgentDeviceInstalled, isDeviceHubInstalled, deviceToolVersions, + AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION, + installedAgentDeviceEntry, } from "./DeviceToolchain.ts"; const HUB_READY_TIMEOUT_MS = 30_000; @@ -624,34 +626,35 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } }); - // The daemon may predate this process (an earlier server was killed), so derive - // the tool from the install rather than from this session's startup. + // The daemon may predate this process (an earlier server was killed, possibly on an + // older pin), so stop it with the CLI of its recorded version when that is installed. const stopAgentDeviceDaemon = Effect.gen(function* () { if (!(yield* fs.exists(daemonFilePath()))) return; - const installed = yield* isAgentDeviceInstalled(config.baseDir); - if (!installed) return; - const agentTool = yield* ensureAgentDevice(config.baseDir); + const recorded = yield* readDaemonFile().pipe(Effect.option); + const entryPath = yield* installedAgentDeviceEntry(config.baseDir, [ + ...(Option.isSome(recorded) && recorded.value.version ? [recorded.value.version] : []), + AGENT_DEVICE_VERSION, + ]); + if (!entryPath) return; const nodePath = yield* resolveNodeExecutable("Local device support", hostEnvironment).pipe( Effect.provideService(HostProcess.Platform, hostPlatform), ); yield* runner.run({ command: nodePath, args: [ - agentTool.entryPath, + entryPath, "daemon", "stop", "--state-dir", agentDeviceStateDir(path, config.stateDir), ], env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, - // Shutdown must finish inside the desktop's 5 s quit grace. - timeout: Duration.seconds(3), + timeout: Duration.seconds(10), timeoutBehavior: "timedOutResult", }); }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), - Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.ignore, ); From 422df87eb136a13c61c0968eb6a8a798ac50aff2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 23:56:58 +0000 Subject: [PATCH 4/6] fix(server): skip agent-device stop when the recorded daemon is already dead When daemon.json recorded a pid that is no longer alive, every graceful quit spawned Node to run `agent-device daemon stop`, which found nothing, left the stale file in place and cost up to seconds of the quit grace. Remove the stale record instead and only run `daemon stop` when the pid is alive or unrecorded. --- apps/server/src/device/LocalDeviceHost.test.ts | 15 +++++++++++++++ apps/server/src/device/LocalDeviceHost.ts | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 4f9e9f53ded16..708d04dfbc7ed 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -333,6 +333,21 @@ describe("AgentDevice startup", () => { expect(runs.find((run) => run.args.includes("daemon"))?.args[0]).toBe(olderEntry); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("drops a dead daemon's record without running agent-device when the host stops", () => + Effect.gen(function* () { + // Above the Linux and macOS PID limits, so never a live process. + const deadPid = 4_194_305; + const { host, fs, stateDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: deadPid, version: AGENT_DEVICE_VERSION }, + ); + yield* host.stop; + expect(runs.filter((run) => run.args.includes("daemon"))).toEqual([]); + expect(yield* fs.exists(`${stateDir}/daemon.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 9f3c38b685eea..70b990d511096 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -631,6 +631,13 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const stopAgentDeviceDaemon = Effect.gen(function* () { if (!(yield* fs.exists(daemonFilePath()))) return; const recorded = yield* readDaemonFile().pipe(Effect.option); + if ( + Option.isSome(recorded) && + recorded.value.pid && + !(yield* isProcessAlive(recorded.value.pid)) + ) { + return yield* fs.remove(daemonFilePath(), { force: true }); + } const entryPath = yield* installedAgentDeviceEntry(config.baseDir, [ ...(Option.isSome(recorded) && recorded.value.version ? [recorded.value.version] : []), AGENT_DEVICE_VERSION, From 9f3180ba42ff5597d9a3d8a2f62ff41ce52bab92 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:05:37 +0000 Subject: [PATCH 5/6] test(server): previous daemon falls back to the current agent-device pin A daemon.json recording an agent-device version that is no longer installed must still be stopped with the current pin's CLI. --- .../server/src/device/LocalDeviceHost.test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 708d04dfbc7ed..5e4c1338540cd 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -334,10 +334,35 @@ describe("AgentDevice startup", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("stops a previous server's daemon with the current pin when its version is gone", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { host, baseDir, runs } = yield* agentFixture( + () => Effect.die("A session that never readies agent tools must not start the daemon"), + processOutput(0), + { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" }, + ); + yield* host.stop; + expect(runs.find((run) => run.args.includes("daemon"))?.args[0]).toBe( + path.join( + baseDir, + "tools", + "agent-device", + AGENT_DEVICE_VERSION, + "node_modules", + "agent-device", + "bin", + "agent-device.mjs", + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("drops a dead daemon's record without running agent-device when the host stops", () => Effect.gen(function* () { // Above the Linux and macOS PID limits, so never a live process. const deadPid = 4_194_305; + const path = yield* Path.Path; const { host, fs, stateDir, runs } = yield* agentFixture( () => Effect.die("A session that never readies agent tools must not start the daemon"), processOutput(0), @@ -345,7 +370,7 @@ describe("AgentDevice startup", () => { ); yield* host.stop; expect(runs.filter((run) => run.args.includes("daemon"))).toEqual([]); - expect(yield* fs.exists(`${stateDir}/daemon.json`)).toBe(false); + expect(yield* fs.exists(path.join(stateDir, "daemon.json"))).toBe(false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); From 6071c9a467375c04e3bd8471abeef332226c0329 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 00:41:00 +0000 Subject: [PATCH 6/6] test(server): fold the previous-daemon stop into the current-pin fallback test Both exercised a live orphan stopped with the current pin's CLI; the fallback test now asserts the full daemon stop command and carries the #33552 regression marker. --- .../server/src/device/LocalDeviceHost.test.ts | 36 +++++-------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 5e4c1338540cd..e133b4f9db36c 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -285,31 +285,6 @@ describe("AgentDevice startup", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - // regression: #33552 a daemon left by a previous server survived every later graceful quit - it.effect("stops a daemon recorded by a previous server when the host stops", () => - Effect.gen(function* () { - const { host, stateDir, runs } = yield* agentFixture( - () => Effect.die("A session that never readies agent tools must not start the daemon"), - processOutput(0), - { - httpPort: 12345, - token: "previous-token", - pid: process.pid, - version: AGENT_DEVICE_VERSION, - }, - ); - yield* host.stop; - const daemonCommands = runs.map((run) => run.args).filter((args) => args.includes("daemon")); - expect(daemonCommands).toContainEqual([ - expect.stringMatching(/agent-device\.mjs$/), - "daemon", - "stop", - "--state-dir", - stateDir, - ]); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("stops a previous server's daemon with the agent-device version it recorded", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -334,16 +309,17 @@ describe("AgentDevice startup", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + // regression: #33552 a daemon left by a previous server survived every later graceful quit it.effect("stops a previous server's daemon with the current pin when its version is gone", () => Effect.gen(function* () { const path = yield* Path.Path; - const { host, baseDir, runs } = yield* agentFixture( + const { host, baseDir, stateDir, runs } = yield* agentFixture( () => Effect.die("A session that never readies agent tools must not start the daemon"), processOutput(0), { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" }, ); yield* host.stop; - expect(runs.find((run) => run.args.includes("daemon"))?.args[0]).toBe( + expect(runs.find((run) => run.args.includes("daemon"))?.args).toEqual([ path.join( baseDir, "tools", @@ -354,7 +330,11 @@ describe("AgentDevice startup", () => { "bin", "agent-device.mjs", ), - ); + "daemon", + "stop", + "--state-dir", + stateDir, + ]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), );