// 重构前 index.ts 只读参考：commit 6a04144d3f5103014f75b637d7e6bc2f45bf007f，blob 56e590c1a6b5ca7ad128bf2c992f60e46c355a58；可用 `git show 6a04144d3f5103014f75b637d7e6bc2f45bf007f:src/components/microservices/code-queue/src/index.ts` 查看。

import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import * as readline from "node:readline";
import type { AppServerExit, CodexEventSummary, CodexRunResult, JsonValue, QueueTask, RuntimeConfig, TerminalStatus } from "../types";
import type { ActiveRun, CodeAgentClient } from "./common";
import { codeAgentGitConfigEntries, deepseekChatModel, extractRecord, minimaxM27Model, minimaxM3Model, normalizeCodeModel, stripAnsi, withCodeAgentGitConfigEnv } from "./common";
import { classifyRunnerError, runnerErrorClassificationJson } from "../runner-error-classifier";

export interface OpenCodePortContext {
  config: Pick<RuntimeConfig, "deepseekApiBase" | "deepseekApiKey" | "deepseekModel" | "defaultWorkdir" | "minimaxApiBase" | "minimaxApiKey" | "minimaxModel" | "minimaxM3Model" | "resolvedRunnerSkillsPath" | "turnNoActivityTimeoutMs">;
  activeRuns: Map<string, ActiveRun>;
  addEvent: (task: QueueTask, event: CodexEventSummary) => void;
  appendOutput: (task: QueueTask, channel: "system" | "assistant" | "reasoning" | "command" | "diff" | "tool" | "error", text: string, method?: string, itemId?: string, append?: boolean) => unknown;
  buildDevContainerPlan: (providerId: string, body: Record<string, unknown>) => { containerName: string; remoteOpencodeXdgDir: string };
  compactRetryTaskContext: (task: QueueTask) => string;
  ensureTaskExecutionContainer: (task: QueueTask) => Promise<void>;
  judgeReasonForPrompt: (reason: string) => string;
  logger: (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue) => void;
  nowIso: () => string;
  openCodeFreshRecoveryPrompt: (task: QueueTask, prompt: string, reason: string) => string;
  openCodeXdgEnv: (root?: string) => Record<string, string>;
  persistTaskState: (task: QueueTask) => void;
  providerIsMain: (providerId: string) => boolean;
  queueIdOf: (task: QueueTask) => string;
  recordStringField: (record: Record<string, unknown> | null, keys: string[], max?: number) => string;
  refreshSchedulerHeartbeat: (task: QueueTask, options?: { observedAgentEvent?: boolean; persistedTrace?: boolean }) => void;
  remoteHostWorkdirForTask: (task: QueueTask) => string;
  safePreview: (value: string, max?: number) => string;
  shellQuote: (value: string) => string;
  shutdownRequested: () => boolean;
}

let context: OpenCodePortContext | null = null;

export function configureOpenCodePort(runtimeContext: OpenCodePortContext): void {
  context = runtimeContext;
}

function ctx(): OpenCodePortContext {
  if (context === null) throw new Error("opencode port is not configured");
  return context;
}

interface OpenCodeTextParts {
  reasoning: string;
  assistant: string;
}

function stripThinkBlocks(text: string): string {
  return String(text || "").replace(/<(?:think|thinking)\b[^>]*>[\s\S]*?<\/(?:think|thinking)>/giu, "").trim();
}

function splitOpenCodeAssistantText(text: string): OpenCodeTextParts {
  const reasoning: string[] = [];
  const withoutClosedThink = String(text || "").replace(/<(?:think|thinking)\b[^>]*>([\s\S]*?)<\/(?:think|thinking)>/giu, (_match, body) => {
    const item = String(body || "").trim();
    if (item.length > 0) reasoning.push(item);
    return "";
  });
  const closeThinkMatches = Array.from(withoutClosedThink.matchAll(/<\/(?:think|thinking)>/giu));
  const lastClose = closeThinkMatches.at(-1);
  const assistant = lastClose?.index === undefined
    ? withoutClosedThink.trim()
    : withoutClosedThink.slice(lastClose.index + lastClose[0].length).trim();
  return { reasoning: reasoning.join("\n\n").trim(), assistant };
}

