Skip to content

Commit 4e469e3

Browse files
committed
fix(cli): complete automation safety boundaries
1 parent 7f306fb commit 4e469e3

11 files changed

Lines changed: 331 additions & 95 deletions

File tree

apps/zoo/src/__tests__/fixtures/fake-host.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ process.on("message", (message) => {
5656
})
5757
stream({ type: "task.created", requestId: message.id, rootTaskId: "root-1", taskId: "root-1" })
5858
stream({ type: "task.started", rootTaskId: "root-1", taskId: "root-1" })
59-
stream({ type: "task.lifecycle", rootTaskId: "root-1", taskId: "root-1", state: "running" })
6059
if (scenario === "crash") {
6160
setImmediate(() => process.exit(70))
6261
return

apps/zoo/src/automation.ts

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { runOverrides, type OutputFormat, type SharedOptions } from "./options.js"
1818
import { initialProjection, reduceSession } from "./projection.js"
1919
import { createRenderer } from "./render.js"
20-
import { defaultStorageRoot, HostClient } from "./supervisor.js"
20+
import { defaultStorageRoot, HostClient, ZooClientError } from "./supervisor.js"
2121

2222
type AutomationOptions = Omit<SharedOptions, "timeout"> & {
2323
format: OutputFormat
@@ -54,7 +54,15 @@ export async function runAutomation(
5454
const makeResult = (
5555
outcome: "needs_input" | "cancelled" | "timed_out" | "failed",
5656
rootTaskId: string,
57-
input: { currentTaskId?: string; resumable: boolean; code?: ZooErrorCode; message?: string; content?: string },
57+
input: {
58+
currentTaskId?: string
59+
resumable: boolean
60+
code?: ZooErrorCode
61+
message?: string
62+
content?: string
63+
kind?: "configuration" | "provider" | "runtime"
64+
phase?: string
65+
},
5866
): ZooRunResult =>
5967
zooRunResultSchema.parse({
6068
schemaVersion: ZOO_PUBLIC_SCHEMA_VERSION,
@@ -68,7 +76,12 @@ export async function runAutomation(
6876
content: input.content,
6977
error:
7078
outcome === "failed" || outcome === "timed_out"
71-
? { code: input.code ?? "task_failed", message: input.message ?? "Task failed", kind: "runtime" }
79+
? {
80+
code: input.code ?? "task_failed",
81+
message: input.message ?? "Task failed",
82+
kind: input.kind ?? "runtime",
83+
phase: input.phase,
84+
}
7285
: undefined,
7386
elapsedMs: Date.now() - startedAt,
7487
cancellationReason: outcome === "cancelled" ? "signal" : undefined,
@@ -106,6 +119,18 @@ export async function runAutomation(
106119
let clientStopped = false
107120
const deadline = options.timeout === undefined ? undefined : startedAt + options.timeout
108121
const remainingDeadline = () => (deadline === undefined ? 7_000 : Math.max(0, deadline - Date.now()))
122+
const commandBudget = () => {
123+
const remaining = remainingDeadline()
124+
if (remaining <= 0) throw new ZooClientError("task_timed_out", "Task deadline exceeded")
125+
return Math.max(1, Math.min(15_000, remaining))
126+
}
127+
const runCommand = <T>(operation: Promise<T>) =>
128+
Promise.race([
129+
operation,
130+
signalPromise.then(() => {
131+
throw new ZooClientError("cancel_failed", `Received ${signal}`)
132+
}),
133+
])
109134
const renderFinal = (result: ZooRunResult) => {
110135
if (finalRendered) return
111136
finalRendered = true
@@ -138,7 +163,11 @@ export async function runAutomation(
138163
: result.outcome === "cancelled"
139164
? { outcome: "cancelled", signal }
140165
: result.outcome === "timed_out"
141-
? { outcome: "timed_out", errorCode: result.error?.code === "cleanup_timed_out" ? "cleanup_timed_out" : "task_timed_out" }
166+
? {
167+
outcome: "timed_out",
168+
errorCode:
169+
result.error?.code === "cleanup_timed_out" ? "cleanup_timed_out" : "task_timed_out",
170+
}
142171
: { outcome: result.outcome },
143172
)
144173
}
@@ -163,7 +192,7 @@ export async function runAutomation(
163192
if (options.timeout !== undefined) timeout = setTimeout(() => notifyTimeout?.(), options.timeout)
164193
try {
165194
const startup = await Promise.race([
166-
client.start().then(() => "started" as const),
195+
client.start(remainingDeadline()).then(() => "started" as const),
167196
timeoutPromise,
168197
signalPromise.then(() => "signal" as const),
169198
])
@@ -182,28 +211,35 @@ export async function runAutomation(
182211
overrides,
183212
}
184213
} else {
185-
let taskId = request.taskId
186-
if (!taskId) {
187-
const history = await client.command({ type: "history.list", workspace: options.workspace })
188-
if (history.data.commandType !== "history.list" || history.data.tasks.length === 0) {
189-
throw new Error("No session exists for this workspace")
190-
}
191-
taskId = history.data.tasks[0]!.rootTaskId
214+
const history = await runCommand(
215+
client.command({ type: "history.list", workspace: options.workspace }, commandBudget()),
216+
)
217+
if (history.data.commandType !== "history.list" || history.data.tasks.length === 0) {
218+
throw new ZooClientError("invalid_session", "No session exists for this workspace")
192219
}
193-
command = { type: "task.resume", taskId, rootTaskId: taskId, overrides }
220+
const selected = request.taskId
221+
? history.data.tasks.find(
222+
(task) => task.rootTaskId === request.taskId || task.currentTaskId === request.taskId,
223+
)
224+
: history.data.tasks[0]
225+
if (!selected) throw new ZooClientError("invalid_session", `Unknown session ${request.taskId}`)
226+
const taskId = request.taskId === selected.currentTaskId ? request.taskId : selected.currentTaskId
227+
command = { type: "task.resume", taskId, rootTaskId: selected.rootTaskId, overrides }
194228
}
195-
const accepted = await client.command(command)
229+
const accepted = await runCommand(client.command(command, commandBudget()))
196230
if (accepted.data.commandType !== "task.start" && accepted.data.commandType !== "task.resume") {
197231
throw new Error("Host returned an invalid task acceptance")
198232
}
199233
rootTaskId = accepted.data.task.rootTaskId
200234
if (signal) void client.command({ type: "task.cancel", rootTaskId, reason: "signal" }).catch(() => undefined)
201235
const localSettlement = Promise.race([
202236
timeoutPromise.then(() => {
203-
void client.command({ type: "task.cancel", rootTaskId: rootTaskId!, reason: "timeout" }, 1).catch(() => undefined)
237+
void client
238+
.command({ type: "task.cancel", rootTaskId: rootTaskId!, reason: "timeout" }, 1)
239+
.catch(() => undefined)
204240
return makeResult("timed_out", rootTaskId!, {
205241
currentTaskId: projection.currentTaskId,
206-
resumable: true,
242+
resumable: false,
207243
code: "task_timed_out",
208244
message: "Task deadline exceeded",
209245
})
@@ -233,8 +269,10 @@ export async function runAutomation(
233269
makeResult("failed", rootTaskId!, {
234270
currentTaskId: projection.currentTaskId,
235271
resumable: false,
236-
code: "host_crashed",
272+
code: error instanceof ZooClientError ? error.code : "host_crashed",
237273
message: error.message,
274+
kind: error instanceof ZooClientError ? error.detail?.kind : "runtime",
275+
phase: error instanceof ZooClientError ? error.detail?.phase : undefined,
238276
}),
239277
),
240278
])
@@ -254,16 +292,14 @@ export async function runAutomation(
254292
return resultExitCode(finalResult)
255293
} catch (error) {
256294
const message = error instanceof Error ? error.message : String(error)
257-
const parsedCode = zooErrorCodeSchema.safeParse(message.split(":", 1)[0])
295+
const parsedCode = zooErrorCodeSchema.safeParse(error instanceof ZooClientError ? error.code : undefined)
258296
const code: ZooErrorCode = parsedCode.success
259297
? parsedCode.data
260-
: message.includes("protocol") || message.includes("negotiat")
261-
? "protocol_incompatible"
262-
: rootTaskId
263-
? "task_failed"
264-
: "host_start_failed"
298+
: rootTaskId
299+
? "task_failed"
300+
: "host_start_failed"
265301
const result =
266-
message.includes("deadline")
302+
code === "task_timed_out" || message.includes("deadline")
267303
? makeResult("timed_out", rootTaskId ?? "unavailable", {
268304
resumable: Boolean(rootTaskId),
269305
code: "task_timed_out",
@@ -275,6 +311,8 @@ export async function runAutomation(
275311
resumable: false,
276312
code,
277313
message,
314+
kind: error instanceof ZooClientError ? error.detail?.kind : "runtime",
315+
phase: error instanceof ZooClientError ? error.detail?.phase : undefined,
278316
})
279317
renderFinal(result)
280318
return resultExitCode(result)

apps/zoo/src/supervisor.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
type HostHello,
2020
type ParentHello,
2121
type ZooCapability,
22+
type ZooError,
23+
type ZooErrorCode,
2224
type ZooStreamEvent,
2325
} from "@roo-code/zoo-protocol"
2426

@@ -48,6 +50,17 @@ const requiredCapabilities: ZooCapability[] = [
4850
"host:shutdown",
4951
]
5052

53+
export class ZooClientError extends Error {
54+
constructor(
55+
public readonly code: ZooErrorCode,
56+
message: string,
57+
public readonly detail?: ZooError,
58+
) {
59+
super(message)
60+
this.name = "ZooClientError"
61+
}
62+
}
63+
5164
export class HostClient {
5265
private child: ChildProcess | undefined
5366
private parser: ReturnType<typeof createHostEventStreamParser> | undefined
@@ -69,7 +82,8 @@ export class HostClient {
6982

7083
constructor(private readonly options: HostClientOptions) {}
7184

72-
public async start(): Promise<void> {
85+
public async start(timeoutMs = 45_000): Promise<void> {
86+
const deadline = Date.now() + timeoutMs
7387
const hostPath =
7488
process.env.ZOO_HOST_PATH ??
7589
fileURLToPath(new URL("../../../packages/zoo-host/dist/child.js", import.meta.url))
@@ -98,10 +112,13 @@ export class HostClient {
98112
child.once("exit", (code, signal) => this.fail(new Error(`Zoo host exited (${signal ?? code ?? "unknown"})`)))
99113
child.once("error", (error) => this.fail(error))
100114

101-
const hello = await Promise.race([this.waitForHello(child, 15_000), this.failed])
115+
const hello = await Promise.race([
116+
this.waitForHello(child, Math.max(1, Math.min(15_000, deadline - Date.now()))),
117+
this.failed,
118+
])
102119
this.hello = hello
103120
const negotiation = negotiateProtocol(hello, [ZOO_HOST_PROTOCOL_VERSION], requiredCapabilities)
104-
if (!negotiation.ok) throw new Error(negotiation.message)
121+
if (!negotiation.ok) throw new ZooClientError("protocol_incompatible", negotiation.message)
105122
this.parser = createHostEventStreamParser({ hostId: hello.hostId })
106123
child.on("message", (message) => this.receive(message))
107124
this.selection = parentHelloSchema.parse({
@@ -119,7 +136,7 @@ export class HostClient {
119136
new Promise<never>((_, reject) => {
120137
initializationTimer = setTimeout(
121138
() => reject(new Error("Zoo host initialization timed out")),
122-
30_000,
139+
Math.max(1, Math.min(30_000, deadline - Date.now())),
123140
)
124141
}),
125142
])
@@ -142,7 +159,7 @@ export class HostClient {
142159
return new Promise<Extract<HostEvent, { type: "command.done" }>>((resolve, reject) => {
143160
const timer = setTimeout(() => {
144161
this.pending.delete(id)
145-
reject(new Error(`Host command timed out: ${command.type}`))
162+
reject(new ZooClientError("task_timed_out", `Host command timed out: ${command.type}`))
146163
}, timeoutMs)
147164
this.pending.set(id, {
148165
acknowledged: false,
@@ -219,7 +236,7 @@ export class HostClient {
219236
if (!this.hello || !this.selection)
220237
throw new Error("Host initialized before protocol negotiation")
221238
const validation = validateNegotiatedStreamSession(this.hello, this.selection, [event.event])
222-
if (!validation.ok) throw new Error(validation.message)
239+
if (!validation.ok) throw new ZooClientError(validation.code, validation.message)
223240
this.resolveInitialized?.()
224241
}
225242
if (event.event.type === "task.result") {
@@ -245,8 +262,9 @@ export class HostClient {
245262
if (event.type === "command.error") {
246263
const pending = this.pending.get(event.commandId)
247264
if (!pending?.acknowledged) throw new Error(`ERROR preceded ACK for command ${event.commandId}`)
248-
pending.reject(new Error(`${event.error.code}: ${event.error.message}`))
265+
pending.reject(new ZooClientError(event.error.code, event.error.message, event.error))
249266
this.pending.delete(event.commandId)
267+
this.flushResult()
250268
}
251269
}
252270
} catch (error) {
@@ -261,7 +279,7 @@ export class HostClient {
261279
initiatingCommandId: this.initiatingCommandId,
262280
commandIds: this.commands.map((command) => command.id),
263281
})
264-
if (!validation.ok) throw new Error(`${validation.code}: ${validation.message}`)
282+
if (!validation.ok) throw new ZooClientError(validation.code, validation.message)
265283
const result = this.pendingResult
266284
this.pendingResult = undefined
267285
this.options.onEvent(result)

packages/types/src/api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,26 @@ export type RunOverrides = {
2626
approval?: "interactive" | "safe" | "auto"
2727
}
2828

29+
export type HeadlessApiErrorCode =
30+
| "invalid_provider"
31+
| "invalid_profile"
32+
| "invalid_model"
33+
| "invalid_mode"
34+
| "invalid_session"
35+
| "credentials_missing"
36+
| "cancel_failed"
37+
38+
export class HeadlessApiError extends Error {
39+
constructor(
40+
public readonly code: HeadlessApiErrorCode,
41+
message: string,
42+
public readonly kind: "configuration" | "provider" | "runtime" = "runtime",
43+
) {
44+
super(message)
45+
this.name = "HeadlessApiError"
46+
}
47+
}
48+
2949
export type HeadlessTaskReference = { taskId: string; rootTaskId: string }
3050

3151
export type HeadlessAskResponse =
@@ -66,6 +86,7 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
6686
}): Promise<HeadlessTaskReference>
6787
resumeHeadlessTask(taskId: string, overrides?: RunOverrides): Promise<HeadlessTaskReference>
6888
respondToHeadlessAsk(input: { taskId: string; askId: string; response: HeadlessAskResponse }): Promise<void>
89+
submitHeadlessTaskInput(input: { taskId: string; text?: string; images?: string[] }): Promise<void>
6990
settleHeadlessNeedsInput(input: { rootTaskId: string; taskId: string; content?: string }): Promise<void>
7091
cancelHeadlessTask(input: {
7192
rootTaskId: string

0 commit comments

Comments
 (0)