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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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/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/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts",
"test": "tsx src/config.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/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand All @@ -45,6 +45,7 @@
"@opencode-ai/sdk": "^1.17.13",
"@pierre/diffs": "^1.2.5",
"ajv": "^8.20.0",
"better-result": "^2.10.0",
"better-sqlite3": "^12.10.0",
"diff": "^8.0.3",
"drizzle-orm": "^0.45.2",
Expand Down
6 changes: 5 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ import { expandHomePath } from "./roots.js";
import { shutdownHttpServer } from "./server-shutdown.js";

import { runWorkflowCommand } from "./workflow-cli.js";
import {
isWorkflowOperationError,
workflowCliExitCode,
} from "./workflow-errors.js";

type Command = "serve" | "init" | "doctor" | "config" | "agents" | "workflow" | "help" | "version";
const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -775,5 +779,5 @@ function checkBashShell(): string {

main(process.argv.slice(2)).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
process.exitCode = isWorkflowOperationError(error) ? workflowCliExitCode(error) : 1;
});
19 changes: 18 additions & 1 deletion src/local-agent-adapters.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import { resolve } from "node:path";
import { Readable, Writable } from "node:stream";
import { Result, type Result as BetterResult } from "better-result";
import type {
EffortLevel,
OutputFormat,
} from "@anthropic-ai/claude-agent-sdk";
import type { JsonSchema } from "./json-types.js";
import {
classifyAgentProviderError,
type AgentProviderError,
} from "./local-agent-errors.js";
import type { LocalAgentProvider } from "./local-agent-profiles.js";
import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js";
import {
Expand All @@ -31,7 +36,19 @@ export async function runLocalAgentProvider(
provider: LocalAgentProvider,
input: LocalAgentRunInput,
): Promise<LocalAgentRunResult> {
return createLocalAgentAdapter(provider).run(input);
const result = await runLocalAgentProviderResult(provider, input);
if (result.isErr()) throw result.error;
return result.value;
}

export async function runLocalAgentProviderResult(
provider: LocalAgentProvider,
input: LocalAgentRunInput,
): Promise<BetterResult<LocalAgentRunResult, AgentProviderError>> {
return Result.tryPromise({
try: () => createLocalAgentAdapter(provider).run(input),
catch: (cause) => classifyAgentProviderError(provider, cause),
});
}

export function createLocalAgentAdapter(provider: LocalAgentProvider): LocalAgentAdapter {
Expand Down
133 changes: 133 additions & 0 deletions src/local-agent-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { TaggedError } from "better-result";
import type { LocalAgentProvider } from "./local-agent-profiles.js";

export class ProviderUnavailableError extends TaggedError(
"ProviderUnavailableError",
)<{
provider: LocalAgentProvider;
message: string;
}>() {
constructor(provider: LocalAgentProvider, message?: string) {
super({
provider,
message: message ?? `Agent provider is unavailable: ${provider}`,
});
}
}

export class ProviderSchemaUnsupportedError extends TaggedError(
"ProviderSchemaUnsupportedError",
)<{
provider: LocalAgentProvider;
cause: unknown;
message: string;
}>() {
constructor(provider: LocalAgentProvider, cause: unknown) {
super({
provider,
cause,
message: `${provider} does not support the requested native output schema: ${errorMessage(cause)}`,
});
}
}

export class ProviderCancelledError extends TaggedError(
"ProviderCancelledError",
)<{
provider: LocalAgentProvider;
cause: unknown;
message: string;
}>() {
constructor(provider: LocalAgentProvider, cause: unknown) {
super({
provider,
cause,
message: `Agent provider was cancelled: ${provider}`,
});
}
}

export class ProviderExecutionError extends TaggedError(
"ProviderExecutionError",
)<{
provider: LocalAgentProvider;
retryable: boolean;
cause: unknown;
message: string;
}>() {
constructor(input: {
provider: LocalAgentProvider;
cause: unknown;
retryable?: boolean;
}) {
super({
provider: input.provider,
retryable: input.retryable ?? false,
cause: input.cause,
message: `${input.provider} agent execution failed: ${errorMessage(input.cause)}`,
});
}
}

export type AgentProviderError =
| ProviderUnavailableError
| ProviderSchemaUnsupportedError
| ProviderCancelledError
| ProviderExecutionError;

export function isAgentProviderError(error: unknown): error is AgentProviderError {
return (
ProviderUnavailableError.is(error) ||
ProviderSchemaUnsupportedError.is(error) ||
ProviderCancelledError.is(error) ||
ProviderExecutionError.is(error)
);
}

export function isProviderSchemaUnsupportedError(
error: unknown,
): error is ProviderSchemaUnsupportedError {
return ProviderSchemaUnsupportedError.is(error);
}

export function isNativeSchemaUnsupportedFailure(error: unknown): boolean {
const message = errorMessage(error).toLowerCase();
const mentionsSchema =
/output[ _-]?schema/.test(message) ||
/json[ _-]?schema/.test(message) ||
/structured[ _-]?output/.test(message) ||
/output[ _-]?format/.test(message);
const unsupported =
/not supported/.test(message) ||
/unsupported/.test(message) ||
/invalid (?:output|json )?schema/.test(message) ||
/schema (?:is )?invalid/.test(message) ||
/unknown (?:field|parameter|option)/.test(message) ||
/not available/.test(message);
return mentionsSchema && unsupported;
}

export function classifyAgentProviderError(
provider: LocalAgentProvider,
cause: unknown,
): AgentProviderError {
if (isAgentProviderError(cause)) return cause;
if (isCancellation(cause)) return new ProviderCancelledError(provider, cause);
if (isNativeSchemaUnsupportedFailure(cause)) {
return new ProviderSchemaUnsupportedError(provider, cause);
}
return new ProviderExecutionError({ provider, cause });
}

function isCancellation(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"name" in error &&
String((error as { name?: unknown }).name) === "AbortError",
);
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
47 changes: 10 additions & 37 deletions src/local-agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import type {
} from "@openai/codex-sdk";
import type { JsonSchema } from "./json-types.js";
import type { LocalAgentProvider } from "./local-agent-profiles.js";
import {
isNativeSchemaUnsupportedFailure,
ProviderSchemaUnsupportedError,
} from "./local-agent-errors.js";

export {
isNativeSchemaUnsupportedFailure,
isProviderSchemaUnsupportedError,
ProviderSchemaUnsupportedError,
} from "./local-agent-errors.js";

export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access";

Expand Down Expand Up @@ -38,39 +48,6 @@ export interface LocalAgentRuntime {
run(input: LocalAgentRunInput): Promise<LocalAgentRunResult>;
}

export class ProviderSchemaUnsupportedError extends Error {
constructor(
readonly provider: string,
readonly cause: unknown,
) {
super(`${provider} does not support the requested native output schema: ${errorMessage(cause)}`);
this.name = "ProviderSchemaUnsupportedError";
}
}

export function isProviderSchemaUnsupportedError(
error: unknown,
): error is ProviderSchemaUnsupportedError {
return error instanceof ProviderSchemaUnsupportedError;
}

export function isNativeSchemaUnsupportedFailure(error: unknown): boolean {
const message = errorMessage(error).toLowerCase();
const mentionsSchema =
/output[ _-]?schema/.test(message) ||
/json[ _-]?schema/.test(message) ||
/structured[ _-]?output/.test(message) ||
/output[ _-]?format/.test(message);
const unsupported =
/not supported/.test(message) ||
/unsupported/.test(message) ||
/invalid (?:output|json )?schema/.test(message) ||
/schema (?:is )?invalid/.test(message) ||
/unknown (?:field|parameter|option)/.test(message) ||
/not available/.test(message);
return mentionsSchema && unsupported;
}

interface CodexThreadLike {
readonly id: string | null;
run(prompt: string, turnOptions?: TurnOptions): Promise<RunResult>;
Expand Down Expand Up @@ -159,7 +136,3 @@ async function defaultCodexFactory(): Promise<CodexFactory> {
const module = await import("@openai/codex-sdk");
return (options) => new module.Codex(options) as Codex;
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
32 changes: 23 additions & 9 deletions src/workflow-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
await semaphore.acquire(deps.signal);
let worktree: WorkflowWorktreeHandle | null = null;
let worktreePath: string | undefined;
let agentCallBegun = false;
try {
throwIfCancelled(deps);

Expand Down Expand Up @@ -307,6 +308,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
isolation,
worktreePath,
});
agentCallBegun = true;
deps.journal.appendEvent({
runId: deps.runId,
type: "agent_call_started",
Expand Down Expand Up @@ -422,6 +424,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
return returnValue;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
let cleanupError: string | undefined;
if (worktree) {
try {
const finalized = await worktree.finalize("failure");
Expand All @@ -438,22 +441,33 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
outcome: "failure",
},
});
} catch {
// preserve original error
} catch (cleanupFailure) {
cleanupError =
cleanupFailure instanceof Error
? cleanupFailure.message
: String(cleanupFailure);
}
}
deps.journal.failAgentCall({
runId: deps.runId,
callIndex: index,
error: message,
worktreePath,
});
if (agentCallBegun) {
deps.journal.failAgentCall({
runId: deps.runId,
callIndex: index,
error: message,
worktreePath,
});
}
deps.journal.appendEvent({
runId: deps.runId,
type: "agent_call_failed",
phase,
label: agentOpts.label,
data: { callIndex: index, error: message, isolation, worktreePath },
data: {
callIndex: index,
error: message,
cleanupError,
isolation,
worktreePath,
},
});
throw error;
} finally {
Expand Down
Loading
Loading