function openCodeModelId(model: string): string {
  const normalized = normalizeCodeModel(model);
  if (normalized === minimaxM27Model) {
    const providerModel = ctx().config.minimaxModel.trim() || "MiniMax-M2.7";
    return `minimax/${providerModel}`;
  }
  if (normalized === minimaxM3Model) {
    const providerModel = ctx().config.minimaxM3Model.trim() || "MiniMax-M3";
    return `minimax/${providerModel}`;
  }
  if (normalized === deepseekChatModel) {
    const providerModel = ctx().config.deepseekModel.trim() || "deepseek-chat";
    return `deepseek/${providerModel}`;
  }
  throw new Error(`OpenCode port does not support model ${model}`);
}

function openCodeConfigContent(): string {
  const providerModel = ctx().config.minimaxModel.trim() || "MiniMax-M2.7";
  const providerModelM3 = ctx().config.minimaxM3Model.trim() || "MiniMax-M3";
  const deepseekModel = ctx().config.deepseekModel.trim() || "deepseek-chat";
  return JSON.stringify({
    $schema: "https://opencode.ai/config.json",
    provider: {
      minimax: {
        npm: "@ai-sdk/openai-compatible",
        name: "MiniMax",
        options: {
          baseURL: ctx().config.minimaxApiBase,
          apiKey: "{env:MINIMAX_API_KEY}",
        },
        models: {
          [providerModel]: {
            name: minimaxM27Model,
            limit: { context: 200000, output: 16384 },
          },
          [providerModelM3]: {
            name: minimaxM3Model,
            limit: { context: 200000, output: 16384 },
          },
        },
      },
      deepseek: {
        npm: "@ai-sdk/openai-compatible",
        name: "DeepSeek",
        options: {
          baseURL: ctx().config.deepseekApiBase,
          apiKey: "{env:DEEPSEEK_API_KEY}",
        },
        models: {
          [deepseekModel]: {
            name: deepseekChatModel,
            limit: { context: 128000, output: 16384 },
          },
        },
      },
    },
  });
}

function openCodeTaskXdgRoot(task: QueueTask, baseRoot?: string): string {
  return resolve(baseRoot ?? ctx().openCodeXdgEnv().XDG_DATA_HOME, "..", "tasks", task.id);
}

function ensureOpenCodeXdgDirs(env: Record<string, string>): void {
  for (const dir of Object.values(env)) mkdirSync(dir, { recursive: true });
}

function openCodeEnv(task: QueueTask): NodeJS.ProcessEnv {
  const xdgEnv = ctx().openCodeXdgEnv(openCodeTaskXdgRoot(task));
  ensureOpenCodeXdgDirs(xdgEnv);
  return withCodeAgentGitConfigEnv({
    ...process.env,
    ...xdgEnv,
    DEEPSEEK_API_KEY: ctx().config.deepseekApiKey,
    DEEPSEEK_API_BASE: ctx().config.deepseekApiBase,
    DEEPSEEK_MODEL: ctx().config.deepseekModel,
    MINIMAX_API_KEY: ctx().config.minimaxApiKey,
    MINIMAX_API_BASE: ctx().config.minimaxApiBase,
    MINIMAX_MODEL: ctx().config.minimaxModel,
    MINIMAX_M3_MODEL: ctx().config.minimaxM3Model,
    OPENCODE_CONFIG_CONTENT: openCodeConfigContent(),
    UNIDESK_SKILLS_PATH: ctx().config.resolvedRunnerSkillsPath(),
  });
}

function shellJoin(args: string[]): string {
  return args.map(ctx().shellQuote).join(" ");
}

function openCodeRunArgs(task: QueueTask, prompt: string): string[] {
  const args = ["run", "--format", "json", "--model", openCodeModelId(task.model), "--dir", task.cwd, "--dangerously-skip-permissions"];
  if (task.codexThreadId !== null && task.codexThreadId.trim().length > 0) args.push("--session", task.codexThreadId);
  args.push(prompt);
  return args;
}

