Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,17 @@ try {
]
: request.method === "hello"
? {
state: "ready",
protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
pid: process.pid,
endpoint: daemonSocket,
startedAt: "now",
activeTurns: 0,
runtimeCount: 0,
clientConnections: 1,
status: {
state: "ready",
protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
pid: process.pid,
endpoint: daemonSocket,
startedAt: "now",
activeTurns: 0,
runtimeCount: 0,
clientConnections: 1,
},
configMatches: true,
}
: null;
socket.end(encodeLocalAgentDaemonResponse({
Expand Down
77 changes: 64 additions & 13 deletions src/local-agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
import { matchError, Result, type Result as BetterResult } from "better-result";
import type { ServerConfig } from "./config.js";
import {
AgentDaemonConfigChangedError,
AgentDaemonInvalidRequestError,
AgentDaemonInvalidResponseError,
AgentDaemonProtocolMismatchError,
Expand All @@ -23,6 +24,7 @@ import {
decodeAgentRecord,
decodeAgentRecordList,
decodeAgentWaitResults,
decodeDaemonHello,
decodeDaemonLogs,
decodeDaemonStatus,
decodeLocalAgentDaemonResponse,
Expand All @@ -33,6 +35,7 @@ import {
type LocalAgentDaemonResponse,
type LocalAgentDaemonStatus,
} from "./local-agent-daemon-protocol.js";
import { localAgentProviderConfigRevision } from "./local-agent-config.js";
import {
LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
ensureLocalAgentDaemonSecret,
Expand Down Expand Up @@ -68,6 +71,7 @@ type RequestError<M extends LocalAgentDaemonRequest["method"]> =

export interface LocalAgentClientOptions {
stateDir: string;
configRevision: string;
configDir?: string;
startupTimeoutMs?: number;
requestTimeoutMs?: number;
Expand All @@ -78,6 +82,7 @@ export interface LocalAgentClientOptions {
export class LocalAgentClient {
private readonly stateDir: string;
private readonly paths: LocalAgentDaemonPaths;
private readonly configRevision: string;
private readonly endpoint: string;
private readonly startupTimeoutMs: number;
private readonly requestTimeoutMs: number;
Expand All @@ -86,6 +91,7 @@ export class LocalAgentClient {

constructor(options: LocalAgentClientOptions) {
this.stateDir = options.stateDir;
this.configRevision = options.configRevision;
this.paths = localAgentDaemonPaths(options.stateDir);
this.endpoint = options.endpoint ?? this.paths.endpoint;
this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
Expand Down Expand Up @@ -226,6 +232,7 @@ export class LocalAgentClient {
authToken: authToken.value,
method: "hello",
params: {},
configRevision: this.configRevision,
}, this.requestTimeoutMs);
if (response.isErr()) {
if (
Expand Down Expand Up @@ -253,8 +260,28 @@ export class LocalAgentClient {
}
return error.code === "DAEMON_UNAVAILABLE" ? Result.ok(undefined) : Result.err(error);
}
const decoded = decodeValue(response.value.result, "hello", decodeDaemonStatus);
return decoded.map((status) => status.state === "ready" ? status : undefined);
const decoded = decodeValue(response.value.result, "hello", decodeDaemonHello);
if (decoded.isErr()) return decoded;
if (!decoded.value.configMatches) {
return this.replaceIdleChangedDaemon(authToken.value, decoded.value.status);
}
return Result.ok(decoded.value.status.state === "ready" ? decoded.value.status : undefined);
}

private async replaceIdleChangedDaemon(
authToken: string,
status: LocalAgentDaemonStatus,
): Promise<BetterResult<LocalAgentDaemonStatus | undefined, AgentDaemonError>> {
const changed = new AgentDaemonConfigChangedError({
code: "DAEMON_CONFIG_CHANGED",
operation: "startup",
retryable: true,
message: status.activeTurns > 0
? "The local agent daemon is running active turns with an older provider configuration. Retry after they finish."
: "The local agent daemon is using an older provider configuration.",
});
if (status.activeTurns > 0) return Result.err(changed);
return this.stopIdleDaemon(authToken, LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, status, changed);
Comment thread
Waishnav marked this conversation as resolved.
}

private async replaceIdleOlderDaemon(
Expand All @@ -268,6 +295,7 @@ export class LocalAgentClient {
authToken,
method: "hello",
params: {},
configRevision: this.configRevision,
}, this.requestTimeoutMs);
if (statusResponse.isErr() || !statusResponse.value.ok) return Result.err(mismatch);
const status = decodeValue(statusResponse.value.result, "hello", decodeDaemonStatus);
Expand All @@ -282,14 +310,27 @@ export class LocalAgentClient {
}));
}

return this.stopIdleDaemon(authToken, protocolVersion, status.value, mismatch);
}

private async stopIdleDaemon(
authToken: string,
protocolVersion: number,
status: LocalAgentDaemonStatus,
cause: AgentDaemonProtocolMismatchError | AgentDaemonConfigChangedError,
): Promise<BetterResult<LocalAgentDaemonStatus | undefined, AgentDaemonError>> {
const stopResponse = await sendRequest(this.endpoint, {
requestId: randomUUID(),
protocolVersion,
authToken,
method: "daemon.stop",
params: {},
// Older daemons do not support atomic idle replacement. Their existing
// best-effort upgrade path remains available through the legacy shape.
params: protocolVersion === LOCAL_AGENT_DAEMON_PROTOCOL_VERSION
? { ifIdle: true }
: {},
}, this.requestTimeoutMs);
if (stopResponse.isErr() || !stopResponse.value.ok) return Result.err(mismatch);
if (stopResponse.isErr() || !stopResponse.value.ok) return Result.err(cause);

const deadline = Date.now() + this.startupTimeoutMs;
while (Date.now() < deadline) {
Expand All @@ -300,28 +341,33 @@ export class LocalAgentClient {
authToken,
method: "hello",
params: {},
configRevision: this.configRevision,
}, Math.min(this.requestTimeoutMs, 250));
if (probe.isErr() && probe.error.code === "DAEMON_UNAVAILABLE") {
if (!existsSync(this.paths.lockPath) || !isProcessAlive(status.value.pid)) {
if (!existsSync(this.paths.lockPath) || !isProcessAlive(status.pid)) {
return Result.ok(undefined);
}
continue;
}
if (
probe.isOk()
&& probe.value.protocolVersion >= LOCAL_AGENT_DAEMON_PROTOCOL_VERSION
) {
if (probe.isOk() && probe.value.protocolVersion > protocolVersion) {
// Another client completed the replacement while this client was
// waiting for the old endpoint to disappear.
return this.tryHello();
}
if (probe.isOk() && probe.value.ok && protocolVersion === LOCAL_AGENT_DAEMON_PROTOCOL_VERSION) {
const hello = decodeValue(probe.value.result, "hello", decodeDaemonHello);
if (hello.isErr()) return hello;
if (hello.value.configMatches && hello.value.status.state === "ready") {
return Result.ok(hello.value.status);
}
}
}
return Result.err(new AgentDaemonStartupError({
code: "DAEMON_STARTUP_FAILURE",
operation: "startup",
retryable: true,
cause: mismatch,
message: "The older local agent daemon did not stop in time for the upgrade.",
cause,
message: "The local agent daemon did not stop in time for replacement.",
}));
}

Expand Down Expand Up @@ -430,9 +476,13 @@ export class LocalAgentClient {
}

export function createLocalAgentClient(
config: Pick<ServerConfig, "configDir" | "stateDir">,
config: Pick<ServerConfig, "configDir" | "stateDir" | "subagents">,
): LocalAgentClient {
return new LocalAgentClient({ configDir: config.configDir, stateDir: config.stateDir });
return new LocalAgentClient({
configDir: config.configDir,
stateDir: config.stateDir,
configRevision: localAgentProviderConfigRevision(config.subagents),
});
}

export function spawnLocalAgentDaemon(
Expand Down Expand Up @@ -607,6 +657,7 @@ function isRequestError(
AgentDaemonStartupError: () => "daemon" as const,
AgentDaemonTimeoutError: () => "daemon" as const,
AgentDaemonProtocolMismatchError: () => "daemon" as const,
AgentDaemonConfigChangedError: () => "daemon" as const,
AgentDaemonUnauthorizedError: () => "daemon" as const,
AgentDaemonInvalidRequestError: () => "daemon" as const,
AgentDaemonInvalidResponseError: () => "daemon" as const,
Expand Down
28 changes: 28 additions & 0 deletions src/local-agent-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import {
isSubagentProviderEnabled,
localAgentProviderConfigRevision,
localAgentProviderEnvironment,
subagentProviderConfig,
subagentsConfigSchema,
Expand Down Expand Up @@ -55,6 +56,33 @@ assert.deepEqual(inherited, {
OPENAI_API_KEY: "inherited",
UNCHANGED: "yes",
});
assert.equal(
localAgentProviderConfigRevision(config),
localAgentProviderConfigRevision(subagentsConfigSchema.parse({
enabled: true,
providers: [
{ id: "claude", enabled: false, model: "sonnet" },
{
id: "codex",
enabled: true,
effort: "high",
model: "gpt-5.4",
command: "/opt/bin/codex-wrapper",
env: { EMPTY_VALUE: "", OPENAI_API_KEY: "configured" },
},
],
})),
"provider and environment key order must not restart the daemon",
);
assert.notEqual(
localAgentProviderConfigRevision(config),
localAgentProviderConfigRevision(subagentsConfigSchema.parse({
...config,
providers: config.providers.map((provider) => provider.id === "codex"
? { ...provider, command: "/opt/bin/another-wrapper" }
: provider),
})),
);
assert.throws(
() => subagentsConfigSchema.parse({
enabled: true,
Expand Down
23 changes: 23 additions & 0 deletions src/local-agent-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import * as z from "zod/v4";
import {
LOCAL_AGENT_PROVIDERS,
Expand Down Expand Up @@ -93,3 +94,25 @@ export function providerCommandVariable(provider: LocalAgentProvider): string |
return undefined;
}
}

export function localAgentProviderConfigRevision(config: SubagentsConfig): string {
const providers = [...config.providers]
.sort((left, right) => left.id.localeCompare(right.id))
.map((provider) => ({
id: provider.id,
enabled: provider.enabled,
...(provider.model ? { model: provider.model } : {}),
...(provider.effort ? { effort: provider.effort } : {}),
...(provider.command ? { command: provider.command } : {}),
...(provider.env && Object.keys(provider.env).length > 0
? {
env: Object.fromEntries(
Object.entries(provider.env).sort(([left], [right]) => left.localeCompare(right)),
),
}
: {}),
}));
return createHash("sha256")
Comment thread
Waishnav marked this conversation as resolved.
.update(JSON.stringify({ enabled: config.enabled, providers }))
.digest("hex");
}
2 changes: 1 addition & 1 deletion src/local-agent-daemon-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "node:fs";
import { join, resolve } from "node:path";

export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 4;
export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 5;
export const LOCAL_AGENT_DAEMON_SOCKET_NAME = "agentd.sock";
export const LOCAL_AGENT_DAEMON_PID_NAME = "agentd.pid";
export const LOCAL_AGENT_DAEMON_LOCK_NAME = "agentd.lock";
Expand Down
2 changes: 2 additions & 0 deletions src/local-agent-daemon-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { LocalAgentManager } from "./local-agent-manager.js";
import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js";
import { LocalAgentStore } from "./local-agent-store.js";
import { localAgentProviderConfigRevision } from "./local-agent-config.js";

const config = loadConfig();
const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000;
Expand All @@ -33,6 +34,7 @@ const manager = new LocalAgentManager({
const daemon = new LocalAgentDaemon({
stateDir: paths.stateDir,
manager,
configRevision: localAgentProviderConfigRevision(config.subagents),
onLockAcquired: () => {
const reconciled = manager.reconcileActiveRuns();
if (reconciled.isErr()) throw reconciled.error;
Expand Down
48 changes: 48 additions & 0 deletions src/local-agent-daemon-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import {
decodeAgentRecord,
decodeAgentWaitResults,
decodeDaemonHello,
decodeLocalAgentDaemonRequest,
decodeLocalAgentDaemonResponse,
encodeLocalAgentDaemonResponse,
Expand Down Expand Up @@ -55,6 +56,53 @@ const directRequest = decodeLocalAgentDaemonRequest({
if (directRequest.method !== "agent.start") throw new Error("expected agent.start request");
assert.equal(directRequest.params.workspaceId, undefined);

const helloRequest = decodeLocalAgentDaemonRequest({
requestId: "req_hello",
protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
authToken: "test-secret",
method: "hello",
params: {},
configRevision: "provider-config-revision",
});
assert.equal(helloRequest.method, "hello");
if (helloRequest.method !== "hello") throw new Error("expected hello request");
assert.equal(helloRequest.configRevision, "provider-config-revision");
const conditionalStop = decodeLocalAgentDaemonRequest({
requestId: "req_stop",
protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
authToken: "test-secret",
method: "daemon.stop",
params: { ifIdle: true },
});
assert.equal(conditionalStop.method, "daemon.stop");
if (conditionalStop.method !== "daemon.stop") throw new Error("expected daemon.stop request");
assert.equal(conditionalStop.params.ifIdle, true);
assert.deepEqual(decodeDaemonHello({
status: {
state: "ready",
protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
pid: 123,
endpoint: "/tmp/agentd.sock",
startedAt: "now",
activeTurns: 0,
runtimeCount: 0,
clientConnections: 1,
},
configMatches: false,
}), {
status: {
state: "ready",
protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION,
pid: 123,
endpoint: "/tmp/agentd.sock",
startedAt: "now",
activeTurns: 0,
runtimeCount: 0,
clientConnections: 1,
},
configMatches: false,
});

assert.throws(
() => decodeLocalAgentDaemonRequest({
requestId: "req_2",
Expand Down
Loading
Loading