From 466734d5544412c2c17dbc0e91efd725bb577426 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:28:55 +0530 Subject: [PATCH 01/35] docs: revise local agent ownership around daemon --- docs/local-agent-daemon.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/local-agent-daemon.md diff --git a/docs/local-agent-daemon.md b/docs/local-agent-daemon.md new file mode 100644 index 000000000..25afb83f4 --- /dev/null +++ b/docs/local-agent-daemon.md @@ -0,0 +1,33 @@ +# Local agent daemon + +Local agent execution is owned by an on-demand `devspace-agentd` process, not +by the MCP server and not by an individual CLI invocation. The daemon is an +internal implementation detail: the normal workflow remains: + +```text +devspace agents run/show/ls + │ + ▼ + devspace-agentd + │ + ├── LocalAgentManager + ├── LocalAgentStore + ├── LocalAgentRuntimePool + └── provider runtimes +``` + +The CLI starts the daemon automatically when an agent command needs it. The +MCP server can use the same local client when an MCP operation needs agent +functionality, but `devspace serve` is not required for local-agent execution. +The daemon is scoped to one DevSpace `stateDir`, so one SQLite store and one +runtime owner serve all clients using that configuration. + +Communication uses a private Unix domain socket on Linux/macOS or a named pipe +on Windows. The endpoint is not exposed through the public MCP HTTP port. +Provider session identifiers and logical agent records are durable; live +provider runtimes are disposable and may be recreated after a daemon restart. + +The daemon is started on demand and may exit after its active turns, clients, +and warm runtime idle periods have ended. Users do not need to manage it during +normal operation. Diagnostic commands may inspect or stop it when debugging +startup, process, or cleanup problems. From 5aeaa56a1beb17c5705dc7bbc6332bc039c8474d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 19:38:39 +0530 Subject: [PATCH 02/35] refactor: add durable local agent runtime core --- src/local-agent-manager.test.ts | 153 +++++++++++++++++ src/local-agent-manager.ts | 280 ++++++++++++++++++++++++++++++++ src/local-agent-runtime-pool.ts | 247 ++++++++++++++++++++++++++++ src/local-agent-runtime.test.ts | 154 ++++++++---------- src/local-agent-runtime.ts | 104 +++--------- src/local-agent-store.ts | 12 ++ 6 files changed, 789 insertions(+), 161 deletions(-) create mode 100644 src/local-agent-manager.test.ts create mode 100644 src/local-agent-manager.ts create mode 100644 src/local-agent-runtime-pool.ts diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts new file mode 100644 index 000000000..232c64443 --- /dev/null +++ b/src/local-agent-manager.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadConfig } from "./config.js"; +import { LocalAgentManager } from "./local-agent-manager.js"; +import type { LocalAgentProfile } from "./local-agent-profiles.js"; +import type { + LocalAgentDriver, + LocalAgentRunInput, + LocalAgentRunResult, + LocalAgentRuntime, + LocalAgentRuntimeContext, +} from "./local-agent-runtime.js"; +import { LocalAgentStore } from "./local-agent-store.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-agent-manager-test-")); +const config = loadConfig({ + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_STATE_DIR: join(root, "state"), + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + PORT: "1", +}); +const profile: LocalAgentProfile = { + name: "reviewer", + description: "Test reviewer", + provider: "codex", + filePath: join(root, "reviewer.md"), + body: "Review only.", + disabled: false, +}; + +class FakeRuntime implements LocalAgentRuntime { + readonly provider = "codex" as const; + readonly inputs: LocalAgentRunInput[] = []; + closed = false; + private releaseHold: (() => void) | undefined; + + async run(input: LocalAgentRunInput): Promise { + this.inputs.push(input); + if (input.prompt.includes("fail")) throw new Error("provider failed"); + if (input.prompt.includes("hold")) { + await new Promise((resolve) => { this.releaseHold = resolve; }); + } + return { + provider: this.provider, + providerSessionId: "thread_test", + finalResponse: `response:${input.prompt}`, + items: [], + }; + } + + release(): void { + this.releaseHold?.(); + this.releaseHold = undefined; + } + + releaseSession(): Promise { + return Promise.resolve(); + } + + isAlive(): boolean { + return !this.closed; + } + + async close(): Promise { + this.closed = true; + this.release(); + } +} + +const runtimes = new Map(); +const driver: LocalAgentDriver = { + provider: "codex", + runtimeKey: (context: LocalAgentRuntimeContext) => context.agentId, + createRuntime: async (context) => { + const runtime = new FakeRuntime(); + runtimes.set(context.agentId, runtime); + return runtime; + }, +}; + +const store = new LocalAgentStore(config.stateDir); +const stale = store.create({ + workspaceRoot: root, + profileName: "reviewer", + provider: "codex", +}); +store.update(stale.id, { status: "running", latestResponse: "previous response" }); + +const manager = new LocalAgentManager(config, { + store, + drivers: [driver], + loadProfiles: async () => [profile], +}); + +assert.equal(manager.get(stale.id)?.status, "error"); +assert.equal(manager.get(stale.id)?.latestResponse, "previous response"); +assert.equal( + manager.get(stale.id)?.error, + "DevSpace restarted while this agent turn was running.", +); + +const first = await manager.start({ + target: "reviewer", + prompt: "hold", + workspaceRoot: root, +}); +assert.equal(first.status, "running"); +await waitFor(() => runtimes.get(first.id)?.inputs.length === 1); +await assert.rejects( + () => manager.continue(first.id, "another prompt"), + new RegExp(`Agent ${first.id} already has a running turn\\.`), +); + +runtimes.get(first.id)!.release(); +await waitFor(() => manager.get(first.id)?.status === "idle"); +assert.equal(manager.get(first.id)?.providerSessionId, "thread_test"); +assert.match(manager.get(first.id)?.latestResponse ?? "", /Task:\nhold/); + +const continued = await manager.continue(first.id, "continue"); +assert.equal(continued.status, "running"); +await waitFor(() => manager.get(first.id)?.status === "idle"); + +const second = await manager.start({ + target: "reviewer", + prompt: "second agent", + workspaceRoot: root, +}); +await waitFor(() => manager.get(second.id)?.status === "idle"); +assert.notEqual(first.id, second.id); +assert.equal(runtimes.size, 2, "different agents receive independent logical runtimes"); + +const failed = await manager.start({ + target: "reviewer", + prompt: "fail", + workspaceRoot: root, +}); +await waitFor(() => manager.get(failed.id)?.status === "error"); +assert.equal(manager.get(failed.id)?.error, "provider failed"); + +await manager.close(); +await manager.close(); +await rm(root, { recursive: true, force: true }); + +async function waitFor(check: () => boolean): Promise { + const deadline = Date.now() + 2_000; + while (!check() && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal(check(), true, "condition did not become true before timeout"); +} diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts new file mode 100644 index 000000000..15036a1e8 --- /dev/null +++ b/src/local-agent-manager.ts @@ -0,0 +1,280 @@ +import type { ServerConfig } from "./config.js"; +import { + loadLocalAgentProfiles, + type LocalAgentProfile, + type LocalAgentProvider, +} from "./local-agent-profiles.js"; +import { + resolveLocalAgentTarget, +} from "./local-agent-targets.js"; +import { + createLocalAgentStore, + type LocalAgentListScope, + type LocalAgentRecord, + type LocalAgentStore, +} from "./local-agent-store.js"; +import { + type LocalAgentDriver, + type LocalAgentRunInput, + type LocalAgentRuntimeContext, + type LocalAgentWriteMode, +} from "./local-agent-runtime.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; + +export interface StartLocalAgentInput { + target: string; + prompt: string; + workspaceRoot: string; + workspaceId?: string; + model?: string; + thinking?: string; + writeMode?: LocalAgentWriteMode; +} + +export interface RunOverrides { + model?: string; + thinking?: string; + writeMode?: LocalAgentWriteMode; +} + +export interface LocalAgentManagerLogger { + (level: "info" | "warn" | "error", event: string, fields: Record): void; +} + +export interface LocalAgentManagerOptions { + store?: LocalAgentStore; + drivers: readonly LocalAgentDriver[]; + pool?: LocalAgentRuntimePool; + loadProfiles?: (workspaceRoot: string) => Promise; + agentDir?: string; + logger?: LocalAgentManagerLogger; +} + +/** + * Owns one durable DevSpace agent's turn lifecycle. Provider runtimes remain + * below this seam; this class only translates records into provider inputs and + * persists the result. + */ +export class LocalAgentManager { + private readonly store: LocalAgentStore; + private readonly drivers = new Map(); + private readonly pool: LocalAgentRuntimePool; + private readonly loadProfiles: (workspaceRoot: string) => Promise; + private readonly agentDir?: string; + private readonly logger?: LocalAgentManagerLogger; + private readonly activeTurns = new Map>(); + private accepting = true; + private closePromise?: Promise; + + constructor(config: ServerConfig, options: LocalAgentManagerOptions) { + this.store = options.store ?? createLocalAgentStore(config); + for (const driver of options.drivers) this.drivers.set(driver.provider, driver); + this.pool = options.pool ?? new LocalAgentRuntimePool({ logger: options.logger }); + this.loadProfiles = options.loadProfiles ?? ((workspaceRoot) => loadLocalAgentProfiles(config, workspaceRoot)); + this.agentDir = options.agentDir ?? config.agentDir; + this.logger = options.logger; + this.store.reconcileActiveRuns(); + } + + async start(input: StartLocalAgentInput): Promise { + this.assertAccepting(); + const profiles = await this.loadProfiles(input.workspaceRoot); + const target = resolveLocalAgentTarget(input.target, profiles, input.model, input.thinking); + if (!target) { + throw new Error(`Unknown subagent profile or provider: ${input.target}`); + } + this.assertDriver(target.provider); + + const record = this.store.create({ + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + profileName: target.name, + provider: target.provider, + model: target.model, + thinking: target.thinking, + }); + return this.begin(record, input.prompt, { + model: target.model, + thinking: target.thinking, + writeMode: input.writeMode, + }); + } + + async continue( + agentId: string, + prompt: string, + overrides: RunOverrides = {}, + ): Promise { + this.assertAccepting(); + const record = this.store.get(agentId); + if (!record) throw new Error(`Unknown subagent id: ${agentId}`); + this.assertDriver(record.provider); + return this.begin(record, prompt, overrides); + } + + get(agentId: string): LocalAgentRecord | undefined { + return this.store.get(agentId); + } + + list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { + return this.store.list(scope); + } + + async close(): Promise { + if (this.closePromise) return this.closePromise; + this.accepting = false; + const turns = Array.from(this.activeTurns.values()); + this.closePromise = (async () => { + const results = await Promise.allSettled([ + this.pool.close(), + ...turns, + ]); + for (const result of results) { + if (result.status === "rejected") { + this.log("warn", "local_agent_close_failed", { error: errorMessage(result.reason) }); + } + } + this.store.close(); + })(); + return this.closePromise; + } + + get activeTurnCount(): number { + return this.activeTurns.size; + } + + private begin( + record: LocalAgentRecord, + prompt: string, + overrides: RunOverrides, + ): LocalAgentRecord { + if (this.activeTurns.has(record.id)) { + throw new Error(`Agent ${record.id} already has a running turn.`); + } + + const updated = this.store.update(record.id, { + status: "running", + model: overrides.model ?? record.model, + thinking: overrides.thinking ?? record.thinking, + latestResponse: undefined, + error: undefined, + }); + const turn = this.runTurn(updated, prompt, overrides); + this.activeTurns.set(record.id, turn); + void turn.catch(() => undefined); + return updated; + } + + private async runTurn( + record: LocalAgentRecord, + prompt: string, + overrides: RunOverrides, + ): Promise { + const startedAt = Date.now(); + this.log("info", "agent_run_started", { + provider: record.provider, + agentId: record.id, + providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), + }); + try { + const profiles = await this.loadProfiles(record.workspaceRoot); + const profile = profiles.find((candidate) => candidate.name === record.profileName); + const input = this.buildRunInput(record, profile, prompt, overrides); + const driver = this.assertDriver(record.provider); + const context: LocalAgentRuntimeContext = { + agentId: record.id, + provider: driver.provider, + workspace: record.workspaceRoot, + providerSessionId: record.providerSessionId, + writeMode: input.writeMode, + model: input.model, + thinking: input.thinking, + agentDir: this.agentDir, + }; + const result = await this.pool.run(driver, context, input); + const current = this.store.get(record.id); + if (!current) return; + const updated = this.store.update(record.id, { + providerSessionId: result.providerSessionId ?? current.providerSessionId, + status: "idle", + latestResponse: result.finalResponse, + error: undefined, + }); + this.log("info", "agent_run_completed", { + provider: updated.provider, + agentId: updated.id, + providerSessionIdPrefix: updated.providerSessionId?.slice(0, 8), + durationMs: Math.max(0, Date.now() - startedAt), + }); + } catch (error) { + const current = this.store.get(record.id); + if (current) { + this.store.update(record.id, { + status: "error", + error: errorMessage(error), + }); + } + this.log("error", "agent_run_failed", { + provider: record.provider, + agentId: record.id, + providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), + durationMs: Math.max(0, Date.now() - startedAt), + error: errorMessage(error), + }); + throw error; + } finally { + this.activeTurns.delete(record.id); + } + } + + private buildRunInput( + record: LocalAgentRecord, + profile: LocalAgentProfile | undefined, + prompt: string, + overrides: RunOverrides, + ): LocalAgentRunInput { + const isRawProvider = record.profileName === record.provider; + if (!profile && !isRawProvider) { + throw new Error(`Subagent profile not found: ${record.profileName}`); + } + const body = profile?.body.trim(); + const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; + return { + prompt: fullPrompt, + workspace: record.workspaceRoot, + providerSessionId: record.providerSessionId, + writeMode: overrides.writeMode ?? "allowed", + model: record.model ?? profile?.model, + thinking: record.thinking ?? profile?.thinking, + }; + } + + private assertDriver(provider: string): LocalAgentDriver { + const driver = this.drivers.get(provider as LocalAgentProvider); + if (!driver) throw new Error(`No local agent driver is configured for provider: ${provider}`); + return driver; + } + + private assertAccepting(): void { + if (!this.accepting) throw new Error("Local agent manager is closed."); + } + + private log( + level: "info" | "warn" | "error", + event: string, + fields: Record, + ): void { + this.logger?.(level, event, fields); + } +} + +export function createLocalAgentManager( + config: ServerConfig, + options: Omit & { drivers: readonly LocalAgentDriver[] }, +): LocalAgentManager { + return new LocalAgentManager(config, options); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts new file mode 100644 index 000000000..d7c890348 --- /dev/null +++ b/src/local-agent-runtime-pool.ts @@ -0,0 +1,247 @@ +import { createHash } from "node:crypto"; +import type { + LocalAgentDriver, + LocalAgentRunInput, + LocalAgentRunResult, + LocalAgentRuntime, + LocalAgentRuntimeContext, +} from "./local-agent-runtime.js"; + +const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000; + +export interface LocalAgentRuntimePoolLogger { + (level: "info" | "warn" | "error", event: string, fields: Record): void; +} + +interface RuntimeEntry { + readonly key: string; + readonly driver: LocalAgentDriver; + readonly idleTimeoutMs: number; + readonly createPromise: Promise; + runtime?: LocalAgentRuntime; + activeRuns: number; + lastUsedAt: number; + closePromise?: Promise; + idleTimer?: NodeJS.Timeout; + closing: boolean; +} + +export interface LocalAgentRuntimePoolOptions { + now?: () => number; + logger?: LocalAgentRuntimePoolLogger; +} + +/** + * Owns live provider resources, not logical agent identity. Acquisition is + * single-flight per runtime key and an entry is removed before its close + * begins, so a new caller can never race with a closing runtime. + */ +export class LocalAgentRuntimePool { + private readonly entries = new Map(); + private readonly now: () => number; + private readonly logger?: LocalAgentRuntimePoolLogger; + private closing = false; + private closePromise?: Promise; + + constructor(options: LocalAgentRuntimePoolOptions = {}) { + this.now = options.now ?? Date.now; + this.logger = options.logger; + } + + async run( + driver: LocalAgentDriver, + context: LocalAgentRuntimeContext, + input: LocalAgentRunInput, + ): Promise { + if (this.closing) throw new Error("Local agent runtime pool is closed."); + + let entry = await this.acquire(driver, context); + let runtime = entry.runtime; + if (!runtime) throw new Error("Local agent runtime was created without a runtime."); + if (!runtime.isAlive()) { + await this.removeAndClose(entry, "runtime_not_alive"); + entry = await this.acquire(driver, context); + runtime = entry.runtime; + if (!runtime || !runtime.isAlive()) { + await this.removeAndClose(entry, "runtime_not_alive"); + throw new Error("Local agent runtime exited during startup."); + } + } + + this.clearIdleTimer(entry); + entry.activeRuns += 1; + const startedAt = this.now(); + try { + return await runtime.run(input); + } catch (error) { + if (!runtime.isAlive()) { + await this.removeAndClose(entry, "runtime_crashed"); + this.log("warn", "harness_runtime_crashed", { + provider: driver.provider, + runtimeKeyHash: hashRuntimeKey(entry.key), + agentId: context.agentId, + providerSessionIdPrefix: input.providerSessionId?.slice(0, 8), + durationMs: Math.max(0, Math.round(this.now() - startedAt)), + error: errorMessage(error), + }); + } + throw error; + } finally { + entry.activeRuns -= 1; + entry.lastUsedAt = this.now(); + if (entry.activeRuns === 0 && !entry.closing) this.scheduleIdleClose(entry); + } + } + + /** Evict entries whose runtime has been idle beyond their driver's TTL. */ + async evictIdle(now = this.now()): Promise { + const evictions: Promise[] = []; + for (const entry of this.entries.values()) { + if (entry.closing || entry.activeRuns > 0 || !entry.runtime) continue; + if (now - entry.lastUsedAt < entry.idleTimeoutMs) continue; + evictions.push(this.removeAndClose(entry, "idle_timeout")); + } + await Promise.all(evictions); + } + + async close(): Promise { + if (this.closePromise) return this.closePromise; + this.closing = true; + const entries = Array.from(this.entries.values()); + this.entries.clear(); + this.closePromise = Promise.all(entries.map((entry) => this.closeEntry(entry, "server_shutdown"))).then(() => undefined); + return this.closePromise; + } + + get size(): number { + return this.entries.size; + } + + private async acquire( + driver: LocalAgentDriver, + context: LocalAgentRuntimeContext, + ): Promise { + const key = driver.runtimeKey(context); + const existing = this.entries.get(key); + if (existing && !existing.closing) { + if (!existing.runtime || existing.runtime.isAlive()) { + this.clearIdleTimer(existing); + if (existing.runtime) { + this.log("info", "harness_runtime_reused", { + provider: driver.provider, + runtimeKeyHash: hashRuntimeKey(key), + agentId: context.agentId, + }); + } + await existing.createPromise; + if (existing.runtime?.isAlive()) return existing; + } + await this.removeAndClose(existing, "runtime_not_alive"); + } + + let entry!: RuntimeEntry; + const createPromise = Promise.resolve() + .then(() => driver.createRuntime(context)) + .then((runtime) => { + entry.runtime = runtime; + entry.lastUsedAt = this.now(); + this.log("info", "harness_runtime_started", { + provider: driver.provider, + runtimeKeyHash: hashRuntimeKey(key), + agentId: context.agentId, + }); + return runtime; + }) + .catch((error) => { + if (this.entries.get(key) === entry) this.entries.delete(key); + throw error; + }); + + entry = { + key, + driver, + idleTimeoutMs: driver.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + createPromise, + activeRuns: 0, + lastUsedAt: this.now(), + closing: false, + }; + this.entries.set(key, entry); + await createPromise; + return entry; + } + + private scheduleIdleClose(entry: RuntimeEntry): void { + this.clearIdleTimer(entry); + if (!Number.isFinite(entry.idleTimeoutMs) || entry.idleTimeoutMs <= 0) return; + entry.idleTimer = setTimeout(() => { + void this.evictIdle().catch((error) => { + this.log("warn", "harness_runtime_close_failed", { + provider: entry.driver.provider, + runtimeKeyHash: hashRuntimeKey(entry.key), + reason: "idle_timeout", + error: errorMessage(error), + }); + }); + }, entry.idleTimeoutMs); + entry.idleTimer.unref(); + } + + private clearIdleTimer(entry: RuntimeEntry): void { + if (!entry.idleTimer) return; + clearTimeout(entry.idleTimer); + entry.idleTimer = undefined; + } + + private async removeAndClose(entry: RuntimeEntry, reason: string): Promise { + if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key); + await this.closeEntry(entry, reason); + } + + private async closeEntry(entry: RuntimeEntry, reason: string): Promise { + if (entry.closePromise) return entry.closePromise; + entry.closing = true; + this.clearIdleTimer(entry); + entry.closePromise = (async () => { + let runtime: LocalAgentRuntime; + try { + runtime = await entry.createPromise; + } catch { + return; + } + try { + await runtime.close(); + this.log("info", "harness_runtime_closed", { + provider: entry.driver.provider, + runtimeKeyHash: hashRuntimeKey(entry.key), + reason, + }); + } catch (error) { + this.log("warn", "harness_runtime_close_failed", { + provider: entry.driver.provider, + runtimeKeyHash: hashRuntimeKey(entry.key), + reason, + error: errorMessage(error), + }); + throw error; + } + })(); + return entry.closePromise; + } + + private log( + level: "info" | "warn" | "error", + event: string, + fields: Record, + ): void { + this.logger?.(level, event, fields); + } +} + +function hashRuntimeKey(key: string): string { + return createHash("sha256").update(key).digest("hex").slice(0, 12); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 1d45d1662..b2fcad573 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -1,100 +1,88 @@ import assert from "node:assert/strict"; -import type { RunResult, ThreadOptions } from "@openai/codex-sdk"; -import { - CodexSdkLocalAgentRuntime, - createCodexSdkLocalAgentRuntime, +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; +import type { + LocalAgentDriver, + LocalAgentRunInput, + LocalAgentRunResult, + LocalAgentRuntime, + LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -const emptyTurn = (finalResponse: string): RunResult => ({ - finalResponse, - items: [], - usage: null, -}); +const context: LocalAgentRuntimeContext = { + agentId: "agt_test", + provider: "codex", + workspace: "/tmp/project", +}; +const input: LocalAgentRunInput = { prompt: "inspect", workspace: "/tmp/project" }; -class FakeThread { - prompts: string[] = []; +class FakeRuntime implements LocalAgentRuntime { + readonly provider = "codex" as const; + alive = true; + closeCount = 0; + runCount = 0; + private readonly pending: Array<() => void> = []; - constructor(readonly id: string | null) {} + releaseWait(): void { + for (const resolve of this.pending.splice(0)) resolve(); + } - async run(prompt: string): Promise { - this.prompts.push(prompt); - return emptyTurn(`response:${prompt}`); + async run(runInput: LocalAgentRunInput): Promise { + this.runCount += 1; + if (runInput.prompt === "wait") await new Promise((resolve) => this.pending.push(resolve)); + return { + provider: this.provider, + providerSessionId: "thread_1", + finalResponse: `done:${runInput.prompt}`, + items: [], + }; } -} -class FakeCodex { - started: ThreadOptions[] = []; - resumed: Array<{ id: string; options?: ThreadOptions }> = []; - readonly startThreadInstance = new FakeThread("new-thread"); - readonly resumeThreadInstance = new FakeThread("resumed-thread"); + releaseSession(): Promise { + return Promise.resolve(); + } - startThread(options?: ThreadOptions): FakeThread { - this.started.push(options ?? {}); - return this.startThreadInstance; + isAlive(): boolean { + return this.alive; } - resumeThread(id: string, options?: ThreadOptions): FakeThread { - this.resumed.push({ id, options }); - return this.resumeThreadInstance; + async close(): Promise { + this.closeCount += 1; + this.alive = false; + this.releaseWait(); } } -const codex = new FakeCodex(); -const runtime = new CodexSdkLocalAgentRuntime(codex); -const readOnly = await runtime.run({ - prompt: "inspect only", - workspace: "/tmp/project", -}); - -assert.equal(readOnly.provider, "codex"); -assert.equal(readOnly.providerSessionId, "new-thread"); -assert.equal(readOnly.finalResponse, "response:inspect only"); -assert.deepEqual(codex.startThreadInstance.prompts, ["inspect only"]); -assert.deepEqual(codex.started[0], { - workingDirectory: "/tmp/project", - sandboxMode: "read-only", - approvalPolicy: "never", - model: undefined, - modelReasoningEffort: undefined, -}); - -await runtime.run({ - prompt: "make change", - workspace: "/tmp/project", - writeMode: "allowed", - model: "gpt-5.4", - thinking: "high", -}); - -assert.deepEqual(codex.started[1], { - workingDirectory: "/tmp/project", - sandboxMode: "workspace-write", - approvalPolicy: "never", - model: "gpt-5.4", - modelReasoningEffort: "high", -}); - -const resumed = await runtime.run({ - prompt: "continue", - workspace: "/tmp/project", - providerSessionId: "existing-thread", - writeMode: "full_access", -}); - -assert.equal(resumed.providerSessionId, "resumed-thread"); -assert.deepEqual(codex.resumeThreadInstance.prompts, ["continue"]); -assert.deepEqual(codex.resumed, [ - { - id: "existing-thread", - options: { - workingDirectory: "/tmp/project", - sandboxMode: "danger-full-access", - approvalPolicy: "never", - model: undefined, - modelReasoningEffort: undefined, - }, +const runtime = new FakeRuntime(); +let createCount = 0; +const driver: LocalAgentDriver = { + provider: "codex", + idleTimeoutMs: Number.POSITIVE_INFINITY, + runtimeKey: () => "shared", + createRuntime: async () => { + createCount += 1; + await Promise.resolve(); + return runtime; }, +}; + +const pool = new LocalAgentRuntimePool(); +const [first, second] = await Promise.all([ + pool.run(driver, context, input), + pool.run(driver, { ...context, agentId: "agt_other" }, { ...input, prompt: "second" }), ]); +assert.equal(createCount, 1, "runtime creation is single-flight per runtime key"); +assert.equal(first.finalResponse, "done:inspect"); +assert.equal(second.finalResponse, "done:second"); +assert.equal(runtime.runCount, 2); + +const running = pool.run(driver, context, { ...input, prompt: "wait" }); +await new Promise((resolve) => setImmediate(resolve)); +await pool.evictIdle(Date.now() + 10_000_000); +assert.equal(runtime.closeCount, 0, "active runtimes are not evicted"); +runtime.releaseWait(); +await running; -const created = await createCodexSdkLocalAgentRuntime(undefined, () => new FakeCodex()); -assert.equal(created.provider, "codex"); +await pool.close(); +await pool.close(); +assert.equal(runtime.closeCount, 1, "runtime close is idempotent"); +assert.equal(pool.size, 0); diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 54130c2e2..06697d5c9 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -1,11 +1,4 @@ -import type { - Codex, - CodexOptions, - ModelReasoningEffort, - RunResult, - SandboxMode, - ThreadOptions, -} from "@openai/codex-sdk"; +import type { LocalAgentProvider } from "./local-agent-profiles.js"; export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; @@ -25,78 +18,33 @@ export interface LocalAgentRunResult { items: unknown[]; } -export interface LocalAgentRuntime { - readonly provider: string; - run(input: LocalAgentRunInput): Promise; -} - -interface CodexThreadLike { - readonly id: string | null; - run(prompt: string): Promise; -} - -interface CodexClientLike { - startThread(options?: ThreadOptions): CodexThreadLike; - resumeThread(id: string, options?: ThreadOptions): CodexThreadLike; -} - -type CodexFactory = (options?: CodexOptions) => CodexClientLike; - -function sandboxModeFor(writeMode: LocalAgentWriteMode | undefined): SandboxMode { - switch (writeMode) { - case "allowed": - return "workspace-write"; - case "full_access": - return "danger-full-access"; - case "read_only": - case undefined: - return "read-only"; - } -} - -function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { - return { - workingDirectory: input.workspace, - sandboxMode: sandboxModeFor(input.writeMode), - approvalPolicy: "never", - model: input.model, - modelReasoningEffort: input.thinking as ModelReasoningEffort | undefined, - }; -} - -export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { - readonly provider = "codex" as const; - private readonly codex: CodexClientLike; - - constructor(codex: CodexClientLike) { - this.codex = codex; - } - - async run(input: LocalAgentRunInput): Promise { - const options = threadOptionsFor(input); - const thread = input.providerSessionId - ? this.codex.resumeThread(input.providerSessionId, options) - : this.codex.startThread(options); - const turn = await thread.run(input.prompt); - - return { - provider: this.provider, - providerSessionId: thread.id, - finalResponse: turn.finalResponse, - items: turn.items, - }; - } +export interface LocalAgentRuntimeContext { + agentId: string; + provider: LocalAgentProvider; + workspace: string; + providerSessionId?: string; + writeMode?: LocalAgentWriteMode; + model?: string; + thinking?: string; + agentDir?: string; } -export async function createCodexSdkLocalAgentRuntime( - options?: CodexOptions, - codexFactory?: CodexFactory, -): Promise { - const factory = codexFactory ?? (await defaultCodexFactory()); - return new CodexSdkLocalAgentRuntime(factory(options)); +/** + * A runtime is deliberately disposable. Nothing from this interface is + * persisted; the provider session ID in LocalAgentStore is the continuation + * identity used when a later runtime is created. + */ +export interface LocalAgentRuntime { + readonly provider: LocalAgentProvider; + run(input: LocalAgentRunInput): Promise; + releaseSession(providerSessionId: string): Promise; + close(): Promise; + isAlive(): boolean; } -async function defaultCodexFactory(): Promise { - const module = await import("@openai/codex-sdk"); - return (options) => new module.Codex(options) as Codex; +export interface LocalAgentDriver { + readonly provider: LocalAgentProvider; + runtimeKey(context: LocalAgentRuntimeContext): string; + createRuntime(context: LocalAgentRuntimeContext): Promise; + readonly idleTimeoutMs?: number; } diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index a850ca9f6..405d1ad3e 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -196,6 +196,18 @@ export class LocalAgentStore { return updated; } + reconcileActiveRuns(message = "DevSpace restarted while this agent turn was running."): number { + const now = new Date().toISOString(); + const result = this.database.sqlite + .prepare( + `update local_agent_sessions + set status = 'error', error = ?, updated_at = ? + where status in ('starting', 'running')`, + ) + .run(message, now); + return Number(result.changes); + } + close(): void { this.database.close(); } From c4e821a9ee67c32cfc906b58809e42eab29b3bc6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:29:51 +0530 Subject: [PATCH 03/35] refactor: make local agent core host-independent --- src/local-agent-manager.test.ts | 15 +++++---------- src/local-agent-manager.ts | 34 +++++++++++++++++---------------- src/local-agent-store.ts | 5 ++--- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 232c64443..c1a8cc794 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { loadConfig } from "./config.js"; import { LocalAgentManager } from "./local-agent-manager.js"; import type { LocalAgentProfile } from "./local-agent-profiles.js"; import type { @@ -12,16 +11,11 @@ import type { LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agent-manager-test-")); -const config = loadConfig({ - DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_STATE_DIR: join(root, "state"), - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - PORT: "1", -}); +const stateDir = join(root, "state"); const profile: LocalAgentProfile = { name: "reviewer", description: "Test reviewer", @@ -81,7 +75,7 @@ const driver: LocalAgentDriver = { }, }; -const store = new LocalAgentStore(config.stateDir); +const store = new LocalAgentStore(stateDir); const stale = store.create({ workspaceRoot: root, profileName: "reviewer", @@ -89,9 +83,10 @@ const stale = store.create({ }); store.update(stale.id, { status: "running", latestResponse: "previous response" }); -const manager = new LocalAgentManager(config, { +const manager = new LocalAgentManager({ store, drivers: [driver], + pool: new LocalAgentRuntimePool(), loadProfiles: async () => [profile], }); diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 15036a1e8..7e87a2153 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -1,6 +1,4 @@ -import type { ServerConfig } from "./config.js"; import { - loadLocalAgentProfiles, type LocalAgentProfile, type LocalAgentProvider, } from "./local-agent-profiles.js"; @@ -8,7 +6,6 @@ import { resolveLocalAgentTarget, } from "./local-agent-targets.js"; import { - createLocalAgentStore, type LocalAgentListScope, type LocalAgentRecord, type LocalAgentStore, @@ -42,10 +39,10 @@ export interface LocalAgentManagerLogger { } export interface LocalAgentManagerOptions { - store?: LocalAgentStore; + store: LocalAgentStore; drivers: readonly LocalAgentDriver[]; - pool?: LocalAgentRuntimePool; - loadProfiles?: (workspaceRoot: string) => Promise; + pool: LocalAgentRuntimePool; + loadProfiles: (workspaceRoot: string) => Promise; agentDir?: string; logger?: LocalAgentManagerLogger; } @@ -66,12 +63,12 @@ export class LocalAgentManager { private accepting = true; private closePromise?: Promise; - constructor(config: ServerConfig, options: LocalAgentManagerOptions) { - this.store = options.store ?? createLocalAgentStore(config); + constructor(options: LocalAgentManagerOptions) { + this.store = options.store; for (const driver of options.drivers) this.drivers.set(driver.provider, driver); - this.pool = options.pool ?? new LocalAgentRuntimePool({ logger: options.logger }); - this.loadProfiles = options.loadProfiles ?? ((workspaceRoot) => loadLocalAgentProfiles(config, workspaceRoot)); - this.agentDir = options.agentDir ?? config.agentDir; + this.pool = options.pool; + this.loadProfiles = options.loadProfiles; + this.agentDir = options.agentDir; this.logger = options.logger; this.store.reconcileActiveRuns(); } @@ -143,6 +140,14 @@ export class LocalAgentManager { return this.activeTurns.size; } + get runtimeCount(): number { + return this.pool.size; + } + + async evictIdle(now?: number): Promise { + await this.pool.evictIdle(now); + } + private begin( record: LocalAgentRecord, prompt: string, @@ -268,11 +273,8 @@ export class LocalAgentManager { } } -export function createLocalAgentManager( - config: ServerConfig, - options: Omit & { drivers: readonly LocalAgentDriver[] }, -): LocalAgentManager { - return new LocalAgentManager(config, options); +export function createLocalAgentManager(options: LocalAgentManagerOptions): LocalAgentManager { + return new LocalAgentManager(options); } function errorMessage(error: unknown): string { diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 405d1ad3e..89abdef4e 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; -import type { ServerConfig } from "./config.js"; export type LocalAgentStatus = "starting" | "running" | "idle" | "error" | "stopped"; @@ -220,8 +219,8 @@ export class LocalAgentStore { } } -export function createLocalAgentStore(config: ServerConfig): LocalAgentStore { - return new LocalAgentStore(config.stateDir); +export function createLocalAgentStore(stateDir: string): LocalAgentStore { + return new LocalAgentStore(stateDir); } function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { From abeb678fd2e00f0e8f8df13a1862ae8e0257b3ca Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:31:00 +0530 Subject: [PATCH 04/35] feat: add standalone local agent daemon lifecycle --- src/local-agent-daemon-lifecycle.test.ts | 38 ++++++ src/local-agent-daemon-lifecycle.ts | 141 +++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/local-agent-daemon-lifecycle.test.ts create mode 100644 src/local-agent-daemon-lifecycle.ts diff --git a/src/local-agent-daemon-lifecycle.test.ts b/src/local-agent-daemon-lifecycle.test.ts new file mode 100644 index 000000000..3f095cc6e --- /dev/null +++ b/src/local-agent-daemon-lifecycle.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + LocalAgentDaemonAlreadyRunningError, + LocalAgentDaemonLock, + ensureLocalAgentDaemonStateDir, + isProcessAlive, + localAgentDaemonPaths, + removeLocalAgentDaemonFiles, + writeLocalAgentDaemonPid, +} from "./local-agent-daemon-lifecycle.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-agentd-lifecycle-test-")); +try { + const paths = localAgentDaemonPaths(join(root, "state")); + ensureLocalAgentDaemonStateDir(paths.stateDir); + const lock = new LocalAgentDaemonLock(paths); + lock.acquire(); + assert.equal(await readFile(paths.lockPath, "utf8"), `${process.pid}\n`); + assert.throws( + () => new LocalAgentDaemonLock(paths).acquire(), + (error: unknown) => error instanceof LocalAgentDaemonAlreadyRunningError, + ); + lock.release(); + + await writeFile(paths.pidPath, "999999\n", { mode: 0o600 }); + const recovered = new LocalAgentDaemonLock(paths); + recovered.acquire(); + writeLocalAgentDaemonPid(paths); + assert.equal(await readFile(paths.pidPath, "utf8"), `${process.pid}\n`); + assert.equal(isProcessAlive(process.pid), true); + recovered.release(); + removeLocalAgentDaemonFiles(paths); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts new file mode 100644 index 000000000..4ef7dcd20 --- /dev/null +++ b/src/local-agent-daemon-lifecycle.ts @@ -0,0 +1,141 @@ +import { createHash } from "node:crypto"; +import { + chmodSync, + closeSync, + mkdirSync, + openSync, + readFileSync, + rmSync, + writeSync, +} from "node:fs"; +import { join, resolve } from "node:path"; + +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 1; +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"; +export const LOCAL_AGENT_DAEMON_LOG_NAME = "agentd.log"; + +export interface LocalAgentDaemonPaths { + stateDir: string; + socketPath: string; + pidPath: string; + lockPath: string; + logPath: string; + endpoint: string; +} + +export function localAgentDaemonPaths( + stateDir: string, + platform: NodeJS.Platform = process.platform, +): LocalAgentDaemonPaths { + const resolvedStateDir = resolve(stateDir); + const socketPath = join(resolvedStateDir, LOCAL_AGENT_DAEMON_SOCKET_NAME); + return { + stateDir: resolvedStateDir, + socketPath, + pidPath: join(resolvedStateDir, LOCAL_AGENT_DAEMON_PID_NAME), + lockPath: join(resolvedStateDir, LOCAL_AGENT_DAEMON_LOCK_NAME), + logPath: join(resolvedStateDir, LOCAL_AGENT_DAEMON_LOG_NAME), + endpoint: platform === "win32" + ? `\\\\.\\pipe\\devspace-agentd-${hashStateDir(resolvedStateDir)}` + : socketPath, + }; +} + +export function ensureLocalAgentDaemonStateDir(stateDir: string): void { + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + chmodSync(stateDir, 0o700); +} + +export class LocalAgentDaemonAlreadyRunningError extends Error { + readonly code = "DAEMON_ALREADY_RUNNING" as const; + + constructor(readonly pid?: number) { + super(pid ? `Local agent daemon is already running (pid ${pid}).` : "Local agent daemon is already running."); + this.name = "LocalAgentDaemonAlreadyRunningError"; + } +} + +export class LocalAgentDaemonLock { + private fileDescriptor?: number; + + constructor(readonly paths: LocalAgentDaemonPaths) {} + + acquire(): void { + ensureLocalAgentDaemonStateDir(this.paths.stateDir); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const fileDescriptor = openSync(this.paths.lockPath, "wx", 0o600); + writeSync(fileDescriptor, `${process.pid}\n`); + chmodSync(this.paths.lockPath, 0o600); + writeFileSecure(this.paths.pidPath, `${process.pid}\n`); + this.fileDescriptor = fileDescriptor; + return; + } catch (error) { + if (!isFileExistsError(error)) throw error; + const pid = readDaemonPid(this.paths.pidPath); + if (pid !== undefined && isProcessAlive(pid)) { + throw new LocalAgentDaemonAlreadyRunningError(pid); + } + rmSync(this.paths.lockPath, { force: true }); + } + } + throw new LocalAgentDaemonAlreadyRunningError(readDaemonPid(this.paths.pidPath)); + } + + release(): void { + if (this.fileDescriptor === undefined) return; + closeSync(this.fileDescriptor); + this.fileDescriptor = undefined; + rmSync(this.paths.pidPath, { force: true }); + rmSync(this.paths.lockPath, { force: true }); + } +} + +export function writeLocalAgentDaemonPid(paths: LocalAgentDaemonPaths): void { + writeFileSecure(paths.pidPath, `${process.pid}\n`); +} + +export function removeLocalAgentDaemonFiles(paths: LocalAgentDaemonPaths): void { + rmSync(paths.pidPath, { force: true }); + if (process.platform !== "win32") rmSync(paths.socketPath, { force: true }); +} + +export function readDaemonPid(pidPath: string): number | undefined { + try { + const value = readFileSync(pidPath, "utf8").trim(); + if (!/^\d+$/.test(value)) return undefined; + const pid = Number(value); + return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined; + } catch { + return undefined; + } +} + +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function writeFileSecure(path: string, content: string): void { + const fileDescriptor = openSync(path, "w", 0o600); + try { + writeSync(fileDescriptor, content); + chmodSync(path, 0o600); + } finally { + closeSync(fileDescriptor); + } +} + +function isFileExistsError(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "EEXIST"; +} + +function hashStateDir(stateDir: string): string { + return createHash("sha256").update(stateDir).digest("hex").slice(0, 24); +} From d17898abded73225761ef29df8b38af356865694 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:36:43 +0530 Subject: [PATCH 05/35] feat: add local agent daemon IPC protocol --- src/local-agent-client.ts | 246 ++++++++++++++++++ src/local-agent-daemon-protocol.test.ts | 52 ++++ src/local-agent-daemon-protocol.ts | 310 +++++++++++++++++++++++ src/local-agent-daemon.ts | 319 ++++++++++++++++++++++++ 4 files changed, 927 insertions(+) create mode 100644 src/local-agent-client.ts create mode 100644 src/local-agent-daemon-protocol.test.ts create mode 100644 src/local-agent-daemon-protocol.ts create mode 100644 src/local-agent-daemon.ts diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts new file mode 100644 index 000000000..7a0520b35 --- /dev/null +++ b/src/local-agent-client.ts @@ -0,0 +1,246 @@ +import { existsSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { createConnection } from "node:net"; +import { fileURLToPath } from "node:url"; +import type { ServerConfig } from "./config.js"; +import { + decodeAgentRecord, + decodeAgentRecordList, + decodeDaemonLogs, + decodeDaemonStatus, + decodeLocalAgentDaemonResponse, + encodeLocalAgentDaemonRequest, + LocalAgentDaemonProtocolError, + type LocalAgentDaemonRequest, + type LocalAgentDaemonResponse, + type LocalAgentDaemonStatus, +} from "./local-agent-daemon-protocol.js"; +import { + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + localAgentDaemonPaths, +} from "./local-agent-daemon-lifecycle.js"; +import type { LocalAgentManager, RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; +import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; + +const DEFAULT_STARTUP_TIMEOUT_MS = 8_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const RETRY_DELAY_MS = 40; + +export interface LocalAgentClientOptions { + stateDir: string; + startupTimeoutMs?: number; + requestTimeoutMs?: number; + spawnDaemon?: () => void; + endpoint?: string; +} + +export class LocalAgentDaemonClientError extends Error { + constructor(readonly code: string, message: string) { + super(message); + this.name = "LocalAgentDaemonClientError"; + } +} + +export class LocalAgentClient { + private readonly stateDir: string; + private readonly endpoint: string; + private readonly startupTimeoutMs: number; + private readonly requestTimeoutMs: number; + private readonly spawnDaemon: () => void; + private startupPromise?: Promise; + + constructor(options: LocalAgentClientOptions) { + this.stateDir = options.stateDir; + this.endpoint = options.endpoint ?? localAgentDaemonPaths(options.stateDir).endpoint; + this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.spawnDaemon = options.spawnDaemon ?? (() => spawnLocalAgentDaemon(options.stateDir)); + } + + async run(input: StartLocalAgentInput): Promise { + const result = await this.request("agent.run", input); + return decodeAgentRecord(result); + } + + async start(input: StartLocalAgentInput): Promise { + const result = await this.request("agent.start", input); + return decodeAgentRecord(result); + } + + async continue(agentId: string, prompt: string, overrides?: RunOverrides): Promise { + const result = await this.request("agent.continue", { + id: agentId, + prompt, + ...(overrides ? { overrides } : {}), + }); + return decodeAgentRecord(result); + } + + async get(agentId: string): Promise { + const result = await this.request("agent.get", { id: agentId }); + return result === null ? undefined : decodeAgentRecord(result); + } + + async list(scope: LocalAgentListScope = {}): Promise { + return decodeAgentRecordList(await this.request("agent.list", scope)); + } + + async status(): Promise { + return decodeDaemonStatus(await this.request("daemon.status", {})); + } + + async stop(): Promise { + return decodeDaemonStatus(await this.request("daemon.stop", {})); + } + + async logs(lines = 200): Promise { + return decodeDaemonLogs(await this.request("daemon.logs", { lines })); + } + + async ensureReady(): Promise { + if (this.startupPromise) return this.startupPromise; + this.startupPromise = this.ensureReadyInternal().finally(() => { + this.startupPromise = undefined; + }); + return this.startupPromise; + } + + private async ensureReadyInternal(): Promise { + const existing = await this.tryHello(); + if (existing) return existing; + + this.spawnDaemon(); + const deadline = Date.now() + this.startupTimeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + await delay(RETRY_DELAY_MS); + try { + const ready = await this.tryHello(); + if (ready) return ready; + } catch (error) { + lastError = error; + if (error instanceof LocalAgentDaemonClientError && error.code === "PROTOCOL_MISMATCH") throw error; + } + } + const suffix = lastError instanceof Error ? `: ${lastError.message}` : ""; + throw new LocalAgentDaemonClientError( + "DAEMON_START_FAILED", + `Unable to start the local agent daemon in ${this.stateDir}${suffix}`, + ); + } + + private async tryHello(): Promise { + try { + const response = await sendRequest(this.endpoint, { + requestId: randomUUID(), + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + method: "hello", + params: {}, + }, this.requestTimeoutMs); + if (!response.ok) { + if (response.error.code === "PROTOCOL_MISMATCH") { + throw new LocalAgentDaemonClientError(response.error.code, response.error.message); + } + return undefined; + } + const status = decodeDaemonStatus(response.result); + return status.state === "ready" ? status : undefined; + } catch (error) { + if (error instanceof LocalAgentDaemonClientError && error.code === "PROTOCOL_MISMATCH") throw error; + return undefined; + } + } + + private async request( + method: M, + params: Extract['params'], + ): Promise { + await this.ensureReady(); + const response = await sendRequest(this.endpoint, { + requestId: randomUUID(), + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + method, + params, + } as LocalAgentDaemonRequest, this.requestTimeoutMs); + if (!response.ok) { + throw new LocalAgentDaemonClientError(response.error.code, response.error.message); + } + return response.result; + } +} + +export function createLocalAgentClient(config: Pick): LocalAgentClient { + return new LocalAgentClient({ stateDir: config.stateDir }); +} + +export function spawnLocalAgentDaemon(stateDir: string, env: NodeJS.ProcessEnv = process.env): void { + const entrypoint = resolveDaemonEntrypoint(); + const child = spawn(process.execPath, [...process.execArgv, entrypoint], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { ...env, DEVSPACE_STATE_DIR: stateDir }, + }); + child.unref(); +} + +export function resolveDaemonEntrypoint(): string { + const compiled = fileURLToPath(new URL("./local-agent-daemon-main.js", import.meta.url)); + if (existsSync(compiled)) return compiled; + return fileURLToPath(new URL("./local-agent-daemon-main.ts", import.meta.url)); +} + +async function sendRequest( + endpoint: string, + request: LocalAgentDaemonRequest, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(endpoint); + let buffer = ""; + let settled = false; + const timer = setTimeout(() => { + finish(new LocalAgentDaemonClientError("REQUEST_TIMEOUT", "Timed out waiting for the local agent daemon."), true); + }, timeoutMs); + + const finish = (error?: unknown, destroy = false) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (destroy) socket.destroy(); + if (error) reject(error); + }; + + socket.setEncoding("utf8"); + socket.on("data", (chunk: string | Buffer) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + try { + const response = decodeLocalAgentDaemonResponse(JSON.parse(buffer.slice(0, newline)) as unknown); + if (response.requestId !== request.requestId) { + throw new LocalAgentDaemonProtocolError("INVALID_RESPONSE", "Daemon response request id did not match."); + } + settled = true; + clearTimeout(timer); + resolve(response); + socket.end(); + } catch (error) { + finish(error, true); + } + }); + socket.once("error", (error) => finish(new LocalAgentDaemonClientError( + (error as NodeJS.ErrnoException).code ?? "DAEMON_UNAVAILABLE", + error.message, + ))); + socket.once("close", () => { + if (!settled) finish(new LocalAgentDaemonClientError("DAEMON_UNAVAILABLE", "Local agent daemon closed the connection.")); + }); + socket.once("connect", () => socket.write(encodeLocalAgentDaemonRequest(request))); + }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts new file mode 100644 index 000000000..e69103b51 --- /dev/null +++ b/src/local-agent-daemon-protocol.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { + decodeAgentRecord, + decodeLocalAgentDaemonRequest, + decodeLocalAgentDaemonResponse, + encodeLocalAgentDaemonRequest, + LocalAgentDaemonProtocolError, +} from "./local-agent-daemon-protocol.js"; + +const request = decodeLocalAgentDaemonRequest({ + requestId: "req_1", + protocolVersion: 1, + method: "agent.start", + params: { + target: "reviewer", + prompt: "Review this", + workspaceRoot: "/tmp/project", + writeMode: "read_only", + }, +}); +assert.equal(request.method, "agent.start"); +assert.equal(request.params.writeMode, "read_only"); +assert.match(encodeLocalAgentDaemonRequest(request), /"method":"agent.start"/); + +assert.throws( + () => decodeLocalAgentDaemonRequest({ + requestId: "req_2", + protocolVersion: 1, + method: "agent.start", + params: { target: "reviewer", prompt: "" }, + }), + (error: unknown) => error instanceof LocalAgentDaemonProtocolError && error.code === "INVALID_PARAMS", +); + +const record = decodeAgentRecord({ + id: "agt_1234", + workspaceRoot: "/tmp/project", + profileName: "reviewer", + provider: "codex", + status: "idle", + createdAt: "now", + updatedAt: "now", +}); +assert.equal(record.id, "agt_1234"); + +const response = decodeLocalAgentDaemonResponse({ + requestId: "req_1", + protocolVersion: 1, + ok: true, + result: record, +}); +assert.equal(response.ok, true); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts new file mode 100644 index 000000000..d8c3c8498 --- /dev/null +++ b/src/local-agent-daemon-protocol.ts @@ -0,0 +1,310 @@ +import type { + LocalAgentListScope, + LocalAgentRecord, + LocalAgentStatus, +} from "./local-agent-store.js"; +import type { + RunOverrides, + StartLocalAgentInput, +} from "./local-agent-manager.js"; +import type { LocalAgentWriteMode } from "./local-agent-runtime.js"; +import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; + +export type LocalAgentDaemonMethod = + | "hello" + | "agent.run" + | "agent.start" + | "agent.continue" + | "agent.get" + | "agent.list" + | "daemon.status" + | "daemon.stop" + | "daemon.logs"; + +export interface AgentDaemonRunParams extends StartLocalAgentInput {} + +export type LocalAgentDaemonRequest = + | AgentDaemonRequestBase<"hello", Record> + | AgentDaemonRequestBase<"agent.run", AgentDaemonRunParams> + | AgentDaemonRequestBase<"agent.start", StartLocalAgentInput> + | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; overrides?: RunOverrides }> + | AgentDaemonRequestBase<"agent.get", { id: string }> + | AgentDaemonRequestBase<"agent.list", LocalAgentListScope> + | AgentDaemonRequestBase<"daemon.status", Record> + | AgentDaemonRequestBase<"daemon.stop", Record> + | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; + +interface AgentDaemonRequestBase< + M extends LocalAgentDaemonMethod, + P, +> { + requestId: string; + protocolVersion: number; + method: M; + params: P; +} + +export interface LocalAgentDaemonStatus { + state: "ready" | "stopping"; + protocolVersion: number; + pid: number; + endpoint: string; + startedAt: string; + activeTurns: number; + runtimeCount: number; + clientConnections: number; +} + +export interface LocalAgentDaemonErrorPayload { + code: string; + message: string; +} + +export type LocalAgentDaemonResponse = + | { + requestId: string; + protocolVersion: number; + ok: true; + result: unknown; + } + | { + requestId: string; + protocolVersion: number; + ok: false; + error: LocalAgentDaemonErrorPayload; + }; + +export function encodeLocalAgentDaemonRequest(request: LocalAgentDaemonRequest): string { + return `${JSON.stringify(request)}\n`; +} + +export function encodeLocalAgentDaemonResponse(response: LocalAgentDaemonResponse): string { + return `${JSON.stringify(response)}\n`; +} + +export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonRequest { + const record = asRecord(value); + const requestId = requiredString(record?.requestId, "requestId"); + const protocolVersion = requiredInteger(record?.protocolVersion, "protocolVersion"); + const method = requiredString(record?.method, "method") as LocalAgentDaemonMethod; + const params = record?.params; + + switch (method) { + case "hello": + case "daemon.status": + case "daemon.stop": + return { requestId, protocolVersion, method, params: decodeEmptyParams(params) } as LocalAgentDaemonRequest; + case "agent.run": + case "agent.start": + return { + requestId, + protocolVersion, + method, + params: decodeStartInput(params), + } as LocalAgentDaemonRequest; + case "agent.continue": + return { + requestId, + protocolVersion, + method, + params: decodeContinueInput(params), + } as LocalAgentDaemonRequest; + case "agent.get": + return { + requestId, + protocolVersion, + method, + params: { id: requiredString(asRecord(params)?.id, "id") }, + } as LocalAgentDaemonRequest; + case "agent.list": + return { + requestId, + protocolVersion, + method, + params: decodeListScope(params), + } as LocalAgentDaemonRequest; + case "daemon.logs": + return { + requestId, + protocolVersion, + method, + params: decodeLogsParams(params), + } as LocalAgentDaemonRequest; + default: + throw new LocalAgentDaemonProtocolError("UNKNOWN_METHOD", `Unknown daemon method: ${method}`); + } +} + +export function decodeLocalAgentDaemonResponse(value: unknown): LocalAgentDaemonResponse { + const record = asRecord(value); + const requestId = requiredString(record?.requestId, "requestId"); + const protocolVersion = requiredInteger(record?.protocolVersion, "protocolVersion"); + if (record?.ok === true) { + return { requestId, protocolVersion, ok: true, result: record.result }; + } + if (record?.ok === false) { + const error = asRecord(record.error); + return { + requestId, + protocolVersion, + ok: false, + error: { + code: requiredString(error?.code, "error.code"), + message: requiredString(error?.message, "error.message"), + }, + }; + } + throw new LocalAgentDaemonProtocolError("INVALID_RESPONSE", "Daemon returned an invalid response."); +} + +export function decodeAgentRecord(value: unknown): LocalAgentRecord { + const record = asRecord(value); + const status = requiredString(record?.status, "status"); + if (!isLocalAgentStatus(status)) throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent status."); + return { + id: requiredString(record?.id, "id"), + workspaceId: optionalString(record?.workspaceId), + workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), + profileName: requiredString(record?.profileName, "profileName"), + provider: requiredString(record?.provider, "provider"), + model: optionalString(record?.model), + thinking: optionalString(record?.thinking), + providerSessionId: optionalString(record?.providerSessionId), + status, + latestResponse: optionalString(record?.latestResponse), + error: optionalString(record?.error), + createdAt: requiredString(record?.createdAt, "createdAt"), + updatedAt: requiredString(record?.updatedAt, "updatedAt"), + }; +} + +export function decodeAgentRecordList(value: unknown): LocalAgentRecord[] { + if (!Array.isArray(value)) throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid agent list."); + return value.map(decodeAgentRecord); +} + +export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { + const record = asRecord(value); + const state = requiredString(record?.state, "state"); + if (state !== "ready" && state !== "stopping") { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned an invalid status."); + } + return { + state, + protocolVersion: requiredInteger(record?.protocolVersion, "protocolVersion"), + pid: requiredInteger(record?.pid, "pid"), + endpoint: requiredString(record?.endpoint, "endpoint"), + startedAt: requiredString(record?.startedAt, "startedAt"), + activeTurns: requiredInteger(record?.activeTurns, "activeTurns"), + runtimeCount: requiredInteger(record?.runtimeCount, "runtimeCount"), + clientConnections: requiredInteger(record?.clientConnections, "clientConnections"), + }; +} + +export function decodeDaemonLogs(value: unknown): string { + if (typeof value !== "string") throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid logs."); + return value; +} + +export class LocalAgentDaemonProtocolError extends Error { + constructor(readonly code: string, message: string) { + super(message); + this.name = "LocalAgentDaemonProtocolError"; + } +} + +function decodeEmptyParams(value: unknown): Record { + if (value === undefined) return {}; + const record = asRecord(value); + if (!record || Object.keys(record).length > 0) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "This daemon method does not accept parameters."); + } + return {}; +} + +function decodeStartInput(value: unknown): StartLocalAgentInput { + const record = asRecord(value); + return { + target: requiredString(record?.target, "target"), + prompt: requiredString(record?.prompt, "prompt"), + workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), + workspaceId: optionalString(record?.workspaceId), + model: optionalString(record?.model), + thinking: optionalString(record?.thinking), + writeMode: decodeWriteMode(record?.writeMode), + }; +} + +function decodeContinueInput(value: unknown): { id: string; prompt: string; overrides?: RunOverrides } { + const record = asRecord(value); + const overrides = asRecord(record?.overrides); + return { + id: requiredString(record?.id, "id"), + prompt: requiredString(record?.prompt, "prompt"), + ...(overrides ? { overrides: { + model: optionalString(overrides.model), + thinking: optionalString(overrides.thinking), + writeMode: decodeWriteMode(overrides.writeMode), + } } : {}), + }; +} + +function decodeListScope(value: unknown): LocalAgentListScope { + if (value === undefined) return {}; + const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "List scope must be an object."); + return { + workspaceId: optionalString(record.workspaceId), + workspaceRoot: optionalString(record.workspaceRoot), + }; +} + +function decodeLogsParams(value: unknown): { lines?: number } { + if (value === undefined) return {}; + const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Log options must be an object."); + const lines = record.lines; + if (lines === undefined) return {}; + if (typeof lines !== "number" || !Number.isInteger(lines) || lines < 1 || lines > 10_000) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Log lines must be an integer between 1 and 10000."); + } + return { lines }; +} + +function decodeWriteMode(value: unknown): LocalAgentWriteMode | undefined { + if (value === undefined) return undefined; + if (value === "read_only" || value === "allowed" || value === "full_access") return value; + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Invalid write mode."); +} + +function isLocalAgentStatus(value: string): value is LocalAgentStatus { + return value === "starting" || value === "running" || value === "idle" || value === "error" || value === "stopped"; +} + +function requiredString(value: unknown, field: string): string { + const result = optionalString(value); + if (!result) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Missing ${field}.`); + return result; +} + +function requiredInteger(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); + } + return value; +} + +function optionalString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + return value as Record; +} + +export function supportedDaemonProtocolVersion(): number { + return LOCAL_AGENT_DAEMON_PROTOCOL_VERSION; +} diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts new file mode 100644 index 000000000..362cd786e --- /dev/null +++ b/src/local-agent-daemon.ts @@ -0,0 +1,319 @@ +import { appendFileSync, chmodSync, readFileSync, rmSync } from "node:fs"; +import { createServer, type Server as NetServer, type Socket } from "node:net"; +import { + LocalAgentDaemonAlreadyRunningError, + LocalAgentDaemonLock, + ensureLocalAgentDaemonStateDir, + localAgentDaemonPaths, + removeLocalAgentDaemonFiles, + type LocalAgentDaemonPaths, +} from "./local-agent-daemon-lifecycle.js"; +import { + decodeLocalAgentDaemonRequest, + encodeLocalAgentDaemonResponse, + type LocalAgentDaemonRequest, + type LocalAgentDaemonResponse, + type LocalAgentDaemonStatus, + LocalAgentDaemonProtocolError, +} from "./local-agent-daemon-protocol.js"; +import type { LocalAgentManager, RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; +import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; + +const MAX_REQUEST_BYTES = 512 * 1024; +const DEFAULT_DAEMON_IDLE_SHUTDOWN_MS = 30_000; +const DEFAULT_IDLE_CHECK_INTERVAL_MS = 1_000; + +export interface LocalAgentDaemonManager { + start(input: StartLocalAgentInput): Promise; + continue(agentId: string, prompt: string, overrides?: RunOverrides): Promise; + get(agentId: string): LocalAgentRecord | undefined; + list(scope?: LocalAgentListScope): LocalAgentRecord[]; + evictIdle(now?: number): Promise; + close(): Promise; + readonly activeTurnCount: number; + readonly runtimeCount: number; +} + +export interface LocalAgentDaemonOptions { + stateDir: string; + manager: LocalAgentDaemonManager; + idleShutdownMs?: number; + idleCheckIntervalMs?: number; + now?: () => number; + paths?: LocalAgentDaemonPaths; +} + +export class LocalAgentDaemon { + readonly paths: LocalAgentDaemonPaths; + private readonly manager: LocalAgentDaemonManager; + private readonly lock: LocalAgentDaemonLock; + private readonly idleShutdownMs: number; + private readonly idleCheckIntervalMs: number; + private readonly now: () => number; + private readonly sockets = new Set(); + private server?: NetServer; + private idleTimer?: NodeJS.Timeout; + private idleSince?: number; + private closePromise?: Promise; + private startedAt?: string; + private accepting = false; + private stopping = false; + + constructor(options: LocalAgentDaemonOptions) { + this.paths = options.paths ?? localAgentDaemonPaths(options.stateDir); + this.manager = options.manager; + this.lock = new LocalAgentDaemonLock(this.paths); + this.idleShutdownMs = options.idleShutdownMs ?? DEFAULT_DAEMON_IDLE_SHUTDOWN_MS; + this.idleCheckIntervalMs = options.idleCheckIntervalMs ?? DEFAULT_IDLE_CHECK_INTERVAL_MS; + this.now = options.now ?? Date.now; + if (!Number.isFinite(this.idleShutdownMs) || this.idleShutdownMs < 0) { + throw new Error("Agent daemon idle shutdown must be a non-negative finite duration."); + } + } + + async start(): Promise { + if (this.server) return this.status(); + ensureLocalAgentDaemonStateDir(this.paths.stateDir); + try { + this.lock.acquire(); + if (process.platform !== "win32") rmSync(this.paths.socketPath, { force: true }); + const server = createServer((socket) => this.handleConnection(socket)); + this.server = server; + await listen(server, this.paths.endpoint); + if (process.platform !== "win32") chmodSync(this.paths.socketPath, 0o600); + this.startedAt = new Date(this.now()).toISOString(); + this.accepting = true; + this.stopping = false; + this.idleTimer = setInterval(() => { + void this.maintainIdle().catch((error) => { + writeLocalAgentDaemonLog(this.paths, "warn", "daemon_idle_check_failed", { + error: errorMessage(error), + }); + }); + }, this.idleCheckIntervalMs); + this.idleTimer.unref(); + writeLocalAgentDaemonLog(this.paths, "info", "daemon_started", { pid: process.pid }); + return this.status(); + } catch (error) { + this.server = undefined; + this.lock.release(); + removeLocalAgentDaemonFiles(this.paths); + if (error instanceof LocalAgentDaemonAlreadyRunningError) throw error; + throw error; + } + } + + status(): LocalAgentDaemonStatus { + if (!this.startedAt) throw new Error("Local agent daemon is not started."); + return { + state: this.stopping ? "stopping" : "ready", + protocolVersion: 1, + pid: process.pid, + endpoint: this.paths.endpoint, + startedAt: this.startedAt, + activeTurns: this.manager.activeTurnCount, + runtimeCount: this.manager.runtimeCount, + clientConnections: this.sockets.size, + }; + } + + async close(): Promise { + if (this.closePromise) return this.closePromise; + this.accepting = false; + this.stopping = true; + if (this.idleTimer) clearInterval(this.idleTimer); + this.closePromise = (async () => { + writeLocalAgentDaemonLog(this.paths, "info", "daemon_stopping", { + activeTurns: this.manager.activeTurnCount, + runtimeCount: this.manager.runtimeCount, + }); + const [serverResult, managerResult] = await Promise.allSettled([ + closeServer(this.server), + this.manager.close(), + ]); + if (serverResult.status === "rejected") { + writeLocalAgentDaemonLog(this.paths, "warn", "daemon_socket_close_failed", { + error: errorMessage(serverResult.reason), + }); + } + if (managerResult.status === "rejected") { + writeLocalAgentDaemonLog(this.paths, "warn", "daemon_manager_close_failed", { + error: errorMessage(managerResult.reason), + }); + } + for (const socket of this.sockets) socket.destroy(); + this.sockets.clear(); + removeLocalAgentDaemonFiles(this.paths); + this.lock.release(); + writeLocalAgentDaemonLog(this.paths, "info", "daemon_stopped", {}); + this.server = undefined; + })(); + return this.closePromise; + } + + private handleConnection(socket: Socket): void { + this.sockets.add(socket); + socket.setEncoding("utf8"); + let buffer = ""; + let handled = false; + socket.on("data", (chunk: string | Buffer) => { + if (handled) return; + buffer += chunk.toString(); + if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) { + handled = true; + this.writeError(socket, "", "REQUEST_TOO_LARGE", "Daemon request is too large."); + return; + } + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + handled = true; + const line = buffer.slice(0, newline); + void this.handleLine(socket, line); + }); + socket.on("error", () => undefined); + socket.on("close", () => this.sockets.delete(socket)); + } + + private async handleLine(socket: Socket, line: string): Promise { + let requestId = ""; + try { + const parsed: unknown = JSON.parse(line); + requestId = readRequestId(parsed); + const request = decodeLocalAgentDaemonRequest(parsed); + const response = await this.dispatch(request); + socket.end(encodeLocalAgentDaemonResponse({ + requestId: request.requestId, + protocolVersion: 1, + ok: true, + result: response, + })); + if (request.method === "daemon.stop") setImmediate(() => { void this.close(); }); + } catch (error) { + this.writeError(socket, requestId, errorCode(error), errorMessage(error)); + } + } + + private async dispatch(request: LocalAgentDaemonRequest): Promise { + if (request.protocolVersion !== 1) { + throw new LocalAgentDaemonProtocolError( + "PROTOCOL_MISMATCH", + `Unsupported daemon protocol version ${request.protocolVersion}; expected 1.`, + ); + } + if (!this.accepting && request.method !== "hello" && request.method !== "daemon.status") { + throw new Error("Local agent daemon is stopping."); + } + + switch (request.method) { + case "hello": + return this.status(); + case "agent.run": { + const existing = this.manager.get(request.params.target); + return existing + ? this.manager.continue(request.params.target, request.params.prompt, request.params) + : this.manager.start(request.params); + } + case "agent.start": + return this.manager.start(request.params); + case "agent.continue": + return this.manager.continue(request.params.id, request.params.prompt, request.params.overrides); + case "agent.get": + return this.manager.get(request.params.id) ?? null; + case "agent.list": + return this.manager.list(request.params); + case "daemon.status": + return this.status(); + case "daemon.stop": + this.stopping = true; + this.accepting = false; + return this.status(); + case "daemon.logs": + return readLocalAgentDaemonLogs(this.paths, request.params.lines); + } + } + + private writeError(socket: Socket, requestId: string, code: string, message: string): void { + socket.end(encodeLocalAgentDaemonResponse({ + requestId, + protocolVersion: 1, + ok: false, + error: { code, message }, + })); + } + + private async maintainIdle(): Promise { + await this.manager.evictIdle(this.now()); + if (this.stopping || this.manager.activeTurnCount > 0 || this.manager.runtimeCount > 0 || this.sockets.size > 0) { + this.idleSince = undefined; + return; + } + const now = this.now(); + this.idleSince ??= now; + if (now - this.idleSince >= this.idleShutdownMs) await this.close(); + } +} + +async function listen(server: NetServer, endpoint: string): Promise { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(endpoint); + }); +} + +async function closeServer(server: NetServer | undefined): Promise { + if (!server) return; + if (!server.listening) return; + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} + +function readRequestId(value: unknown): string { + if (!value || typeof value !== "object" || Array.isArray(value)) return ""; + const requestId = (value as Record).requestId; + return typeof requestId === "string" ? requestId : ""; +} + +function errorCode(error: unknown): string { + if (error instanceof LocalAgentDaemonProtocolError) return error.code; + if (errorMessage(error).includes("already has a running turn")) return "CONFLICT"; + if (errorMessage(error).includes("is stopping")) return "DAEMON_STOPPING"; + return "AGENT_ERROR"; +} + +export function writeLocalAgentDaemonLog( + paths: LocalAgentDaemonPaths, + level: "info" | "warn" | "error", + event: string, + fields: Record, +): void { + try { + ensureLocalAgentDaemonStateDir(paths.stateDir); + appendFileSync(paths.logPath, `${JSON.stringify({ at: new Date().toISOString(), level, event, ...fields })}\n`, { mode: 0o600 }); + chmodSync(paths.logPath, 0o600); + } catch { + // Diagnostics must never break agent execution or shutdown. + } +} + +export function readLocalAgentDaemonLogs(paths: LocalAgentDaemonPaths, lines = 200): string { + try { + const content = readFileSync(paths.logPath, "utf8"); + return content.split(/\r?\n/).filter(Boolean).slice(-Math.max(1, lines)).join("\n"); + } catch { + return ""; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} From 9eb9b91aba2d19a8c1f9e5ba6cb113dd19854613 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:38:20 +0530 Subject: [PATCH 06/35] refactor: move manager and store ownership into agent daemon --- package.json | 3 +- src/local-agent-adapters.ts | 69 ++++++++++++++++++++++++++++++++-- src/local-agent-daemon-main.ts | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/local-agent-daemon-main.ts diff --git a/package.json b/package.json index 129839120..09e4fe0c1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "node": ">=22.19 <27" }, "bin": { - "devspace": "dist/cli.js" + "devspace": "dist/cli.js", + "devspace-agentd": "dist/local-agent-daemon-main.js" }, "files": [ "dist", diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 457b8e08e..2bb88d139 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,14 +1,22 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; +import type { + ModelReasoningEffort, + SandboxMode, + ThreadOptions, +} from "@openai/codex-sdk"; import type { EffortLevel } from "@anthropic-ai/claude-agent-sdk"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { - createCodexSdkLocalAgentRuntime, type LocalAgentRunInput, type LocalAgentRunResult, + type LocalAgentRuntime, + type LocalAgentRuntimeContext, + type LocalAgentDriver, } from "./local-agent-runtime.js"; +import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; export interface LocalAgentAdapter { readonly provider: LocalAgentProvider; @@ -44,15 +52,70 @@ export function createLocalAgentAdapter(provider: LocalAgentProvider): LocalAgen } } +export function createLocalAgentDrivers(): LocalAgentDriver[] { + return LOCAL_AGENT_PROVIDERS.map((provider) => new LegacyLocalAgentDriver(provider)); +} + +class LegacyLocalAgentDriver implements LocalAgentDriver { + readonly idleTimeoutMs = 60_000; + + constructor(readonly provider: LocalAgentProvider) {} + + runtimeKey(context: LocalAgentRuntimeContext): string { + return `legacy:${this.provider}:${context.agentId}`; + } + + async createRuntime(_context: LocalAgentRuntimeContext): Promise { + const adapter = createLocalAgentAdapter(this.provider); + return { + provider: this.provider, + run: (input) => adapter.run(input), + releaseSession: async () => undefined, + close: async () => undefined, + isAlive: () => true, + }; + } +} + class CodexLocalAgentAdapter implements LocalAgentAdapter { readonly provider = "codex" as const; async run(input: LocalAgentRunInput): Promise { - const runtime = await createCodexSdkLocalAgentRuntime(); - return runtime.run(input); + const module = await import("@openai/codex-sdk"); + const codex = new module.Codex(); + const options = threadOptionsFor(input); + const thread = input.providerSessionId + ? codex.resumeThread(input.providerSessionId, options) + : codex.startThread(options); + const turn = await thread.run(input.prompt); + return { + provider: this.provider, + providerSessionId: thread.id, + finalResponse: turn.finalResponse, + items: turn.items, + }; } } +function sandboxModeFor(writeMode: LocalAgentRunInput["writeMode"]): SandboxMode { + switch (writeMode) { + case "allowed": return "workspace-write"; + case "full_access": return "danger-full-access"; + case "read_only": + case undefined: return "read-only"; + } +} + +function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { + return { + workingDirectory: input.workspace, + sandboxMode: sandboxModeFor(input.writeMode), + approvalPolicy: "never", + model: input.model, + modelReasoningEffort: input.thinking as ModelReasoningEffort | undefined, + }; +} + class ClaudeLocalAgentAdapter implements LocalAgentAdapter { readonly provider = "claude" as const; diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts new file mode 100644 index 000000000..6fe5695d0 --- /dev/null +++ b/src/local-agent-daemon-main.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import { loadConfig } from "./config.js"; +import { createLocalAgentDrivers } from "./local-agent-adapters.js"; +import { loadLocalAgentProfiles } from "./local-agent-profiles.js"; +import { LocalAgentDaemon, writeLocalAgentDaemonLog } from "./local-agent-daemon.js"; +import { + LocalAgentDaemonAlreadyRunningError, + localAgentDaemonPaths, +} from "./local-agent-daemon-lifecycle.js"; +import { LocalAgentManager } from "./local-agent-manager.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; +import { LocalAgentStore } from "./local-agent-store.js"; + +const config = loadConfig(); +const paths = localAgentDaemonPaths(config.stateDir); +const log = ( + level: "info" | "warn" | "error", + event: string, + fields: Record, +) => writeLocalAgentDaemonLog(paths, level, event, fields); +const store = new LocalAgentStore(paths.stateDir); +const manager = new LocalAgentManager({ + store, + drivers: createLocalAgentDrivers(), + pool: new LocalAgentRuntimePool({ logger: log }), + loadProfiles: (workspaceRoot) => loadLocalAgentProfiles(config, workspaceRoot), + agentDir: config.agentDir, + logger: log, +}); +const daemon = new LocalAgentDaemon({ + stateDir: paths.stateDir, + manager, + idleShutdownMs: parseIdleShutdownMs(process.env.DEVSPACE_AGENTD_IDLE_TIMEOUT_MS), +}); + +let shuttingDown = false; +const shutdown = () => { + if (shuttingDown) return; + shuttingDown = true; + void daemon.close().finally(() => process.exit(0)); +}; +process.once("SIGINT", shutdown); +process.once("SIGTERM", shutdown); + +try { + await daemon.start(); +} catch (error) { + if (error instanceof LocalAgentDaemonAlreadyRunningError) { + await manager.close(); + process.exit(0); + } + log("error", "daemon_start_failed", { error: error instanceof Error ? error.message : String(error) }); + await manager.close(); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} + +function parseIdleShutdownMs(value: string | undefined): number { + if (value === undefined || value.trim() === "") return 30_000; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error("DEVSPACE_AGENTD_IDLE_TIMEOUT_MS must be a non-negative duration."); + } + return parsed; +} From ed7b41b52652c0f2dfec920637bb6afb576e9898 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:41:15 +0530 Subject: [PATCH 07/35] refactor: route CLI agent commands through daemon client --- src/cli.test.ts | 82 +++++++++++++++----- src/cli.ts | 194 +++++++++--------------------------------------- 2 files changed, 101 insertions(+), 175 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 97b7084a8..560a4eb82 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,11 +1,17 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer as createNetServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { promisify } from "node:util"; import { loadConfig } from "./config.js"; +import { localAgentDaemonPaths } from "./local-agent-daemon-lifecycle.js"; +import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js"; import { LocalAgentStore } from "./local-agent-store.js"; +const execFileAsync = promisify(execFile); + const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; }; @@ -65,24 +71,66 @@ try { ); store.close(); - const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", "agents", "ls"], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - DEVSPACE_CONFIG_DIR: configDir, - DEVSPACE_ALLOWED_ROOTS: projectRoot, - DEVSPACE_STATE_DIR: stateDir, - DEVSPACE_WORKSPACE_ID: "ws_current", - DEVSPACE_WORKSPACE_ROOT: projectRoot, - DEVSPACE_SUBAGENTS: "1", - DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", - }, + const daemonSocket = localAgentDaemonPaths(stateDir).endpoint; + const daemon = createNetServer((socket) => { + let buffer = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string | Buffer) => { + buffer += chunk.toString(); + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + const request = JSON.parse(buffer.slice(0, newline)) as { requestId: string; method: string }; + const result = request.method === "agent.list" + ? [current] + : request.method === "hello" + ? { + state: "ready", + protocolVersion: 1, + pid: process.pid, + endpoint: daemonSocket, + startedAt: "now", + activeTurns: 0, + runtimeCount: 0, + clientConnections: 1, + } + : null; + socket.end(encodeLocalAgentDaemonResponse({ + requestId: request.requestId, + protocolVersion: 1, + ok: true, + result, + })); + }); }); + await new Promise((resolveListen, rejectListen) => { + daemon.once("error", rejectListen); + daemon.listen(daemonSocket, resolveListen); + }); + + try { + const { stdout: output } = await execFileAsync("node", ["--import", "tsx", "src/cli.ts", "agents", "ls"], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + DEVSPACE_CONFIG_DIR: configDir, + DEVSPACE_ALLOWED_ROOTS: projectRoot, + DEVSPACE_STATE_DIR: stateDir, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + DEVSPACE_SUBAGENTS: "1", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }, + }); - assert.match(output, new RegExp(`${current.id} idle reviewer codex gpt-5\\.4 thinking=high`)); - assert.doesNotMatch(output, /profile reviewer/); - assert.doesNotMatch(output, new RegExp(other.id)); + assert.match(output, new RegExp(`${current.id} idle reviewer codex gpt-5\\.4 thinking=high`)); + assert.doesNotMatch(output, /profile reviewer/); + assert.doesNotMatch(output, new RegExp(other.id)); + } finally { + await new Promise((resolveClose, rejectClose) => { + daemon.close((error) => error ? rejectClose(error) : resolveClose()); + }); + } assert.equal(loadConfig({ DEVSPACE_CONFIG_DIR: configDir, diff --git a/src/cli.ts b/src/cli.ts index 7a1ac63fe..b358567aa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,33 +1,19 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { stdin as input, stdout as output } from "node:process"; -import { spawn } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; import * as prompts from "@clack/prompts"; import { getShellConfig } from "@earendil-works/pi-coding-agent"; import { satisfies } from "semver"; import { loadConfig } from "./config.js"; -import { runLocalAgentProvider } from "./local-agent-adapters.js"; import { - isLocalAgentProvider, - loadLocalAgentProfiles, - type LocalAgentProfile, -} from "./local-agent-profiles.js"; -import { - assertLocalAgentProviderAvailable, formatLocalAgentProviderAvailabilitySummary, } from "./local-agent-availability.js"; import { - formatAvailableLocalAgentTargets, parseLocalAgentRunArgs, - resolveLocalAgentTarget, } from "./local-agent-targets.js"; -import { createLocalAgentStore, type LocalAgentRecord } from "./local-agent-store.js"; -import type { LocalAgentRunResult } from "./local-agent-runtime.js"; +import { createLocalAgentClient } from "./local-agent-client.js"; +import type { LocalAgentRecord } from "./local-agent-store.js"; import { ensureDevspaceDefaultSkills, generateOwnerToken, @@ -312,6 +298,7 @@ function printHelp(): void { " devspace agents ls List subagent sessions", " devspace agents run [--model ] ", " devspace agents show ", + " devspace agents daemon ", " devspace -v, --version Print the installed version", "", "For temporary tunnels:", @@ -333,8 +320,8 @@ async function runAgentsCommand(args: string[]): Promise { case "show": await runAgentsShow(rest); return; - case "__worker": - await runAgentsWorker(rest); + case "daemon": + await runAgentsDaemon(rest); return; case undefined: case "help": @@ -349,8 +336,8 @@ async function runAgentsCommand(args: string[]): Promise { async function runAgentsList(): Promise { const config = loadConfig(); - const store = createLocalAgentStore(config); - const agents = store.list(resolveCurrentWorkspaceScope()); + const client = createLocalAgentClient(config); + const agents = await client.list(resolveCurrentWorkspaceScope()); if (agents.length === 0) { console.log("No subagent sessions found for this workspace."); @@ -364,56 +351,18 @@ async function runAgentsList(): Promise { async function runAgentsRun(args: string[]): Promise { const parsed = parseLocalAgentRunArgs(args); - const config = loadConfig(); const workspaceRoot = resolveCurrentWorkspaceRoot(); - const store = createLocalAgentStore(config); - const existing = store.get(parsed.target); - - if (existing) { - if (!isLocalAgentProvider(existing.provider)) { - throw new Error(`Unknown subagent provider for existing session: ${existing.provider}`); - } - assertLocalAgentProviderAvailable(existing.provider); - const promptFile = writeAgentPromptFile(parsed.prompt); - store.update(existing.id, { - status: "starting", - model: parsed.model ?? existing.model, - thinking: parsed.thinking ?? existing.thinking, - latestResponse: undefined, - error: undefined, - }); - spawnAgentWorker(existing.id, promptFile); - console.log(formatAgentLine({ - ...existing, - status: "running", - model: parsed.model ?? existing.model, - thinking: parsed.thinking ?? existing.thinking, - })); - return; - } - - const profiles = await loadLocalAgentProfiles(config, workspaceRoot); - const target = resolveLocalAgentTarget(parsed.target, profiles, parsed.model, parsed.thinking); - if (!target) { - throw new Error( - `Unknown subagent profile, provider, or id: ${parsed.target}. Available ${formatAvailableLocalAgentTargets(profiles)}`, - ); - } - assertLocalAgentProviderAvailable(target.provider); - - const promptFile = writeAgentPromptFile(parsed.prompt); - const record = store.create({ - workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + const client = createLocalAgentClient(config); + const record = await client.run({ + target: parsed.target, + prompt: parsed.prompt, workspaceRoot, - profileName: target.name, - provider: target.provider, - model: target.model, - thinking: target.thinking, + workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + model: parsed.model, + thinking: parsed.thinking, }); - - spawnAgentWorker(record.id, promptFile); - console.log(formatAgentLine({ ...record, status: "running" })); + console.log(formatAgentLine(record)); } async function runAgentsShow(args: string[]): Promise { @@ -421,14 +370,14 @@ async function runAgentsShow(args: string[]): Promise { if (!id) throw new Error("Usage: devspace agents show "); const config = loadConfig(); - const store = createLocalAgentStore(config); - let record = store.get(id); + const client = createLocalAgentClient(config); + let record = await client.get(id); if (!record) throw new Error(`Unknown subagent id: ${id}`); const deadline = Date.now() + 15_000; while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { await sleep(500); - record = store.get(id) ?? record; + record = await client.get(id) ?? record; } console.log(formatAgentLine(record)); @@ -445,96 +394,24 @@ async function runAgentsShow(args: string[]): Promise { } } -async function runAgentsWorker(args: string[]): Promise { - const [id, promptFileFlag, promptFile] = args; - if (!id || promptFileFlag !== "--prompt-file" || !promptFile) { - throw new Error("Usage: devspace agents __worker --prompt-file "); - } - +async function runAgentsDaemon(args: string[]): Promise { + const [subcommand] = args; const config = loadConfig(); - const store = createLocalAgentStore(config); - const record = store.get(id); - if (!record) throw new Error(`Unknown subagent id: ${id}`); - - store.update(record.id, { status: "running", error: undefined }); - try { - const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot); - const profile = profiles.find((candidate) => candidate.name === record.profileName); - const prompt = await readFile(promptFile, "utf8"); - const result = profile - ? await runLocalAgentProfile(profile, record, prompt) - : await runRawLocalAgentProvider(record, prompt); - store.update(record.id, { - providerSessionId: result.providerSessionId ?? undefined, - status: "idle", - latestResponse: result.finalResponse, - error: undefined, - }); - } catch (error) { - store.update(record.id, { - status: "error", - error: error instanceof Error ? error.message : String(error), - }); - } -} - -async function runLocalAgentProfile( - profile: LocalAgentProfile, - record: LocalAgentRecord, - prompt: string, -): Promise { - const body = profile.body.trim(); - const fullPrompt = body ? `${body}\n\nTask:\n${prompt}` : prompt; - return runLocalAgentProvider(profile.provider, { - prompt: fullPrompt, - workspace: record.workspaceRoot, - providerSessionId: record.providerSessionId, - writeMode: "allowed", - model: record.model ?? profile.model, - thinking: record.thinking ?? profile.thinking, - }); -} - -async function runRawLocalAgentProvider( - record: LocalAgentRecord, - prompt: string, -): Promise { - if (record.profileName !== record.provider || !isLocalAgentProvider(record.provider)) { - throw new Error(`Subagent profile not found: ${record.profileName}`); + const client = createLocalAgentClient(config); + switch (subcommand) { + case "status": + console.log(JSON.stringify(await client.status(), null, 2)); + return; + case "stop": + await client.stop(); + console.log("Local agent daemon stopped."); + return; + case "logs": + console.log(await client.logs()); + return; + default: + throw new Error("Usage: devspace agents daemon "); } - - return runLocalAgentProvider(record.provider, { - prompt, - workspace: record.workspaceRoot, - providerSessionId: record.providerSessionId, - writeMode: "allowed", - model: record.model, - thinking: record.thinking, - }); -} - -function spawnAgentWorker(agentId: string, promptFile: string): void { - const child = spawn(process.execPath, [ - ...process.execArgv, - fileURLToPath(import.meta.url), - "agents", - "__worker", - agentId, - "--prompt-file", - promptFile, - ], { - detached: true, - stdio: "ignore", - env: process.env, - }); - child.unref(); -} - -function writeAgentPromptFile(prompt: string): string { - const directory = mkdtempSync(join(tmpdir(), "devspace-agent-prompt-")); - const filePath = join(directory, "prompt.txt"); - writeFileSync(filePath, prompt, { mode: 0o600 }); - return filePath; } function resolveCurrentWorkspaceRoot(): string { @@ -570,6 +447,7 @@ function printAgentsHelp(): void { " devspace agents ls", " devspace agents run [--model ] [--thinking ] ", " devspace agents show ", + " devspace agents daemon ", ].join("\n"), ); } From ad1f0e9bdc6446d1618b3c062a90e9541fcbb641 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:42:57 +0530 Subject: [PATCH 08/35] docs: route MCP agent work through the daemon --- docs/agent-profile-schema.md | 8 +++++--- docs/gotchas.md | 2 ++ skills/subagent-delegation/SKILL.md | 5 +++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 0dc3db951..0bf189781 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -2,7 +2,8 @@ DevSpace agent profiles are user-owned markdown files with YAML frontmatter. They describe roles such as reviewer, explorer, or implementer. -DevSpace owns provider invocation. +The internal on-demand `devspace-agentd` process owns provider invocation. The +CLI and MCP server use it as clients when they need agent execution. Profiles are discovered from: @@ -161,5 +162,6 @@ profile. - Inferring changed files, tests, or diffs from worker output. - Exposing raw provider transcripts by default. - Teaching the model provider-specific CLIs. -- First-class MCP agent tools. Future tools should wrap the same provider - adapter registry used by `devspace agents`. +- First-class MCP agent tools. Future tools should call the same local agent + daemon used by `devspace agents` rather than executing providers in the MCP + server process. diff --git a/docs/gotchas.md b/docs/gotchas.md index 639f54a9a..a043d0473 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -225,6 +225,8 @@ When `DEVSPACE_SUBAGENTS=1`, DevSpace loads agent profiles from compact profile catalog through `open_workspace`. The bundled `subagent-delegation` skill keeps the model-facing workflow to `devspace agents ls`, `devspace agents run`, and `devspace agents show`. +Those commands automatically manage the internal local agent daemon; `devspace +serve` is not a prerequisite. `devspace agents ls` lists existing subagent sessions, not profile definitions. diff --git a/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md index fb269df5c..ea6430d7f 100644 --- a/skills/subagent-delegation/SKILL.md +++ b/skills/subagent-delegation/SKILL.md @@ -37,6 +37,11 @@ profile is needed. Built-in providers are listed by `open_workspace`. running, `show` waits briefly. If there is still no final response, call `show` again later. +The commands automatically start the internal `devspace-agentd` process when +needed. `devspace serve` is not required for local-agent execution. The daemon +owns shared agent sessions and provider runtimes for the configured DevSpace +state directory. + Do not run provider CLIs such as `codex`, `claude`, `opencode`, `pi`, `cursor-agent`, or `copilot` directly unless you are explicitly debugging DevSpace agent integration. From cee6840931030ed34cbe570941d5cd8b21a3d1da Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:48:07 +0530 Subject: [PATCH 09/35] test: cover daemon startup shutdown and recovery --- package.json | 2 +- src/local-agent-client.ts | 2 +- src/local-agent-daemon.test.ts | 114 +++++++++++++++++++++++++++++++++ src/local-agent-daemon.ts | 19 ++++-- 4 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 src/local-agent-daemon.test.ts diff --git a/package.json b/package.json index 09e4fe0c1..3ac3f08ae 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 7a0520b35..dc86f2237 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -20,7 +20,7 @@ import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, localAgentDaemonPaths, } from "./local-agent-daemon-lifecycle.js"; -import type { LocalAgentManager, RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; +import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; const DEFAULT_STARTUP_TIMEOUT_MS = 8_000; diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts new file mode 100644 index 000000000..29ae02004 --- /dev/null +++ b/src/local-agent-daemon.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { LocalAgentClient } from "./local-agent-client.js"; +import { LocalAgentDaemon, type LocalAgentDaemonManager } from "./local-agent-daemon.js"; +import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; +import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-agentd-test-")); +const record: LocalAgentRecord = { + id: "agt_test", + workspaceRoot: join(root, "project"), + profileName: "reviewer", + provider: "codex", + status: "running", + createdAt: "now", + updatedAt: "now", +}; + +class FakeManager implements LocalAgentDaemonManager { + activeTurnCount = 1; + runtimeCount = 0; + closed = false; + lastInput?: StartLocalAgentInput; + + async start(input: StartLocalAgentInput): Promise { + this.lastInput = input; + return record; + } + + async continue(_agentId: string, _prompt: string, _overrides?: RunOverrides): Promise { + return { ...record, status: "running" }; + } + + get(id: string): LocalAgentRecord | undefined { + return id === record.id ? record : undefined; + } + + list(_scope?: LocalAgentListScope): LocalAgentRecord[] { + return [record]; + } + + async evictIdle(): Promise {} + + async close(): Promise { + this.closed = true; + this.activeTurnCount = 0; + } +} + +const manager = new FakeManager(); +const daemon = new LocalAgentDaemon({ + stateDir: join(root, "state"), + manager, + idleShutdownMs: 60_000, +}); +const client = new LocalAgentClient({ + stateDir: join(root, "state"), + startupTimeoutMs: 2_000, + requestTimeoutMs: 2_000, + spawnDaemon: () => { void daemon.start(); }, +}); + +try { + const started = await client.run({ + target: "reviewer", + prompt: "Review this", + workspaceRoot: join(root, "project"), + }); + assert.equal(started.id, record.id); + assert.equal(manager.lastInput?.prompt, "Review this"); + assert.equal((await client.get(record.id))?.id, record.id); + assert.equal((await client.list())[0]?.id, record.id); + assert.equal((await client.status()).state, "ready"); + + await client.stop(); + await waitFor(() => manager.closed && !existsSync(daemon.paths.socketPath)); +} finally { + await daemon.close(); +} + +const idleStateDir = join(root, "idle-state"); +const idleManager = new FakeManager(); +idleManager.activeTurnCount = 0; +const idleDaemon = new LocalAgentDaemon({ + stateDir: idleStateDir, + manager: idleManager, + idleShutdownMs: 200, + idleCheckIntervalMs: 10, +}); +const idleClient = new LocalAgentClient({ + stateDir: idleStateDir, + startupTimeoutMs: 2_000, + requestTimeoutMs: 2_000, + spawnDaemon: () => { void idleDaemon.start(); }, +}); + +try { + await idleClient.ensureReady(); + await waitFor(() => idleManager.closed && !existsSync(idleDaemon.paths.socketPath)); +} finally { + await idleDaemon.close(); + await rm(root, { recursive: true, force: true }); +} + +async function waitFor(check: () => boolean): Promise { + const deadline = Date.now() + 2_000; + while (!check() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(check(), true, "condition did not become true before timeout"); +} diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index 362cd786e..5b8637234 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -1,6 +1,7 @@ import { appendFileSync, chmodSync, readFileSync, rmSync } from "node:fs"; import { createServer, type Server as NetServer, type Socket } from "node:net"; import { + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, LocalAgentDaemonAlreadyRunningError, LocalAgentDaemonLock, ensureLocalAgentDaemonStateDir, @@ -16,7 +17,7 @@ import { type LocalAgentDaemonStatus, LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; -import type { LocalAgentManager, RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; +import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; const MAX_REQUEST_BYTES = 512 * 1024; @@ -107,7 +108,7 @@ export class LocalAgentDaemon { if (!this.startedAt) throw new Error("Local agent daemon is not started."); return { state: this.stopping ? "stopping" : "ready", - protocolVersion: 1, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, pid: process.pid, endpoint: this.paths.endpoint, startedAt: this.startedAt, @@ -183,7 +184,7 @@ export class LocalAgentDaemon { const response = await this.dispatch(request); socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 1, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: response, })); @@ -194,10 +195,10 @@ export class LocalAgentDaemon { } private async dispatch(request: LocalAgentDaemonRequest): Promise { - if (request.protocolVersion !== 1) { + if (request.protocolVersion !== LOCAL_AGENT_DAEMON_PROTOCOL_VERSION) { throw new LocalAgentDaemonProtocolError( "PROTOCOL_MISMATCH", - `Unsupported daemon protocol version ${request.protocolVersion}; expected 1.`, + `Unsupported daemon protocol version ${request.protocolVersion}; expected ${LOCAL_AGENT_DAEMON_PROTOCOL_VERSION}.`, ); } if (!this.accepting && request.method !== "hello" && request.method !== "daemon.status") { @@ -210,7 +211,11 @@ export class LocalAgentDaemon { case "agent.run": { const existing = this.manager.get(request.params.target); return existing - ? this.manager.continue(request.params.target, request.params.prompt, request.params) + ? this.manager.continue(request.params.target, request.params.prompt, { + model: request.params.model, + thinking: request.params.thinking, + writeMode: request.params.writeMode, + }) : this.manager.start(request.params); } case "agent.start": @@ -235,7 +240,7 @@ export class LocalAgentDaemon { private writeError(socket: Socket, requestId: string, code: string, message: string): void { socket.end(encodeLocalAgentDaemonResponse({ requestId, - protocolVersion: 1, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: false, error: { code, message }, })); From e34e41f7883bbb07e682b2f4ff707482d9c9f542 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:54:46 +0530 Subject: [PATCH 10/35] feat: bound daemon shutdown and recovery --- src/local-agent-daemon-main.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index 6fe5695d0..2aa409711 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -12,6 +12,7 @@ import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; import { LocalAgentStore } from "./local-agent-store.js"; const config = loadConfig(); +const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; const paths = localAgentDaemonPaths(config.stateDir); const log = ( level: "info" | "warn" | "error", @@ -37,6 +38,16 @@ let shuttingDown = false; const shutdown = () => { if (shuttingDown) return; shuttingDown = true; + const forceTimer = setTimeout(() => { + log("error", "daemon_forced_shutdown", { + activeTurns: manager.activeTurnCount, + runtimeCount: manager.runtimeCount, + }); + // Active records intentionally remain durable. The next daemon startup + // reconciles them to error while preserving provider continuation data. + process.exit(1); + }, parseShutdownTimeoutMs(process.env.DEVSPACE_AGENTD_SHUTDOWN_TIMEOUT_MS)); + forceTimer.unref(); void daemon.close().finally(() => process.exit(0)); }; process.once("SIGINT", shutdown); @@ -63,3 +74,12 @@ function parseIdleShutdownMs(value: string | undefined): number { } return parsed; } + +function parseShutdownTimeoutMs(value: string | undefined): number { + if (value === undefined || value.trim() === "") return DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error("DEVSPACE_AGENTD_SHUTDOWN_TIMEOUT_MS must be a non-negative duration."); + } + return parsed; +} From 6cf9b71ddff6bf9195c4a91752ebdfc8e3c071d0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:54:57 +0530 Subject: [PATCH 11/35] docs: document daemon recovery and diagnostics --- docs/local-agent-daemon.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/local-agent-daemon.md b/docs/local-agent-daemon.md index 25afb83f4..fa1debcde 100644 --- a/docs/local-agent-daemon.md +++ b/docs/local-agent-daemon.md @@ -27,7 +27,23 @@ on Windows. The endpoint is not exposed through the public MCP HTTP port. Provider session identifiers and logical agent records are durable; live provider runtimes are disposable and may be recreated after a daemon restart. +The daemon state directory contains the socket or pipe identity, an atomic +lock, a PID marker, and diagnostic logs. A second client cannot start another +daemon for the same state directory. Stale lock and socket files are recovered +only after the recorded PID is no longer alive. + The daemon is started on demand and may exit after its active turns, clients, and warm runtime idle periods have ended. Users do not need to manage it during -normal operation. Diagnostic commands may inspect or stop it when debugging -startup, process, or cleanup problems. +normal operation. Diagnostic commands are available for startup, process, and +cleanup problems: + +```bash +devspace agents daemon status +devspace agents daemon stop +devspace agents daemon logs +``` + +Shutdown gives active turns a bounded graceful window. If that window expires, +the process exits with active records left durable; the next daemon startup +reconciles stale `starting` and `running` records to `error` without discarding +their `providerSessionId` or `latestResponse`. From 42c5bd1c9184b5fbb2fdd54c05201f2bd150d28a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 23:57:07 +0530 Subject: [PATCH 12/35] chore: sync daemon package metadata --- package-lock.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 79993030d..9b5de5f54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,8 @@ "zod": "^4.4.3" }, "bin": { - "devspace": "dist/cli.js" + "devspace": "dist/cli.js", + "devspace-agentd": "dist/local-agent-daemon-main.js" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", From 414864ecd8cecfbc33fc922079ec9c9667fa795c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 00:23:07 +0530 Subject: [PATCH 13/35] fix: harden local agent runtime pooling --- src/local-agent-runtime-pool.ts | 118 ++++++++++++++++++++++++++++++-- src/local-agent-runtime.test.ts | 67 +++++++++++++++++- src/local-agent-runtime.ts | 11 ++- 3 files changed, 188 insertions(+), 8 deletions(-) diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index d7c890348..bd99384ca 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import type { LocalAgentDriver, + LocalAgentRunCallbacks, LocalAgentRunInput, LocalAgentRunResult, LocalAgentRuntime, @@ -8,6 +9,7 @@ import type { } from "./local-agent-runtime.js"; const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000; +const DEFAULT_SESSION_IDLE_TIMEOUT_MS = 60_000; export interface LocalAgentRuntimePoolLogger { (level: "info" | "warn" | "error", event: string, fields: Record): void; @@ -17,6 +19,7 @@ interface RuntimeEntry { readonly key: string; readonly driver: LocalAgentDriver; readonly idleTimeoutMs: number; + readonly sessionIdleTimeoutMs: number; readonly createPromise: Promise; runtime?: LocalAgentRuntime; activeRuns: number; @@ -24,11 +27,18 @@ interface RuntimeEntry { closePromise?: Promise; idleTimer?: NodeJS.Timeout; closing: boolean; + readonly sessions: Map; +} + +interface SessionEntry { + activeRuns: number; + lastUsedAt: number; } export interface LocalAgentRuntimePoolOptions { now?: () => number; logger?: LocalAgentRuntimePoolLogger; + sessionIdleTimeoutMs?: number; } /** @@ -40,18 +50,24 @@ export class LocalAgentRuntimePool { private readonly entries = new Map(); private readonly now: () => number; private readonly logger?: LocalAgentRuntimePoolLogger; + private readonly sessionIdleTimeoutMs: number; private closing = false; private closePromise?: Promise; constructor(options: LocalAgentRuntimePoolOptions = {}) { this.now = options.now ?? Date.now; this.logger = options.logger; + this.sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS; + if (!Number.isFinite(this.sessionIdleTimeoutMs) || this.sessionIdleTimeoutMs < 0) { + throw new Error("Local agent session idle timeout must be a non-negative finite duration."); + } } async run( driver: LocalAgentDriver, context: LocalAgentRuntimeContext, input: LocalAgentRunInput, + inputCallbacks?: LocalAgentRunCallbacks, ): Promise { if (this.closing) throw new Error("Local agent runtime pool is closed."); @@ -70,12 +86,39 @@ export class LocalAgentRuntimePool { this.clearIdleTimer(entry); entry.activeRuns += 1; + const sessionIds = new Set(); + const registerSession = (providerSessionId: string): void => { + if (!providerSessionId || sessionIds.has(providerSessionId)) return; + sessionIds.add(providerSessionId); + const session = entry.sessions.get(providerSessionId) ?? { activeRuns: 0, lastUsedAt: this.now() }; + session.activeRuns += 1; + session.lastUsedAt = this.now(); + entry.sessions.set(providerSessionId, session); + }; + registerSession(input.providerSessionId ?? ""); + const callbacks: LocalAgentRunCallbacks = { + onSessionId: async (providerSessionId) => { + registerSession(providerSessionId); + await inputCallbacks?.onSessionId?.(providerSessionId); + }, + }; const startedAt = this.now(); try { - return await runtime.run(input); + const result = await runtime.run(input, callbacks); + registerSession(result.providerSessionId ?? ""); + return result; } catch (error) { if (!runtime.isAlive()) { - await this.removeAndClose(entry, "runtime_crashed"); + try { + await this.removeAndClose(entry, "runtime_crashed"); + } catch (cleanupError) { + this.log("warn", "harness_runtime_close_failed", { + provider: driver.provider, + runtimeKeyHash: hashRuntimeKey(entry.key), + reason: "runtime_crashed", + error: errorMessage(cleanupError), + }); + } this.log("warn", "harness_runtime_crashed", { provider: driver.provider, runtimeKeyHash: hashRuntimeKey(entry.key), @@ -87,6 +130,12 @@ export class LocalAgentRuntimePool { } throw error; } finally { + for (const providerSessionId of sessionIds) { + const session = entry.sessions.get(providerSessionId); + if (!session) continue; + session.activeRuns = Math.max(0, session.activeRuns - 1); + session.lastUsedAt = this.now(); + } entry.activeRuns -= 1; entry.lastUsedAt = this.now(); if (entry.activeRuns === 0 && !entry.closing) this.scheduleIdleClose(entry); @@ -97,9 +146,11 @@ export class LocalAgentRuntimePool { async evictIdle(now = this.now()): Promise { const evictions: Promise[] = []; for (const entry of this.entries.values()) { - if (entry.closing || entry.activeRuns > 0 || !entry.runtime) continue; - if (now - entry.lastUsedAt < entry.idleTimeoutMs) continue; - evictions.push(this.removeAndClose(entry, "idle_timeout")); + if (entry.closing || !entry.runtime) continue; + await this.releaseIdleSessions(entry, now); + if (entry.activeRuns === 0 && now - entry.lastUsedAt >= entry.idleTimeoutMs) { + evictions.push(this.removeAndClose(entry, "idle_timeout")); + } } await Promise.all(evictions); } @@ -161,13 +212,19 @@ export class LocalAgentRuntimePool { key, driver, idleTimeoutMs: driver.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + sessionIdleTimeoutMs: this.sessionIdleTimeoutMs, createPromise, activeRuns: 0, lastUsedAt: this.now(), closing: false, + sessions: new Map(), }; this.entries.set(key, entry); await createPromise; + if (this.closing || entry.closing || this.entries.get(key) !== entry) { + await this.closeEntry(entry, "pool_shutdown_during_creation"); + throw new Error("Local agent runtime pool is closed."); + } return entry; } @@ -209,6 +266,7 @@ export class LocalAgentRuntimePool { } catch { return; } + await this.releaseSessions(entry, runtime, reason); try { await runtime.close(); this.log("info", "harness_runtime_closed", { @@ -229,6 +287,56 @@ export class LocalAgentRuntimePool { return entry.closePromise; } + private async releaseIdleSessions(entry: RuntimeEntry, now: number): Promise { + const releases: Promise[] = []; + for (const [providerSessionId, session] of entry.sessions) { + if (session.activeRuns > 0 || now - session.lastUsedAt < entry.sessionIdleTimeoutMs) continue; + releases.push(this.releaseSession(entry, providerSessionId)); + } + await Promise.all(releases); + } + + private async releaseSessions( + entry: RuntimeEntry, + runtime: LocalAgentRuntime, + reason: string, + ): Promise { + const releases = Array.from(entry.sessions.keys()).map(async (providerSessionId) => { + try { + await runtime.releaseSession(providerSessionId); + } catch (error) { + this.log("warn", "harness_session_release_failed", { + provider: entry.driver.provider, + runtimeKeyHash: hashRuntimeKey(entry.key), + providerSessionIdPrefix: providerSessionId.slice(0, 8), + reason, + error: errorMessage(error), + }); + } + }); + await Promise.all(releases); + entry.sessions.clear(); + } + + private async releaseSession(entry: RuntimeEntry, providerSessionId: string): Promise { + const runtime = entry.runtime; + if (!runtime || entry.closing) return; + try { + await runtime.releaseSession(providerSessionId); + if (entry.sessions.get(providerSessionId)?.activeRuns === 0) { + entry.sessions.delete(providerSessionId); + } + } catch (error) { + this.log("warn", "harness_session_release_failed", { + provider: entry.driver.provider, + runtimeKeyHash: hashRuntimeKey(entry.key), + providerSessionIdPrefix: providerSessionId.slice(0, 8), + reason: "idle_timeout", + error: errorMessage(error), + }); + } + } + private log( level: "info" | "warn" | "error", event: string, diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index b2fcad573..d412b04e0 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -20,6 +20,7 @@ class FakeRuntime implements LocalAgentRuntime { alive = true; closeCount = 0; runCount = 0; + readonly releasedSessions: string[] = []; private readonly pending: Array<() => void> = []; releaseWait(): void { @@ -37,7 +38,8 @@ class FakeRuntime implements LocalAgentRuntime { }; } - releaseSession(): Promise { + releaseSession(providerSessionId: string): Promise { + this.releasedSessions.push(providerSessionId); return Promise.resolve(); } @@ -75,7 +77,7 @@ assert.equal(first.finalResponse, "done:inspect"); assert.equal(second.finalResponse, "done:second"); assert.equal(runtime.runCount, 2); -const running = pool.run(driver, context, { ...input, prompt: "wait" }); +const running = pool.run(driver, context, { ...input, prompt: "wait", providerSessionId: "thread_1" }); await new Promise((resolve) => setImmediate(resolve)); await pool.evictIdle(Date.now() + 10_000_000); assert.equal(runtime.closeCount, 0, "active runtimes are not evicted"); @@ -85,4 +87,65 @@ await running; await pool.close(); await pool.close(); assert.equal(runtime.closeCount, 1, "runtime close is idempotent"); +assert.deepEqual(runtime.releasedSessions, ["thread_1"]); assert.equal(pool.size, 0); + +let clock = 0; +const sessionRuntime = new FakeRuntime(); +const sessionPool = new LocalAgentRuntimePool({ + now: () => clock, + sessionIdleTimeoutMs: 10, +}); +const sessionDriver: LocalAgentDriver = { + provider: "codex", + idleTimeoutMs: Number.POSITIVE_INFINITY, + runtimeKey: () => "session-runtime", + createRuntime: async () => sessionRuntime, +}; +await sessionPool.run(sessionDriver, context, input); +clock = 11; +await sessionPool.evictIdle(); +assert.deepEqual(sessionRuntime.releasedSessions, ["thread_1"]); +assert.equal(sessionPool.size, 1, "releasing an idle session does not close the runtime"); +await sessionPool.close(); + +class CleanupFailureRuntime extends FakeRuntime { + override async close(): Promise { + throw new Error("cleanup failed"); + } + + override async run(): Promise { + this.alive = false; + throw new Error("provider failed"); + } +} + +const cleanupPool = new LocalAgentRuntimePool(); +const cleanupRuntime = new CleanupFailureRuntime(); +const cleanupDriver: LocalAgentDriver = { + provider: "codex", + runtimeKey: () => "cleanup-runtime", + createRuntime: async () => cleanupRuntime, +}; +await assert.rejects( + cleanupPool.run(cleanupDriver, context, input), + /provider failed/, + "runtime cleanup must not replace the provider error", +); + +let resolveCreation!: (runtime: LocalAgentRuntime) => void; +const creating = new Promise((resolve) => { resolveCreation = resolve; }); +const raceRuntime = new FakeRuntime(); +const racePool = new LocalAgentRuntimePool(); +const raceDriver: LocalAgentDriver = { + provider: "codex", + runtimeKey: () => "creation-race", + createRuntime: async () => creating, +}; +const pendingRun = racePool.run(raceDriver, context, input); +await new Promise((resolve) => setImmediate(resolve)); +const pendingClose = racePool.close(); +resolveCreation(raceRuntime); +await pendingClose; +await assert.rejects(pendingRun, /closed/); +assert.equal(raceRuntime.closeCount, 1, "a runtime created during shutdown is closed"); diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 06697d5c9..0f2a27c94 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -18,6 +18,15 @@ export interface LocalAgentRunResult { items: unknown[]; } +export interface LocalAgentRunCallbacks { + /** + * Called as soon as a provider creates or resolves a durable continuation + * identity. The callback is awaited before the provider starts work that + * could otherwise fail and lose that identity. + */ + onSessionId?: (providerSessionId: string) => void | Promise; +} + export interface LocalAgentRuntimeContext { agentId: string; provider: LocalAgentProvider; @@ -36,7 +45,7 @@ export interface LocalAgentRuntimeContext { */ export interface LocalAgentRuntime { readonly provider: LocalAgentProvider; - run(input: LocalAgentRunInput): Promise; + run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks): Promise; releaseSession(providerSessionId: string): Promise; close(): Promise; isAlive(): boolean; From 037c78b9e437ec8da676c091e5024f94349fa7d6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 00:24:27 +0530 Subject: [PATCH 14/35] refactor: make daemon agent ownership explicit --- docs/agent-profile-schema.md | 7 +++- docs/chatgpt-coding-workflow.md | 3 +- docs/configuration.md | 3 +- docs/gotchas.md | 3 +- docs/local-agent-daemon.md | 7 +++- skills/subagent-delegation/SKILL.md | 5 ++- src/cli.ts | 23 +++++++++-- src/local-agent-client.ts | 3 +- src/local-agent-daemon-main.ts | 2 + src/local-agent-daemon-protocol.ts | 5 --- src/local-agent-daemon.ts | 18 +++------ src/local-agent-manager.test.ts | 39 ++++++++++++++++-- src/local-agent-manager.ts | 61 ++++++++++++++++++++++------- src/local-agent-store.test.ts | 10 ++--- src/local-agent-store.ts | 34 ++++++---------- src/local-agent-targets.ts | 30 +++++++++++++- 16 files changed, 177 insertions(+), 76 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 0bf189781..7817f6e55 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -134,7 +134,8 @@ The Subagent skill teaches only: ```bash devspace agents ls -devspace agents run "" +devspace agents run "" +devspace agents continue "" devspace agents show ``` @@ -153,6 +154,10 @@ devspace agents show `devspace agents ls` lists existing subagent sessions for the current workspace; it does not list profile definitions. +Use `devspace agents continue ` for a later turn. The logical agent ID is +the `agt_...` value returned by `run` or `ls`; provider session IDs are not +accepted as substitutes. + The full profile body stays out of the model context until DevSpace launches the profile. diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index ac2bdc7c2..20f263aad 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -143,7 +143,8 @@ Skill paths may be outside the workspace. DevSpace only permits reading: Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. Set `DEVSPACE_SUBAGENTS=1` to expose the experimental subagent catalog and `subagent-delegation` skill. That skill teaches the minimal -`devspace agents ls`, `devspace agents run`, and `devspace agents show` +`devspace agents ls`, `devspace agents run`, `devspace agents continue`, and +`devspace agents show` workflow. The catalog comes from `open_workspace`; `devspace agents ls` lists existing subagent sessions for that workspace. diff --git a/docs/configuration.md b/docs/configuration.md index 3502a98b2..2a22ea036 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -152,7 +152,8 @@ agent without reading provider-specific launch details. `devspace agents ls` lists existing subagent sessions for the current workspace, scoped by the workspace environment injected into shell commands. The `subagent-delegation` skill teaches the model to use only the minimal `devspace agents ls`, -`devspace agents run`, and `devspace agents show` workflow. +`devspace agents run`, `devspace agents continue`, and `devspace agents show` +workflow. Starter profile templates are available under `examples/agents/`. Copy or adapt them into one of the active profile directories before use. diff --git a/docs/gotchas.md b/docs/gotchas.md index a043d0473..5769d3e09 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -224,7 +224,8 @@ When `DEVSPACE_SUBAGENTS=1`, DevSpace loads agent profiles from `~/.devspace/agents/*.md` and project `.devspace/agents/*.md`, then exposes a compact profile catalog through `open_workspace`. The bundled `subagent-delegation` skill keeps the model-facing workflow to -`devspace agents ls`, `devspace agents run`, and `devspace agents show`. +`devspace agents ls`, `devspace agents run`, `devspace agents continue`, and +`devspace agents show`. Those commands automatically manage the internal local agent daemon; `devspace serve` is not a prerequisite. `devspace agents ls` lists existing subagent sessions, not profile diff --git a/docs/local-agent-daemon.md b/docs/local-agent-daemon.md index fa1debcde..b8195e28b 100644 --- a/docs/local-agent-daemon.md +++ b/docs/local-agent-daemon.md @@ -5,7 +5,7 @@ by the MCP server and not by an individual CLI invocation. The daemon is an internal implementation detail: the normal workflow remains: ```text -devspace agents run/show/ls +devspace agents run/continue/show/ls │ ▼ devspace-agentd @@ -43,6 +43,11 @@ devspace agents daemon stop devspace agents daemon logs ``` +Agent identity is explicit at the client boundary. `agents run` starts a new +logical agent from a profile or provider; `agents continue ` continues an +existing logical agent. Provider session IDs are never accepted as logical +agent IDs, and the daemon does not resolve ambiguous prefixes. + Shutdown gives active turns a bounded graceful window. If that window expires, the process exits with active records left durable; the next daemon startup reconciles stale `starting` and `running` records to `error` without discarding diff --git a/skills/subagent-delegation/SKILL.md b/skills/subagent-delegation/SKILL.md index ea6430d7f..d143d272a 100644 --- a/skills/subagent-delegation/SKILL.md +++ b/skills/subagent-delegation/SKILL.md @@ -18,12 +18,15 @@ Use only these commands for normal delegation: ```bash devspace agents ls -devspace agents run "" +devspace agents run "" +devspace agents continue "" devspace agents show ``` `ls` shows existing subagent sessions for the current workspace. DevSpace scopes it automatically from the shell environment injected by the workspace tool. +Use the returned logical `agt_...` ID with `continue`; provider session IDs and +prefixes are not interchangeable with logical agent IDs. `run ""` starts a new configured profile and prints a DevSpace agent id. diff --git a/src/cli.ts b/src/cli.ts index b358567aa..a0aa25605 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { formatLocalAgentProviderAvailabilitySummary, } from "./local-agent-availability.js"; import { + parseLocalAgentContinueArgs, parseLocalAgentRunArgs, } from "./local-agent-targets.js"; import { createLocalAgentClient } from "./local-agent-client.js"; @@ -296,7 +297,8 @@ function printHelp(): void { " devspace config get Print persisted config", " devspace config set publicBaseUrl ", " devspace agents ls List subagent sessions", - " devspace agents run [--model ] ", + " devspace agents run [--model ] ", + " devspace agents continue [--model ] ", " devspace agents show ", " devspace agents daemon ", " devspace -v, --version Print the installed version", @@ -317,6 +319,9 @@ async function runAgentsCommand(args: string[]): Promise { case "run": await runAgentsRun(rest); return; + case "continue": + await runAgentsContinue(rest); + return; case "show": await runAgentsShow(rest); return; @@ -354,7 +359,7 @@ async function runAgentsRun(args: string[]): Promise { const config = loadConfig(); const workspaceRoot = resolveCurrentWorkspaceRoot(); const client = createLocalAgentClient(config); - const record = await client.run({ + const record = await client.start({ target: parsed.target, prompt: parsed.prompt, workspaceRoot, @@ -365,6 +370,17 @@ async function runAgentsRun(args: string[]): Promise { console.log(formatAgentLine(record)); } +async function runAgentsContinue(args: string[]): Promise { + const parsed = parseLocalAgentContinueArgs(args); + const config = loadConfig(); + const client = createLocalAgentClient(config); + const record = await client.continue(parsed.agentId, parsed.prompt, { + model: parsed.model, + thinking: parsed.thinking, + }); + console.log(formatAgentLine(record)); +} + async function runAgentsShow(args: string[]): Promise { const [id] = args; if (!id) throw new Error("Usage: devspace agents show "); @@ -445,7 +461,8 @@ function printAgentsHelp(): void { "", "Usage:", " devspace agents ls", - " devspace agents run [--model ] [--thinking ] ", + " devspace agents run [--model ] [--thinking ] ", + " devspace agents continue [--model ] [--thinking ] ", " devspace agents show ", " devspace agents daemon ", ].join("\n"), diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index dc86f2237..305f7f12f 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -59,8 +59,7 @@ export class LocalAgentClient { } async run(input: StartLocalAgentInput): Promise { - const result = await this.request("agent.run", input); - return decodeAgentRecord(result); + return this.start(input); } async start(input: StartLocalAgentInput): Promise { diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index 2aa409711..240ededab 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -26,11 +26,13 @@ const manager = new LocalAgentManager({ pool: new LocalAgentRuntimePool({ logger: log }), loadProfiles: (workspaceRoot) => loadLocalAgentProfiles(config, workspaceRoot), agentDir: config.agentDir, + allowedRoots: config.allowedRoots, logger: log, }); const daemon = new LocalAgentDaemon({ stateDir: paths.stateDir, manager, + onLockAcquired: () => { manager.reconcileActiveRuns(); }, idleShutdownMs: parseIdleShutdownMs(process.env.DEVSPACE_AGENTD_IDLE_TIMEOUT_MS), }); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index d8c3c8498..622a18927 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -12,7 +12,6 @@ import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecy export type LocalAgentDaemonMethod = | "hello" - | "agent.run" | "agent.start" | "agent.continue" | "agent.get" @@ -21,11 +20,8 @@ export type LocalAgentDaemonMethod = | "daemon.stop" | "daemon.logs"; -export interface AgentDaemonRunParams extends StartLocalAgentInput {} - export type LocalAgentDaemonRequest = | AgentDaemonRequestBase<"hello", Record> - | AgentDaemonRequestBase<"agent.run", AgentDaemonRunParams> | AgentDaemonRequestBase<"agent.start", StartLocalAgentInput> | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; overrides?: RunOverrides }> | AgentDaemonRequestBase<"agent.get", { id: string }> @@ -94,7 +90,6 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR case "daemon.status": case "daemon.stop": return { requestId, protocolVersion, method, params: decodeEmptyParams(params) } as LocalAgentDaemonRequest; - case "agent.run": case "agent.start": return { requestId, diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index 5b8637234..a38b4ed17 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -17,7 +17,7 @@ import { type LocalAgentDaemonStatus, LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; -import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; +import { LocalAgentConflictError, type RunOverrides, type StartLocalAgentInput } from "./local-agent-manager.js"; import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; const MAX_REQUEST_BYTES = 512 * 1024; @@ -42,6 +42,7 @@ export interface LocalAgentDaemonOptions { idleCheckIntervalMs?: number; now?: () => number; paths?: LocalAgentDaemonPaths; + onLockAcquired?: () => void | Promise; } export class LocalAgentDaemon { @@ -51,6 +52,7 @@ export class LocalAgentDaemon { private readonly idleShutdownMs: number; private readonly idleCheckIntervalMs: number; private readonly now: () => number; + private readonly onLockAcquired?: () => void | Promise; private readonly sockets = new Set(); private server?: NetServer; private idleTimer?: NodeJS.Timeout; @@ -67,6 +69,7 @@ export class LocalAgentDaemon { this.idleShutdownMs = options.idleShutdownMs ?? DEFAULT_DAEMON_IDLE_SHUTDOWN_MS; this.idleCheckIntervalMs = options.idleCheckIntervalMs ?? DEFAULT_IDLE_CHECK_INTERVAL_MS; this.now = options.now ?? Date.now; + this.onLockAcquired = options.onLockAcquired; if (!Number.isFinite(this.idleShutdownMs) || this.idleShutdownMs < 0) { throw new Error("Agent daemon idle shutdown must be a non-negative finite duration."); } @@ -77,6 +80,7 @@ export class LocalAgentDaemon { ensureLocalAgentDaemonStateDir(this.paths.stateDir); try { this.lock.acquire(); + await this.onLockAcquired?.(); if (process.platform !== "win32") rmSync(this.paths.socketPath, { force: true }); const server = createServer((socket) => this.handleConnection(socket)); this.server = server; @@ -208,16 +212,6 @@ export class LocalAgentDaemon { switch (request.method) { case "hello": return this.status(); - case "agent.run": { - const existing = this.manager.get(request.params.target); - return existing - ? this.manager.continue(request.params.target, request.params.prompt, { - model: request.params.model, - thinking: request.params.thinking, - writeMode: request.params.writeMode, - }) - : this.manager.start(request.params); - } case "agent.start": return this.manager.start(request.params); case "agent.continue": @@ -290,7 +284,7 @@ function readRequestId(value: unknown): string { function errorCode(error: unknown): string { if (error instanceof LocalAgentDaemonProtocolError) return error.code; - if (errorMessage(error).includes("already has a running turn")) return "CONFLICT"; + if (error instanceof LocalAgentConflictError) return "CONFLICT"; if (errorMessage(error).includes("is stopping")) return "DAEMON_STOPPING"; return "AGENT_ERROR"; } diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index c1a8cc794..98eb1b94b 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { LocalAgentManager } from "./local-agent-manager.js"; +import { LocalAgentConflictError, LocalAgentManager } from "./local-agent-manager.js"; import type { LocalAgentProfile } from "./local-agent-profiles.js"; import type { LocalAgentDriver, @@ -31,8 +31,12 @@ class FakeRuntime implements LocalAgentRuntime { closed = false; private releaseHold: (() => void) | undefined; - async run(input: LocalAgentRunInput): Promise { + async run(input: LocalAgentRunInput, callbacks?: { onSessionId?: (id: string) => void | Promise }): Promise { this.inputs.push(input); + if (input.prompt.includes("early-fail")) { + await callbacks?.onSessionId?.("thread_early"); + throw new Error("provider failed after session creation"); + } if (input.prompt.includes("fail")) throw new Error("provider failed"); if (input.prompt.includes("hold")) { await new Promise((resolve) => { this.releaseHold = resolve; }); @@ -88,8 +92,16 @@ const manager = new LocalAgentManager({ drivers: [driver], pool: new LocalAgentRuntimePool(), loadProfiles: async () => [profile], + allowedRoots: [root], }); +await assert.rejects( + manager.start({ target: "reviewer", prompt: "outside", workspaceRoot: join(tmpdir(), "outside") }), + /outside allowed roots/, +); + +assert.equal(manager.get(stale.id)?.status, "running"); +manager.reconcileActiveRuns(); assert.equal(manager.get(stale.id)?.status, "error"); assert.equal(manager.get(stale.id)?.latestResponse, "previous response"); assert.equal( @@ -106,7 +118,7 @@ assert.equal(first.status, "running"); await waitFor(() => runtimes.get(first.id)?.inputs.length === 1); await assert.rejects( () => manager.continue(first.id, "another prompt"), - new RegExp(`Agent ${first.id} already has a running turn\\.`), + (error: unknown) => error instanceof LocalAgentConflictError && error.agentId === first.id, ); runtimes.get(first.id)!.release(); @@ -135,7 +147,26 @@ const failed = await manager.start({ await waitFor(() => manager.get(failed.id)?.status === "error"); assert.equal(manager.get(failed.id)?.error, "provider failed"); -await manager.close(); +const earlyFailure = await manager.start({ + target: "reviewer", + prompt: "early-fail", + workspaceRoot: root, +}); +await waitFor(() => manager.get(earlyFailure.id)?.status === "error"); +assert.equal(manager.get(earlyFailure.id)?.providerSessionId, "thread_early"); + +const shuttingDown = await manager.start({ + target: "reviewer", + prompt: "hold during shutdown", + workspaceRoot: root, +}); +await waitFor(() => runtimes.get(shuttingDown.id)?.inputs.length === 1); +const closing = manager.close(); +await new Promise((resolve) => setImmediate(resolve)); +assert.equal(runtimes.get(shuttingDown.id)?.closed, false); +runtimes.get(shuttingDown.id)!.release(); +await closing; + await manager.close(); await rm(root, { recursive: true, force: true }); diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 7e87a2153..5342dbd89 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -12,11 +12,13 @@ import { } from "./local-agent-store.js"; import { type LocalAgentDriver, + type LocalAgentRunCallbacks, type LocalAgentRunInput, type LocalAgentRuntimeContext, type LocalAgentWriteMode, } from "./local-agent-runtime.js"; import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; +import { assertAllowedPath } from "./roots.js"; export interface StartLocalAgentInput { target: string; @@ -44,9 +46,19 @@ export interface LocalAgentManagerOptions { pool: LocalAgentRuntimePool; loadProfiles: (workspaceRoot: string) => Promise; agentDir?: string; + allowedRoots?: readonly string[]; logger?: LocalAgentManagerLogger; } +export class LocalAgentConflictError extends Error { + readonly code = "CONFLICT" as const; + + constructor(readonly agentId: string) { + super(`Agent ${agentId} already has a running turn.`); + this.name = "LocalAgentConflictError"; + } +} + /** * Owns one durable DevSpace agent's turn lifecycle. Provider runtimes remain * below this seam; this class only translates records into provider inputs and @@ -58,6 +70,7 @@ export class LocalAgentManager { private readonly pool: LocalAgentRuntimePool; private readonly loadProfiles: (workspaceRoot: string) => Promise; private readonly agentDir?: string; + private readonly allowedRoots?: readonly string[]; private readonly logger?: LocalAgentManagerLogger; private readonly activeTurns = new Map>(); private accepting = true; @@ -69,13 +82,18 @@ export class LocalAgentManager { this.pool = options.pool; this.loadProfiles = options.loadProfiles; this.agentDir = options.agentDir; + this.allowedRoots = options.allowedRoots; this.logger = options.logger; - this.store.reconcileActiveRuns(); + } + + reconcileActiveRuns(message?: string): number { + return this.store.reconcileActiveRuns(message); } async start(input: StartLocalAgentInput): Promise { this.assertAccepting(); - const profiles = await this.loadProfiles(input.workspaceRoot); + const workspaceRoot = this.authorizeWorkspace(input.workspaceRoot); + const profiles = await this.loadProfiles(workspaceRoot); const target = resolveLocalAgentTarget(input.target, profiles, input.model, input.thinking); if (!target) { throw new Error(`Unknown subagent profile or provider: ${input.target}`); @@ -84,7 +102,7 @@ export class LocalAgentManager { const record = this.store.create({ workspaceId: input.workspaceId, - workspaceRoot: input.workspaceRoot, + workspaceRoot, profileName: target.name, provider: target.provider, model: target.model, @@ -103,14 +121,15 @@ export class LocalAgentManager { overrides: RunOverrides = {}, ): Promise { this.assertAccepting(); - const record = this.store.get(agentId); + const record = this.store.getById(agentId); if (!record) throw new Error(`Unknown subagent id: ${agentId}`); + this.authorizeWorkspace(record.workspaceRoot); this.assertDriver(record.provider); return this.begin(record, prompt, overrides); } get(agentId: string): LocalAgentRecord | undefined { - return this.store.get(agentId); + return this.store.getById(agentId); } list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { @@ -122,15 +141,17 @@ export class LocalAgentManager { this.accepting = false; const turns = Array.from(this.activeTurns.values()); this.closePromise = (async () => { - const results = await Promise.allSettled([ - this.pool.close(), - ...turns, - ]); - for (const result of results) { + const turnResults = await Promise.allSettled(turns); + for (const result of turnResults) { if (result.status === "rejected") { this.log("warn", "local_agent_close_failed", { error: errorMessage(result.reason) }); } } + try { + await this.pool.close(); + } catch (error) { + this.log("warn", "local_agent_close_failed", { error: errorMessage(error) }); + } this.store.close(); })(); return this.closePromise; @@ -154,7 +175,7 @@ export class LocalAgentManager { overrides: RunOverrides, ): LocalAgentRecord { if (this.activeTurns.has(record.id)) { - throw new Error(`Agent ${record.id} already has a running turn.`); + throw new LocalAgentConflictError(record.id); } const updated = this.store.update(record.id, { @@ -196,8 +217,15 @@ export class LocalAgentManager { thinking: input.thinking, agentDir: this.agentDir, }; - const result = await this.pool.run(driver, context, input); - const current = this.store.get(record.id); + const callbacks: LocalAgentRunCallbacks = { + onSessionId: (providerSessionId) => { + const current = this.store.getById(record.id); + if (!current || current.providerSessionId === providerSessionId) return; + this.store.update(record.id, { providerSessionId }); + }, + }; + const result = await this.pool.run(driver, context, input, callbacks); + const current = this.store.getById(record.id); if (!current) return; const updated = this.store.update(record.id, { providerSessionId: result.providerSessionId ?? current.providerSessionId, @@ -212,7 +240,7 @@ export class LocalAgentManager { durationMs: Math.max(0, Date.now() - startedAt), }); } catch (error) { - const current = this.store.get(record.id); + const current = this.store.getById(record.id); if (current) { this.store.update(record.id, { status: "error", @@ -264,6 +292,11 @@ export class LocalAgentManager { if (!this.accepting) throw new Error("Local agent manager is closed."); } + private authorizeWorkspace(workspaceRoot: string): string { + if (!this.allowedRoots) return workspaceRoot; + return assertAllowedPath(workspaceRoot, [...this.allowedRoots]); + } + private log( level: "info" | "warn" | "error", event: string, diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index cf7265a9f..f96ee420c 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -21,9 +21,9 @@ try { assert.match(created.id, /^agt_[a-f0-9]{8}$/); assert.equal(created.status, "starting"); - assert.equal(store.get(created.id)?.thinking, "high"); - assert.equal(store.get(created.id)?.profileName, "reviewer"); - assert.equal(store.get(created.id.slice(0, 7))?.id, created.id); + assert.equal(store.getById(created.id)?.thinking, "high"); + assert.equal(store.getById(created.id)?.profileName, "reviewer"); + assert.equal(store.getById(created.id.slice(0, 7)), undefined); const updated = store.update(created.id, { status: "idle", @@ -34,8 +34,8 @@ try { assert.equal(updated.status, "idle"); assert.equal(updated.thinking, "medium"); - assert.equal(store.get("thread_123")?.id, created.id); - assert.equal(store.get(created.id)?.thinking, "medium"); + assert.equal(store.getById("thread_123"), undefined); + assert.equal(store.getById(created.id)?.thinking, "medium"); assert.equal(store.update(created.id, { latestResponse: undefined }).latestResponse, undefined); assert.deepEqual( store.list({ workspaceRoot: join(root, "project") }).map((agent) => agent.latestResponse), diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 89abdef4e..b6f78702b 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -130,25 +130,23 @@ export class LocalAgentStore { return record; } - get(idOrPrefix: string): LocalAgentRecord | undefined { + getById(id: string): LocalAgentRecord | undefined { const exact = this.database.sqlite .prepare( `select * from local_agent_sessions - where id = ? or provider_session_id = ? + where id = ? limit 1`, ) - .get(idOrPrefix, idOrPrefix) as LocalAgentRow | undefined; - if (exact) return rowToLocalAgentRecord(exact); - - const matches = this.database.sqlite - .prepare( - `select * from local_agent_sessions - where id like ? escape '\\' or provider_session_id like ? escape '\\' - order by updated_at desc`, - ) - .all(`${escapeLike(idOrPrefix)}%`, `${escapeLike(idOrPrefix)}%`) as LocalAgentRow[]; + .get(id) as LocalAgentRow | undefined; + return exact ? rowToLocalAgentRecord(exact) : undefined; + } - return matches.length === 1 ? rowToLocalAgentRecord(matches[0]!) : undefined; + /** + * Compatibility alias for callers that already use the store directly. + * Identity lookup is exact and never falls back to provider session IDs. + */ + get(id: string): LocalAgentRecord | undefined { + return this.getById(id); } update(id: string, patch: Partial>): LocalAgentRecord { @@ -211,12 +209,6 @@ export class LocalAgentStore { this.database.close(); } - private getById(id: string): LocalAgentRecord | undefined { - const row = this.database.sqlite - .prepare("select * from local_agent_sessions where id = ?") - .get(id) as LocalAgentRow | undefined; - return row ? rowToLocalAgentRecord(row) : undefined; - } } export function createLocalAgentStore(stateDir: string): LocalAgentStore { @@ -253,7 +245,3 @@ function readStatus(status: string): LocalAgentStatus { } return "error"; } - -function escapeLike(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); -} diff --git a/src/local-agent-targets.ts b/src/local-agent-targets.ts index 917e28041..ab6faaf05 100644 --- a/src/local-agent-targets.ts +++ b/src/local-agent-targets.ts @@ -12,6 +12,13 @@ export interface ParsedLocalAgentRunArgs { thinking?: string; } +export interface ParsedLocalAgentContinueArgs { + agentId: string; + prompt: string; + model?: string; + thinking?: string; +} + export type LocalAgentTarget = | { kind: "profile"; @@ -30,9 +37,28 @@ export type LocalAgentTarget = }; export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs { + const parsed = parseAgentPromptArgs( + args, + 'Usage: devspace agents run [--model ] [--thinking ] ""', + ); + return parsed; +} + +export function parseLocalAgentContinueArgs(args: string[]): ParsedLocalAgentContinueArgs { + const parsed = parseAgentPromptArgs( + args, + 'Usage: devspace agents continue [--model ] [--thinking ] ""', + ); + return { agentId: parsed.target, prompt: parsed.prompt, model: parsed.model, thinking: parsed.thinking }; +} + +function parseAgentPromptArgs( + args: string[], + usage: string, +): ParsedLocalAgentRunArgs { const [target, ...rest] = args; if (!target) { - throw new Error('Usage: devspace agents run [--model ] [--thinking ] ""'); + throw new Error(usage); } let model: string | undefined; @@ -71,7 +97,7 @@ export function parseLocalAgentRunArgs(args: string[]): ParsedLocalAgentRunArgs const prompt = promptParts.join(" ").trim(); if (!prompt) { - throw new Error('Usage: devspace agents run [--model ] [--thinking ] ""'); + throw new Error(usage); } return { target, prompt, model, thinking }; From b825a3dcec3232d24e5021d89fc5277241ba3e32 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 09:05:14 +0530 Subject: [PATCH 15/35] fix: harden local agent daemon ownership --- src/local-agent-client.ts | 8 ++- src/local-agent-daemon-lifecycle.test.ts | 8 +++ src/local-agent-daemon-lifecycle.ts | 54 ++++++++++++++++- src/local-agent-daemon-protocol.test.ts | 2 + src/local-agent-daemon-protocol.ts | 9 ++- src/local-agent-daemon.ts | 76 ++++++++++++++++++++++-- 6 files changed, 146 insertions(+), 11 deletions(-) diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 305f7f12f..af045cc19 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -18,7 +18,9 @@ import { } from "./local-agent-daemon-protocol.js"; import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + ensureLocalAgentDaemonSecret, localAgentDaemonPaths, + type LocalAgentDaemonPaths, } from "./local-agent-daemon-lifecycle.js"; import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; @@ -44,6 +46,7 @@ export class LocalAgentDaemonClientError extends Error { export class LocalAgentClient { private readonly stateDir: string; + private readonly paths: LocalAgentDaemonPaths; private readonly endpoint: string; private readonly startupTimeoutMs: number; private readonly requestTimeoutMs: number; @@ -52,7 +55,8 @@ export class LocalAgentClient { constructor(options: LocalAgentClientOptions) { this.stateDir = options.stateDir; - this.endpoint = options.endpoint ?? localAgentDaemonPaths(options.stateDir).endpoint; + this.paths = localAgentDaemonPaths(options.stateDir); + this.endpoint = options.endpoint ?? this.paths.endpoint; this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; this.spawnDaemon = options.spawnDaemon ?? (() => spawnLocalAgentDaemon(options.stateDir)); @@ -134,6 +138,7 @@ export class LocalAgentClient { const response = await sendRequest(this.endpoint, { requestId: randomUUID(), protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: ensureLocalAgentDaemonSecret(this.paths), method: "hello", params: {}, }, this.requestTimeoutMs); @@ -159,6 +164,7 @@ export class LocalAgentClient { const response = await sendRequest(this.endpoint, { requestId: randomUUID(), protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: ensureLocalAgentDaemonSecret(this.paths), method, params, } as LocalAgentDaemonRequest, this.requestTimeoutMs); diff --git a/src/local-agent-daemon-lifecycle.test.ts b/src/local-agent-daemon-lifecycle.test.ts index 3f095cc6e..53438de19 100644 --- a/src/local-agent-daemon-lifecycle.test.ts +++ b/src/local-agent-daemon-lifecycle.test.ts @@ -9,6 +9,7 @@ import { isProcessAlive, localAgentDaemonPaths, removeLocalAgentDaemonFiles, + ensureLocalAgentDaemonSecret, writeLocalAgentDaemonPid, } from "./local-agent-daemon-lifecycle.js"; @@ -23,6 +24,13 @@ try { () => new LocalAgentDaemonLock(paths).acquire(), (error: unknown) => error instanceof LocalAgentDaemonAlreadyRunningError, ); + await writeFile(paths.pidPath, "999999\n", { mode: 0o600 }); + assert.throws( + () => new LocalAgentDaemonLock(paths).acquire(), + (error: unknown) => error instanceof LocalAgentDaemonAlreadyRunningError, + "a stale diagnostic PID must not override the live lock owner", + ); + assert.equal(ensureLocalAgentDaemonSecret(paths).length, 64); lock.release(); await writeFile(paths.pidPath, "999999\n", { mode: 0o600 }); diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 4ef7dcd20..383d7f41c 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { chmodSync, closeSync, @@ -6,6 +6,7 @@ import { openSync, readFileSync, rmSync, + statSync, writeSync, } from "node:fs"; import { join, resolve } from "node:path"; @@ -14,6 +15,7 @@ export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 1; 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"; +export const LOCAL_AGENT_DAEMON_SECRET_NAME = "agentd.secret"; export const LOCAL_AGENT_DAEMON_LOG_NAME = "agentd.log"; export interface LocalAgentDaemonPaths { @@ -21,6 +23,7 @@ export interface LocalAgentDaemonPaths { socketPath: string; pidPath: string; lockPath: string; + secretPath: string; logPath: string; endpoint: string; } @@ -36,6 +39,7 @@ export function localAgentDaemonPaths( socketPath, pidPath: join(resolvedStateDir, LOCAL_AGENT_DAEMON_PID_NAME), lockPath: join(resolvedStateDir, LOCAL_AGENT_DAEMON_LOCK_NAME), + secretPath: join(resolvedStateDir, LOCAL_AGENT_DAEMON_SECRET_NAME), logPath: join(resolvedStateDir, LOCAL_AGENT_DAEMON_LOG_NAME), endpoint: platform === "win32" ? `\\\\.\\pipe\\devspace-agentd-${hashStateDir(resolvedStateDir)}` @@ -74,14 +78,17 @@ export class LocalAgentDaemonLock { return; } catch (error) { if (!isFileExistsError(error)) throw error; - const pid = readDaemonPid(this.paths.pidPath); + const pid = readDaemonPid(this.paths.lockPath); if (pid !== undefined && isProcessAlive(pid)) { throw new LocalAgentDaemonAlreadyRunningError(pid); } + if (pid === undefined && isRecentlyCreated(this.paths.lockPath)) { + throw new LocalAgentDaemonAlreadyRunningError(); + } rmSync(this.paths.lockPath, { force: true }); } } - throw new LocalAgentDaemonAlreadyRunningError(readDaemonPid(this.paths.pidPath)); + throw new LocalAgentDaemonAlreadyRunningError(readDaemonPid(this.paths.lockPath)); } release(): void { @@ -97,6 +104,33 @@ export function writeLocalAgentDaemonPid(paths: LocalAgentDaemonPaths): void { writeFileSecure(paths.pidPath, `${process.pid}\n`); } +export function ensureLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): string { + ensureLocalAgentDaemonStateDir(paths.stateDir); + try { + const secret = readFileSync(paths.secretPath, "utf8").trim(); + if (secret.length >= 32) return secret; + } catch { + // Create the secret below. + } + + const secret = randomBytes(32).toString("hex"); + try { + const fileDescriptor = openSync(paths.secretPath, "wx", 0o600); + try { + writeSync(fileDescriptor, `${secret}\n`); + chmodSync(paths.secretPath, 0o600); + return secret; + } finally { + closeSync(fileDescriptor); + } + } catch (error) { + if (!isFileExistsError(error)) throw error; + const existing = readFileSync(paths.secretPath, "utf8").trim(); + if (existing.length < 32) throw new Error("Local agent daemon secret is invalid."); + return existing; + } +} + export function removeLocalAgentDaemonFiles(paths: LocalAgentDaemonPaths): void { rmSync(paths.pidPath, { force: true }); if (process.platform !== "win32") rmSync(paths.socketPath, { force: true }); @@ -136,6 +170,20 @@ function isFileExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === "EEXIST"; } +function isRecentlyCreated(path: string): boolean { + try { + return Date.now() - requireStat(path) < 1_000; + } catch { + return false; + } +} + +function requireStat(path: string): number { + // Keep the lock recovery path synchronous so no caller can observe a + // second owner between reading and deciding whether to remove the lock. + return statSync(path).mtimeMs; +} + function hashStateDir(stateDir: string): string { return createHash("sha256").update(stateDir).digest("hex").slice(0, 24); } diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index e69103b51..480a8b331 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -10,6 +10,7 @@ import { const request = decodeLocalAgentDaemonRequest({ requestId: "req_1", protocolVersion: 1, + authToken: "test-secret", method: "agent.start", params: { target: "reviewer", @@ -26,6 +27,7 @@ assert.throws( () => decodeLocalAgentDaemonRequest({ requestId: "req_2", protocolVersion: 1, + authToken: "test-secret", method: "agent.start", params: { target: "reviewer", prompt: "" }, }), diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 622a18927..cd3b6c5c6 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -36,6 +36,7 @@ interface AgentDaemonRequestBase< > { requestId: string; protocolVersion: number; + authToken: string; method: M; params: P; } @@ -82,6 +83,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR const record = asRecord(value); const requestId = requiredString(record?.requestId, "requestId"); const protocolVersion = requiredInteger(record?.protocolVersion, "protocolVersion"); + const authToken = requiredString(record?.authToken, "authToken"); const method = requiredString(record?.method, "method") as LocalAgentDaemonMethod; const params = record?.params; @@ -89,11 +91,12 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR case "hello": case "daemon.status": case "daemon.stop": - return { requestId, protocolVersion, method, params: decodeEmptyParams(params) } as LocalAgentDaemonRequest; + return { requestId, protocolVersion, authToken, method, params: decodeEmptyParams(params) } as LocalAgentDaemonRequest; case "agent.start": return { requestId, protocolVersion, + authToken, method, params: decodeStartInput(params), } as LocalAgentDaemonRequest; @@ -101,6 +104,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR return { requestId, protocolVersion, + authToken, method, params: decodeContinueInput(params), } as LocalAgentDaemonRequest; @@ -109,12 +113,14 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR requestId, protocolVersion, method, + authToken, params: { id: requiredString(asRecord(params)?.id, "id") }, } as LocalAgentDaemonRequest; case "agent.list": return { requestId, protocolVersion, + authToken, method, params: decodeListScope(params), } as LocalAgentDaemonRequest; @@ -122,6 +128,7 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR return { requestId, protocolVersion, + authToken, method, params: decodeLogsParams(params), } as LocalAgentDaemonRequest; diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index a38b4ed17..80e94d474 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { appendFileSync, chmodSync, readFileSync, rmSync } from "node:fs"; import { createServer, type Server as NetServer, type Socket } from "node:net"; import { @@ -5,6 +6,7 @@ import { LocalAgentDaemonAlreadyRunningError, LocalAgentDaemonLock, ensureLocalAgentDaemonStateDir, + ensureLocalAgentDaemonSecret, localAgentDaemonPaths, removeLocalAgentDaemonFiles, type LocalAgentDaemonPaths, @@ -23,6 +25,8 @@ import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store. const MAX_REQUEST_BYTES = 512 * 1024; const DEFAULT_DAEMON_IDLE_SHUTDOWN_MS = 30_000; const DEFAULT_IDLE_CHECK_INTERVAL_MS = 1_000; +const DEFAULT_REQUEST_READ_TIMEOUT_MS = 5_000; +const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; export interface LocalAgentDaemonManager { start(input: StartLocalAgentInput): Promise; @@ -40,6 +44,8 @@ export interface LocalAgentDaemonOptions { manager: LocalAgentDaemonManager; idleShutdownMs?: number; idleCheckIntervalMs?: number; + requestReadTimeoutMs?: number; + shutdownTimeoutMs?: number; now?: () => number; paths?: LocalAgentDaemonPaths; onLockAcquired?: () => void | Promise; @@ -51,6 +57,8 @@ export class LocalAgentDaemon { private readonly lock: LocalAgentDaemonLock; private readonly idleShutdownMs: number; private readonly idleCheckIntervalMs: number; + private readonly requestReadTimeoutMs: number; + private readonly shutdownTimeoutMs: number; private readonly now: () => number; private readonly onLockAcquired?: () => void | Promise; private readonly sockets = new Set(); @@ -61,6 +69,7 @@ export class LocalAgentDaemon { private startedAt?: string; private accepting = false; private stopping = false; + private authToken?: string; constructor(options: LocalAgentDaemonOptions) { this.paths = options.paths ?? localAgentDaemonPaths(options.stateDir); @@ -68,18 +77,29 @@ export class LocalAgentDaemon { this.lock = new LocalAgentDaemonLock(this.paths); this.idleShutdownMs = options.idleShutdownMs ?? DEFAULT_DAEMON_IDLE_SHUTDOWN_MS; this.idleCheckIntervalMs = options.idleCheckIntervalMs ?? DEFAULT_IDLE_CHECK_INTERVAL_MS; + this.requestReadTimeoutMs = options.requestReadTimeoutMs ?? DEFAULT_REQUEST_READ_TIMEOUT_MS; + this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS; this.now = options.now ?? Date.now; this.onLockAcquired = options.onLockAcquired; if (!Number.isFinite(this.idleShutdownMs) || this.idleShutdownMs < 0) { throw new Error("Agent daemon idle shutdown must be a non-negative finite duration."); } + if (!Number.isFinite(this.requestReadTimeoutMs) || this.requestReadTimeoutMs <= 0) { + throw new Error("Agent daemon request read timeout must be a positive finite duration."); + } + if (!Number.isFinite(this.shutdownTimeoutMs) || this.shutdownTimeoutMs < 0) { + throw new Error("Agent daemon shutdown timeout must be a non-negative finite duration."); + } } async start(): Promise { if (this.server) return this.status(); ensureLocalAgentDaemonStateDir(this.paths.stateDir); + let lockAcquired = false; try { this.lock.acquire(); + lockAcquired = true; + this.authToken = ensureLocalAgentDaemonSecret(this.paths); await this.onLockAcquired?.(); if (process.platform !== "win32") rmSync(this.paths.socketPath, { force: true }); const server = createServer((socket) => this.handleConnection(socket)); @@ -101,8 +121,11 @@ export class LocalAgentDaemon { return this.status(); } catch (error) { this.server = undefined; - this.lock.release(); - removeLocalAgentDaemonFiles(this.paths); + this.authToken = undefined; + if (lockAcquired) { + this.lock.release(); + removeLocalAgentDaemonFiles(this.paths); + } if (error instanceof LocalAgentDaemonAlreadyRunningError) throw error; throw error; } @@ -132,9 +155,11 @@ export class LocalAgentDaemon { activeTurns: this.manager.activeTurnCount, runtimeCount: this.manager.runtimeCount, }); + for (const socket of this.sockets) socket.destroy(); + this.sockets.clear(); const [serverResult, managerResult] = await Promise.allSettled([ - closeServer(this.server), - this.manager.close(), + withTimeout(closeServer(this.server), this.shutdownTimeoutMs, "daemon socket shutdown"), + withTimeout(this.manager.close(), this.shutdownTimeoutMs, "daemon manager shutdown"), ]); if (serverResult.status === "rejected") { writeLocalAgentDaemonLog(this.paths, "warn", "daemon_socket_close_failed", { @@ -146,12 +171,11 @@ export class LocalAgentDaemon { error: errorMessage(managerResult.reason), }); } - for (const socket of this.sockets) socket.destroy(); - this.sockets.clear(); removeLocalAgentDaemonFiles(this.paths); this.lock.release(); writeLocalAgentDaemonLog(this.paths, "info", "daemon_stopped", {}); this.server = undefined; + this.authToken = undefined; })(); return this.closePromise; } @@ -161,6 +185,12 @@ export class LocalAgentDaemon { socket.setEncoding("utf8"); let buffer = ""; let handled = false; + const requestTimer = setTimeout(() => { + if (handled) return; + handled = true; + this.writeError(socket, "", "REQUEST_TIMEOUT", "Timed out waiting for a complete daemon request."); + }, this.requestReadTimeoutMs); + requestTimer.unref(); socket.on("data", (chunk: string | Buffer) => { if (handled) return; buffer += chunk.toString(); @@ -172,11 +202,13 @@ export class LocalAgentDaemon { const newline = buffer.indexOf("\n"); if (newline === -1) return; handled = true; + clearTimeout(requestTimer); const line = buffer.slice(0, newline); void this.handleLine(socket, line); }); socket.on("error", () => undefined); socket.on("close", () => this.sockets.delete(socket)); + socket.on("error", () => clearTimeout(requestTimer)); } private async handleLine(socket: Socket, line: string): Promise { @@ -205,6 +237,7 @@ export class LocalAgentDaemon { `Unsupported daemon protocol version ${request.protocolVersion}; expected ${LOCAL_AGENT_DAEMON_PROTOCOL_VERSION}.`, ); } + this.assertAuthenticated(request.authToken); if (!this.accepting && request.method !== "hello" && request.method !== "daemon.status") { throw new Error("Local agent daemon is stopping."); } @@ -240,6 +273,13 @@ export class LocalAgentDaemon { })); } + private assertAuthenticated(authToken: string): void { + const expected = this.authToken; + if (!expected || !safeEqual(authToken, expected)) { + throw new LocalAgentDaemonProtocolError("UNAUTHORIZED", "Invalid local agent daemon credentials."); + } + } + private async maintainIdle(): Promise { await this.manager.evictIdle(this.now()); if (this.stopping || this.manager.activeTurnCount > 0 || this.manager.runtimeCount > 0 || this.sockets.size > 0) { @@ -276,6 +316,30 @@ async function closeServer(server: NetServer | undefined): Promise { }); } +async function withTimeout(promise: Promise, timeoutMs: number, operation: string): Promise { + if (timeoutMs === 0) { + throw new Error(`${operation} timed out.`); + } + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${operation} timed out.`)), timeoutMs); + timer.unref(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function safeEqual(actual: string, expected: string): boolean { + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); +} + function readRequestId(value: unknown): string { if (!value || typeof value !== "object" || Array.isArray(value)) return ""; const requestId = (value as Record).requestId; From d8223d3319cd9021ddebea9c1119aa9c2274f6da Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 09:05:18 +0530 Subject: [PATCH 16/35] fix: serialize local agent session release --- src/local-agent-runtime-pool.ts | 87 +++++++++++++++++++++------------ src/local-agent-runtime.test.ts | 39 +++++++++++++-- 2 files changed, 93 insertions(+), 33 deletions(-) diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index bd99384ca..4a3fa6c0e 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -28,11 +28,13 @@ interface RuntimeEntry { idleTimer?: NodeJS.Timeout; closing: boolean; readonly sessions: Map; + readonly activeRunWaiters: Set<() => void>; } interface SessionEntry { activeRuns: number; lastUsedAt: number; + releasePromise?: Promise; } export interface LocalAgentRuntimePoolOptions { @@ -87,25 +89,34 @@ export class LocalAgentRuntimePool { this.clearIdleTimer(entry); entry.activeRuns += 1; const sessionIds = new Set(); - const registerSession = (providerSessionId: string): void => { + const reserveSession = async (providerSessionId: string): Promise => { if (!providerSessionId || sessionIds.has(providerSessionId)) return; - sessionIds.add(providerSessionId); - const session = entry.sessions.get(providerSessionId) ?? { activeRuns: 0, lastUsedAt: this.now() }; - session.activeRuns += 1; - session.lastUsedAt = this.now(); - entry.sessions.set(providerSessionId, session); + while (true) { + const existing = entry.sessions.get(providerSessionId); + if (existing?.releasePromise) { + await existing.releasePromise; + continue; + } + if (entry.closing) throw new Error("Local agent runtime is closing."); + const session = existing ?? { activeRuns: 0, lastUsedAt: this.now() }; + sessionIds.add(providerSessionId); + session.activeRuns += 1; + session.lastUsedAt = this.now(); + entry.sessions.set(providerSessionId, session); + return; + } }; - registerSession(input.providerSessionId ?? ""); + await reserveSession(input.providerSessionId ?? ""); const callbacks: LocalAgentRunCallbacks = { onSessionId: async (providerSessionId) => { - registerSession(providerSessionId); + await reserveSession(providerSessionId); await inputCallbacks?.onSessionId?.(providerSessionId); }, }; const startedAt = this.now(); try { const result = await runtime.run(input, callbacks); - registerSession(result.providerSessionId ?? ""); + await reserveSession(result.providerSessionId ?? ""); return result; } catch (error) { if (!runtime.isAlive()) { @@ -137,6 +148,10 @@ export class LocalAgentRuntimePool { session.lastUsedAt = this.now(); } entry.activeRuns -= 1; + if (entry.activeRuns === 0) { + for (const resolve of entry.activeRunWaiters) resolve(); + entry.activeRunWaiters.clear(); + } entry.lastUsedAt = this.now(); if (entry.activeRuns === 0 && !entry.closing) this.scheduleIdleClose(entry); } @@ -218,6 +233,7 @@ export class LocalAgentRuntimePool { lastUsedAt: this.now(), closing: false, sessions: new Map(), + activeRunWaiters: new Set(), }; this.entries.set(key, entry); await createPromise; @@ -266,6 +282,9 @@ export class LocalAgentRuntimePool { } catch { return; } + if (reason !== "runtime_crashed" && reason !== "runtime_not_alive") { + await this.waitForNoActiveRuns(entry); + } await this.releaseSessions(entry, runtime, reason); try { await runtime.close(); @@ -301,9 +320,27 @@ export class LocalAgentRuntimePool { runtime: LocalAgentRuntime, reason: string, ): Promise { - const releases = Array.from(entry.sessions.keys()).map(async (providerSessionId) => { + const releases = Array.from(entry.sessions.keys()).map((providerSessionId) => + this.releaseSession(entry, providerSessionId, runtime, reason)); + await Promise.all(releases); + entry.sessions.clear(); + } + + private async releaseSession( + entry: RuntimeEntry, + providerSessionId: string, + runtime = entry.runtime, + reason = "idle_timeout", + ): Promise { + const session = entry.sessions.get(providerSessionId); + if (!runtime || !session) return; + if (session.releasePromise) return session.releasePromise; + const releasePromise = (async () => { try { await runtime.releaseSession(providerSessionId); + if (entry.sessions.get(providerSessionId) === session && session.activeRuns === 0) { + entry.sessions.delete(providerSessionId); + } } catch (error) { this.log("warn", "harness_session_release_failed", { provider: entry.driver.provider, @@ -313,30 +350,20 @@ export class LocalAgentRuntimePool { error: errorMessage(error), }); } - }); - await Promise.all(releases); - entry.sessions.clear(); - } - - private async releaseSession(entry: RuntimeEntry, providerSessionId: string): Promise { - const runtime = entry.runtime; - if (!runtime || entry.closing) return; + })(); + session.releasePromise = releasePromise; try { - await runtime.releaseSession(providerSessionId); - if (entry.sessions.get(providerSessionId)?.activeRuns === 0) { - entry.sessions.delete(providerSessionId); - } - } catch (error) { - this.log("warn", "harness_session_release_failed", { - provider: entry.driver.provider, - runtimeKeyHash: hashRuntimeKey(entry.key), - providerSessionIdPrefix: providerSessionId.slice(0, 8), - reason: "idle_timeout", - error: errorMessage(error), - }); + await releasePromise; + } finally { + if (entry.sessions.get(providerSessionId) === session) session.releasePromise = undefined; } } + private async waitForNoActiveRuns(entry: RuntimeEntry): Promise { + if (entry.activeRuns === 0) return; + await new Promise((resolve) => entry.activeRunWaiters.add(resolve)); + } + private log( level: "info" | "warn" | "error", event: string, diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index d412b04e0..75d6d3529 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -22,12 +22,22 @@ class FakeRuntime implements LocalAgentRuntime { runCount = 0; readonly releasedSessions: string[] = []; private readonly pending: Array<() => void> = []; + releaseBlocked = false; + releaseStarted = false; + releaseInFlight = false; + private releaseResolve?: () => void; releaseWait(): void { for (const resolve of this.pending.splice(0)) resolve(); } + finishSessionRelease(): void { + this.releaseResolve?.(); + this.releaseResolve = undefined; + } + async run(runInput: LocalAgentRunInput): Promise { + assert.equal(this.releaseInFlight, false, "a session turn must not overlap session release"); this.runCount += 1; if (runInput.prompt === "wait") await new Promise((resolve) => this.pending.push(resolve)); return { @@ -38,9 +48,14 @@ class FakeRuntime implements LocalAgentRuntime { }; } - releaseSession(providerSessionId: string): Promise { + async releaseSession(providerSessionId: string): Promise { + this.releaseInFlight = true; + this.releaseStarted = true; this.releasedSessions.push(providerSessionId); - return Promise.resolve(); + if (this.releaseBlocked) { + await new Promise((resolve) => { this.releaseResolve = resolve; }); + } + this.releaseInFlight = false; } isAlive(): boolean { @@ -104,7 +119,17 @@ const sessionDriver: LocalAgentDriver = { }; await sessionPool.run(sessionDriver, context, input); clock = 11; -await sessionPool.evictIdle(); +sessionRuntime.releaseBlocked = true; +const releasing = sessionPool.evictIdle(); +await waitFor(() => sessionRuntime.releaseStarted); +const reused = sessionPool.run(sessionDriver, context, { ...input, providerSessionId: "thread_1", prompt: "reuse" }); +await new Promise((resolve) => setImmediate(resolve)); +assert.equal(sessionRuntime.runCount, 1, "reuse waits for the in-flight session release"); +sessionRuntime.finishSessionRelease(); +await releasing; +await reused; +sessionRuntime.releaseBlocked = false; +assert.equal(sessionRuntime.releaseInFlight, false); assert.deepEqual(sessionRuntime.releasedSessions, ["thread_1"]); assert.equal(sessionPool.size, 1, "releasing an idle session does not close the runtime"); await sessionPool.close(); @@ -149,3 +174,11 @@ resolveCreation(raceRuntime); await pendingClose; await assert.rejects(pendingRun, /closed/); assert.equal(raceRuntime.closeCount, 1, "a runtime created during shutdown is closed"); + +async function waitFor(check: () => boolean): Promise { + const deadline = Date.now() + 2_000; + while (!check() && Date.now() < deadline) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal(check(), true, "condition did not become true before timeout"); +} From bf266e1c7ac92fe1e25e78c723b08f99e2acb706 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 09:07:44 +0530 Subject: [PATCH 17/35] fix: enforce workspace scoped agent access --- src/cli.ts | 8 +++++--- src/local-agent-client.ts | 11 ++++++----- src/local-agent-daemon-protocol.ts | 22 ++++++++++++++++++---- src/local-agent-daemon.test.ts | 2 +- src/local-agent-daemon.ts | 10 +++++----- src/local-agent-manager.test.ts | 5 +++++ src/local-agent-manager.ts | 20 +++++++++++++++++--- src/local-agent-runtime-pool.ts | 2 +- src/local-agent-store.ts | 5 +++++ 9 files changed, 63 insertions(+), 22 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index a0aa25605..222f1611e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -374,10 +374,11 @@ async function runAgentsContinue(args: string[]): Promise { const parsed = parseLocalAgentContinueArgs(args); const config = loadConfig(); const client = createLocalAgentClient(config); + const scope = resolveCurrentWorkspaceScope(); const record = await client.continue(parsed.agentId, parsed.prompt, { model: parsed.model, thinking: parsed.thinking, - }); + }, scope); console.log(formatAgentLine(record)); } @@ -387,13 +388,14 @@ async function runAgentsShow(args: string[]): Promise { const config = loadConfig(); const client = createLocalAgentClient(config); - let record = await client.get(id); + const scope = resolveCurrentWorkspaceScope(); + let record = await client.get(id, scope); if (!record) throw new Error(`Unknown subagent id: ${id}`); const deadline = Date.now() + 15_000; while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { await sleep(500); - record = await client.get(id) ?? record; + record = await client.get(id, scope) ?? record; } console.log(formatAgentLine(record)); diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index af045cc19..398fe276d 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -23,7 +23,7 @@ import { type LocalAgentDaemonPaths, } from "./local-agent-daemon-lifecycle.js"; import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; -import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; +import type { LocalAgentListScope, LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; const DEFAULT_STARTUP_TIMEOUT_MS = 8_000; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -71,17 +71,18 @@ export class LocalAgentClient { return decodeAgentRecord(result); } - async continue(agentId: string, prompt: string, overrides?: RunOverrides): Promise { + async continue(agentId: string, prompt: string, overrides: RunOverrides = {}, scope: LocalAgentWorkspaceScope): Promise { const result = await this.request("agent.continue", { id: agentId, prompt, - ...(overrides ? { overrides } : {}), + scope, + ...(Object.keys(overrides).length > 0 ? { overrides } : {}), }); return decodeAgentRecord(result); } - async get(agentId: string): Promise { - const result = await this.request("agent.get", { id: agentId }); + async get(agentId: string, scope: LocalAgentWorkspaceScope): Promise { + const result = await this.request("agent.get", { id: agentId, scope }); return result === null ? undefined : decodeAgentRecord(result); } diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index cd3b6c5c6..dabb0549e 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -2,6 +2,7 @@ import type { LocalAgentListScope, LocalAgentRecord, LocalAgentStatus, + LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import type { RunOverrides, @@ -23,8 +24,8 @@ export type LocalAgentDaemonMethod = export type LocalAgentDaemonRequest = | AgentDaemonRequestBase<"hello", Record> | AgentDaemonRequestBase<"agent.start", StartLocalAgentInput> - | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; overrides?: RunOverrides }> - | AgentDaemonRequestBase<"agent.get", { id: string }> + | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides }> + | AgentDaemonRequestBase<"agent.get", { id: string; scope: LocalAgentWorkspaceScope }> | AgentDaemonRequestBase<"agent.list", LocalAgentListScope> | AgentDaemonRequestBase<"daemon.status", Record> | AgentDaemonRequestBase<"daemon.stop", Record> @@ -114,7 +115,10 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR protocolVersion, method, authToken, - params: { id: requiredString(asRecord(params)?.id, "id") }, + params: { + id: requiredString(asRecord(params)?.id, "id"), + scope: decodeWorkspaceScope(asRecord(params)?.scope), + }, } as LocalAgentDaemonRequest; case "agent.list": return { @@ -237,12 +241,13 @@ function decodeStartInput(value: unknown): StartLocalAgentInput { }; } -function decodeContinueInput(value: unknown): { id: string; prompt: string; overrides?: RunOverrides } { +function decodeContinueInput(value: unknown): { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides } { const record = asRecord(value); const overrides = asRecord(record?.overrides); return { id: requiredString(record?.id, "id"), prompt: requiredString(record?.prompt, "prompt"), + scope: decodeWorkspaceScope(record?.scope), ...(overrides ? { overrides: { model: optionalString(overrides.model), thinking: optionalString(overrides.thinking), @@ -251,6 +256,15 @@ function decodeContinueInput(value: unknown): { id: string; prompt: string; over }; } +function decodeWorkspaceScope(value: unknown): LocalAgentWorkspaceScope { + const record = asRecord(value); + if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Workspace scope is required."); + return { + workspaceId: optionalString(record.workspaceId), + workspaceRoot: requiredString(record.workspaceRoot, "scope.workspaceRoot"), + }; +} + function decodeListScope(value: unknown): LocalAgentListScope { if (value === undefined) return {}; const record = asRecord(value); diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 29ae02004..178a0611f 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -71,7 +71,7 @@ try { }); assert.equal(started.id, record.id); assert.equal(manager.lastInput?.prompt, "Review this"); - assert.equal((await client.get(record.id))?.id, record.id); + assert.equal((await client.get(record.id, { workspaceRoot: record.workspaceRoot }))?.id, record.id); assert.equal((await client.list())[0]?.id, record.id); assert.equal((await client.status()).state, "ready"); diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index 80e94d474..8a9f9c6df 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -20,7 +20,7 @@ import { LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; import { LocalAgentConflictError, type RunOverrides, type StartLocalAgentInput } from "./local-agent-manager.js"; -import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; +import type { LocalAgentListScope, LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; const MAX_REQUEST_BYTES = 512 * 1024; const DEFAULT_DAEMON_IDLE_SHUTDOWN_MS = 30_000; @@ -30,8 +30,8 @@ const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; export interface LocalAgentDaemonManager { start(input: StartLocalAgentInput): Promise; - continue(agentId: string, prompt: string, overrides?: RunOverrides): Promise; - get(agentId: string): LocalAgentRecord | undefined; + continue(agentId: string, prompt: string, overrides?: RunOverrides, scope?: LocalAgentWorkspaceScope): Promise; + get(agentId: string, scope?: LocalAgentWorkspaceScope): LocalAgentRecord | undefined; list(scope?: LocalAgentListScope): LocalAgentRecord[]; evictIdle(now?: number): Promise; close(): Promise; @@ -248,9 +248,9 @@ export class LocalAgentDaemon { case "agent.start": return this.manager.start(request.params); case "agent.continue": - return this.manager.continue(request.params.id, request.params.prompt, request.params.overrides); + return this.manager.continue(request.params.id, request.params.prompt, request.params.overrides, request.params.scope); case "agent.get": - return this.manager.get(request.params.id) ?? null; + return this.manager.get(request.params.id, request.params.scope) ?? null; case "agent.list": return this.manager.list(request.params); case "daemon.status": diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 98eb1b94b..8f7e8fb57 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -155,6 +155,11 @@ const earlyFailure = await manager.start({ await waitFor(() => manager.get(earlyFailure.id)?.status === "error"); assert.equal(manager.get(earlyFailure.id)?.providerSessionId, "thread_early"); +await assert.rejects( + () => manager.continue(first.id, "wrong workspace", {}, { workspaceRoot: join(root, "other") }), + /different workspace/, +); + const shuttingDown = await manager.start({ target: "reviewer", prompt: "hold during shutdown", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 5342dbd89..c0043f450 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -9,6 +9,7 @@ import { type LocalAgentListScope, type LocalAgentRecord, type LocalAgentStore, + type LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import { type LocalAgentDriver, @@ -119,17 +120,20 @@ export class LocalAgentManager { agentId: string, prompt: string, overrides: RunOverrides = {}, + scope?: LocalAgentWorkspaceScope, ): Promise { this.assertAccepting(); const record = this.store.getById(agentId); if (!record) throw new Error(`Unknown subagent id: ${agentId}`); - this.authorizeWorkspace(record.workspaceRoot); + if (scope) this.assertAgentWorkspace(record, scope); this.assertDriver(record.provider); return this.begin(record, prompt, overrides); } - get(agentId: string): LocalAgentRecord | undefined { - return this.store.getById(agentId); + get(agentId: string, scope?: LocalAgentWorkspaceScope): LocalAgentRecord | undefined { + const record = this.store.getById(agentId); + if (record && scope) this.assertAgentWorkspace(record, scope); + return record; } list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { @@ -297,6 +301,16 @@ export class LocalAgentManager { return assertAllowedPath(workspaceRoot, [...this.allowedRoots]); } + private assertAgentWorkspace(record: LocalAgentRecord, scope: LocalAgentWorkspaceScope): void { + const workspaceRoot = this.authorizeWorkspace(scope.workspaceRoot); + if (workspaceRoot !== record.workspaceRoot) { + throw new Error(`Subagent ${record.id} belongs to a different workspace.`); + } + if (record.workspaceId && record.workspaceId !== scope.workspaceId) { + throw new Error(`Subagent ${record.id} belongs to a different workspace.`); + } + } + private log( level: "info" | "warn" | "error", event: string, diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index 4a3fa6c0e..1092c4abc 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -106,7 +106,6 @@ export class LocalAgentRuntimePool { return; } }; - await reserveSession(input.providerSessionId ?? ""); const callbacks: LocalAgentRunCallbacks = { onSessionId: async (providerSessionId) => { await reserveSession(providerSessionId); @@ -115,6 +114,7 @@ export class LocalAgentRuntimePool { }; const startedAt = this.now(); try { + await reserveSession(input.providerSessionId ?? ""); const result = await runtime.run(input, callbacks); await reserveSession(result.providerSessionId ?? ""); return result; diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index b6f78702b..411cf9b6b 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -29,6 +29,11 @@ export interface CreateLocalAgentRecordInput { thinking?: string; } +export interface LocalAgentWorkspaceScope { + workspaceId?: string; + workspaceRoot: string; +} + export interface LocalAgentListScope { workspaceId?: string; workspaceRoot?: string; From 95f2962478c9dde58e563bf9bb9803df6d60b30d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 09:09:08 +0530 Subject: [PATCH 18/35] test: prove daemon ownership and socket shutdown --- src/local-agent-daemon.test.ts | 65 +++++++++++++++++++++++++++++++++- src/local-agent-daemon.ts | 4 +++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 178a0611f..2dfa6d394 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; +import { createConnection } from "node:net"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { LocalAgentClient } from "./local-agent-client.js"; @@ -105,6 +106,61 @@ try { await rm(root, { recursive: true, force: true }); } +const ownershipStateDir = join(root, "ownership-state"); +const ownerManager = new FakeManager(); +const competingManager = new FakeManager(); +const ownerDaemon = new LocalAgentDaemon({ + stateDir: ownershipStateDir, + manager: ownerManager, + idleShutdownMs: 60_000, +}); +const competingDaemon = new LocalAgentDaemon({ + stateDir: ownershipStateDir, + manager: competingManager, + idleShutdownMs: 60_000, +}); + +try { + await ownerDaemon.start(); + const lockBefore = readFileSync(ownerDaemon.paths.lockPath, "utf8"); + const pidBefore = readFileSync(ownerDaemon.paths.pidPath, "utf8"); + assert.notEqual(ownerDaemon.paths.endpoint, ""); + await assert.rejects(competingDaemon.start(), /already running/); + assert.equal(readFileSync(ownerDaemon.paths.lockPath, "utf8"), lockBefore); + assert.equal(readFileSync(ownerDaemon.paths.pidPath, "utf8"), pidBefore); + assert.equal(existsSync(ownerDaemon.paths.socketPath), true); +} finally { + await competingDaemon.close(); + await ownerDaemon.close(); +} + +const socketStateDir = join(root, "socket-state"); +const socketManager = new FakeManager(); +socketManager.activeTurnCount = 0; +const socketDaemon = new LocalAgentDaemon({ + stateDir: socketStateDir, + manager: socketManager, + requestReadTimeoutMs: 30, + shutdownTimeoutMs: 100, + idleShutdownMs: 60_000, +}); + +try { + await socketDaemon.start(); + const idleSocket = createConnection(socketDaemon.paths.endpoint); + await onceSocket(idleSocket, "connect"); + await onceSocket(idleSocket, "close"); + + const shutdownSocket = createConnection(socketDaemon.paths.endpoint); + await onceSocket(shutdownSocket, "connect"); + const startedAt = Date.now(); + await socketDaemon.close(); + assert.ok(Date.now() - startedAt < 500, "shutdown should destroy idle client sockets before closing the server"); +} finally { + await socketDaemon.close(); + await rm(root, { recursive: true, force: true }); +} + async function waitFor(check: () => boolean): Promise { const deadline = Date.now() + 2_000; while (!check() && Date.now() < deadline) { @@ -112,3 +168,10 @@ async function waitFor(check: () => boolean): Promise { } assert.equal(check(), true, "condition did not become true before timeout"); } + +function onceSocket(socket: ReturnType, event: "connect" | "close"): Promise { + return new Promise((resolve, reject) => { + socket.once(event, () => resolve()); + socket.once("error", reject); + }); +} diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index 8a9f9c6df..c40584eb6 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -70,6 +70,7 @@ export class LocalAgentDaemon { private accepting = false; private stopping = false; private authToken?: string; + private ownsLock = false; constructor(options: LocalAgentDaemonOptions) { this.paths = options.paths ?? localAgentDaemonPaths(options.stateDir); @@ -99,6 +100,7 @@ export class LocalAgentDaemon { try { this.lock.acquire(); lockAcquired = true; + this.ownsLock = true; this.authToken = ensureLocalAgentDaemonSecret(this.paths); await this.onLockAcquired?.(); if (process.platform !== "win32") rmSync(this.paths.socketPath, { force: true }); @@ -124,6 +126,7 @@ export class LocalAgentDaemon { this.authToken = undefined; if (lockAcquired) { this.lock.release(); + this.ownsLock = false; removeLocalAgentDaemonFiles(this.paths); } if (error instanceof LocalAgentDaemonAlreadyRunningError) throw error; @@ -147,6 +150,7 @@ export class LocalAgentDaemon { async close(): Promise { if (this.closePromise) return this.closePromise; + if (!this.ownsLock && !this.server) return; this.accepting = false; this.stopping = true; if (this.idleTimer) clearInterval(this.idleTimer); From 03be0647664530c0b5922d33a2ca541d57ff7194 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 09:50:05 +0530 Subject: [PATCH 19/35] test: make daemon ownership check cross-platform --- src/local-agent-daemon.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 2dfa6d394..0ecf64a07 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -128,7 +128,11 @@ try { await assert.rejects(competingDaemon.start(), /already running/); assert.equal(readFileSync(ownerDaemon.paths.lockPath, "utf8"), lockBefore); assert.equal(readFileSync(ownerDaemon.paths.pidPath, "utf8"), pidBefore); - assert.equal(existsSync(ownerDaemon.paths.socketPath), true); + if (process.platform === "win32") { + assert.match(ownerDaemon.paths.endpoint, /^\\\\\.\\pipe\\/); + } else { + assert.equal(existsSync(ownerDaemon.paths.socketPath), true); + } } finally { await competingDaemon.close(); await ownerDaemon.close(); From 2c2d8a3fe5d4d22f51f09cca17bf1be936e86472 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 09:54:16 +0530 Subject: [PATCH 20/35] fix: force provider cleanup during daemon shutdown --- src/local-agent-daemon-main.ts | 1 + src/local-agent-daemon.ts | 4 ++++ src/local-agent-manager.test.ts | 3 +-- src/local-agent-manager.ts | 8 +++----- src/local-agent-runtime-pool.ts | 28 ++++++++++++++++++++++++++-- src/local-agent-runtime.test.ts | 2 +- 6 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/local-agent-daemon-main.ts b/src/local-agent-daemon-main.ts index 240ededab..59a685f95 100644 --- a/src/local-agent-daemon-main.ts +++ b/src/local-agent-daemon-main.ts @@ -33,6 +33,7 @@ const daemon = new LocalAgentDaemon({ stateDir: paths.stateDir, manager, onLockAcquired: () => { manager.reconcileActiveRuns(); }, + onClosed: () => { if (!shuttingDown) process.exit(0); }, idleShutdownMs: parseIdleShutdownMs(process.env.DEVSPACE_AGENTD_IDLE_TIMEOUT_MS), }); diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index c40584eb6..31736886a 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -49,6 +49,7 @@ export interface LocalAgentDaemonOptions { now?: () => number; paths?: LocalAgentDaemonPaths; onLockAcquired?: () => void | Promise; + onClosed?: () => void; } export class LocalAgentDaemon { @@ -61,6 +62,7 @@ export class LocalAgentDaemon { private readonly shutdownTimeoutMs: number; private readonly now: () => number; private readonly onLockAcquired?: () => void | Promise; + private readonly onClosed?: () => void; private readonly sockets = new Set(); private server?: NetServer; private idleTimer?: NodeJS.Timeout; @@ -82,6 +84,7 @@ export class LocalAgentDaemon { this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS; this.now = options.now ?? Date.now; this.onLockAcquired = options.onLockAcquired; + this.onClosed = options.onClosed; if (!Number.isFinite(this.idleShutdownMs) || this.idleShutdownMs < 0) { throw new Error("Agent daemon idle shutdown must be a non-negative finite duration."); } @@ -180,6 +183,7 @@ export class LocalAgentDaemon { writeLocalAgentDaemonLog(this.paths, "info", "daemon_stopped", {}); this.server = undefined; this.authToken = undefined; + this.onClosed?.(); })(); return this.closePromise; } diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 8f7e8fb57..e08db7637 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -168,8 +168,7 @@ const shuttingDown = await manager.start({ await waitFor(() => runtimes.get(shuttingDown.id)?.inputs.length === 1); const closing = manager.close(); await new Promise((resolve) => setImmediate(resolve)); -assert.equal(runtimes.get(shuttingDown.id)?.closed, false); -runtimes.get(shuttingDown.id)!.release(); +assert.equal(runtimes.get(shuttingDown.id)?.closed, true); await closing; await manager.close(); diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index c0043f450..0cfb9cd81 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -145,17 +145,15 @@ export class LocalAgentManager { this.accepting = false; const turns = Array.from(this.activeTurns.values()); this.closePromise = (async () => { + // Closing pooled runtimes is what interrupts provider turns. Waiting for + // those turns first can strand a provider process indefinitely. + await this.pool.close(); const turnResults = await Promise.allSettled(turns); for (const result of turnResults) { if (result.status === "rejected") { this.log("warn", "local_agent_close_failed", { error: errorMessage(result.reason) }); } } - try { - await this.pool.close(); - } catch (error) { - this.log("warn", "local_agent_close_failed", { error: errorMessage(error) }); - } this.store.close(); })(); return this.closePromise; diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index 1092c4abc..c70850b4e 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -175,7 +175,8 @@ export class LocalAgentRuntimePool { this.closing = true; const entries = Array.from(this.entries.values()); this.entries.clear(); - this.closePromise = Promise.all(entries.map((entry) => this.closeEntry(entry, "server_shutdown"))).then(() => undefined); + this.closePromise = Promise.allSettled(entries.map((entry) => this.closeEntry(entry, "server_shutdown"))) + .then(() => undefined); return this.closePromise; } @@ -282,9 +283,32 @@ export class LocalAgentRuntimePool { } catch { return; } - if (reason !== "runtime_crashed" && reason !== "runtime_not_alive") { + if (reason !== "server_shutdown" && reason !== "runtime_crashed" && reason !== "runtime_not_alive") { await this.waitForNoActiveRuns(entry); } + if (reason === "server_shutdown") { + // Shutdown is terminal for the provider runtime. Closing it first + // aborts stuck turns and avoids waiting forever before process cleanup. + // Do not release individual sessions here: that would race an active + // turn, and the provider runtime owns their final cleanup. + try { + await runtime.close(); + this.log("info", "harness_runtime_closed", { + provider: entry.driver.provider, + runtimeKeyHash: hashRuntimeKey(entry.key), + reason, + }); + } catch (error) { + this.log("warn", "harness_runtime_close_failed", { + provider: entry.driver.provider, + runtimeKeyHash: hashRuntimeKey(entry.key), + reason, + error: errorMessage(error), + }); + } + entry.sessions.clear(); + return; + } await this.releaseSessions(entry, runtime, reason); try { await runtime.close(); diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 75d6d3529..2895e4ec0 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -102,7 +102,7 @@ await running; await pool.close(); await pool.close(); assert.equal(runtime.closeCount, 1, "runtime close is idempotent"); -assert.deepEqual(runtime.releasedSessions, ["thread_1"]); +assert.deepEqual(runtime.releasedSessions, [], "shutdown closes the runtime without racing session release"); assert.equal(pool.size, 0); let clock = 0; From 204272d3181c497fee769baba397d6c54cd97395 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 09:59:30 +0530 Subject: [PATCH 21/35] fix: enforce combined workspace list scopes --- src/local-agent-manager.ts | 4 +++- src/local-agent-store.test.ts | 7 ++++--- src/local-agent-store.ts | 10 +++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index 0cfb9cd81..da9576006 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -137,7 +137,9 @@ export class LocalAgentManager { } list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { - return this.store.list(scope); + return this.store.list(scope.workspaceRoot + ? { ...scope, workspaceRoot: this.authorizeWorkspace(scope.workspaceRoot) } + : scope); } async close(): Promise { diff --git a/src/local-agent-store.test.ts b/src/local-agent-store.test.ts index f96ee420c..b42dbe769 100644 --- a/src/local-agent-store.test.ts +++ b/src/local-agent-store.test.ts @@ -41,9 +41,10 @@ try { store.list({ workspaceRoot: join(root, "project") }).map((agent) => agent.latestResponse), [undefined], ); - assert.deepEqual(store.list({ workspaceId: "ws_1" }).map((agent) => agent.id), [created.id]); - assert.deepEqual(store.list({ workspaceId: "ws_other" }), []); - assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); +assert.deepEqual(store.list({ workspaceId: "ws_1" }).map((agent) => agent.id), [created.id]); +assert.deepEqual(store.list({ workspaceId: "ws_other" }), []); +assert.deepEqual(store.list({ workspaceId: "ws_1", workspaceRoot: join(root, "other") }), []); +assert.deepEqual(store.list({ workspaceRoot: join(root, "other") }), []); const otherStore = new LocalAgentStore(root); stores.push(otherStore); diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 411cf9b6b..504ebdc4a 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -64,7 +64,15 @@ export class LocalAgentStore { list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { let rows: LocalAgentRow[]; - if (scope.workspaceId) { + if (scope.workspaceId && scope.workspaceRoot) { + rows = this.database.sqlite + .prepare( + `select * from local_agent_sessions + where workspace_id = ? and workspace_root = ? + order by updated_at desc`, + ) + .all(scope.workspaceId, resolve(scope.workspaceRoot)) as LocalAgentRow[]; + } else if (scope.workspaceId) { rows = this.database.sqlite .prepare( `select * from local_agent_sessions From dafa843932ed5747f5d7c515dbbdf92f68785cc0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 10:13:57 +0530 Subject: [PATCH 22/35] fix: close timed-out daemon client sockets --- src/local-agent-daemon.test.ts | 7 +++++-- src/local-agent-daemon.ts | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 0ecf64a07..5374046bc 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -64,6 +64,7 @@ const client = new LocalAgentClient({ spawnDaemon: () => { void daemon.start(); }, }); +let shutdownSocket: ReturnType | undefined; try { const started = await client.run({ target: "reviewer", @@ -153,14 +154,16 @@ try { await socketDaemon.start(); const idleSocket = createConnection(socketDaemon.paths.endpoint); await onceSocket(idleSocket, "connect"); - await onceSocket(idleSocket, "close"); + await waitFor(() => socketDaemon.status().clientConnections === 0); + idleSocket.destroy(); - const shutdownSocket = createConnection(socketDaemon.paths.endpoint); + shutdownSocket = createConnection(socketDaemon.paths.endpoint); await onceSocket(shutdownSocket, "connect"); const startedAt = Date.now(); await socketDaemon.close(); assert.ok(Date.now() - startedAt < 500, "shutdown should destroy idle client sockets before closing the server"); } finally { + shutdownSocket?.destroy(); await socketDaemon.close(); await rm(root, { recursive: true, force: true }); } diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index 31736886a..b80b37499 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -197,6 +197,7 @@ export class LocalAgentDaemon { if (handled) return; handled = true; this.writeError(socket, "", "REQUEST_TIMEOUT", "Timed out waiting for a complete daemon request."); + socket.destroy(); }, this.requestReadTimeoutMs); requestTimer.unref(); socket.on("data", (chunk: string | Buffer) => { @@ -278,7 +279,7 @@ export class LocalAgentDaemon { protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: false, error: { code, message }, - })); + }), () => socket.destroy()); } private assertAuthenticated(authToken: string): void { From 124638e8466c1c283743368818b9a856bda4628e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 10:41:06 +0530 Subject: [PATCH 23/35] fix: publish daemon lock ownership atomically --- src/local-agent-daemon-lifecycle.test.ts | 20 +++++++- src/local-agent-daemon-lifecycle.ts | 65 ++++++++++++++++-------- src/local-agent-daemon.test.ts | 27 +++++++--- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/src/local-agent-daemon-lifecycle.test.ts b/src/local-agent-daemon-lifecycle.test.ts index 53438de19..b4438cdcb 100644 --- a/src/local-agent-daemon-lifecycle.test.ts +++ b/src/local-agent-daemon-lifecycle.test.ts @@ -33,13 +33,31 @@ try { assert.equal(ensureLocalAgentDaemonSecret(paths).length, 64); lock.release(); - await writeFile(paths.pidPath, "999999\n", { mode: 0o600 }); + await writeFile(paths.lockPath, "999999999\n", { mode: 0o600 }); + await writeFile(paths.pidPath, "999999999\n", { mode: 0o600 }); const recovered = new LocalAgentDaemonLock(paths); recovered.acquire(); + assert.equal(await readFile(paths.lockPath, "utf8"), `${process.pid}\n`); writeLocalAgentDaemonPid(paths); assert.equal(await readFile(paths.pidPath, "utf8"), `${process.pid}\n`); assert.equal(isProcessAlive(process.pid), true); recovered.release(); + + await writeFile(paths.lockPath, "not-a-pid\n", { mode: 0o600 }); + assert.throws( + () => new LocalAgentDaemonLock(paths).acquire(), + (error: unknown) => error instanceof LocalAgentDaemonAlreadyRunningError, + "an undecodable lock must fail closed instead of being deleted by age", + ); + assert.equal(await readFile(paths.lockPath, "utf8"), "not-a-pid\n"); + await rm(paths.lockPath, { force: true }); + + await writeFile(paths.secretPath, "not-a-hex-secret\n", { mode: 0o600 }); + assert.throws( + () => ensureLocalAgentDaemonSecret(paths), + /secret is invalid/, + "daemon secrets must be exactly 64 hexadecimal characters", + ); removeLocalAgentDaemonFiles(paths); } finally { await rm(root, { recursive: true, force: true }); diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 383d7f41c..29129c54b 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -2,11 +2,12 @@ import { createHash, randomBytes } from "node:crypto"; import { chmodSync, closeSync, + linkSync, mkdirSync, openSync, readFileSync, + renameSync, rmSync, - statSync, writeSync, } from "node:fs"; import { join, resolve } from "node:path"; @@ -62,41 +63,56 @@ export class LocalAgentDaemonAlreadyRunningError extends Error { } export class LocalAgentDaemonLock { - private fileDescriptor?: number; + private acquired = false; constructor(readonly paths: LocalAgentDaemonPaths) {} acquire(): void { ensureLocalAgentDaemonStateDir(this.paths.stateDir); for (let attempt = 0; attempt < 2; attempt += 1) { + const temporaryPath = `${this.paths.lockPath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; + let published = false; try { - const fileDescriptor = openSync(this.paths.lockPath, "wx", 0o600); - writeSync(fileDescriptor, `${process.pid}\n`); + writeFileSecure(temporaryPath, `${process.pid}\n`); + // Publish the owner record atomically. An empty lock must never be + // visible to stale-lock recovery between create and write. + linkSync(temporaryPath, this.paths.lockPath); + published = true; + rmSync(temporaryPath, { force: true }); chmodSync(this.paths.lockPath, 0o600); writeFileSecure(this.paths.pidPath, `${process.pid}\n`); - this.fileDescriptor = fileDescriptor; + this.acquired = true; return; } catch (error) { + rmSync(temporaryPath, { force: true }); + if (published && readDaemonPid(this.paths.lockPath) === process.pid) { + rmSync(this.paths.lockPath, { force: true }); + } if (!isFileExistsError(error)) throw error; const pid = readDaemonPid(this.paths.lockPath); if (pid !== undefined && isProcessAlive(pid)) { throw new LocalAgentDaemonAlreadyRunningError(pid); } - if (pid === undefined && isRecentlyCreated(this.paths.lockPath)) { + if (pid === undefined) { + // An undecodable lock may belong to a process that has not finished + // publishing its owner record. Refuse to delete it automatically. throw new LocalAgentDaemonAlreadyRunningError(); } - rmSync(this.paths.lockPath, { force: true }); + if (!removeStaleLock(this.paths.lockPath)) continue; } } throw new LocalAgentDaemonAlreadyRunningError(readDaemonPid(this.paths.lockPath)); } release(): void { - if (this.fileDescriptor === undefined) return; - closeSync(this.fileDescriptor); - this.fileDescriptor = undefined; - rmSync(this.paths.pidPath, { force: true }); - rmSync(this.paths.lockPath, { force: true }); + if (!this.acquired) return; + this.acquired = false; + if (readDaemonPid(this.paths.pidPath) === process.pid) { + rmSync(this.paths.pidPath, { force: true }); + } + if (readDaemonPid(this.paths.lockPath) === process.pid) { + rmSync(this.paths.lockPath, { force: true }); + } } } @@ -108,7 +124,7 @@ export function ensureLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): stri ensureLocalAgentDaemonStateDir(paths.stateDir); try { const secret = readFileSync(paths.secretPath, "utf8").trim(); - if (secret.length >= 32) return secret; + if (isDaemonSecret(secret)) return secret; } catch { // Create the secret below. } @@ -126,7 +142,7 @@ export function ensureLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): stri } catch (error) { if (!isFileExistsError(error)) throw error; const existing = readFileSync(paths.secretPath, "utf8").trim(); - if (existing.length < 32) throw new Error("Local agent daemon secret is invalid."); + if (!isDaemonSecret(existing)) throw new Error("Local agent daemon secret is invalid."); return existing; } } @@ -170,18 +186,23 @@ function isFileExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === "EEXIST"; } -function isRecentlyCreated(path: string): boolean { +function removeStaleLock(path: string): boolean { + const stalePath = `${path}.stale-${process.pid}-${randomBytes(8).toString("hex")}`; try { - return Date.now() - requireStat(path) < 1_000; - } catch { - return false; + // Rename moves the exact lock we inspected out of the ownership path. If + // another contender publishes a new lock after this point, it is never + // removed with the stale one. + renameSync(path, stalePath); + rmSync(stalePath, { force: true }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; } } -function requireStat(path: string): number { - // Keep the lock recovery path synchronous so no caller can observe a - // second owner between reading and deciding whether to remove the lock. - return statSync(path).mtimeMs; +function isDaemonSecret(secret: string): boolean { + return /^[0-9a-f]{64}$/i.test(secret); } function hashStateDir(stateDir: string): string { diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 5374046bc..2b367a3c9 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -122,18 +122,29 @@ const competingDaemon = new LocalAgentDaemon({ }); try { - await ownerDaemon.start(); + const startupResults = await Promise.allSettled([ + ownerDaemon.start(), + competingDaemon.start(), + ]); + assert.equal( + startupResults.filter((result) => result.status === "fulfilled").length, + 1, + "only one competing daemon may acquire the state-directory lock", + ); + assert.equal( + startupResults.filter((result) => result.status === "rejected").length, + 1, + ); const lockBefore = readFileSync(ownerDaemon.paths.lockPath, "utf8"); const pidBefore = readFileSync(ownerDaemon.paths.pidPath, "utf8"); assert.notEqual(ownerDaemon.paths.endpoint, ""); - await assert.rejects(competingDaemon.start(), /already running/); assert.equal(readFileSync(ownerDaemon.paths.lockPath, "utf8"), lockBefore); assert.equal(readFileSync(ownerDaemon.paths.pidPath, "utf8"), pidBefore); - if (process.platform === "win32") { - assert.match(ownerDaemon.paths.endpoint, /^\\\\\.\\pipe\\/); - } else { - assert.equal(existsSync(ownerDaemon.paths.socketPath), true); - } + const ownerClient = new LocalAgentClient({ + stateDir: ownershipStateDir, + spawnDaemon: () => { throw new Error("the winning daemon should already be reachable"); }, + }); + assert.equal((await ownerClient.status()).pid, process.pid); } finally { await competingDaemon.close(); await ownerDaemon.close(); @@ -159,8 +170,10 @@ try { shutdownSocket = createConnection(socketDaemon.paths.endpoint); await onceSocket(shutdownSocket, "connect"); + const shutdownSocketClosed = onceSocket(shutdownSocket, "close"); const startedAt = Date.now(); await socketDaemon.close(); + await shutdownSocketClosed; assert.ok(Date.now() - startedAt < 500, "shutdown should destroy idle client sockets before closing the server"); } finally { shutdownSocket?.destroy(); From 9f23b7cbe8a9b234783d371e100fa7b312ae8a3f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 10:49:27 +0530 Subject: [PATCH 24/35] fix: harden runtime acquisition and turn tracking --- src/local-agent-manager.ts | 10 ++- src/local-agent-runtime-pool.ts | 106 ++++++++++++++++++-------------- 2 files changed, 67 insertions(+), 49 deletions(-) diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index da9576006..c6ac0449e 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -1,6 +1,7 @@ import { type LocalAgentProfile, type LocalAgentProvider, + isLocalAgentProvider, } from "./local-agent-profiles.js"; import { resolveLocalAgentTarget, @@ -189,7 +190,9 @@ export class LocalAgentManager { latestResponse: undefined, error: undefined, }); - const turn = this.runTurn(updated, prompt, overrides); + // Defer invocation until after the tracking entry is visible. This keeps + // cleanup correct even if runTurn later gains a synchronous completion path. + const turn = Promise.resolve().then(() => this.runTurn(updated, prompt, overrides)); this.activeTurns.set(record.id, turn); void turn.catch(() => undefined); return updated; @@ -287,7 +290,10 @@ export class LocalAgentManager { } private assertDriver(provider: string): LocalAgentDriver { - const driver = this.drivers.get(provider as LocalAgentProvider); + if (!isLocalAgentProvider(provider)) { + throw new Error(`No local agent driver is configured for provider: ${provider}`); + } + const driver = this.drivers.get(provider); if (!driver) throw new Error(`No local agent driver is configured for provider: ${provider}`); return driver; } diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index c70850b4e..3c3e23bb1 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -189,60 +189,72 @@ export class LocalAgentRuntimePool { context: LocalAgentRuntimeContext, ): Promise { const key = driver.runtimeKey(context); - const existing = this.entries.get(key); - if (existing && !existing.closing) { - if (!existing.runtime || existing.runtime.isAlive()) { - this.clearIdleTimer(existing); - if (existing.runtime) { - this.log("info", "harness_runtime_reused", { + while (true) { + const existing = this.entries.get(key); + if (existing && !existing.closing) { + if (!existing.runtime || existing.runtime.isAlive()) { + this.clearIdleTimer(existing); + if (existing.runtime) { + this.log("info", "harness_runtime_reused", { + provider: driver.provider, + runtimeKeyHash: hashRuntimeKey(key), + agentId: context.agentId, + }); + } + await existing.createPromise; + if ( + !this.closing && + !existing.closing && + this.entries.get(key) === existing && + existing.runtime?.isAlive() + ) { + return existing; + } + } + await this.removeAndClose(existing, "runtime_not_alive"); + continue; + } + + if (this.closing) throw new Error("Local agent runtime pool is closed."); + + let entry!: RuntimeEntry; + const createPromise = Promise.resolve() + .then(() => driver.createRuntime(context)) + .then((runtime) => { + entry.runtime = runtime; + entry.lastUsedAt = this.now(); + this.log("info", "harness_runtime_started", { provider: driver.provider, runtimeKeyHash: hashRuntimeKey(key), agentId: context.agentId, }); - } - await existing.createPromise; - if (existing.runtime?.isAlive()) return existing; - } - await this.removeAndClose(existing, "runtime_not_alive"); - } - - let entry!: RuntimeEntry; - const createPromise = Promise.resolve() - .then(() => driver.createRuntime(context)) - .then((runtime) => { - entry.runtime = runtime; - entry.lastUsedAt = this.now(); - this.log("info", "harness_runtime_started", { - provider: driver.provider, - runtimeKeyHash: hashRuntimeKey(key), - agentId: context.agentId, + return runtime; + }) + .catch((error) => { + if (this.entries.get(key) === entry) this.entries.delete(key); + throw error; }); - return runtime; - }) - .catch((error) => { - if (this.entries.get(key) === entry) this.entries.delete(key); - throw error; - }); - entry = { - key, - driver, - idleTimeoutMs: driver.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, - sessionIdleTimeoutMs: this.sessionIdleTimeoutMs, - createPromise, - activeRuns: 0, - lastUsedAt: this.now(), - closing: false, - sessions: new Map(), - activeRunWaiters: new Set(), - }; - this.entries.set(key, entry); - await createPromise; - if (this.closing || entry.closing || this.entries.get(key) !== entry) { - await this.closeEntry(entry, "pool_shutdown_during_creation"); - throw new Error("Local agent runtime pool is closed."); + entry = { + key, + driver, + idleTimeoutMs: driver.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + sessionIdleTimeoutMs: this.sessionIdleTimeoutMs, + createPromise, + activeRuns: 0, + lastUsedAt: this.now(), + closing: false, + sessions: new Map(), + activeRunWaiters: new Set(), + }; + this.entries.set(key, entry); + await createPromise; + if (this.closing || entry.closing || this.entries.get(key) !== entry) { + await this.closeEntry(entry, "pool_shutdown_during_creation"); + throw new Error("Local agent runtime pool is closed."); + } + return entry; } - return entry; } private scheduleIdleClose(entry: RuntimeEntry): void { From ed67a53ea84cbf001bdc09fb3d871a9f0c261334 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 10:55:37 +0530 Subject: [PATCH 25/35] fix: coordinate runtime shutdown with session release --- src/local-agent-runtime-pool.ts | 13 +++++++++++-- src/local-agent-runtime.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index 3c3e23bb1..24802ecf9 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -301,8 +301,10 @@ export class LocalAgentRuntimePool { if (reason === "server_shutdown") { // Shutdown is terminal for the provider runtime. Closing it first // aborts stuck turns and avoids waiting forever before process cleanup. - // Do not release individual sessions here: that would race an active - // turn, and the provider runtime owns their final cleanup. + // Do not start new individual releases here: that would race an active + // turn, and the provider runtime owns their final cleanup. Existing + // idle-release work is awaited so provider cleanup never overlaps it. + await this.waitForSessionReleases(entry); try { await runtime.close(); this.log("info", "harness_runtime_closed", { @@ -400,6 +402,13 @@ export class LocalAgentRuntimePool { await new Promise((resolve) => entry.activeRunWaiters.add(resolve)); } + private async waitForSessionReleases(entry: RuntimeEntry): Promise { + const releases = Array.from(entry.sessions.values()) + .map((session) => session.releasePromise) + .filter((release): release is Promise => Boolean(release)); + await Promise.all(releases); + } + private log( level: "info" | "warn" | "error", event: string, diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts index 2895e4ec0..5321d3b7a 100644 --- a/src/local-agent-runtime.test.ts +++ b/src/local-agent-runtime.test.ts @@ -134,6 +134,29 @@ assert.deepEqual(sessionRuntime.releasedSessions, ["thread_1"]); assert.equal(sessionPool.size, 1, "releasing an idle session does not close the runtime"); await sessionPool.close(); +const shutdownReleaseRuntime = new FakeRuntime(); +const shutdownReleasePool = new LocalAgentRuntimePool({ + now: () => clock, + sessionIdleTimeoutMs: 10, +}); +const shutdownReleaseDriver: LocalAgentDriver = { + provider: "codex", + idleTimeoutMs: Number.POSITIVE_INFINITY, + runtimeKey: () => "shutdown-release-runtime", + createRuntime: async () => shutdownReleaseRuntime, +}; +await shutdownReleasePool.run(shutdownReleaseDriver, context, input); +shutdownReleaseRuntime.releaseBlocked = true; +const shutdownRelease = shutdownReleasePool.evictIdle(30); +await waitFor(() => shutdownReleaseRuntime.releaseStarted); +const shutdown = shutdownReleasePool.close(); +await new Promise((resolve) => setImmediate(resolve)); +assert.equal(shutdownReleaseRuntime.closeCount, 0, "shutdown waits for an in-flight session release"); +shutdownReleaseRuntime.finishSessionRelease(); +await shutdownRelease; +await shutdown; +assert.equal(shutdownReleaseRuntime.closeCount, 1); + class CleanupFailureRuntime extends FakeRuntime { override async close(): Promise { throw new Error("cleanup failed"); From 22a5ebf3bd1d15a5a0d2216232385ea261d80c0a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 10:56:01 +0530 Subject: [PATCH 26/35] fix: block new session releases during shutdown --- src/local-agent-runtime-pool.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/local-agent-runtime-pool.ts b/src/local-agent-runtime-pool.ts index 24802ecf9..fa6e7621d 100644 --- a/src/local-agent-runtime-pool.ts +++ b/src/local-agent-runtime-pool.ts @@ -372,6 +372,7 @@ export class LocalAgentRuntimePool { ): Promise { const session = entry.sessions.get(providerSessionId); if (!runtime || !session) return; + if (entry.closing && reason === "idle_timeout") return; if (session.releasePromise) return session.releasePromise; const releasePromise = (async () => { try { From b99b32bea332933688169b8338db3aaf6caccfbb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 10:57:33 +0530 Subject: [PATCH 27/35] refactor: require client agent list scope --- src/local-agent-client.ts | 2 +- src/local-agent-daemon.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 398fe276d..efdae7e95 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -86,7 +86,7 @@ export class LocalAgentClient { return result === null ? undefined : decodeAgentRecord(result); } - async list(scope: LocalAgentListScope = {}): Promise { + async list(scope: LocalAgentListScope): Promise { return decodeAgentRecordList(await this.request("agent.list", scope)); } diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 2b367a3c9..04099bcdc 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -74,7 +74,7 @@ try { assert.equal(started.id, record.id); assert.equal(manager.lastInput?.prompt, "Review this"); assert.equal((await client.get(record.id, { workspaceRoot: record.workspaceRoot }))?.id, record.id); - assert.equal((await client.list())[0]?.id, record.id); + assert.equal((await client.list({ workspaceRoot: record.workspaceRoot }))[0]?.id, record.id); assert.equal((await client.status()).state, "ready"); await client.stop(); From 77a5f105cc45922afa4f18ce9cacd110422bf0a0 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 10:58:53 +0530 Subject: [PATCH 28/35] refactor: avoid provider probes in availability checks --- src/local-agent-availability.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 747f304fa..ca2372a1c 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { accessSync, constants } from "node:fs"; import { delimiter, resolve } from "node:path"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; import { @@ -101,10 +101,10 @@ function commandAvailability( function resolveCommand(command: string, env: NodeJS.ProcessEnv = process.env): string | undefined { const commandHasPath = command.includes("/") || command.includes("\\"); - if (commandHasPath) return executableExists(command, env) ? command : undefined; + if (commandHasPath) return executableExists(command) ? command : undefined; for (const candidate of candidateCommandPaths(command, env)) { - if (executableExists(candidate, env)) return candidate; + if (executableExists(candidate)) return candidate; } return undefined; } @@ -127,17 +127,14 @@ function candidateCommandPaths(command: string, env: NodeJS.ProcessEnv): string[ return candidates; } -function executableExists(command: string, env: NodeJS.ProcessEnv): boolean { - const result = spawnSync(command, ["--version"], { - encoding: "utf8", - env, - windowsHide: true, - timeout: 5_000, - }); - const code = typeof result.error === "object" && result.error && "code" in result.error - ? result.error.code - : undefined; - return code !== "ENOENT"; +function executableExists(command: string): boolean { + const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK; + try { + accessSync(command, mode); + return true; + } catch { + return false; + } } function piAvailabilityEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { From 28d964bb76df7c9e533dff7b254934f5fa80f0bb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Thu, 13 Aug 2026 13:01:13 +0530 Subject: [PATCH 29/35] test(agentd): bound socket event waits --- src/local-agent-daemon.test.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 04099bcdc..6294ed062 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -189,9 +189,33 @@ async function waitFor(check: () => boolean): Promise { assert.equal(check(), true, "condition did not become true before timeout"); } -function onceSocket(socket: ReturnType, event: "connect" | "close"): Promise { +function onceSocket( + socket: ReturnType, + event: "connect" | "close", + timeoutMs = 2_000, +): Promise { return new Promise((resolve, reject) => { - socket.once(event, () => resolve()); - socket.once("error", reject); + const onEvent = () => { + cleanup(); + resolve(); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Socket did not emit ${event} within ${timeoutMs}ms.`)); + }, timeoutMs); + timeout.unref(); + + const cleanup = () => { + clearTimeout(timeout); + socket.off(event, onEvent); + socket.off("error", onError); + }; + + socket.once(event, onEvent); + socket.once("error", onError); }); } From d749df19976b4d07bf94236e4dd0a120dad926c6 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Thu, 13 Aug 2026 13:01:13 +0530 Subject: [PATCH 30/35] ci: bound and supersede smoke runs --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf3d7ea6..e629fe41b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,15 @@ on: - main pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + jobs: smoke: name: Smoke (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: false matrix: From 2ba2021779e35e2ddc6808386f9272f16f117b55 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 17 Aug 2026 18:48:36 +0530 Subject: [PATCH 31/35] fix: keep daemon diagnostics side effect free --- src/cli.ts | 12 +++++----- src/local-agent-client.ts | 34 ++++++++++++++++++++++++++--- src/local-agent-daemon-lifecycle.ts | 9 ++++++++ src/local-agent-daemon.test.ts | 17 +++++++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 222f1611e..55d54a7c7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -297,8 +297,8 @@ function printHelp(): void { " devspace config get Print persisted config", " devspace config set publicBaseUrl ", " devspace agents ls List subagent sessions", - " devspace agents run [--model ] ", - " devspace agents continue [--model ] ", + " devspace agents run [--model ] [--thinking ] ", + " devspace agents continue [--model ] [--thinking ] ", " devspace agents show ", " devspace agents daemon ", " devspace -v, --version Print the installed version", @@ -422,11 +422,13 @@ async function runAgentsDaemon(args: string[]): Promise { return; case "stop": await client.stop(); - console.log("Local agent daemon stopped."); + console.log("Local agent daemon stop requested."); return; - case "logs": - console.log(await client.logs()); + case "logs": { + const output = await client.logs(); + console.log(output || "No local agent daemon logs found."); return; + } default: throw new Error("Usage: devspace agents daemon "); } diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index efdae7e95..c2ca46d62 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -20,6 +20,7 @@ import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ensureLocalAgentDaemonSecret, localAgentDaemonPaths, + readLocalAgentDaemonSecret, type LocalAgentDaemonPaths, } from "./local-agent-daemon-lifecycle.js"; import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; @@ -91,15 +92,15 @@ export class LocalAgentClient { } async status(): Promise { - return decodeDaemonStatus(await this.request("daemon.status", {})); + return decodeDaemonStatus(await this.requestExisting("daemon.status", {})); } async stop(): Promise { - return decodeDaemonStatus(await this.request("daemon.stop", {})); + return decodeDaemonStatus(await this.requestExisting("daemon.stop", {})); } async logs(lines = 200): Promise { - return decodeDaemonLogs(await this.request("daemon.logs", { lines })); + return decodeDaemonLogs(await this.requestExisting("daemon.logs", { lines })); } async ensureReady(): Promise { @@ -174,6 +175,33 @@ export class LocalAgentClient { } return response.result; } + + private async requestExisting( + method: M, + params: Extract['params'], + ): Promise { + const authToken = readLocalAgentDaemonSecret(this.paths); + if (!authToken) { + throw new LocalAgentDaemonClientError("DAEMON_UNAVAILABLE", "Local agent daemon is not running."); + } + try { + const response = await sendRequest(this.endpoint, { + requestId: randomUUID(), + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken, + method, + params, + } as LocalAgentDaemonRequest, this.requestTimeoutMs); + if (!response.ok) { + throw new LocalAgentDaemonClientError(response.error.code, response.error.message); + } + return response.result; + } catch (error) { + if (error instanceof LocalAgentDaemonClientError && error.code === "PROTOCOL_MISMATCH") throw error; + if (error instanceof LocalAgentDaemonClientError && error.code === "DAEMON_UNAVAILABLE") throw error; + throw new LocalAgentDaemonClientError("DAEMON_UNAVAILABLE", "Local agent daemon is not running."); + } + } } export function createLocalAgentClient(config: Pick): LocalAgentClient { diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index 29129c54b..b15754b78 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -147,6 +147,15 @@ export function ensureLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): stri } } +export function readLocalAgentDaemonSecret(paths: LocalAgentDaemonPaths): string | undefined { + try { + const secret = readFileSync(paths.secretPath, "utf8").trim(); + return isDaemonSecret(secret) ? secret : undefined; + } catch { + return undefined; + } +} + export function removeLocalAgentDaemonFiles(paths: LocalAgentDaemonPaths): void { rmSync(paths.pidPath, { force: true }); if (process.platform !== "win32") rmSync(paths.socketPath, { force: true }); diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 6294ed062..4db43bea4 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -64,6 +64,23 @@ const client = new LocalAgentClient({ spawnDaemon: () => { void daemon.start(); }, }); +const missingDaemonStateDir = join(root, "missing-daemon-state"); +let diagnosticSpawnCount = 0; +const missingDaemonClient = new LocalAgentClient({ + stateDir: missingDaemonStateDir, + startupTimeoutMs: 50, + requestTimeoutMs: 50, + spawnDaemon: () => { diagnosticSpawnCount += 1; }, +}); +for (const diagnostic of [ + () => missingDaemonClient.status(), + () => missingDaemonClient.stop(), + () => missingDaemonClient.logs(), +]) { + await assert.rejects(diagnostic, /Local agent daemon is not running/); +} +assert.equal(diagnosticSpawnCount, 0, "daemon diagnostics must not start a missing daemon"); + let shutdownSocket: ReturnType | undefined; try { const started = await client.run({ From 2e8f29fb9a38e214d9a8cc183c3fd262aa71c9f5 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 17 Aug 2026 18:48:53 +0530 Subject: [PATCH 32/35] fix: strip inspector flags from daemon startup --- src/local-agent-client.ts | 17 ++++++++++++++++- src/local-agent-daemon.test.ts | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index c2ca46d62..31d640e4f 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -210,7 +210,7 @@ export function createLocalAgentClient(config: Pick): export function spawnLocalAgentDaemon(stateDir: string, env: NodeJS.ProcessEnv = process.env): void { const entrypoint = resolveDaemonEntrypoint(); - const child = spawn(process.execPath, [...process.execArgv, entrypoint], { + const child = spawn(process.execPath, [...daemonExecArgv(process.execArgv), entrypoint], { detached: true, stdio: "ignore", windowsHide: true, @@ -219,6 +219,21 @@ export function spawnLocalAgentDaemon(stateDir: string, env: NodeJS.ProcessEnv = child.unref(); } +export function daemonExecArgv(execArgv: readonly string[]): string[] { + const result: string[] = []; + for (let index = 0; index < execArgv.length; index += 1) { + const argument = execArgv[index]!; + if (/^--inspect(?:-brk|-wait)?(?:=.*)?$/.test(argument)) continue; + if (argument === "--inspect-port") { + index += 1; + continue; + } + if (argument.startsWith("--inspect-port=")) continue; + result.push(argument); + } + return result; +} + export function resolveDaemonEntrypoint(): string { const compiled = fileURLToPath(new URL("./local-agent-daemon-main.js", import.meta.url)); if (existsSync(compiled)) return compiled; diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index 4db43bea4..b40033191 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { createConnection } from "node:net"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { LocalAgentClient } from "./local-agent-client.js"; +import { daemonExecArgv, LocalAgentClient } from "./local-agent-client.js"; import { LocalAgentDaemon, type LocalAgentDaemonManager } from "./local-agent-daemon.js"; import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; @@ -81,6 +81,19 @@ for (const diagnostic of [ } assert.equal(diagnosticSpawnCount, 0, "daemon diagnostics must not start a missing daemon"); +assert.deepEqual( + daemonExecArgv([ + "--enable-source-maps", + "--inspect=127.0.0.1:9229", + "--inspect-brk", + "--inspect-wait=127.0.0.1:9230", + "--inspect-port", "9231", + "--trace-warnings", + ]), + ["--enable-source-maps", "--trace-warnings"], + "detached daemon startup must not inherit inspector flags", +); + let shutdownSocket: ReturnType | undefined; try { const started = await client.run({ From 418333acbfcf964e2db078033fb18496a00407b4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 17 Aug 2026 18:49:12 +0530 Subject: [PATCH 33/35] fix: preserve daemon message whitespace --- src/local-agent-daemon-protocol.test.ts | 15 +++++++++++++++ src/local-agent-daemon-protocol.ts | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 480a8b331..f9e82dbe0 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -23,6 +23,19 @@ assert.equal(request.method, "agent.start"); assert.equal(request.params.writeMode, "read_only"); assert.match(encodeLocalAgentDaemonRequest(request), /"method":"agent.start"/); +const whitespaceRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_whitespace", + protocolVersion: 1, + authToken: "test-secret", + method: "agent.start", + params: { + target: "reviewer", + prompt: " keep prompt whitespace \n", + workspaceRoot: "/tmp/project", + }, +}); +assert.equal(whitespaceRequest.params.prompt, " keep prompt whitespace \n"); + assert.throws( () => decodeLocalAgentDaemonRequest({ requestId: "req_2", @@ -40,10 +53,12 @@ const record = decodeAgentRecord({ profileName: "reviewer", provider: "codex", status: "idle", + latestResponse: " response whitespace \n", createdAt: "now", updatedAt: "now", }); assert.equal(record.id, "agt_1234"); +assert.equal(record.latestResponse, " response whitespace \n"); const response = decodeLocalAgentDaemonResponse({ requestId: "req_1", diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index dabb0549e..b02d5b199 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -177,8 +177,8 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { thinking: optionalString(record?.thinking), providerSessionId: optionalString(record?.providerSessionId), status, - latestResponse: optionalString(record?.latestResponse), - error: optionalString(record?.error), + latestResponse: optionalContentString(record?.latestResponse), + error: optionalContentString(record?.error), createdAt: requiredString(record?.createdAt, "createdAt"), updatedAt: requiredString(record?.updatedAt, "updatedAt"), }; @@ -232,7 +232,7 @@ function decodeStartInput(value: unknown): StartLocalAgentInput { const record = asRecord(value); return { target: requiredString(record?.target, "target"), - prompt: requiredString(record?.prompt, "prompt"), + prompt: requiredContentString(record?.prompt, "prompt"), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), workspaceId: optionalString(record?.workspaceId), model: optionalString(record?.model), @@ -246,7 +246,7 @@ function decodeContinueInput(value: unknown): { id: string; prompt: string; scop const overrides = asRecord(record?.overrides); return { id: requiredString(record?.id, "id"), - prompt: requiredString(record?.prompt, "prompt"), + prompt: requiredContentString(record?.prompt, "prompt"), scope: decodeWorkspaceScope(record?.scope), ...(overrides ? { overrides: { model: optionalString(overrides.model), @@ -303,6 +303,12 @@ function requiredString(value: unknown, field: string): string { return result; } +function requiredContentString(value: unknown, field: string): string { + const result = optionalContentString(value); + if (result === undefined) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", `Missing ${field}.`); + return result; +} + function requiredInteger(value: unknown, field: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value)) { throw new LocalAgentDaemonProtocolError("INVALID_PROTOCOL", `Invalid ${field}.`); @@ -316,6 +322,11 @@ function optionalString(value: unknown): string | undefined { return trimmed || undefined; } +function optionalContentString(value: unknown): string | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + return value; +} + function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; return value as Record; From c1445fdc3542960affb16f62651b63f6a25446ac Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 17 Aug 2026 18:51:55 +0530 Subject: [PATCH 34/35] fix: require workspace ownership for agents --- src/cli.ts | 14 ++++--- src/local-agent-daemon-protocol.test.ts | 3 ++ src/local-agent-daemon-protocol.ts | 19 +++------ src/local-agent-daemon.test.ts | 19 ++++++--- src/local-agent-daemon.ts | 8 ++-- src/local-agent-manager.test.ts | 56 +++++++++++++++++-------- src/local-agent-manager.ts | 32 +++++++------- src/local-agent-store.ts | 4 +- 8 files changed, 94 insertions(+), 61 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 55d54a7c7..5ea7293b4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -357,13 +357,13 @@ async function runAgentsList(): Promise { async function runAgentsRun(args: string[]): Promise { const parsed = parseLocalAgentRunArgs(args); const config = loadConfig(); - const workspaceRoot = resolveCurrentWorkspaceRoot(); + const scope = resolveCurrentWorkspaceScope(); const client = createLocalAgentClient(config); const record = await client.start({ target: parsed.target, prompt: parsed.prompt, - workspaceRoot, - workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + workspaceRoot: scope.workspaceRoot, + workspaceId: scope.workspaceId, model: parsed.model, thinking: parsed.thinking, }); @@ -438,9 +438,13 @@ function resolveCurrentWorkspaceRoot(): string { return resolve(process.env.DEVSPACE_WORKSPACE_ROOT || process.cwd()); } -function resolveCurrentWorkspaceScope(): { workspaceId?: string; workspaceRoot: string } { +function resolveCurrentWorkspaceScope(): { workspaceId: string; workspaceRoot: string } { + const workspaceId = process.env.DEVSPACE_WORKSPACE_ID?.trim(); + if (!workspaceId) { + throw new Error("A DevSpace workspace is required. Run this command from an open_workspace session."); + } return { - workspaceId: process.env.DEVSPACE_WORKSPACE_ID, + workspaceId, workspaceRoot: resolveCurrentWorkspaceRoot(), }; } diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index f9e82dbe0..81a2cddc6 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -15,6 +15,7 @@ const request = decodeLocalAgentDaemonRequest({ params: { target: "reviewer", prompt: "Review this", + workspaceId: "ws_test", workspaceRoot: "/tmp/project", writeMode: "read_only", }, @@ -31,6 +32,7 @@ const whitespaceRequest = decodeLocalAgentDaemonRequest({ params: { target: "reviewer", prompt: " keep prompt whitespace \n", + workspaceId: "ws_test", workspaceRoot: "/tmp/project", }, }); @@ -49,6 +51,7 @@ assert.throws( const record = decodeAgentRecord({ id: "agt_1234", + workspaceId: "ws_test", workspaceRoot: "/tmp/project", profileName: "reviewer", provider: "codex", diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index b02d5b199..cd031366f 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -1,5 +1,4 @@ import type { - LocalAgentListScope, LocalAgentRecord, LocalAgentStatus, LocalAgentWorkspaceScope, @@ -26,7 +25,7 @@ export type LocalAgentDaemonRequest = | AgentDaemonRequestBase<"agent.start", StartLocalAgentInput> | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides }> | AgentDaemonRequestBase<"agent.get", { id: string; scope: LocalAgentWorkspaceScope }> - | AgentDaemonRequestBase<"agent.list", LocalAgentListScope> + | AgentDaemonRequestBase<"agent.list", LocalAgentWorkspaceScope> | AgentDaemonRequestBase<"daemon.status", Record> | AgentDaemonRequestBase<"daemon.stop", Record> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; @@ -169,7 +168,7 @@ export function decodeAgentRecord(value: unknown): LocalAgentRecord { if (!isLocalAgentStatus(status)) throw new LocalAgentDaemonProtocolError("INVALID_RECORD", "Invalid agent status."); return { id: requiredString(record?.id, "id"), - workspaceId: optionalString(record?.workspaceId), + workspaceId: requiredString(record?.workspaceId, "workspaceId"), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), profileName: requiredString(record?.profileName, "profileName"), provider: requiredString(record?.provider, "provider"), @@ -234,7 +233,7 @@ function decodeStartInput(value: unknown): StartLocalAgentInput { target: requiredString(record?.target, "target"), prompt: requiredContentString(record?.prompt, "prompt"), workspaceRoot: requiredString(record?.workspaceRoot, "workspaceRoot"), - workspaceId: optionalString(record?.workspaceId), + workspaceId: requiredString(record?.workspaceId, "workspaceId"), model: optionalString(record?.model), thinking: optionalString(record?.thinking), writeMode: decodeWriteMode(record?.writeMode), @@ -260,19 +259,13 @@ function decodeWorkspaceScope(value: unknown): LocalAgentWorkspaceScope { const record = asRecord(value); if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Workspace scope is required."); return { - workspaceId: optionalString(record.workspaceId), + workspaceId: requiredString(record.workspaceId, "scope.workspaceId"), workspaceRoot: requiredString(record.workspaceRoot, "scope.workspaceRoot"), }; } -function decodeListScope(value: unknown): LocalAgentListScope { - if (value === undefined) return {}; - const record = asRecord(value); - if (!record) throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "List scope must be an object."); - return { - workspaceId: optionalString(record.workspaceId), - workspaceRoot: optionalString(record.workspaceRoot), - }; +function decodeListScope(value: unknown): LocalAgentWorkspaceScope { + return decodeWorkspaceScope(value); } function decodeLogsParams(value: unknown): { lines?: number } { diff --git a/src/local-agent-daemon.test.ts b/src/local-agent-daemon.test.ts index b40033191..3176e26bf 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -7,11 +7,12 @@ import { tmpdir } from "node:os"; import { daemonExecArgv, LocalAgentClient } from "./local-agent-client.js"; import { LocalAgentDaemon, type LocalAgentDaemonManager } from "./local-agent-daemon.js"; import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; -import type { LocalAgentListScope, LocalAgentRecord } from "./local-agent-store.js"; +import type { LocalAgentRecord } from "./local-agent-store.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agentd-test-")); const record: LocalAgentRecord = { id: "agt_test", + workspaceId: "ws_test", workspaceRoot: join(root, "project"), profileName: "reviewer", provider: "codex", @@ -31,15 +32,20 @@ class FakeManager implements LocalAgentDaemonManager { return record; } - async continue(_agentId: string, _prompt: string, _overrides?: RunOverrides): Promise { + async continue( + _agentId: string, + _prompt: string, + _overrides: RunOverrides | undefined, + _scope: { workspaceId: string; workspaceRoot: string }, + ): Promise { return { ...record, status: "running" }; } - get(id: string): LocalAgentRecord | undefined { + get(id: string, _scope: { workspaceId: string; workspaceRoot: string }): LocalAgentRecord | undefined { return id === record.id ? record : undefined; } - list(_scope?: LocalAgentListScope): LocalAgentRecord[] { + list(_scope: { workspaceId: string; workspaceRoot: string }): LocalAgentRecord[] { return [record]; } @@ -99,12 +105,13 @@ try { const started = await client.run({ target: "reviewer", prompt: "Review this", + workspaceId: record.workspaceId!, workspaceRoot: join(root, "project"), }); assert.equal(started.id, record.id); assert.equal(manager.lastInput?.prompt, "Review this"); - assert.equal((await client.get(record.id, { workspaceRoot: record.workspaceRoot }))?.id, record.id); - assert.equal((await client.list({ workspaceRoot: record.workspaceRoot }))[0]?.id, record.id); + assert.equal((await client.get(record.id, { workspaceId: record.workspaceId!, workspaceRoot: record.workspaceRoot }))?.id, record.id); + assert.equal((await client.list({ workspaceId: record.workspaceId!, workspaceRoot: record.workspaceRoot }))[0]?.id, record.id); assert.equal((await client.status()).state, "ready"); await client.stop(); diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index b80b37499..d1b51e880 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -20,7 +20,7 @@ import { LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; import { LocalAgentConflictError, type RunOverrides, type StartLocalAgentInput } from "./local-agent-manager.js"; -import type { LocalAgentListScope, LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import type { LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; const MAX_REQUEST_BYTES = 512 * 1024; const DEFAULT_DAEMON_IDLE_SHUTDOWN_MS = 30_000; @@ -30,9 +30,9 @@ const DEFAULT_DAEMON_SHUTDOWN_TIMEOUT_MS = 10_000; export interface LocalAgentDaemonManager { start(input: StartLocalAgentInput): Promise; - continue(agentId: string, prompt: string, overrides?: RunOverrides, scope?: LocalAgentWorkspaceScope): Promise; - get(agentId: string, scope?: LocalAgentWorkspaceScope): LocalAgentRecord | undefined; - list(scope?: LocalAgentListScope): LocalAgentRecord[]; + continue(agentId: string, prompt: string, overrides: RunOverrides | undefined, scope: LocalAgentWorkspaceScope): Promise; + get(agentId: string, scope: LocalAgentWorkspaceScope): LocalAgentRecord | undefined; + list(scope: LocalAgentWorkspaceScope): LocalAgentRecord[]; evictIdle(now?: number): Promise; close(): Promise; readonly activeTurnCount: number; diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index e08db7637..85d2cae6d 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -16,6 +16,7 @@ import { LocalAgentStore } from "./local-agent-store.js"; const root = await mkdtemp(join(tmpdir(), "devspace-agent-manager-test-")); const stateDir = join(root, "state"); +const scope = { workspaceId: "ws_test", workspaceRoot: root }; const profile: LocalAgentProfile = { name: "reviewer", description: "Test reviewer", @@ -81,6 +82,7 @@ const driver: LocalAgentDriver = { const store = new LocalAgentStore(stateDir); const stale = store.create({ + workspaceId: scope.workspaceId, workspaceRoot: root, profileName: "reviewer", provider: "codex", @@ -96,73 +98,93 @@ const manager = new LocalAgentManager({ }); await assert.rejects( - manager.start({ target: "reviewer", prompt: "outside", workspaceRoot: join(tmpdir(), "outside") }), + manager.start({ + target: "reviewer", + prompt: "outside", + workspaceId: scope.workspaceId, + workspaceRoot: join(tmpdir(), "outside"), + }), /outside allowed roots/, ); -assert.equal(manager.get(stale.id)?.status, "running"); +assert.equal(manager.get(stale.id, scope)?.status, "running"); manager.reconcileActiveRuns(); -assert.equal(manager.get(stale.id)?.status, "error"); -assert.equal(manager.get(stale.id)?.latestResponse, "previous response"); +assert.equal(manager.get(stale.id, scope)?.status, "error"); +assert.equal(manager.get(stale.id, scope)?.latestResponse, "previous response"); assert.equal( - manager.get(stale.id)?.error, + manager.get(stale.id, scope)?.error, "DevSpace restarted while this agent turn was running.", ); const first = await manager.start({ target: "reviewer", prompt: "hold", + workspaceId: scope.workspaceId, workspaceRoot: root, }); assert.equal(first.status, "running"); await waitFor(() => runtimes.get(first.id)?.inputs.length === 1); await assert.rejects( - () => manager.continue(first.id, "another prompt"), + () => manager.continue(first.id, "another prompt", {}, scope), (error: unknown) => error instanceof LocalAgentConflictError && error.agentId === first.id, ); runtimes.get(first.id)!.release(); -await waitFor(() => manager.get(first.id)?.status === "idle"); -assert.equal(manager.get(first.id)?.providerSessionId, "thread_test"); -assert.match(manager.get(first.id)?.latestResponse ?? "", /Task:\nhold/); +await waitFor(() => manager.get(first.id, scope)?.status === "idle"); +assert.equal(manager.get(first.id, scope)?.providerSessionId, "thread_test"); +assert.match(manager.get(first.id, scope)?.latestResponse ?? "", /Task:\nhold/); -const continued = await manager.continue(first.id, "continue"); +const continued = await manager.continue(first.id, "continue", {}, scope); assert.equal(continued.status, "running"); -await waitFor(() => manager.get(first.id)?.status === "idle"); +await waitFor(() => manager.get(first.id, scope)?.status === "idle"); const second = await manager.start({ target: "reviewer", prompt: "second agent", + workspaceId: scope.workspaceId, workspaceRoot: root, }); -await waitFor(() => manager.get(second.id)?.status === "idle"); +await waitFor(() => manager.get(second.id, scope)?.status === "idle"); assert.notEqual(first.id, second.id); assert.equal(runtimes.size, 2, "different agents receive independent logical runtimes"); const failed = await manager.start({ target: "reviewer", prompt: "fail", + workspaceId: scope.workspaceId, workspaceRoot: root, }); -await waitFor(() => manager.get(failed.id)?.status === "error"); -assert.equal(manager.get(failed.id)?.error, "provider failed"); +await waitFor(() => manager.get(failed.id, scope)?.status === "error"); +assert.equal(manager.get(failed.id, scope)?.error, "provider failed"); const earlyFailure = await manager.start({ target: "reviewer", prompt: "early-fail", + workspaceId: scope.workspaceId, workspaceRoot: root, }); -await waitFor(() => manager.get(earlyFailure.id)?.status === "error"); -assert.equal(manager.get(earlyFailure.id)?.providerSessionId, "thread_early"); +await waitFor(() => manager.get(earlyFailure.id, scope)?.status === "error"); +assert.equal(manager.get(earlyFailure.id, scope)?.providerSessionId, "thread_early"); await assert.rejects( - () => manager.continue(first.id, "wrong workspace", {}, { workspaceRoot: join(root, "other") }), + () => manager.continue(first.id, "wrong workspace", {}, { + workspaceId: scope.workspaceId, + workspaceRoot: join(root, "other"), + }), + /different workspace/, +); +await assert.rejects( + () => manager.continue(first.id, "wrong workspace id", {}, { + workspaceId: "ws_other", + workspaceRoot: root, + }), /different workspace/, ); const shuttingDown = await manager.start({ target: "reviewer", prompt: "hold during shutdown", + workspaceId: scope.workspaceId, workspaceRoot: root, }); await waitFor(() => runtimes.get(shuttingDown.id)?.inputs.length === 1); diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index c6ac0449e..61f99f81b 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -7,7 +7,6 @@ import { resolveLocalAgentTarget, } from "./local-agent-targets.js"; import { - type LocalAgentListScope, type LocalAgentRecord, type LocalAgentStore, type LocalAgentWorkspaceScope, @@ -26,7 +25,7 @@ export interface StartLocalAgentInput { target: string; prompt: string; workspaceRoot: string; - workspaceId?: string; + workspaceId: string; model?: string; thinking?: string; writeMode?: LocalAgentWriteMode; @@ -121,26 +120,27 @@ export class LocalAgentManager { agentId: string, prompt: string, overrides: RunOverrides = {}, - scope?: LocalAgentWorkspaceScope, + scope: LocalAgentWorkspaceScope, ): Promise { this.assertAccepting(); const record = this.store.getById(agentId); if (!record) throw new Error(`Unknown subagent id: ${agentId}`); - if (scope) this.assertAgentWorkspace(record, scope); + this.assertAgentWorkspace(record, scope); this.assertDriver(record.provider); return this.begin(record, prompt, overrides); } - get(agentId: string, scope?: LocalAgentWorkspaceScope): LocalAgentRecord | undefined { + get(agentId: string, scope: LocalAgentWorkspaceScope): LocalAgentRecord | undefined { const record = this.store.getById(agentId); - if (record && scope) this.assertAgentWorkspace(record, scope); + if (record) this.assertAgentWorkspace(record, scope); return record; } - list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { - return this.store.list(scope.workspaceRoot - ? { ...scope, workspaceRoot: this.authorizeWorkspace(scope.workspaceRoot) } - : scope); + list(scope: LocalAgentWorkspaceScope): LocalAgentRecord[] { + return this.store.list({ + workspaceId: scope.workspaceId, + workspaceRoot: this.authorizeWorkspace(scope.workspaceRoot), + }); } async close(): Promise { @@ -210,14 +210,18 @@ export class LocalAgentManager { providerSessionIdPrefix: record.providerSessionId?.slice(0, 8), }); try { - const profiles = await this.loadProfiles(record.workspaceRoot); + const workspaceRoot = this.authorizeWorkspace(record.workspaceRoot); + const authorizedRecord = workspaceRoot === record.workspaceRoot + ? record + : { ...record, workspaceRoot }; + const profiles = await this.loadProfiles(workspaceRoot); const profile = profiles.find((candidate) => candidate.name === record.profileName); - const input = this.buildRunInput(record, profile, prompt, overrides); + const input = this.buildRunInput(authorizedRecord, profile, prompt, overrides); const driver = this.assertDriver(record.provider); const context: LocalAgentRuntimeContext = { agentId: record.id, provider: driver.provider, - workspace: record.workspaceRoot, + workspace: workspaceRoot, providerSessionId: record.providerSessionId, writeMode: input.writeMode, model: input.model, @@ -312,7 +316,7 @@ export class LocalAgentManager { if (workspaceRoot !== record.workspaceRoot) { throw new Error(`Subagent ${record.id} belongs to a different workspace.`); } - if (record.workspaceId && record.workspaceId !== scope.workspaceId) { + if (!record.workspaceId || record.workspaceId !== scope.workspaceId) { throw new Error(`Subagent ${record.id} belongs to a different workspace.`); } } diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 504ebdc4a..ee345fe20 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -21,7 +21,7 @@ export interface LocalAgentRecord { } export interface CreateLocalAgentRecordInput { - workspaceId?: string; + workspaceId: string; workspaceRoot: string; profileName: string; provider: string; @@ -30,7 +30,7 @@ export interface CreateLocalAgentRecordInput { } export interface LocalAgentWorkspaceScope { - workspaceId?: string; + workspaceId: string; workspaceRoot: string; } From 624733ec612782b978bf552e6783f3022bd40eb3 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 17 Aug 2026 19:18:50 +0530 Subject: [PATCH 35/35] fix: align daemon workspace scope types --- src/local-agent-client.ts | 4 ++-- src/local-agent-daemon-protocol.test.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 31d640e4f..2a6364628 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -24,7 +24,7 @@ import { type LocalAgentDaemonPaths, } from "./local-agent-daemon-lifecycle.js"; import type { RunOverrides, StartLocalAgentInput } from "./local-agent-manager.js"; -import type { LocalAgentListScope, LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; +import type { LocalAgentRecord, LocalAgentWorkspaceScope } from "./local-agent-store.js"; const DEFAULT_STARTUP_TIMEOUT_MS = 8_000; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -87,7 +87,7 @@ export class LocalAgentClient { return result === null ? undefined : decodeAgentRecord(result); } - async list(scope: LocalAgentListScope): Promise { + async list(scope: LocalAgentWorkspaceScope): Promise { return decodeAgentRecordList(await this.request("agent.list", scope)); } diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 81a2cddc6..523488484 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -21,6 +21,7 @@ const request = decodeLocalAgentDaemonRequest({ }, }); assert.equal(request.method, "agent.start"); +if (request.method !== "agent.start") throw new Error("expected agent.start request"); assert.equal(request.params.writeMode, "read_only"); assert.match(encodeLocalAgentDaemonRequest(request), /"method":"agent.start"/); @@ -36,6 +37,7 @@ const whitespaceRequest = decodeLocalAgentDaemonRequest({ workspaceRoot: "/tmp/project", }, }); +if (whitespaceRequest.method !== "agent.start") throw new Error("expected agent.start request"); assert.equal(whitespaceRequest.params.prompt, " keep prompt whitespace \n"); assert.throws(