function remoteOpenCodeRunCommand(task: QueueTask, prompt: string): string {
  const plan = ctx().buildDevContainerPlan(task.providerId, { workdir: ctx().remoteHostWorkdirForTask(task) });
  const xdgEnv = ctx().openCodeXdgEnv(resolve(plan.remoteOpencodeXdgDir, "tasks", task.id));
  const gitConfigEntries = codeAgentGitConfigEntries("");
  const envExports = [
    ...Object.entries(xdgEnv).map(([key, value]) => `export ${key}=${ctx().shellQuote(value)}`),
    `export DEEPSEEK_API_BASE=${ctx().shellQuote(ctx().config.deepseekApiBase)}`,
    `export DEEPSEEK_MODEL=${ctx().shellQuote(ctx().config.deepseekModel)}`,
    `export MINIMAX_API_BASE=${ctx().shellQuote(ctx().config.minimaxApiBase)}`,
    `export MINIMAX_MODEL=${ctx().shellQuote(ctx().config.minimaxModel)}`,
    `export MINIMAX_M3_MODEL=${ctx().shellQuote(ctx().config.minimaxM3Model)}`,
    `export OPENCODE_CONFIG_CONTENT=${ctx().shellQuote(openCodeConfigContent())}`,
    `export UNIDESK_SKILLS_PATH=${ctx().shellQuote(ctx().config.resolvedRunnerSkillsPath())}`,
    ...gitConfigEntries.map(([key, value], index) => `export GIT_CONFIG_KEY_$((\${GIT_CONFIG_COUNT:-0}+${index}))=${ctx().shellQuote(key)} GIT_CONFIG_VALUE_$((\${GIT_CONFIG_COUNT:-0}+${index}))=${ctx().shellQuote(value)}`),
    `export GIT_CONFIG_COUNT=$((\${GIT_CONFIG_COUNT:-0}+${gitConfigEntries.length}))`,
  ].join("; ");
  const inner = [
    "set -euo pipefail",
    `mkdir -p ${Object.values(xdgEnv).map(ctx().shellQuote).join(" ")}`,
    `mkdir -p ${ctx().shellQuote(task.cwd)}`,
    `cd ${ctx().shellQuote(task.cwd)}`,
    envExports,
    `exec opencode ${shellJoin(openCodeRunArgs(task, prompt))}`,
  ].join("; ");
  return `docker exec -i ${ctx().shellQuote(plan.containerName)} bash -lc ${ctx().shellQuote(inner)}`;
}

export function remoteOpenCodeRunCommandForTest(task: QueueTask, prompt: string): string {
  return remoteOpenCodeRunCommand(task, prompt);
}

export function openCodeTransportClosedBeforeTerminal(exitOk: boolean, hasFinalResponse: boolean, stepFinished: boolean): boolean {
  // Some OpenCode JSON streams can exit cleanly with visible assistant text but
  // without a step_finish event. Treat exit=0 plus a current final response as
  // terminal; otherwise the judge safety gate will retry a completed turn.
  return !exitOk || (!hasFinalResponse && !stepFinished);
}

function openCodeSessionIdFromRecord(record: Record<string, unknown>, part: Record<string, unknown> | null): string | null {
  const top = ctx().recordStringField(record, ["sessionID", "sessionId", "session_id"]);
  if (top.length > 0) return top;
  const nested = ctx().recordStringField(part, ["sessionID", "sessionId", "session_id"]);
  return nested.length > 0 ? nested : null;
}

function openCodeEventSummary(record: Record<string, unknown>): CodexEventSummary {
  const part = extractRecord(record.part);
  const type = ctx().recordStringField(record, ["type", "event", "name"]) || ctx().recordStringField(part, ["type"]) || "unknown";
  const text = ctx().recordStringField(part, ["text", "content", "delta", "message"]) || ctx().recordStringField(record, ["text", "content", "delta", "message"]);
  const error = ctx().recordStringField(part, ["error", "message"]) || ctx().recordStringField(record, ["error", "message"]);
  return {
    at: ctx().nowIso(),
    method: `opencode/${type}`,
    itemType: ctx().recordStringField(part, ["type"]) || type,
    status: ctx().recordStringField(part, ["status", "reason"]) || ctx().recordStringField(record, ["status", "reason"]) || undefined,
    message: error.length > 0 ? ctx().safePreview(error, 600) : undefined,
    textPreview: text.length > 0 ? ctx().safePreview(text, 800) : undefined,
  };
}

