From c5f9a83896b19a538890587d010a531e656b517d Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:00:00 +0530 Subject: [PATCH 1/2] feat(agents): add event-driven multi-agent wait --- src/cli.test.ts | 81 ++++++++++- src/cli.ts | 66 +++++++-- src/local-agent-client.ts | 46 +++++-- src/local-agent-daemon-lifecycle.ts | 2 +- src/local-agent-daemon-protocol.test.ts | 48 ++++++- src/local-agent-daemon-protocol.ts | 91 +++++++++++++ src/local-agent-daemon.test.ts | 63 ++++++++- src/local-agent-daemon.ts | 29 +++- src/local-agent-manager.test.ts | 70 ++++++++++ src/local-agent-manager.ts | 171 +++++++++++++++++++++++- src/local-agent-presentation.ts | 5 +- src/local-agent-store.ts | 12 ++ 12 files changed, 633 insertions(+), 51 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 2caa5ae2a..1d9824159 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -8,7 +8,10 @@ import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { loadConfig } from "./config.js"; -import { localAgentDaemonPaths } from "./local-agent-daemon-lifecycle.js"; +import { + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + localAgentDaemonPaths, +} from "./local-agent-daemon-lifecycle.js"; import { encodeLocalAgentDaemonResponse } from "./local-agent-daemon-protocol.js"; import { LocalAgentStore } from "./local-agent-store.js"; import { writeTestDevspaceConfig } from "./test-support/config.test.js"; @@ -100,7 +103,7 @@ try { if (request.method === "agent.start") { socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: false, error: { code: "UNKNOWN_TARGET", @@ -113,10 +116,17 @@ try { } const result = request.method === "agent.list" ? [current] + : request.method === "agent.get" + ? current + : request.method === "agent.wait" + ? [ + { id: current.id, status: "completed", response: "Review complete." }, + { id: other.id, status: "running", wait: "timeout" }, + ] : request.method === "hello" ? { state: "ready", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, pid: process.pid, endpoint: daemonSocket, startedAt: "now", @@ -127,7 +137,7 @@ try { : null; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result, })); @@ -192,6 +202,69 @@ try { const directList = [...daemonRequests].reverse().find((request) => request.method === "agent.list"); assert.deepEqual(directList?.params, { workspaceRoot: realpathSync.native(projectRoot) }); + const { stdout: showOutput } = await execFileAsync( + "node", + ["--import", "tsx", "src/cli.ts", "agents", "show", current.id], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...cliConfigEnv, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + }, + }, + ); + assert.equal( + showOutput, + `Review complete.\n`, + ); + assert.equal( + daemonRequests.filter((request) => request.method === "agent.get").length, + 1, + "show must be an immediate snapshot", + ); + + const { stdout: waitOutput } = await execFileAsync( + "node", + [ + "--import", + "tsx", + "src/cli.ts", + "agents", + "wait", + current.id, + other.id, + "--timeout", + "0", + ], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...cliConfigEnv, + DEVSPACE_WORKSPACE_ID: "ws_current", + DEVSPACE_WORKSPACE_ROOT: projectRoot, + }, + }, + ); + assert.equal( + waitOutput, + [ + `Review complete.`, + ``, + "", + ].join("\n"), + ); + const waitRequest = daemonRequests.find((request) => request.method === "agent.wait"); + assert.deepEqual(waitRequest?.params, { + ids: [current.id, other.id], + scope: { workspaceId: "ws_current", workspaceRoot: realpathSync.native(projectRoot) }, + timeoutMs: 0, + }); + let commandFailure: unknown; try { await execFileAsync( diff --git a/src/cli.ts b/src/cli.ts index d18b488cc..27e7976c2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -459,6 +459,9 @@ async function runAgentsCommand(args: string[]): Promise { case "show": await runAgentWorkflowCommand(json, () => runAgentsShow(commandArgs, json)); return; + case "wait": + await runAgentWorkflowCommand(json, () => runAgentsWait(commandArgs, json)); + return; case "targets": await runAgentWorkflowCommand(json, () => runAgentsTargets(commandArgs, json)); return; @@ -558,22 +561,62 @@ async function runAgentsShow(args: string[], json: boolean): Promise { const client = createLocalAgentClient(config); const scope = resolveCliWorkspaceContext(config.allowedRoots); const initial = await client.get(id, scope); - let record = presentAgentWorkflowResult(initial, json); + const record = presentAgentWorkflowResult(initial, json); if (!record) return; - const deadline = Date.now() + 15_000; - while ((record.status === "starting" || record.status === "running") && Date.now() < deadline) { - await sleep(500); - const refreshed = presentAgentWorkflowResult(await client.get(id, scope), json); - if (!refreshed) return; - record = refreshed; - } - const observation = presentAgentObservation(record); if (json) printJson(observation); else printAgentXml(formatAgentObservation(observation)); } +async function runAgentsWait(args: string[], json: boolean): Promise { + const { ids, timeoutMs } = parseAgentsWaitArgs(args); + const config = loadConfig(); + const client = createLocalAgentClient(config); + const scope = resolveCliWorkspaceContext(config.allowedRoots); + const results = presentAgentWorkflowResult(await client.wait(ids, scope, timeoutMs), json); + if (!results) return; + if (json) { + printJson(results); + return; + } + printAgentXml(results.map(formatAgentObservation).join("\n")); +} + +function parseAgentsWaitArgs(args: string[]): { ids: string[]; timeoutMs?: number } { + const ids: string[] = []; + let timeoutMs: number | undefined; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + if (argument === "--timeout") { + timeoutMs = parseAgentWaitTimeout(args[index + 1]); + index += 1; + continue; + } + if (argument.startsWith("--timeout=")) { + timeoutMs = parseAgentWaitTimeout(argument.slice("--timeout=".length)); + continue; + } + if (argument.startsWith("-")) throw new Error(`Unknown option: ${argument}.`); + ids.push(argument); + } + if (ids.length === 0) { + throw new Error("Usage: devspace agents wait ... [--timeout ] [--json]"); + } + return { ids, ...(timeoutMs === undefined ? {} : { timeoutMs }) }; +} + +function parseAgentWaitTimeout(value: string | undefined): number { + if (!value || !/^\d+$/.test(value)) { + throw new Error("Agent wait timeout must be a non-negative integer number of seconds."); + } + const timeoutMs = Number(value) * 1_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs > 2_147_483_647) { + throw new Error("Agent wait timeout is too large."); + } + return timeoutMs; +} + async function runAgentsDaemon(args: string[], json: boolean): Promise { const [subcommand, ...extra] = args; if (extra.length > 0) throw new Error("Usage: devspace agents daemon [--json]"); @@ -672,10 +715,6 @@ function printJson(value: unknown): void { console.log(JSON.stringify(value)); } -function sleep(ms: number): Promise { - return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); -} - function printAgentsHelp(): void { console.log( [ @@ -686,6 +725,7 @@ function printAgentsHelp(): void { " devspace agents run [--model ] [--effort ] [--json] ", " devspace agents continue [--model ] [--effort ] [--json] ", " devspace agents show [--json]", + " devspace agents wait ... [--timeout ] [--json]", " devspace agents targets [--json]", " devspace agents daemon [--json]", ].join("\n"), diff --git a/src/local-agent-client.ts b/src/local-agent-client.ts index 01f8c1cdc..5d31464b6 100644 --- a/src/local-agent-client.ts +++ b/src/local-agent-client.ts @@ -22,6 +22,7 @@ import { import { decodeAgentRecord, decodeAgentRecordList, + decodeAgentWaitResults, decodeDaemonLogs, decodeDaemonStatus, decodeLocalAgentDaemonResponse, @@ -45,6 +46,8 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentWaitError, + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -60,6 +63,7 @@ type RequestError = : M extends "agent.continue" ? AgentContinueError | AgentDaemonError : M extends "agent.get" ? AgentLookupError | AgentDaemonError : M extends "agent.list" ? AgentListError | AgentDaemonError + : M extends "agent.wait" ? AgentWaitError | AgentDaemonError : AgentDaemonError; export interface LocalAgentClientOptions { @@ -134,6 +138,22 @@ export class LocalAgentClient { return decodeRequestResult(result, "agent.list", decodeAgentRecordList); } + async wait( + agentIds: readonly string[], + scope: LocalAgentWorkspaceScope, + timeoutMs?: number, + ): Promise> { + const transportTimeoutMs = timeoutMs === undefined + ? null + : Math.min(2_147_483_647, timeoutMs + this.requestTimeoutMs); + const result = await this.request("agent.wait", { + ids: [...agentIds], + scope, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }, transportTimeoutMs); + return decodeRequestResult(result, "agent.wait", decodeAgentWaitResults); + } + async status(): Promise> { const result = await this.requestExisting("daemon.status", {}); return decodeRequestResult(result, "daemon.status", decodeDaemonStatus); @@ -308,6 +328,7 @@ export class LocalAgentClient { private async request( method: M, params: Extract['params'], + timeoutMs: number | null = this.requestTimeoutMs, ): Promise>> { const ready = await this.ensureReady(); if (ready.isErr()) return ready as BetterResult>; @@ -319,7 +340,7 @@ export class LocalAgentClient { authToken: authToken.value, method, params, - } as LocalAgentDaemonRequest, this.requestTimeoutMs); + } as LocalAgentDaemonRequest, timeoutMs ?? undefined); if (response.isErr()) return response as BetterResult>; if (!response.value.ok) { const error = decodeRemoteError(response.value.error, method); @@ -459,20 +480,22 @@ export function resolveDaemonEntrypoint(): string { async function sendRequest( endpoint: string, request: LocalAgentDaemonRequest, - timeoutMs: number, + timeoutMs?: number, ): Promise> { return new Promise((resolve) => { const socket = createConnection(endpoint); let buffer = ""; let settled = false; - const timer = setTimeout(() => { - finish(Result.err(new AgentDaemonTimeoutError({ - code: "DAEMON_TIMEOUT", - operation: request.method, - retryable: true, - message: "Timed out waiting for the local agent daemon.", - })), true); - }, timeoutMs); + const timer = timeoutMs === undefined + ? undefined + : setTimeout(() => { + finish(Result.err(new AgentDaemonTimeoutError({ + code: "DAEMON_TIMEOUT", + operation: request.method, + retryable: true, + message: "Timed out waiting for the local agent daemon.", + })), true); + }, timeoutMs); const finish = ( result: BetterResult, @@ -480,7 +503,7 @@ async function sendRequest( ) => { if (settled) return; settled = true; - clearTimeout(timer); + if (timer) clearTimeout(timer); if (destroy) socket.destroy(); resolve(result); }; @@ -599,6 +622,7 @@ function isRequestError( || category === "conflict" || category === "store"; case "agent.get": + case "agent.wait": return category === "target" || category === "scope" || category === "store"; case "agent.list": return category === "scope" || category === "store"; diff --git a/src/local-agent-daemon-lifecycle.ts b/src/local-agent-daemon-lifecycle.ts index df0b81b95..250bb2319 100644 --- a/src/local-agent-daemon-lifecycle.ts +++ b/src/local-agent-daemon-lifecycle.ts @@ -12,7 +12,7 @@ import { } from "node:fs"; import { join, resolve } from "node:path"; -export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 3; +export const LOCAL_AGENT_DAEMON_PROTOCOL_VERSION = 4; 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"; diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index 708180987..b5fb282c1 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -1,15 +1,17 @@ import assert from "node:assert/strict"; import { decodeAgentRecord, + decodeAgentWaitResults, decodeLocalAgentDaemonRequest, decodeLocalAgentDaemonResponse, encodeLocalAgentDaemonResponse, LocalAgentDaemonProtocolError, } from "./local-agent-daemon-protocol.js"; +import { LOCAL_AGENT_DAEMON_PROTOCOL_VERSION } from "./local-agent-daemon-lifecycle.js"; const request = decodeLocalAgentDaemonRequest({ requestId: "req_1", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { @@ -26,7 +28,7 @@ assert.equal(request.params.writeMode, "read_only"); const whitespaceRequest = decodeLocalAgentDaemonRequest({ requestId: "req_whitespace", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { @@ -41,7 +43,7 @@ assert.equal(whitespaceRequest.params.prompt, " keep prompt whitespace \n"); const directRequest = decodeLocalAgentDaemonRequest({ requestId: "req_direct", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { @@ -56,7 +58,7 @@ assert.equal(directRequest.params.workspaceId, undefined); assert.throws( () => decodeLocalAgentDaemonRequest({ requestId: "req_2", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "test-secret", method: "agent.start", params: { target: "reviewer", prompt: "" }, @@ -83,7 +85,7 @@ assert.equal(directRecord.workspaceId, undefined); const response = decodeLocalAgentDaemonResponse({ requestId: "req_1", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: record, }); @@ -91,7 +93,7 @@ assert.equal(response.ok, true); const errorResponse = decodeLocalAgentDaemonResponse(JSON.parse(encodeLocalAgentDaemonResponse({ requestId: "req_error", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: false, error: { code: "PROVIDER_UNAVAILABLE", @@ -126,3 +128,37 @@ const failedRecord = decodeAgentRecord({ }); assert.equal(failedRecord.errorCode, "DAEMON_TIMEOUT"); assert.equal(failedRecord.errorRetryable, true); + +const waitRequest = decodeLocalAgentDaemonRequest({ + requestId: "req_wait", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: "test-secret", + method: "agent.wait", + params: { + ids: ["agt_one", "agt_two"], + scope: { workspaceId: "ws_test", workspaceRoot: "/tmp/project" }, + timeoutMs: 5_000, + }, +}); +assert.equal(waitRequest.method, "agent.wait"); +if (waitRequest.method !== "agent.wait") throw new Error("expected agent.wait request"); +assert.deepEqual(waitRequest.params.ids, ["agt_one", "agt_two"]); +assert.equal(waitRequest.params.timeoutMs, 5_000); + +assert.deepEqual(decodeAgentWaitResults([ + { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_two", status: "running", wait: "timeout" }, + { + id: "agt_three", + status: "failed", + error: { code: "PROVIDER_EXECUTION_ERROR", message: "Failed.", retryable: true }, + }, +]), [ + { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_two", status: "running", wait: "timeout" }, + { + id: "agt_three", + status: "failed", + error: { code: "PROVIDER_EXECUTION_ERROR", message: "Failed.", retryable: true }, + }, +]); diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index bf9bf8e65..402b94bc7 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -4,6 +4,7 @@ import type { LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import type { + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -16,6 +17,7 @@ export type LocalAgentDaemonMethod = | "agent.continue" | "agent.get" | "agent.list" + | "agent.wait" | "daemon.status" | "daemon.stop" | "daemon.logs"; @@ -26,6 +28,11 @@ export type LocalAgentDaemonRequest = | AgentDaemonRequestBase<"agent.continue", { id: string; prompt: string; scope: LocalAgentWorkspaceScope; overrides?: RunOverrides }> | AgentDaemonRequestBase<"agent.get", { id: string; scope: LocalAgentWorkspaceScope }> | AgentDaemonRequestBase<"agent.list", LocalAgentWorkspaceScope> + | AgentDaemonRequestBase<"agent.wait", { + ids: string[]; + scope: LocalAgentWorkspaceScope; + timeoutMs?: number; + }> | AgentDaemonRequestBase<"daemon.status", Record> | AgentDaemonRequestBase<"daemon.stop", Record> | AgentDaemonRequestBase<"daemon.logs", { lines?: number }>; @@ -133,6 +140,14 @@ export function decodeLocalAgentDaemonRequest(value: unknown): LocalAgentDaemonR method, params: decodeListScope(params), } as LocalAgentDaemonRequest; + case "agent.wait": + return { + requestId, + protocolVersion, + authToken, + method, + params: decodeWaitParams(params), + } as LocalAgentDaemonRequest; case "daemon.logs": return { requestId, @@ -202,6 +217,40 @@ export function decodeAgentRecordList(value: unknown): LocalAgentRecord[] { return value.map(decodeAgentRecord); } +export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { + if (!Array.isArray(value)) { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Daemon returned invalid agent wait results."); + } + return value.map((entry): LocalAgentWaitResult => { + const record = asRecord(entry); + const id = requiredString(record?.id, "id"); + const status = requiredString(record?.status, "status"); + switch (status) { + case "running": { + const wait = optionalString(record?.wait); + if (wait !== undefined && wait !== "timeout") { + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait state."); + } + return { id, status, ...(wait ? { wait } : {}) }; + } + case "completed": { + const response = optionalContentString(record?.response); + return { id, status, ...(response === undefined ? {} : { response }) }; + } + case "failed": + return { id, status, error: decodeWaitError(record?.error) }; + case "stopped": + return { + id, + status, + ...(record?.error === undefined ? {} : { error: decodeWaitError(record.error) }), + }; + default: + throw new LocalAgentDaemonProtocolError("INVALID_RESULT", "Invalid agent wait result status."); + } + }); +} + export function decodeDaemonStatus(value: unknown): LocalAgentDaemonStatus { const record = asRecord(value); const state = requiredString(record?.state, "state"); @@ -282,6 +331,48 @@ function decodeListScope(value: unknown): LocalAgentWorkspaceScope { return decodeWorkspaceScope(value); } +function decodeWaitParams(value: unknown): { + ids: string[]; + scope: LocalAgentWorkspaceScope; + timeoutMs?: number; +} { + const record = asRecord(value); + if (!record) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "Agent wait options must be an object."); + } + const ids = record?.ids; + if (!Array.isArray(ids) || ids.length === 0) { + throw new LocalAgentDaemonProtocolError("INVALID_PARAMS", "At least one subagent id is required."); + } + const timeoutMs = record.timeoutMs; + if ( + timeoutMs !== undefined + && (typeof timeoutMs !== "number" + || !Number.isSafeInteger(timeoutMs) + || timeoutMs < 0 + || timeoutMs > 2_147_483_647) + ) { + throw new LocalAgentDaemonProtocolError( + "INVALID_PARAMS", + "Wait timeout must be an integer between 0 and 2147483647 milliseconds.", + ); + } + return { + ids: ids.map((id, index) => requiredString(id, `ids[${index}]`)), + scope: decodeWorkspaceScope(record.scope), + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }; +} + +function decodeWaitError(value: unknown): { code: string; message: string; retryable: boolean } { + const record = asRecord(value); + return { + code: requiredString(record?.code, "error.code"), + message: requiredContentString(record?.message, "error.message"), + retryable: optionalBoolean(record?.retryable) ?? false, + }; +} + function decodeLogsParams(value: unknown): { lines?: number } { 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 6ea66e652..4d196ef3b 100644 --- a/src/local-agent-daemon.test.ts +++ b/src/local-agent-daemon.test.ts @@ -40,6 +40,9 @@ class FakeManager implements LocalAgentDaemonManager { runtimeCount = 0; closed = false; lastInput?: StartLocalAgentInput; + blockWaitUntilAbort = false; + waitStarted = false; + waitAborted = false; async start(input: StartLocalAgentInput) { this.lastInput = input; @@ -63,6 +66,21 @@ class FakeManager implements LocalAgentDaemonManager { return Result.ok([record]); } + async wait(agentIds: readonly string[], _scope: unknown, _timeoutMs?: number, signal?: AbortSignal) { + this.waitStarted = true; + if (this.blockWaitUntilAbort) { + await new Promise((resolveAbort) => { + const onAbort = () => { + this.waitAborted = true; + resolveAbort(); + }; + if (signal?.aborted) onAbort(); + else signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + return Result.ok(agentIds.map((id) => ({ id, status: "running" as const }))); + } + async evictIdle(): Promise {} async close(): Promise { @@ -135,6 +153,9 @@ try { const recordScope = { workspaceId: record.workspaceId!, workspaceRoot: record.workspaceRoot }; assert.equal(unwrap(await client.get(record.id, recordScope)).id, record.id); assert.equal(unwrap(await client.list(recordScope))[0]?.id, record.id); + assert.deepEqual(unwrap(await client.wait([record.id], recordScope, 0)), [ + { id: record.id, status: "running" }, + ]); assert.equal(unwrap(await client.status()).state, "ready"); unwrap(await client.stop()); @@ -247,7 +268,7 @@ const legacyServer = createNetServer((socket) => { ok: false, error: { code: "DAEMON_PROTOCOL_MISMATCH", - message: "Unsupported daemon protocol version 3; expected 1.", + message: `Unsupported daemon protocol version ${LOCAL_AGENT_DAEMON_PROTOCOL_VERSION}; expected 1.`, retryable: false, }, })); @@ -301,10 +322,17 @@ const upgradeClient = new LocalAgentClient({ }, }); try { - assert.equal(unwrap(await upgradeClient.ensureReady()).protocolVersion, 3); + assert.equal( + unwrap(await upgradeClient.ensureReady()).protocolVersion, + LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + ); assert.equal(replacementSpawns, 1); assert.equal(spawnedBeforeLegacyLockReleased, false); - assert.deepEqual(legacyMethods.slice(0, 3), ["hello:3", "hello:1", "daemon.stop:1"]); + assert.deepEqual(legacyMethods.slice(0, 3), [ + `hello:${LOCAL_AGENT_DAEMON_PROTOCOL_VERSION}`, + "hello:1", + "daemon.stop:1", + ]); } finally { legacyLock.release(); await replacementDaemon.close(); @@ -397,11 +425,11 @@ const timeoutServer = createNetServer((socket) => { if (request.method !== "hello") return; socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: { state: "ready", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, pid: process.pid, endpoint: timeoutPaths.endpoint, startedAt: "now", @@ -443,7 +471,7 @@ const invalidServer = createNetServer((socket) => { if (!buffer.includes("\n")) return; socket.end(encodeLocalAgentDaemonResponse({ requestId: "wrong_request_id", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, ok: true, result: {}, })); @@ -487,6 +515,27 @@ const socketDaemon = new LocalAgentDaemon({ try { await socketDaemon.start(); + socketManager.blockWaitUntilAbort = true; + const waitSocket = createConnection(socketDaemon.paths.endpoint); + await new Promise((resolveConnect, rejectConnect) => { + waitSocket.once("error", rejectConnect); + waitSocket.once("connect", resolveConnect); + }); + waitSocket.write(JSON.stringify({ + requestId: "disconnect-wait", + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, + authToken: ensureLocalAgentDaemonSecret(socketDaemon.paths), + method: "agent.wait", + params: { + ids: [record.id], + scope: { workspaceId: record.workspaceId, workspaceRoot: record.workspaceRoot }, + }, + }) + "\n"); + await waitFor(() => socketManager.waitStarted); + waitSocket.destroy(); + await waitFor(() => socketManager.waitAborted); + socketManager.blockWaitUntilAbort = false; + const timedOutRequest = await sendRawRequest(socketDaemon.paths.endpoint); assert.equal(timedOutRequest.ok, false); if (!timedOutRequest.ok) { @@ -497,7 +546,7 @@ try { const unauthorized = await sendRawRequest(socketDaemon.paths.endpoint, JSON.stringify({ requestId: "unauthorized", - protocolVersion: 3, + protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, authToken: "wrong-secret", method: "hello", params: {}, diff --git a/src/local-agent-daemon.ts b/src/local-agent-daemon.ts index dfd3a1499..75a3b6dd4 100644 --- a/src/local-agent-daemon.ts +++ b/src/local-agent-daemon.ts @@ -37,6 +37,8 @@ import type { AgentListError, AgentLookupError, AgentStartError, + AgentWaitError, + LocalAgentWaitResult, RunOverrides, StartLocalAgentInput, } from "./local-agent-manager.js"; @@ -53,6 +55,12 @@ export interface LocalAgentDaemonManager { continue(agentId: string, prompt: string, overrides: RunOverrides | undefined, scope: LocalAgentWorkspaceScope): Promise>; get(agentId: string, scope: LocalAgentWorkspaceScope): Result; list(scope: LocalAgentWorkspaceScope): Result; + wait( + agentIds: readonly string[], + scope: LocalAgentWorkspaceScope, + timeoutMs?: number, + signal?: AbortSignal, + ): Promise>; evictIdle(now?: number): Promise; close(): Promise; readonly activeTurnCount: number; @@ -210,6 +218,7 @@ export class LocalAgentDaemon { private handleConnection(socket: Socket): void { this.sockets.add(socket); + const disconnected = new AbortController(); socket.setEncoding("utf8"); let buffer = ""; let handled = false; @@ -243,14 +252,17 @@ export class LocalAgentDaemon { handled = true; clearTimeout(requestTimer); const line = buffer.slice(0, newline); - void this.handleLine(socket, line); + void this.handleLine(socket, line, disconnected.signal); }); socket.on("error", () => undefined); - socket.on("close", () => this.sockets.delete(socket)); + socket.on("close", () => { + disconnected.abort(); + this.sockets.delete(socket); + }); socket.on("error", () => clearTimeout(requestTimer)); } - private async handleLine(socket: Socket, line: string): Promise { + private async handleLine(socket: Socket, line: string, signal: AbortSignal): Promise { let requestId = ""; try { let parsed: unknown; @@ -261,7 +273,7 @@ export class LocalAgentDaemon { } requestId = readRequestId(parsed); const request = decodeLocalAgentDaemonRequest(parsed); - const response = await this.dispatch(request); + const response = await this.dispatch(request, signal); socket.end(encodeLocalAgentDaemonResponse({ requestId: request.requestId, protocolVersion: LOCAL_AGENT_DAEMON_PROTOCOL_VERSION, @@ -274,7 +286,7 @@ export class LocalAgentDaemon { } } - private async dispatch(request: LocalAgentDaemonRequest): Promise { + private async dispatch(request: LocalAgentDaemonRequest, signal: AbortSignal): Promise { if (request.protocolVersion !== LOCAL_AGENT_DAEMON_PROTOCOL_VERSION) { throw new LocalAgentDaemonProtocolError( "PROTOCOL_MISMATCH", @@ -307,6 +319,13 @@ export class LocalAgentDaemon { return unwrapManagerResult(this.manager.get(request.params.id, request.params.scope)); case "agent.list": return unwrapManagerResult(this.manager.list(request.params)); + case "agent.wait": + return unwrapManagerResult(await this.manager.wait( + request.params.ids, + request.params.scope, + request.params.timeoutMs, + signal, + )); case "daemon.status": return this.status(); case "daemon.stop": diff --git a/src/local-agent-manager.test.ts b/src/local-agent-manager.test.ts index 803866fb9..758f69ae5 100644 --- a/src/local-agent-manager.test.ts +++ b/src/local-agent-manager.test.ts @@ -303,6 +303,76 @@ const earlyFailure = unwrap(await manager.start({ await waitFor(() => getRecord(earlyFailure.id).status === "error"); assert.equal(getRecord(earlyFailure.id).providerSessionId, "thread_early"); +const waitingOne = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold wait one", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +const waitingTwo = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold wait two", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(waitingOne.id)?.inputs.length === 1); +await waitFor(() => runtimes.get(waitingTwo.id)?.inputs.length === 1); +let multiWaitSettled = false; +const multiWait = manager.wait([waitingOne.id, waitingTwo.id, waitingOne.id], scope) + .then((result) => { + multiWaitSettled = true; + return result; + }); +runtimes.get(waitingOne.id)!.release(); +await waitFor(() => getRecord(waitingOne.id).status === "idle"); +assert.equal(multiWaitSettled, false, "multi-agent wait must remain pending until every turn finishes"); +runtimes.get(waitingTwo.id)!.release(); +assert.deepEqual(unwrap(await multiWait).map((result) => ({ id: result.id, status: result.status })), [ + { id: waitingOne.id, status: "completed" }, + { id: waitingTwo.id, status: "completed" }, +]); + +const timedWaitAgent = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold timed wait", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(timedWaitAgent.id)?.inputs.length === 1); +assert.deepEqual(unwrap(await manager.wait([earlyFailure.id, timedWaitAgent.id], scope, 5)), [ + { + id: earlyFailure.id, + status: "failed", + error: { + code: "PROVIDER_EXECUTION_ERROR", + message: "provider failed after session creation", + retryable: false, + }, + }, + { id: timedWaitAgent.id, status: "running", wait: "timeout" }, +]); +runtimes.get(timedWaitAgent.id)!.release(); +await waitFor(() => getRecord(timedWaitAgent.id).status === "idle"); + +const cancelledWaitAgent = unwrap(await manager.start({ + target: "reviewer", + prompt: "hold cancelled wait", + workspaceId: scope.workspaceId, + workspaceRoot: root, +})); +await waitFor(() => runtimes.get(cancelledWaitAgent.id)?.inputs.length === 1); +const waitAbort = new AbortController(); +const cancelledWait = manager.wait([cancelledWaitAgent.id], scope, undefined, waitAbort.signal); +waitAbort.abort(); +assert.deepEqual(unwrap(await cancelledWait), [{ id: cancelledWaitAgent.id, status: "running" }]); +assert.equal(getRecord(cancelledWaitAgent.id).status, "running", "cancelling a waiter must not stop its turn"); +runtimes.get(cancelledWaitAgent.id)!.release(); +await waitFor(() => getRecord(cancelledWaitAgent.id).status === "idle"); + +const invalidWait = await manager.wait([waitingOne.id, "agt_missing"], scope, 5); +assert.equal(invalidWait.isErr(), true); +if (invalidWait.isErr()) assert.equal(invalidWait.error.code, "AGENT_NOT_FOUND"); + const wrongWorkspace = await manager.continue( first.id, "wrong workspace", diff --git a/src/local-agent-manager.ts b/src/local-agent-manager.ts index ef8a3c720..30fe1e1f4 100644 --- a/src/local-agent-manager.ts +++ b/src/local-agent-manager.ts @@ -20,6 +20,7 @@ import { import { type LocalAgentRecord, type LocalAgentStore, + type LocalAgentTurnRecord, type LocalAgentWorkspaceScope, } from "./local-agent-store.js"; import { @@ -71,6 +72,18 @@ export type AgentStartError = AgentTargetError | AgentScopeError | AgentConflict export type AgentContinueError = AgentStartError; export type AgentLookupError = AgentTargetError | AgentScopeError | AgentStoreError; export type AgentListError = AgentScopeError | AgentStoreError; +export type AgentWaitError = AgentLookupError; + +export type LocalAgentWaitResult = + | { id: string; status: "running"; wait?: "timeout" } + | { id: string; status: "completed"; response?: string } + | { id: string; status: "failed"; error: { code: string; message: string; retryable: boolean } } + | { id: string; status: "stopped"; error?: { code: string; message: string; retryable: boolean } }; + +interface ActiveLocalAgentTurn { + turnId: number; + completion: Promise; +} /** * Owns one durable DevSpace agent's turn lifecycle. Provider runtimes remain @@ -86,7 +99,7 @@ export class LocalAgentManager { private readonly allowedRoots?: readonly string[]; private readonly logger?: LocalAgentManagerLogger; private readonly subagents: SubagentsConfig; - private readonly activeTurns = new Map>(); + private readonly activeTurns = new Map(); private accepting = true; private closePromise?: Promise; @@ -199,10 +212,57 @@ export class LocalAgentManager { )); } + async wait( + agentIds: readonly string[], + scope: LocalAgentWorkspaceScope, + timeoutMs?: number, + signal?: AbortSignal, + ): Promise> { + const captures: Array<{ agent: LocalAgentRecord; turn?: LocalAgentTurnRecord }> = []; + for (const agentId of unique(agentIds)) { + const agent = this.get(agentId, scope); + if (agent.isErr()) return agent; + const turn = this.store.getLatestTurnResult(agentId); + if (turn.isErr()) return turn; + captures.push({ agent: agent.value, turn: turn.value }); + } + + const pending: Promise[] = []; + for (const capture of captures) { + if (capture.turn?.status !== "running") continue; + const active = this.activeTurns.get(capture.agent.id); + if (active?.turnId !== capture.turn.id) { + return Result.err(new AgentStoreError( + "wait", + new Error(`Turn ${capture.turn.id} is not active.`), + `Running turn state is unavailable for subagent ${capture.agent.id}.`, + )); + } + pending.push(active.completion); + } + + const timedOut = pending.length > 0 + ? await waitForTurns(pending, timeoutMs, signal) + : false; + const results: LocalAgentWaitResult[] = []; + for (const capture of captures) { + if (!capture.turn) { + results.push(waitResultFromAgent(capture.agent, timedOut)); + continue; + } + const turn = this.store.getTurnByIdResult(capture.turn.id); + if (turn.isErr()) return turn; + results.push(turn.value + ? waitResultFromTurn(turn.value, timedOut) + : waitResultFromAgent(capture.agent, timedOut)); + } + return Result.ok(results); + } + async close(): Promise { if (this.closePromise) return this.closePromise; this.accepting = false; - const turns = Array.from(this.activeTurns.values()); + const turns = Array.from(this.activeTurns.values(), (turn) => turn.completion); this.closePromise = (async () => { // Closing pooled runtimes is what interrupts provider turns. Waiting for // those turns first can strand a provider process indefinitely. @@ -257,7 +317,7 @@ export class LocalAgentManager { const turn = Promise.resolve().then(() => ( this.runTurn(begun.value.agent, begun.value.turn.id, prompt, overrides, workspaceId) )); - this.activeTurns.set(record.id, turn); + this.activeTurns.set(record.id, { turnId: begun.value.turn.id, completion: turn }); void turn.catch(() => undefined); return Result.ok(begun.value.agent); } @@ -604,3 +664,108 @@ function agentNotFound(agentId: string): AgentTargetError { message: `Unknown subagent id: ${agentId}.`, }); } + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +async function waitForTurns( + turns: readonly Promise[], + timeoutMs: number | undefined, + signal: AbortSignal | undefined, +): Promise { + let timer: NodeJS.Timeout | undefined; + let onAbort: (() => void) | undefined; + const timeout = timeoutMs === undefined + ? undefined + : new Promise<"timeout">((resolveTimeout) => { + timer = setTimeout(() => resolveTimeout("timeout"), timeoutMs); + }); + const aborted = signal + ? new Promise<"aborted">((resolveAbort) => { + onAbort = () => resolveAbort("aborted"); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }) + : undefined; + try { + const result = await Promise.race([ + Promise.allSettled(turns).then(() => "completed" as const), + ...(timeout ? [timeout] : []), + ...(aborted ? [aborted] : []), + ]); + return result === "timeout"; + } finally { + if (timer) clearTimeout(timer); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); + } +} + +function waitResultFromTurn(turn: LocalAgentTurnRecord, timedOut: boolean): LocalAgentWaitResult { + switch (turn.status) { + case "running": + return { id: turn.agentId, status: "running", ...(timedOut ? { wait: "timeout" } : {}) }; + case "completed": + return { + id: turn.agentId, + status: "completed", + ...(turn.response === undefined ? {} : { response: turn.response }), + }; + case "failed": + return { id: turn.agentId, status: "failed", error: turnFailure(turn) }; + case "stopped": + return { + id: turn.agentId, + status: "stopped", + ...(hasTurnFailure(turn) ? { error: turnFailure(turn) } : {}), + }; + } +} + +function waitResultFromAgent(agent: LocalAgentRecord, timedOut: boolean): LocalAgentWaitResult { + switch (agent.status) { + case "starting": + case "running": + return { id: agent.id, status: "running", ...(timedOut ? { wait: "timeout" } : {}) }; + case "idle": + return { + id: agent.id, + status: "completed", + ...(agent.latestResponse === undefined ? {} : { response: agent.latestResponse }), + }; + case "error": + return { + id: agent.id, + status: "failed", + error: { + code: agent.errorCode ?? "AGENT_FAILED", + message: agent.error ?? "Subagent failed without an error message.", + retryable: agent.errorRetryable ?? false, + }, + }; + case "stopped": + return { + id: agent.id, + status: "stopped", + ...(agent.error || agent.errorCode || agent.errorRetryable !== undefined + ? { error: { + code: agent.errorCode ?? "AGENT_STOPPED", + message: agent.error ?? "Subagent stopped.", + retryable: agent.errorRetryable ?? false, + } } + : {}), + }; + } +} + +function hasTurnFailure(turn: LocalAgentTurnRecord): boolean { + return turn.error !== undefined || turn.errorCode !== undefined || turn.errorRetryable !== undefined; +} + +function turnFailure(turn: LocalAgentTurnRecord): { code: string; message: string; retryable: boolean } { + return { + code: turn.errorCode ?? "AGENT_FAILED", + message: turn.error ?? "Subagent failed without an error message.", + retryable: turn.errorRetryable ?? false, + }; +} diff --git a/src/local-agent-presentation.ts b/src/local-agent-presentation.ts index 9def4d39f..f5ff569ec 100644 --- a/src/local-agent-presentation.ts +++ b/src/local-agent-presentation.ts @@ -46,7 +46,7 @@ export interface AgentCommandErrorOutput { } export type AgentObservationOutput = - | { id: string; status: "running" } + | { id: string; status: "running"; wait?: "timeout" } | { id: string; status: "completed"; response?: string } | { id: string; status: "failed"; error: AgentFailureOutput } | { id: string; status: "stopped"; error?: AgentFailureOutput }; @@ -123,6 +123,9 @@ export function formatAgentSummary(summary: AgentSummaryOutput): string { } export function formatAgentObservation(observation: AgentObservationOutput): string { + if (observation.status === "running" && observation.wait) { + return ``; + } if (observation.status === "completed" && observation.response !== undefined) { return `${escapeXmlText(observation.response)}`; } diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index bdb014a66..3aab64da6 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -393,6 +393,12 @@ export class LocalAgentStore { return row ? rowToLocalAgentTurnRecord(row) : undefined; } + getTurnByIdResult( + turnId: number, + ): BetterResult { + return storeResult("get_turn", () => this.getTurnById(turnId)); + } + getLatestTurn(agentId: string): LocalAgentTurnRecord | undefined { const row = this.database.sqlite .prepare("select * from local_agent_turns where agent_id = ? order by id desc limit 1") @@ -400,6 +406,12 @@ export class LocalAgentStore { return row ? rowToLocalAgentTurnRecord(row) : undefined; } + getLatestTurnResult( + agentId: string, + ): BetterResult { + return storeResult("get_latest_turn", () => this.getLatestTurn(agentId)); + } + listTurns(agentId: string): LocalAgentTurnRecord[] { const rows = this.database.sqlite .prepare("select * from local_agent_turns where agent_id = ? order by id asc") From f1cffc6b84c8202e55a595ba7786af23425d6ed3 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:01:58 +0530 Subject: [PATCH 2/2] fix(agents): preserve empty wait responses --- src/local-agent-daemon-protocol.test.ts | 4 ++++ src/local-agent-daemon-protocol.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/local-agent-daemon-protocol.test.ts b/src/local-agent-daemon-protocol.test.ts index b5fb282c1..24acaf316 100644 --- a/src/local-agent-daemon-protocol.test.ts +++ b/src/local-agent-daemon-protocol.test.ts @@ -147,6 +147,8 @@ assert.equal(waitRequest.params.timeoutMs, 5_000); assert.deepEqual(decodeAgentWaitResults([ { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_empty", status: "completed", response: "" }, + { id: "agt_whitespace", status: "completed", response: " \n" }, { id: "agt_two", status: "running", wait: "timeout" }, { id: "agt_three", @@ -155,6 +157,8 @@ assert.deepEqual(decodeAgentWaitResults([ }, ]), [ { id: "agt_one", status: "completed", response: "Done." }, + { id: "agt_empty", status: "completed", response: "" }, + { id: "agt_whitespace", status: "completed", response: " \n" }, { id: "agt_two", status: "running", wait: "timeout" }, { id: "agt_three", diff --git a/src/local-agent-daemon-protocol.ts b/src/local-agent-daemon-protocol.ts index 402b94bc7..bf87a4714 100644 --- a/src/local-agent-daemon-protocol.ts +++ b/src/local-agent-daemon-protocol.ts @@ -234,7 +234,7 @@ export function decodeAgentWaitResults(value: unknown): LocalAgentWaitResult[] { return { id, status, ...(wait ? { wait } : {}) }; } case "completed": { - const response = optionalContentString(record?.response); + const response = typeof record?.response === "string" ? record.response : undefined; return { id, status, ...(response === undefined ? {} : { response }) }; } case "failed":