Skip to content

Commit 559e056

Browse files
committed
Simplify daemon recovery and management
1 parent bb4b293 commit 559e056

12 files changed

Lines changed: 117 additions & 83 deletions

File tree

apps/hook/server/index.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ import {
7272
buildPlanFileRule,
7373
} from "@plannotator/shared/prompts";
7474
import { openBrowser } from "@plannotator/server/browser";
75-
import { discoverDaemon } from "@plannotator/server/daemon/client";
75+
import { cleanupDaemonState, discoverDaemon } from "@plannotator/server/daemon/client";
7676
import { startDaemonRuntime } from "@plannotator/server/daemon/runtime";
7777
import { createDaemonSessionFactory } from "@plannotator/server/daemon/session-factory";
7878
import { hostnameOrFallback } from "@plannotator/shared/project";
@@ -266,6 +266,15 @@ async function runDaemonCommand(): Promise<void> {
266266
if (command === "stop") {
267267
const daemon = await discoverDaemon({ validateEnvironment: false });
268268
if (!daemon.ok) {
269+
if (daemon.state && (daemon.code === "incompatible" || daemon.code === "unhealthy")) {
270+
await cleanupDaemonState(daemon.state);
271+
console.log(JSON.stringify({ ok: true, stopped: true, recovered: daemon.code }));
272+
process.exit(0);
273+
}
274+
if (daemon.code === "missing" || daemon.code === "stale" || daemon.code === "malformed") {
275+
console.log(JSON.stringify({ ok: true, stopped: false, code: daemon.code, message: daemon.message }));
276+
process.exit(0);
277+
}
269278
console.log(JSON.stringify({ ok: false, code: daemon.code, message: daemon.message }));
270279
process.exit(1);
271280
}
@@ -280,7 +289,9 @@ async function runDaemonCommand(): Promise<void> {
280289
console.log(JSON.stringify({ ok: true, alreadyRunning: true, status: existing.status }));
281290
process.exit(0);
282291
}
283-
if (existing.code === "incompatible" || existing.code === "unhealthy" || existing.code === "mismatch") {
292+
if (existing.state && (existing.code === "incompatible" || existing.code === "unhealthy")) {
293+
await cleanupDaemonState(existing.state);
294+
} else if (existing.code === "mismatch") {
284295
console.log(JSON.stringify({ ok: false, code: existing.code, message: existing.message }));
285296
process.exit(1);
286297
}
@@ -401,7 +412,9 @@ async function ensureDaemonClient(options: { pluginError?: boolean } = {}) {
401412
const fail = options.pluginError ? emitPluginError : emitCommandError;
402413
const existing = await discoverDaemon();
403414
if (existing.ok) return existing.client;
404-
if (existing.code === "incompatible" || existing.code === "unhealthy" || existing.code === "mismatch") {
415+
if (existing.state && (existing.code === "incompatible" || existing.code === "unhealthy")) {
416+
await cleanupDaemonState(existing.state);
417+
} else if (existing.code === "mismatch") {
405418
fail(`daemon-${existing.code}`, existing.message);
406419
}
407420

@@ -572,7 +585,7 @@ if (args[0] === "sessions") {
572585
// SESSION DISCOVERY MODE
573586
// ============================================
574587

575-
const daemon = await discoverDaemon();
588+
const daemon = await discoverDaemon({ validateEnvironment: false });
576589
if (!daemon.ok) {
577590
console.error("No active Plannotator daemon.");
578591
process.exit(0);

packages/server/daemon/client.test.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { afterEach, describe, expect, test } from "bun:test";
2-
import { mkdtempSync, rmSync } from "fs";
2+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "fs";
33
import { tmpdir } from "os";
44
import { join } from "path";
55
import { getDaemonCapabilities } from "@plannotator/shared/daemon-protocol";
6-
import { createDaemonState, writeDaemonState } from "./state";
7-
import { DaemonClient, discoverDaemon } from "./client";
6+
import { createDaemonState, getDaemonPaths, writeDaemonState } from "./state";
7+
import { cleanupDaemonState, DaemonClient, discoverDaemon } from "./client";
88

99
let dirs: string[] = [];
1010
const envKeys = ["PLANNOTATOR_REMOTE", "PLANNOTATOR_PORT", "SSH_TTY", "SSH_CONNECTION"];
@@ -41,13 +41,12 @@ function state() {
4141
hostname: "127.0.0.1",
4242
isRemote: false,
4343
remoteSource: "local",
44-
token: "token",
4544
startedAt: "2026-01-01T00:00:00.000Z",
4645
});
4746
}
4847

4948
describe("DaemonClient", () => {
50-
test("sends auth token and JSON body to daemon routes", async () => {
49+
test("sends JSON body to daemon routes", async () => {
5150
const calls: Request[] = [];
5251
const client = new DaemonClient(state(), {
5352
fetch: async (input, init) => {
@@ -60,7 +59,7 @@ describe("DaemonClient", () => {
6059
await client.createSession({ request: { action: "plan", origin: "opencode", plan: "x" } });
6160

6261
expect(calls[0].url).toBe("http://127.0.0.1:4321/daemon/sessions");
63-
expect(calls[0].headers.get("authorization")).toBe("Bearer token");
62+
expect(calls[0].headers.get("authorization")).toBeNull();
6463
expect(calls[0].headers.get("content-type")).toBe("application/json");
6564
expect(await calls[0].json()).toEqual({ request: { action: "plan", origin: "opencode", plan: "x" } });
6665
});
@@ -73,6 +72,26 @@ describe("DaemonClient", () => {
7372
expect(result.ok).toBe(false);
7473
expect(result.error.code).toBe("daemon-unhealthy");
7574
});
75+
76+
test("cleans daemon state and best-effort shuts down recorded endpoint", async () => {
77+
const baseDir = tempBase();
78+
const paths = getDaemonPaths({ baseDir });
79+
writeDaemonState(state(), { baseDir });
80+
writeFileSync(paths.lockPath, "123\n", "utf-8");
81+
const calls: string[] = [];
82+
83+
await cleanupDaemonState(state(), {
84+
baseDir,
85+
fetch: async (input) => {
86+
calls.push(String(input));
87+
return Response.json({ ok: true });
88+
},
89+
});
90+
91+
expect(calls).toEqual(["http://127.0.0.1:4321/daemon/shutdown"]);
92+
expect(existsSync(paths.statePath)).toBe(false);
93+
expect(existsSync(paths.lockPath)).toBe(false);
94+
});
7695
});
7796

7897
describe("discoverDaemon", () => {

packages/server/daemon/client.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type DaemonStatus,
1111
} from "@plannotator/shared/daemon-protocol";
1212
import { getServerPort, isRemoteSession } from "../remote";
13-
import { readDaemonState, removeDaemonState, type DaemonState, type DaemonStateOptions } from "./state";
13+
import { readDaemonState, removeDaemonFiles, type DaemonState, type DaemonStateOptions } from "./state";
1414

1515
export interface DaemonClientOptions extends DaemonStateOptions {
1616
fetch?: typeof fetch;
@@ -31,43 +31,42 @@ export class DaemonClient {
3131
}
3232

3333
async capabilities(): Promise<unknown> {
34-
return this.getJson("/daemon/capabilities", false);
34+
return this.getJson("/daemon/capabilities");
3535
}
3636

3737
async status(): Promise<DaemonStatus> {
38-
return this.getJson("/daemon/status", true) as Promise<DaemonStatus>;
38+
return this.getJson("/daemon/status") as Promise<DaemonStatus>;
3939
}
4040

4141
async listSessions(): Promise<unknown> {
42-
return this.getJson("/daemon/sessions", true);
42+
return this.getJson("/daemon/sessions");
4343
}
4444

4545
async createSession(request: DaemonCreateSessionRequest): Promise<DaemonCreateSessionResponse | DaemonErrorResponse> {
4646
return this.requestJson("/daemon/sessions", {
4747
method: "POST",
4848
body: JSON.stringify(request),
49-
}, true) as Promise<DaemonCreateSessionResponse | DaemonErrorResponse>;
49+
}) as Promise<DaemonCreateSessionResponse | DaemonErrorResponse>;
5050
}
5151

5252
async waitForResult<T = unknown>(id: string): Promise<DaemonSessionResultResponse<T> | DaemonErrorResponse> {
53-
return this.getJson(`/daemon/sessions/${encodeURIComponent(id)}/result`, true) as Promise<DaemonSessionResultResponse<T> | DaemonErrorResponse>;
53+
return this.getJson(`/daemon/sessions/${encodeURIComponent(id)}/result`) as Promise<DaemonSessionResultResponse<T> | DaemonErrorResponse>;
5454
}
5555

5656
async cancelSession(id: string): Promise<DaemonCancelSessionResponse | DaemonErrorResponse> {
57-
return this.requestJson(`/daemon/sessions/${encodeURIComponent(id)}/cancel`, { method: "POST" }, true) as Promise<DaemonCancelSessionResponse | DaemonErrorResponse>;
57+
return this.requestJson(`/daemon/sessions/${encodeURIComponent(id)}/cancel`, { method: "POST" }) as Promise<DaemonCancelSessionResponse | DaemonErrorResponse>;
5858
}
5959

6060
async shutdown(): Promise<DaemonShutdownResponse | DaemonErrorResponse> {
61-
return this.requestJson("/daemon/shutdown", { method: "POST" }, true) as Promise<DaemonShutdownResponse | DaemonErrorResponse>;
61+
return this.requestJson("/daemon/shutdown", { method: "POST" }) as Promise<DaemonShutdownResponse | DaemonErrorResponse>;
6262
}
6363

64-
private async getJson(path: string, auth: boolean): Promise<unknown> {
65-
return this.requestJson(path, { method: "GET" }, auth);
64+
private async getJson(path: string): Promise<unknown> {
65+
return this.requestJson(path, { method: "GET" });
6666
}
6767

68-
private async requestJson(path: string, init: RequestInit, auth: boolean): Promise<unknown> {
68+
private async requestJson(path: string, init: RequestInit): Promise<unknown> {
6969
const headers = new Headers(init.headers);
70-
if (auth) headers.set("authorization", `Bearer ${this.state.token}`);
7170
if (init.body && !headers.has("content-type")) headers.set("content-type", "application/json");
7271

7372
const res = await this.fetchImpl(`${this.state.baseUrl}${path}`, {
@@ -82,17 +81,31 @@ export class DaemonClient {
8281
}
8382
}
8483

84+
function stateBaseUrl(state: unknown): string | undefined {
85+
const baseUrl = (state as { baseUrl?: unknown } | null)?.baseUrl;
86+
return typeof baseUrl === "string" ? baseUrl : undefined;
87+
}
88+
89+
export async function cleanupDaemonState(state: unknown, options: DaemonClientOptions = {}): Promise<void> {
90+
const fetchImpl = options.fetch ?? fetch;
91+
const baseUrl = stateBaseUrl(state);
92+
if (baseUrl) {
93+
await fetchImpl(`${baseUrl}/daemon/shutdown`, { method: "POST" }).catch(() => undefined);
94+
}
95+
removeDaemonFiles(options);
96+
}
97+
8598
export async function discoverDaemon(options: DaemonClientOptions = {}): Promise<DaemonDiscoveryResult> {
8699
const stateResult = readDaemonState(options);
87100
if (stateResult.kind === "missing") {
88101
return { ok: false, code: "missing", message: "No Plannotator daemon state found." };
89102
}
90103
if (stateResult.kind === "malformed") {
91-
removeDaemonState(options);
104+
removeDaemonFiles(options);
92105
return { ok: false, code: "malformed", message: stateResult.error };
93106
}
94107
if (stateResult.kind === "stale") {
95-
removeDaemonState(options);
108+
removeDaemonFiles(options);
96109
return { ok: false, code: "stale", message: `Stale Plannotator daemon state for PID ${stateResult.state.pid}.`, state: stateResult.state };
97110
}
98111
if (stateResult.kind === "incompatible") {

packages/server/daemon/runtime.test.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ describe("startDaemonRuntime", () => {
2828
baseDir,
2929
hostname: "127.0.0.1",
3030
port: 0,
31-
token: "token",
3231
createSession: (_request, { endpoint }) => runtime.store.create({
3332
id: "s1",
3433
mode: "plan",
@@ -54,7 +53,6 @@ describe("startDaemonRuntime", () => {
5453
baseDir,
5554
hostname: "127.0.0.1",
5655
port: 0,
57-
token: "token",
5856
createSession: (_request, { endpoint }) => runtime.store.create({
5957
id: "s1",
6058
mode: "plan",
@@ -69,7 +67,6 @@ describe("startDaemonRuntime", () => {
6967
baseDir,
7068
hostname: "127.0.0.1",
7169
port: 0,
72-
token: "other",
7370
createSession: () => {
7471
throw new Error("should not create");
7572
},
@@ -82,7 +79,6 @@ describe("startDaemonRuntime", () => {
8279
baseDir,
8380
hostname: "127.0.0.1",
8481
port: 0,
85-
token: "token",
8682
createSession: (_request, { endpoint }) => runtime.store.create({
8783
id: "s1",
8884
mode: "plan",
@@ -92,10 +88,7 @@ describe("startDaemonRuntime", () => {
9288
}),
9389
});
9490

95-
const res = await fetch(`${runtime.state.baseUrl}/daemon/shutdown`, {
96-
method: "POST",
97-
headers: { authorization: "Bearer token" },
98-
});
91+
const res = await fetch(`${runtime.state.baseUrl}/daemon/shutdown`, { method: "POST" });
9992
expect((await res.json()).shuttingDown).toBe(true);
10093
expect(readDaemonState({ baseDir }).kind).toBe("missing");
10194
});

packages/server/daemon/runtime.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ export interface StartDaemonRuntimeOptions extends DaemonStateOptions {
1212
onShutdown?: () => void | Promise<void>;
1313
hostname?: string;
1414
port?: number;
15-
token?: string;
1615
binaryVersion?: string;
1716
}
1817

@@ -42,10 +41,11 @@ export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Pr
4241
const requestedPort = options.port ?? getServerPort();
4342
let runtime: DaemonRuntime | undefined;
4443
let cleanupTimer: ReturnType<typeof setInterval> | undefined;
44+
let server: ReturnType<typeof Bun.serve> | undefined;
4545

4646
try {
4747
let state: DaemonState | undefined;
48-
const server = Bun.serve({
48+
server = Bun.serve({
4949
hostname,
5050
port: requestedPort,
5151
fetch: (req, server) => {
@@ -70,7 +70,6 @@ export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Pr
7070
hostname,
7171
isRemote,
7272
remoteSource: getRemoteSource(),
73-
token: options.token,
7473
binaryVersion: options.binaryVersion,
7574
requestedPort,
7675
});
@@ -79,17 +78,18 @@ export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Pr
7978
void store.cleanupExpired();
8079
}, 60_000);
8180

81+
const activeServer = server;
8282
runtime = {
8383
state,
8484
store,
85-
server,
85+
server: activeServer,
8686
stop: async () => {
8787
if (cleanupTimer) {
8888
clearInterval(cleanupTimer);
8989
cleanupTimer = undefined;
9090
}
9191
await store.cancelAll();
92-
server.stop();
92+
activeServer.stop();
9393
removeDaemonState(options);
9494
lock?.release();
9595
lock = undefined;
@@ -99,6 +99,7 @@ export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Pr
9999
return runtime;
100100
} catch (err) {
101101
if (cleanupTimer) clearInterval(cleanupTimer);
102+
server?.stop();
102103
lock.release();
103104
throw err;
104105
}

0 commit comments

Comments
 (0)