class OpenCodeRunClient implements CodeAgentClient {
  private child: ChildProcessWithoutNullStreams;
  private stderrChunks: Buffer[] = [];
  private closed = false;
  private closeResolve!: (value: AppServerExit) => void;
  private assistantChunks: string[] = [];
  private sessionAnnounced = false;
  readonly closedPromise: Promise<AppServerExit>;
  readonly runId = `opencode_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
  readonly events: CodexEventSummary[] = [];
  sessionId: string | null = null;
  finalResponse = "";
  stepFinished = false;
  lastActivityAt = Date.now();

  constructor(private readonly task: QueueTask, prompt: string) {
    this.closedPromise = new Promise((resolveClosed) => { this.closeResolve = resolveClosed; });
    this.child = ctx().providerIsMain(task.providerId)
      ? spawn("opencode", openCodeRunArgs(task, prompt), {
        cwd: task.cwd,
        env: openCodeEnv(task),
        stdio: "pipe",
      })
      : spawn("bun", ["scripts/cli.ts", "ssh", task.providerId, remoteOpenCodeRunCommand(task, prompt)], {
        cwd: ctx().config.defaultWorkdir,
        env: process.env,
        stdio: "pipe",
      });
    // opencode waits for stdin EOF even when the prompt is supplied as argv.
    // Close stdin immediately so non-interactive queue runs can start.
    this.child.stdin.end();
    this.child.stderr.on("data", (chunk: Buffer) => {
      this.stderrChunks.push(chunk);
      while (Buffer.concat(this.stderrChunks).length > 96_000) this.stderrChunks.shift();
    });
    const rl = readline.createInterface({ input: this.child.stdout, crlfDelay: Infinity });
    void this.readLines(rl);
    this.child.on("close", (code, signal) => this.handleClose(code, signal));
    this.child.on("error", (error) => this.handleClose(127, error.message));
  }

  stop(): void {
    if (this.closed) return;
    this.child.kill("SIGTERM");
    setTimeout(() => {
      if (!this.closed) this.child.kill("SIGKILL");
    }, 1500).unref?.();
  }

  private async readLines(rl: readline.Interface): Promise<void> {
    try {
      for await (const line of rl) {
        const trimmed = String(line).trim();
        if (trimmed.length === 0) continue;
        this.lastActivityAt = Date.now();
        ctx().refreshSchedulerHeartbeat(this.task, { observedAgentEvent: true });
        this.handleLine(trimmed);
      }
    } catch (error) {
      ctx().appendOutput(this.task, "error", `opencode stream error: ${error instanceof Error ? error.message : String(error)}\n`, "opencode/stream");
    }
  }

  private handleLine(line: string): void {
    let parsed: Record<string, unknown> | null = null;
    try {
      const value = JSON.parse(line) as unknown;
      parsed = extractRecord(value);
    } catch {
      this.appendAssistantText(line, "opencode/stdout", undefined);
      return;
    }
    if (parsed === null) {
      this.appendAssistantText(line, "opencode/stdout", undefined);
      return;
    }
    const part = extractRecord(parsed.part);
    const event = openCodeEventSummary(parsed);
    this.events.push(event);
    ctx().addEvent(this.task, event);

    const sessionId = openCodeSessionIdFromRecord(parsed, part);
    if (sessionId !== null) this.setSessionId(sessionId);

    const type = ctx().recordStringField(parsed, ["type", "event", "name"]) || ctx().recordStringField(part, ["type"]) || "unknown";
    const partType = ctx().recordStringField(part, ["type"]);
    const itemId = ctx().recordStringField(part, ["id"]) || undefined;
    if (type === "step_finish" || type === "step-finish" || partType === "step-finish") {
      this.stepFinished = true;
      return;
    }
    if (type === "step_start" || type === "step-start" || partType === "step-start") {
      return;
    }
    const text = ctx().recordStringField(part, ["text", "content", "delta"]) || ctx().recordStringField(parsed, ["text", "content", "delta"]);
    if (text.length > 0 && (type === "text" || partType === "text" || partType === "reasoning")) {
      if (partType === "reasoning") ctx().appendOutput(this.task, "reasoning", `${text.trimEnd()}\n`, "opencode/reasoning", itemId, true);
      else this.appendAssistantText(text, "opencode/text", itemId);
      return;
    }
    if (/tool|bash|command/iu.test(`${type} ${partType}`)) {
      ctx().appendOutput(this.task, type.includes("command") || partType.includes("command") ? "command" : "tool", `${JSON.stringify(parsed)}\n`, "opencode/tool", itemId);
      return;
    }
    if (/error|failed/iu.test(`${type} ${partType}`)) {
      ctx().appendOutput(this.task, "error", `${ctx().safePreview(JSON.stringify(parsed), 3000)}\n`, "opencode/error", itemId);
    }
  }

  private setSessionId(sessionId: string): void {
    this.sessionId = sessionId;
    if (this.task.codexThreadId === null) this.task.codexThreadId = sessionId;
    const run = ctx().activeRuns.get(ctx().queueIdOf(this.task));
    if (run?.app === this) {
      run.threadId = sessionId;
      run.lastLocalHeartbeatAt = ctx().nowIso();
      run.lastObservedAgentEventAt = run.lastLocalHeartbeatAt;
    }
    ctx().refreshSchedulerHeartbeat(this.task, { observedAgentEvent: true });
    if (this.sessionAnnounced) return;
    const resumed = this.task.currentMode === "retry" || this.task.attempts.length > 0;
    ctx().appendOutput(this.task, "system", `opencode session ${resumed ? "resumed" : "started"} ${sessionId}\n`, resumed ? "opencode/session-resume" : "opencode/session-start");
    this.sessionAnnounced = true;
  }

  private appendAssistantText(rawText: string, method: string, itemId: string | undefined): void {
    const parts = splitOpenCodeAssistantText(rawText);
    if (parts.reasoning.length > 0) ctx().appendOutput(this.task, "reasoning", `${parts.reasoning.trimEnd()}\n`, "opencode/reasoning", itemId, true);
    const visible = parts.assistant.length > 0 ? parts.assistant : parts.reasoning.length > 0 ? "" : rawText.trim();
    if (visible.length === 0) return;
    this.assistantChunks.push(visible);
    this.finalResponse = stripThinkBlocks(this.assistantChunks.join("\n\n").trim());
    this.task.finalResponse = this.finalResponse;
    ctx().appendOutput(this.task, "assistant", `${visible.trimEnd()}\n`, method, itemId, true);
  }

  private handleClose(code: number | null, signal: string | null): void {
    if (this.closed) return;
    this.closed = true;
    this.closeResolve({ code, signal, stderrTail: Buffer.concat(this.stderrChunks).toString("utf8").slice(-8000) });
  }
}



function openCodeExitError(exit: AppServerExit): string {
  const base = `OpenCode exited with code=${exit.code} signal=${exit.signal}`;
  const stderr = stripAnsi(exit.stderrTail).trim();
  return stderr.length > 0 ? `${base}: ${ctx().safePreview(stderr, 800)}` : base;
}

function openCodeSessionMissing(result: CodexRunResult): boolean {
  return /Session not found/iu.test(stripAnsi(`${result.terminalError ?? ""}\n${result.appServerExit.stderrTail}`));
}

function classifyOpenCodeRunnerFailure(task: QueueTask, result: Pick<CodexRunResult, "terminalStatus" | "terminalError" | "transportClosedBeforeTerminal" | "appServerExit">): JsonValue | null {
  if (result.terminalStatus === "completed" && !result.transportClosedBeforeTerminal) return null;
  const message = [
    result.terminalError ?? "",
    result.transportClosedBeforeTerminal ? "transport closed before terminal event" : "",
    result.appServerExit.stderrTail,
  ].filter((part) => part.trim().length > 0).join("\n");
  if (message.trim().length === 0) return null;
  return runnerErrorClassificationJson(classifyRunnerError(message, task.providerId));
}

function missingOpenCodeCredentialMessage(model: string): string | null {
  const normalized = normalizeCodeModel(model);
  if (normalized === minimaxM27Model && ctx().config.minimaxApiKey.length === 0) return "MINIMAX_API_KEY is required for opencode model minimax-m2.7.";
  if (normalized === minimaxM3Model && ctx().config.minimaxApiKey.length === 0) return "MINIMAX_API_KEY is required for opencode model minimax-m3.";
  if (normalized === deepseekChatModel && ctx().config.deepseekApiKey.length === 0) return "DEEPSEEK_API_KEY is required for opencode model deepseek-chat.";
  return null;
}

export async function runOpenCodeTurn(task: QueueTask, prompt: string): Promise<CodexRunResult> {
  const attemptedSessionId = task.codexThreadId;
  const first = await runOpenCodeTurnOnce(task, prompt);
  if (attemptedSessionId === null || task.cancelRequested || ctx().shutdownRequested() || !openCodeSessionMissing(first)) return first;
  const reason = first.terminalError ?? first.appServerExit.stderrTail;
  ctx().appendOutput(task, "system", `opencode session ${attemptedSessionId} was not found; clearing stale session and starting a fresh OpenCode session via the opencode port\n`, "opencode/session-recovery");
  ctx().logger("warn", "opencode_session_missing_recover_fresh", { taskId: task.id, sessionId: attemptedSessionId, reason: ctx().safePreview(stripAnsi(reason), 500) });
  task.codexThreadId = null;
  task.activeTurnId = null;
  ctx().persistTaskState(task);
  return runOpenCodeTurnOnce(task, ctx().openCodeFreshRecoveryPrompt(task, prompt, reason));
}

async function runOpenCodeTurnOnce(task: QueueTask, prompt: string): Promise<CodexRunResult> {
  const queueId = ctx().queueIdOf(task);
  const missingCredential = missingOpenCodeCredentialMessage(task.model);
  if (missingCredential !== null) {
    const message = missingCredential;
    ctx().appendOutput(task, "error", `${message}\n`, "opencode/config");
    return {
      threadId: task.codexThreadId,
      turnId: null,
      finalResponse: "",
      terminalStatus: "failed",
      terminalError: message,
      transportClosedBeforeTerminal: true,
      appServerExit: { code: 1, signal: null, stderrTail: message },
      runnerErrorClassification: runnerErrorClassificationJson(classifyRunnerError(message, task.providerId)),
      events: [],
    };
  }
  await ctx().ensureTaskExecutionContainer(task);
  const app = new OpenCodeRunClient(task, prompt);
  const runStartedAt = ctx().nowIso();
  ctx().activeRuns.set(queueId, { taskId: task.id, queueId, app, port: "opencode", threadId: task.codexThreadId, turnId: app.runId, startedAt: runStartedAt, lastLocalHeartbeatAt: runStartedAt, lastObservedAgentEventAt: null, lastPersistedTraceAt: null });
  task.activeTurnId = null;
  ctx().refreshSchedulerHeartbeat(task);
  ctx().persistTaskState(task);
  const activityWatchdog = setInterval(() => {
    const idleMs = Date.now() - app.lastActivityAt;
    if (idleMs < ctx().config.turnNoActivityTimeoutMs) return;
    const message = `No OpenCode activity for ${Math.round(idleMs / 1000)}s; stopping opencode run so the existing session can retry.`;
    ctx().appendOutput(task, "error", `${message}\n`, "turn/no-activity-watchdog");
    ctx().logger("warn", "opencode_no_activity_watchdog", { taskId: task.id, runId: app.runId, idleMs, timeoutMs: ctx().config.turnNoActivityTimeoutMs });
    app.stop();
  }, 15_000);
  try {
    const exit = await app.closedPromise;
    clearInterval(activityWatchdog);
    const finalResponse = app.finalResponse.trim().length > 0 ? app.finalResponse : "";
    const exitOk = exit.code === 0;
    const hasFinal = finalResponse.trim().length > 0;
    const status: TerminalStatus = exitOk && hasFinal ? "completed" : "failed";
    const terminalError = status === "completed"
      ? null
      : exitOk
        ? "OpenCode returned no final assistant response."
        : openCodeExitError(exit);
    const stderr = stripAnsi(exit.stderrTail).trim();
    ctx().appendOutput(
      task,
      status === "completed" ? "system" : "error",
      `opencode completed status=${status} exit=${exit.code ?? "null"} signal=${exit.signal ?? "null"}${stderr.length > 0 ? ` stderr=${ctx().safePreview(stderr, 1200)}` : ""}\n`,
      "opencode/complete",
    );
    const result: CodexRunResult = {
      threadId: app.sessionId ?? task.codexThreadId,
      turnId: app.runId,
      finalResponse,
      terminalStatus: status,
      terminalError,
      transportClosedBeforeTerminal: openCodeTransportClosedBeforeTerminal(exitOk, hasFinal, app.stepFinished),
      appServerExit: exit,
      events: app.events,
    };
    result.runnerErrorClassification = classifyOpenCodeRunnerFailure(task, result);
    return result;
  } catch (error) {
    clearInterval(activityWatchdog);
    const message = error instanceof Error ? error.message : String(error);
    ctx().appendOutput(task, "error", `${message}\n`, "opencode");
    app.stop();
    const exit = await app.closedPromise;
    const result: CodexRunResult = {
      threadId: app.sessionId ?? task.codexThreadId,
      turnId: app.runId,
      finalResponse: app.finalResponse.trim().length > 0 ? app.finalResponse : "",
      terminalStatus: "failed",
      terminalError: message,
      transportClosedBeforeTerminal: true,
      appServerExit: exit,
      events: app.events,
    };
    result.runnerErrorClassification = classifyOpenCodeRunnerFailure(task, result);
    return result;
  } finally {
    clearInterval(activityWatchdog);
    if (ctx().activeRuns.get(queueId)?.app === app) ctx().activeRuns.delete(queueId);
    app.stop();
  }
}
