import {
  access,
  mkdir,
  readdir,
  readFile,
  rm,
  stat,
  writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { ConvexHttpClient } from 'convex/browser';

import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';

import type { NormalizedAgentEvent } from './agent-events';
import type { AdapterWorkspace, Claim } from './runtime/provider';
import type { BoxHandle } from './user-container';
import type { AgentRuntimeName } from './runtime/agent-runtime';
import { getAdapter } from './runtime/agent-runtime';
import {
  resolveTurnOutcome,
  shouldAppendCompletedContent,
} from './runtime/turn-outcome';
import './runtime/register';
import { env } from './env';
import {
  cloneRepository,
  commitAndPush,
  getStatus,
  getWorktreeDiff,
  run,
} from './git';
import { getInstallationToken, openDraftPullRequest } from './github';
import { createHeartbeatController } from './heartbeat-controller';
import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env';
import { assertContainedRealPath } from './path-containment';
import { createRedactor, truncate } from './redact';
import {
  listWorkspaceContainerNames,
  runExecInContainer,
} from './runtime/docker';
import { collectJsonStringValues } from './runtime/provider';
import { acquireUserBox, runningBoxUsernames } from './user-container';
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
import { teardownWorkspace } from './workspace-teardown';

type ActiveWorkspace = AdapterWorkspace & {
  // Phase 2: the per-user box container this thread execs into.
  boxHandle: BoxHandle;
  githubToken: string;
  runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
  agentTurnActive?: boolean;
  cancelRequested?: boolean;
  resolveTurn?: () => void;
  lastRecordedDiffSignature?: string;
  // Absolute path of the last materialized env file so teardown can delete it.
  materializedEnvPath?: string;
};

type FileTreeNode = {
  name: string;
  path: string;
  type: 'file' | 'directory';
  children?: FileTreeNode[];
};

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const client = new ConvexHttpClient(env.convexUrl);
const activeWorkspaces = new Map<string, ActiveWorkspace>();

export const _setActiveWorkspaceForTests = (
  jobId: string,
  workspace: ActiveWorkspace,
) => activeWorkspaces.set(jobId, workspace);

export const _resetActiveWorkspacesForTests = () => activeWorkspaces.clear();

const appendEvent = async (
  jobId: Id<'agentJobs'>,
  level: 'debug' | 'info' | 'warn' | 'error',
  phase:
    | 'queued'
    | 'clone'
    | 'plan'
    | 'edit'
    | 'install'
    | 'check'
    | 'test'
    | 'commit'
    | 'push'
    | 'pr'
    | 'cleanup',
  message: string,
  metadata?: string,
) =>
  await client.mutation(api.agentJobs.appendEvent, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    jobId,
    level,
    phase,
    message,
    metadata,
  });

const updateStatus = async (
  jobId: Id<'agentJobs'>,
  status:
    | 'queued'
    | 'claimed'
    | 'preparing'
    | 'running'
    | 'checks_running'
    | 'changes_ready'
    | 'draft_pr_opened'
    | 'failed'
    | 'cancelled'
    | 'timed_out',
  extra?: { error?: string; summary?: string },
) =>
  await client.mutation(api.agentJobs.updateStatus, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    jobId,
    status,
    ...extra,
  });

const addArtifact = async (args: {
  jobId: Id<'agentJobs'>;
  kind: 'plan' | 'diff' | 'test_output' | 'summary' | 'error' | 'pr_body';
  title: string;
  content: string;
  contentType:
    | 'text/markdown'
    | 'text/plain'
    | 'application/json'
    | 'text/x-diff';
}) =>
  await client.mutation(api.agentJobs.addArtifact, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const completeWithDraftPr = async (args: {
  jobId: Id<'agentJobs'>;
  commitSha: string;
  pullRequestUrl: string;
  pullRequestNumber: number;
  summary: string;
}) =>
  await client.mutation(api.agentJobs.completeWithDraftPr, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const applyMaintenanceDecision = async (
  jobId: Id<'agentJobs'>,
  decision: MaintenanceDecision,
) =>
  await client.mutation(api.agentJobs.applyMaintenanceDecision, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    jobId,
    ...decision,
  });

const markWorkspaceActive = async (args: {
  jobId: Id<'agentJobs'>;
  opencodeSessionId?: string;
  containerId?: string;
  workspaceUrl?: string;
}) =>
  await client.mutation(api.agentJobs.markWorkspaceActive, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    workspaceExpiresAt: Date.now() + 2 * 60 * 60 * 1000,
    ...args,
  });

const markWorkspaceStopped = async (
  jobId: Id<'agentJobs'>,
  workspaceStatus: 'stopped' | 'expired' | 'failed' = 'stopped',
) =>
  await client.mutation(api.agentJobs.markWorkspaceStopped, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    jobId,
    workspaceStatus,
  });

const heartbeatWorkspaceMutation = async (jobId: Id<'agentJobs'>) =>
  (await client.mutation(api.agentJobs.heartbeatWorkspace, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    jobId,
  })) as { success: true; cancelRequested: boolean };

const heartbeatController = createHeartbeatController({
  intervalMs: 30_000,
  heartbeat: async (jobId) =>
    await heartbeatWorkspaceMutation(jobId as Id<'agentJobs'>),
  onCancel: async (jobId) => await cancelWorkspace(jobId),
  onError: (error) => console.error(error),
});

const stopHeartbeat = (jobId: string) => heartbeatController.stop(jobId);
const startHeartbeat = (jobId: Id<'agentJobs'>) =>
  heartbeatController.start(jobId);

const appendMessage = async (args: {
  jobId: Id<'agentJobs'>;
  role: 'user' | 'assistant' | 'system' | 'tool';
  content: string;
  status: 'queued' | 'streaming' | 'completed' | 'failed';
  metadata?: string;
}) =>
  await client.mutation(api.agentJobs.appendMessage, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const updateMessage = async (args: {
  messageId: Id<'agentJobMessages'>;
  content?: string;
  status?: 'queued' | 'streaming' | 'completed' | 'failed';
  metadata?: string;
}) =>
  await client.mutation(api.agentJobs.updateMessage, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const setRuntimeSession = async (args: {
  jobId: Id<'agentJobs'>;
  agentRuntimeMode: 'opencode_server' | 'codex_exec' | 'legacy_cli';
  opencodeSessionId?: string;
  codexSessionId?: string;
  containerId?: string;
}) =>
  await client.mutation(api.agentJobs.setRuntimeSession, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const setCodexSessionId = async (
  jobId: Id<'agentJobs'>,
  codexSessionId: string,
) =>
  await client.mutation(api.agentJobs.setCodexSessionId, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    jobId,
    codexSessionId,
  });

// Persist the runtime session id for continuity, keyed on the workspace runtime
// (replaces the per-runtime setter call sites so dispatch stays generic).
const persistRuntimeSession = async (
  workspace: ActiveWorkspace,
  sessionId: string,
) => {
  if (workspace.runtime === 'codex') {
    workspace.codexSessionId = sessionId;
    await setCodexSessionId(workspace.claim.job._id, sessionId);
  } else if (workspace.runtime === 'opencode') {
    workspace.opencodeSessionId = sessionId;
  } else {
    workspace.claudeSessionId = sessionId;
  }
};

const createInteractionRequest = async (args: {
  jobId: Id<'agentJobs'>;
  runtime: 'opencode' | 'codex';
  externalRequestId: string;
  kind: 'question' | 'permission' | 'tool_confirmation';
  title: string;
  body: string;
  options?: string[];
  metadata?: string;
}) =>
  await client.mutation(api.agentJobs.createInteractionRequest, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const _patchInteractionRequest = async (args: {
  interactionId: Id<'agentInteractionRequests'>;
  status: 'pending' | 'answered' | 'approved' | 'rejected' | 'expired';
  response?: string;
  metadata?: string;
}) =>
  await client.mutation(api.agentJobs.patchInteractionRequest, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const recordWorkspaceChange = async (args: {
  jobId: Id<'agentJobs'>;
  path: string;
  source: 'user' | 'agent' | 'command';
  changeType: 'added' | 'modified' | 'deleted' | 'renamed';
  diff?: string;
}) =>
  await client.mutation(api.agentJobs.recordWorkspaceChange, {
    workerToken: env.workerToken,
    workerId: env.workerId,
    ...args,
  });

const commandToShell = (command: string) => ['bash', '-lc', command];

const handleAgentEvent = async (args: {
  workspace: ActiveWorkspace;
  event: NormalizedAgentEvent;
  assistantMessageId: Id<'agentJobMessages'>;
  assistantContent: { value: string };
}) => {
  const { workspace, event, assistantMessageId, assistantContent } = args;
  const jobId = workspace.claim.job._id;
  if (event.kind === 'assistant_delta') {
    assistantContent.value = truncate(
      `${assistantContent.value}${event.content}`,
      40_000,
    );
    await updateMessage({
      messageId: assistantMessageId,
      content: assistantContent.value,
      status: 'streaming',
      metadata: event.externalMessageId
        ? JSON.stringify({ externalMessageId: event.externalMessageId })
        : undefined,
    });
    return;
  }
  if (event.kind === 'assistant_completed') {
    workspace.agentTurnActive = false;
    workspace.resolveTurn?.();
    workspace.resolveTurn = undefined;
    if (shouldAppendCompletedContent(assistantContent.value, event.content)) {
      assistantContent.value = truncate(
        `${assistantContent.value}${event.content}`,
        40_000,
      );
    }
    await updateMessage({
      messageId: assistantMessageId,
      content: assistantContent.value,
      status: 'completed',
    });
    return;
  }
  if (event.kind === 'session') {
    // Session persistence is centralized in persistRuntimeSession (called from
    // the dispatch after the turn); here we only track it in memory so a later
    // turn can resume.
    if (workspace.runtimeMode === 'codex_exec') {
      workspace.codexSessionId = event.sessionId;
    }
    return;
  }
  if (event.kind === 'tool_started' || event.kind === 'tool_completed') {
    const detail = event.kind === 'tool_started' ? event.input : event.output;
    await appendMessage({
      jobId,
      role: 'tool',
      status: event.kind === 'tool_started' ? 'streaming' : 'completed',
      content: truncate(
        `${event.name}${detail ? `\n\n${detail}` : ''}`,
        20_000,
      ),
      metadata: JSON.stringify({
        kind: event.kind,
        externalMessageId: event.externalMessageId,
      }),
    });
    return;
  }
  if (event.kind === 'file_edited') {
    const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
    await recordWorkspaceChange({
      jobId,
      path: event.path,
      source: 'agent',
      changeType: await fileChangedType(workspace.repoDir, event.path),
      diff: truncate(diff.output, 50_000),
    });
    await appendEvent(jobId, 'info', 'edit', `Agent edited ${event.path}.`);
    return;
  }
  if (event.kind === 'command_executed') {
    await appendEvent(
      jobId,
      event.exitCode && event.exitCode !== 0 ? 'warn' : 'info',
      'check',
      event.command,
      event.output ? truncate(event.output, 10_000) : undefined,
    );
    return;
  }
  if (
    event.kind === 'permission_requested' ||
    event.kind === 'question_requested'
  ) {
    await createInteractionRequest({
      jobId,
      runtime: workspace.runtimeMode === 'codex_exec' ? 'codex' : 'opencode',
      externalRequestId: event.externalRequestId,
      kind: event.kind === 'permission_requested' ? 'permission' : 'question',
      title: event.title,
      body: truncate(event.body, 20_000),
      options: event.kind === 'question_requested' ? event.options : undefined,
      metadata: event.metadata,
    });
    await appendMessage({
      jobId,
      role: 'system',
      status: 'completed',
      content: `${event.title}\n\n${truncate(event.body, 20_000)}`,
      metadata: JSON.stringify({ kind: event.kind }),
    });
    return;
  }
  if (event.kind === 'status') {
    await appendEvent(
      jobId,
      'debug',
      'plan',
      event.status,
      event.metadata ? truncate(event.metadata, 10_000) : undefined,
    );
    return;
  }
  // event.kind === 'error'
  // The adapter captures the real Codex failure reason (Codex can emit
  // `error`/`turn.failed` events and still exit 0) and surfaces it as
  // TurnResult.error; here we just log it.
  await appendEvent(jobId, 'error', 'plan', truncate(event.message, 20_000));
};

const systemPromptForJob = (claim: Claim) => {
  const base = [
    `Spoon: ${claim.spoon.name}`,
    `Fork: ${claim.job.forkOwner}/${claim.job.forkRepo}`,
    `Upstream: ${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
    `Selected secret names: ${claim.secrets.map((secret) => secret.name).join(', ') || 'none'}`,
  ].join('\n');
  if (claim.job.jobType === 'maintenance_review') {
    return `${base}

You are reviewing upstream changes for a maintained fork.
Determine whether the upstream commits can be safely applied.
If the fork has no relevant customizations, recommend sync.
If upstream changes are irrelevant to this fork, recommend ignore and list commit SHAs.
If changes may affect custom fork commits, explain risks and recommend review PR or manual review.
Do not claim tests passed unless commands were run.
End with a JSON maintenance decision in this exact shape:
{
  "decision": "sync" | "ignore" | "open_review_pr" | "manual_review" | "conflict_resolution" | "unknown",
  "risk": "low" | "medium" | "high" | "unknown",
  "summary": "string",
  "ignoredCommitShas": ["string"],
  "ignoredReason": "string",
  "recommendedAction": "string",
  "requiresUserApproval": true
}

User/system request:
${claim.job.prompt}`;
  }
  if (claim.job.jobType === 'conflict_resolution') {
    return `${base}

You are resolving upstream merge conflicts in a maintained fork.
Preserve fork customizations unless the user explicitly removed that upstream behavior.
Prefer small, reviewable changes.
Produce a draft PR rather than committing to main.

Request:
${claim.job.prompt}`;
  }
  return `${base}

You are working on a maintained fork managed by Spoon.
Make the requested change only.
Preserve fork-specific customizations.
Do not commit secrets.
Use selected environment variables only for running/building/testing.
Open a draft PR only when instructed by Spoon.

Request:
${claim.job.prompt}`;
};

type MaintenanceDecision = {
  decision:
    | 'sync'
    | 'ignore'
    | 'open_review_pr'
    | 'manual_review'
    | 'conflict_resolution'
    | 'unknown';
  risk: 'low' | 'medium' | 'high' | 'unknown';
  summary: string;
  ignoredCommitShas: string[];
  ignoredReason: string;
  recommendedAction: string;
  requiresUserApproval: boolean;
};

const parseMaintenanceDecision = (
  output: string,
): MaintenanceDecision | null => {
  const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(output)?.[1];
  const candidates = [fenced, output.slice(output.indexOf('{'))].filter(
    Boolean,
  ) as string[];
  for (const candidate of candidates) {
    try {
      const parsed = JSON.parse(
        candidate.trim(),
      ) as Partial<MaintenanceDecision>;
      if (
        parsed.decision &&
        parsed.risk &&
        typeof parsed.summary === 'string'
      ) {
        return {
          decision: parsed.decision,
          risk: parsed.risk,
          summary: parsed.summary,
          ignoredCommitShas: Array.isArray(parsed.ignoredCommitShas)
            ? parsed.ignoredCommitShas.filter(
                (sha): sha is string => typeof sha === 'string',
              )
            : [],
          ignoredReason:
            typeof parsed.ignoredReason === 'string'
              ? parsed.ignoredReason
              : '',
          recommendedAction:
            typeof parsed.recommendedAction === 'string'
              ? parsed.recommendedAction
              : parsed.summary,
          requiresUserApproval: parsed.requiresUserApproval ?? true,
        };
      }
    } catch {
      // Try the next candidate.
    }
  }
  return null;
};

const fileExists = async (filePath: string) => {
  try {
    await access(filePath);
    return true;
  } catch {
    return false;
  }
};

const runProjectCommand = async (args: {
  command: string;
  phase: 'install' | 'check' | 'test';
  claim: Claim;
  boxName: string;
  containerHome: string;
  containerCwd: string;
  repoDir: string;
  redact: (value: string) => string;
}) => {
  await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
  const secretEnv = Object.fromEntries(
    args.claim.secrets.map((secret) => [secret.name, secret.value]),
  );
  const result =
    env.runtime === 'docker'
      ? await runExecInContainer({
          containerName: args.boxName,
          command: commandToShell(args.command),
          containerCwd: args.containerCwd,
          environment: { HOME: args.containerHome, ...secretEnv },
          redact: args.redact,
          timeoutMs: env.jobTimeoutMs,
        })
      : await run('bash', ['-lc', args.command], {
          cwd: args.repoDir,
          env: secretEnv,
          redact: args.redact,
          timeoutMs: env.jobTimeoutMs,
        });
  await addArtifact({
    jobId: args.claim.job._id,
    kind: args.phase === 'test' ? 'test_output' : 'summary',
    title: args.command,
    content: truncate(result.output, 100_000),
    contentType: 'text/plain',
  });
  if (result.exitCode !== 0) {
    throw new Error(`${args.command} failed:\n${result.output}`);
  }
};

const resolveWorkspace = (jobId: string) => {
  const workspace = activeWorkspaces.get(jobId);
  if (!workspace) {
    throw new Error('Agent workspace is not active on this worker.');
  }
  return workspace;
};

const fileChangedType = async (repoDir: string, filePath: string) => {
  const status = await run('git', ['status', '--short', '--', filePath], {
    cwd: repoDir,
    redact: (value) => value,
    timeoutMs: 60_000,
  });
  const code = status.output.trim().slice(0, 2);
  if (code.includes('D')) return 'deleted' as const;
  if (code.includes('A') || code.includes('?')) return 'added' as const;
  if (code.includes('R')) return 'renamed' as const;
  return 'modified' as const;
};

const sensitiveWorkspacePath = (filePath: string) => {
  const parts = filePath.split('/');
  if (parts.includes('.git') || parts.includes('.codex')) return true;
  const name = parts.at(-1) ?? filePath;
  if (name === '.env') return true;
  if (name.startsWith('.env.') && name !== '.env.example') return true;
  return false;
};

const changedFilesFromStatus = async (
  repoDir: string,
  redact: (value: string) => string,
) => {
  const status = await run('git', ['status', '--short'], {
    cwd: repoDir,
    redact,
    timeoutMs: 60_000,
  });
  if (status.exitCode !== 0) return [];
  return status.output
    .split('\n')
    .map((line) => {
      if (line.length < 4) return null;
      const code = line.slice(0, 2);
      const rawPath = line.slice(3).trim();
      if (!rawPath) return null;
      const filePath = rawPath.includes(' -> ')
        ? rawPath.split(' -> ').at(-1)?.trim()
        : rawPath;
      if (!filePath || sensitiveWorkspacePath(filePath)) return null;
      const changeType = code.includes('D')
        ? 'deleted'
        : code.includes('R')
          ? 'renamed'
          : code.includes('A') || code.includes('?')
            ? 'added'
            : 'modified';
      return {
        path: filePath,
        changeType,
      };
    })
    .filter(
      (
        value,
      ): value is {
        path: string;
        changeType: 'added' | 'modified' | 'deleted' | 'renamed';
      } => Boolean(value),
    );
};

const recordChangedFiles = async (
  workspace: ActiveWorkspace,
  source: 'agent' | 'command',
  diff: string,
) => {
  const changes = await changedFilesFromStatus(
    workspace.repoDir,
    workspace.redact,
  );
  const signature = JSON.stringify({ diff, changes });
  if (signature === workspace.lastRecordedDiffSignature) return;
  workspace.lastRecordedDiffSignature = signature;
  for (const change of changes) {
    await recordWorkspaceChange({
      jobId: workspace.claim.job._id,
      path: change.path,
      source,
      changeType: change.changeType,
      diff: truncate(diff, 50_000),
    });
  }
  if (changes.length > 0) {
    await appendEvent(
      workspace.claim.job._id,
      'info',
      'edit',
      `Workspace has ${changes.length} changed file${
        changes.length === 1 ? '' : 's'
      }.`,
      JSON.stringify(changes),
    );
  }
};

const materializeEnvFile = async (workspace: ActiveWorkspace) => {
  const { claim, repoDir } = workspace;
  if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
  workspace.materializedEnvPath = await writeMaterializedEnv(
    repoDir,
    claim.job.envFilePath,
    claim.secrets,
  );
  await appendEvent(
    claim.job._id,
    'info',
    'clone',
    `Materialized selected secrets into ${claim.job.envFilePath}.`,
  );
};

const detectPackageCommands = async (
  repoDir: string,
): Promise<{
  packageManager?: string;
  scripts?: string[];
  install?: string;
  check?: string;
  test?: string;
  build?: string;
}> => {
  const packageJsonPath = path.join(repoDir, 'package.json');
  try {
    const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
      packageManager?: string;
      scripts?: Record<string, string>;
    };
    const scripts = packageJson.scripts ?? {};
    const declaredPackageManager = packageJson.packageManager?.split('@')[0];
    const detectedPackageManager = (await fileExists(
      path.join(repoDir, 'bun.lock'),
    ))
      ? 'bun'
      : (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
        ? 'pnpm'
        : (await fileExists(path.join(repoDir, 'yarn.lock')))
          ? 'yarn'
          : 'npm';
    const packageManager = declaredPackageManager ?? detectedPackageManager;
    const runScript = (script: string) =>
      packageManager === 'npm'
        ? `npm run ${script}`
        : `${packageManager} run ${script}`;

    return {
      packageManager,
      scripts: Object.keys(scripts).sort(),
      install: `${packageManager} install`,
      check: scripts.typecheck
        ? runScript('typecheck')
        : scripts.lint
          ? runScript('lint')
          : undefined,
      test: scripts.test
        ? packageManager === 'npm'
          ? 'npm test'
          : `${packageManager} test`
        : undefined,
      build: scripts.build ? runScript('build') : undefined,
    };
  } catch {
    return {};
  }
};

const buildPrBody = (args: {
  prompt: string;
  summary: string;
  commands: string[];
  limitations: string[];
}) => `## Spoon agent request

${args.prompt}

## Summary

${args.summary}

## Validation

${
  args.commands.length
    ? args.commands.map((command) => `- \`${command}\``).join('\n')
    : '- No validation commands were requested by the agent.'
}

## Limitations

${
  args.limitations.length
    ? args.limitations.map((item) => `- ${item}`).join('\n')
    : '- No limitations reported.'
}

Generated by Spoon.`;

const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
  const status = await run('git', ['status', '--short'], {
    cwd: workspace.repoDir,
    redact: workspace.redact,
    timeoutMs: 60_000,
  });
  const envLine = status.output
    .split('\n')
    .find((line) => /\s\.env(?:$|[./-])/.test(line));
  if (envLine) {
    throw new Error(`Refusing to commit env file changes: ${envLine.trim()}`);
  }
};

const slugify = (value: string) =>
  value
    .toLowerCase()
    .replace(/[^a-z0-9._-]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .replace(/-{2,}/g, '-') || 'x';

export const planWorkdirCleanup = (args: {
  root: string;
  rootEntries: { name: string; isDirectory: boolean }[];
  homesEntries: Record<string, { name: string; isDirectory: boolean }[]>;
  codeLeaves: Record<string, string[]>;
  activeWorkdirs: Set<string>;
  runningBoxUsernames: Set<string>;
}): { removeDirs: string[] } => {
  const removeDirs: string[] = [];
  for (const entry of args.rootEntries) {
    if (!entry.isDirectory || entry.name.startsWith('.')) continue;
    if (entry.name === 'homes') continue;
    const abs = path.resolve(args.root, entry.name);
    if (args.activeWorkdirs.has(abs)) continue;
    removeDirs.push(abs);
  }
  for (const [username, leaves] of Object.entries(args.codeLeaves)) {
    if (args.runningBoxUsernames.has(username)) continue;
    for (const leaf of leaves) removeDirs.push(leaf);
  }
  return { removeDirs };
};

const runClaim = async (claim: Claim) => {
  const jobId = claim.job._id;
  const secretValues = [
    claim.openai.apiKey ?? '',
    claim.aiProviderProfile?.secret ?? '',
    ...collectJsonStringValues(claim.aiProviderProfile?.secret),
    ...claim.secrets.map((secret) => secret.value),
  ].filter(Boolean);
  const redact = createRedactor(secretValues);
  let acquiredBoxHandle: BoxHandle | undefined;
  // job.runtime is authoritative (validated against the profile at queue time).
  const runtime = (claim.job.runtime ?? 'opencode') as AgentRuntimeName;
  try {
    await updateStatus(jobId, 'preparing');
    await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
    if (!claim.github.installationId) {
      throw new Error('GitHub installation ID is missing.');
    }
    const githubToken = await getInstallationToken(claim.github.installationId);

    // Resolve the persistent per-user home and lay the checkout out as
    // ~/Code/{spoon}/{branch} inside it, so dotfiles/tools persist and every
    // thread shows up as a folder in one home.
    const userEnv = await fetchUserEnvironment(jobId);
    const username = userEnv?.username ?? 'user';
    const homeDir = path.resolve(env.workdir, 'homes', username);
    const containerHome = path.posix.join('/home', username);
    const spoonSlug = slugify(claim.spoon.name);
    const branchSlug = slugify(claim.job.workBranch);
    const checkoutParent = path.join(homeDir, 'Code', spoonSlug);
    const containerRepo = path.posix.join(
      containerHome,
      'Code',
      spoonSlug,
      branchSlug,
    );

    // Start (or reuse) the persistent per-user box that this thread — and the
    // terminal — exec into. It mounts the home, so the clone below is visible.
    const boxHandle = await acquireUserBox({
      username,
      workdir: homeDir,
      containerHome,
    });
    acquiredBoxHandle = boxHandle;
    const boxName = boxHandle.boxName;

    const repoDir = await cloneRepository({
      workdir: checkoutParent,
      dirName: branchSlug,
      token: githubToken,
      owner: claim.job.forkOwner,
      repo: claim.job.forkRepo,
      baseBranch: claim.job.baseBranch,
      workBranch: claim.job.workBranch,
      redact,
      timeoutMs: env.jobTimeoutMs,
    });
    const workspace: ActiveWorkspace = {
      claim,
      workdir: homeDir,
      homeDir,
      username,
      containerHome,
      containerRepo,
      repoDir,
      boxName,
      boxHandle,
      githubToken,
      redact,
      runtime,
    };
    if (userEnv) {
      await appendEvent(
        jobId,
        'info',
        'clone',
        'Applying your dotfiles and environment.',
      );
      await materializeUserHome({
        homeDir,
        containerHome,
        boxName,
        userEnv,
        redact,
      });
    }
    await getAdapter(runtime).prepareAuth(workspace);
    await materializeEnvFile(workspace);
    const detected = await detectPackageCommands(repoDir);
    await addArtifact({
      jobId,
      kind: 'summary',
      title: 'Detected project commands',
      content: JSON.stringify(detected, null, 2),
      contentType: 'application/json',
    });
    activeWorkspaces.set(jobId, workspace);
    await markWorkspaceActive({ jobId });
    startHeartbeat(jobId);
    await updateStatus(jobId, 'running', {
      summary: 'Workspace is active.',
    });
    await appendMessage({
      jobId,
      role: 'system',
      status: 'completed',
      content:
        'Workspace is ready. You can browse files, edit manually, run commands, or send messages to the agent.',
    });
    await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
    await appendEvent(
      jobId,
      'info',
      'plan',
      `Worker runtime ${env.workerId} build ${env.buildSha} (${env.buildCreatedAt}).`,
    );

    await sendWorkspaceMessage(jobId, systemPromptForJob(claim), {
      recordUserMessage: false,
    });
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    const workspaceStatus = message.toLowerCase().includes('timed out')
      ? 'expired'
      : 'failed';
    try {
      await appendEvent(
        jobId,
        'error',
        'cleanup',
        truncate(redact(message), 20_000),
      );
      await addArtifact({
        jobId,
        kind: 'error',
        title: 'Failure',
        content: truncate(redact(message), 50_000),
        contentType: 'text/plain',
      });
      await updateStatus(
        jobId,
        workspaceStatus === 'expired' ? 'timed_out' : 'failed',
        { error: truncate(redact(message), 10_000) },
      );
    } finally {
      const workspace = activeWorkspaces.get(jobId);
      if (workspace) {
        const teardown = await teardownActiveWorkspace({
          jobId,
          workspace,
          abortAgent: true,
          workspaceStatus,
        });
        reportTeardownErrors(teardown.errors);
      } else {
        stopHeartbeat(jobId);
        await markWorkspaceStopped(jobId, workspaceStatus).catch(
          (stopError: unknown) => console.error(stopError),
        );
        acquiredBoxHandle?.release();
      }
    }
  }
};

export const listWorkspaceTree = async (jobId: string) => {
  const workspace = resolveWorkspace(jobId);
  const buildNode = async (
    absolutePath: string,
    relativePath: string,
  ): Promise<FileTreeNode | null> => {
    const basename = path.basename(absolutePath);
    if (['.git', 'node_modules', '.next', 'dist', 'build'].includes(basename)) {
      return null;
    }
    const stats = await stat(absolutePath);
    if (stats.isDirectory()) {
      const entries = await readdir(absolutePath);
      const children = (
        await Promise.all(
          entries
            .sort((a, b) => a.localeCompare(b))
            .map((entry) =>
              buildNode(
                path.join(absolutePath, entry),
                path.join(relativePath, entry),
              ),
            ),
        )
      ).filter((node): node is FileTreeNode => Boolean(node));
      return {
        name: relativePath ? basename : workspace.claim.job.forkRepo,
        path: relativePath,
        type: 'directory',
        children,
      };
    }
    return { name: basename, path: relativePath, type: 'file' };
  };
  return await buildNode(workspace.repoDir, '');
};

export const readWorkspaceFile = async (jobId: string, filePath: string) => {
  const workspace = resolveWorkspace(jobId);
  const target = await assertContainedRealPath(workspace.repoDir, filePath);
  return await readFile(target, 'utf8');
};

export const writeWorkspaceFile = async (
  jobId: string,
  filePath: string,
  content: string,
) => {
  const workspace = resolveWorkspace(jobId);
  const target = await assertContainedRealPath(workspace.repoDir, filePath, {
    forWrite: true,
  });
  await mkdir(path.dirname(target), { recursive: true });
  await writeFile(target, content);
  const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
  await recordWorkspaceChange({
    jobId: workspace.claim.job._id,
    path: filePath,
    source: 'user',
    changeType: await fileChangedType(workspace.repoDir, filePath),
    diff: truncate(diff.output, 50_000),
  });
  await appendEvent(
    workspace.claim.job._id,
    'info',
    'edit',
    `Saved ${filePath}.`,
  );
  return { success: true };
};

export const getWorkspaceDiff = async (jobId: string) => {
  const workspace = resolveWorkspace(jobId);
  const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
  return diff.output;
};

export const runWorkspaceCommand = async (jobId: string, command: string) => {
  const workspace = resolveWorkspace(jobId);
  await updateStatus(workspace.claim.job._id, 'checks_running');
  await runProjectCommand({
    command,
    phase: command.includes('test') ? 'test' : 'check',
    claim: workspace.claim,
    boxName: workspace.boxName,
    containerHome: workspace.containerHome,
    containerCwd: workspace.containerRepo,
    repoDir: workspace.repoDir,
    redact: workspace.redact,
  });
  await updateStatus(workspace.claim.job._id, 'running');
  await recordChangedFiles(
    workspace,
    'command',
    (await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
  );
  return { success: true };
};

// Non-throwing accessor for the interactive terminal: returns the active
// workspace's mount info, or null when the workspace is not active here.
export const getTerminalWorkspace = (jobId: string) => {
  const workspace = activeWorkspaces.get(jobId);
  if (!workspace) return null;
  return {
    workdir: workspace.workdir,
    containerHome: workspace.containerHome,
    containerRepo: workspace.containerRepo,
    username: workspace.username,
    secrets: workspace.claim.secrets,
  };
};

export const getWorkspaceAgentStatus = (jobId: string) => {
  const workspace = resolveWorkspace(jobId);
  return {
    runtimeMode: workspace.runtime,
    codexSessionId: workspace.codexSessionId,
    opencodeSessionId: workspace.opencodeSessionId,
    claudeSessionId: workspace.claudeSessionId,
    active: Boolean(workspace.agentTurnActive),
  };
};

const abortWorkspaceAgentForWorkspace = async (workspace: ActiveWorkspace) => {
  await getAdapter(workspace.runtime).abort(workspace);
  workspace.agentTurnActive = false;
  workspace.resolveTurn?.();
  workspace.resolveTurn = undefined;
  await appendEvent(
    workspace.claim.job._id,
    'warn',
    'cleanup',
    'Agent turn aborted.',
  );
  return { success: true };
};

export const abortWorkspaceAgent = async (jobId: string) =>
  await abortWorkspaceAgentForWorkspace(resolveWorkspace(jobId));

export const replyToInteraction = async (
  _jobId: string,
  _args: {
    interactionId: Id<'agentInteractionRequests'>;
    externalRequestId: string;
    response: string;
  },
  // eslint-disable-next-line @typescript-eslint/require-await -- keep async so callers receive a rejected promise
) => {
  // Every runtime now runs its CLI non-interactively inside the box, so no
  // runtime emits an interaction request that expects a reply.
  throw new Error('Interactive replies are not supported by exec-based runtimes.');
};

export const sendWorkspaceMessage = async (
  jobId: string,
  prompt: string,
  options: { recordUserMessage?: boolean } = {},
) => {
  const workspace = resolveWorkspace(jobId);
  const { claim, redact } = workspace;
  if (workspace.agentTurnActive) {
    throw new Error('Wait for the current agent turn to finish or abort it.');
  }
  // Re-materialize the env file fresh each turn so it is present for the
  // adapter and can be removed between turns / on teardown.
  await materializeEnvFile(workspace);
  if (options.recordUserMessage ?? true) {
    await appendMessage({
      jobId: claim.job._id,
      role: 'user',
      status: 'completed',
      content: prompt,
    });
  }
  await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');

  let assistantMessageId: Id<'agentJobMessages'> | undefined;
  try {
    workspace.agentTurnActive = true;
    assistantMessageId = await appendMessage({
      jobId: claim.job._id,
      role: 'assistant',
      status: 'streaming',
      content: '',
    });
    const assistantContent = { value: '' };
    // Every runtime now runs its CLI inside the per-user box via its adapter
    // (Codex `codex exec`, OpenCode `opencode run`); there is no per-job side
    // container. `runtimeMode` keeps its legacy label per runtime for the UI.
    const messageId = assistantMessageId;
    const runtimeMode =
      workspace.runtime === 'codex' ? 'codex_exec' : 'opencode_server';
    workspace.runtimeMode = runtimeMode;
    await appendEvent(
      claim.job._id,
      'info',
      'plan',
      `Starting ${workspace.runtime} turn in the workspace box.`,
    );
    await setRuntimeSession({
      jobId: claim.job._id,
      agentRuntimeMode: runtimeMode,
      codexSessionId: workspace.codexSessionId,
      opencodeSessionId: workspace.opencodeSessionId,
    });
    if (workspace.cancelRequested) {
      throw new Error('Workspace cancellation requested.');
    }
    const onEvent = (event: NormalizedAgentEvent) =>
      handleAgentEvent({
        workspace,
        event,
        assistantMessageId: messageId,
        assistantContent,
      });
    const adapter = getAdapter(workspace.runtime);
    const result = await adapter.runTurn(workspace, prompt, onEvent);
    if (result.sessionId) {
      await persistRuntimeSession(workspace, result.sessionId);
    }
    const recoveredText = result.finalMessage
      ? truncate(redact(result.finalMessage), 40_000)
      : undefined;
    const outcome = resolveTurnOutcome({
      assistantText: assistantContent.value,
      recoveredText,
      error: result.error,
      runtime: workspace.runtime,
    });
    // Persist any recovered finalMessage text so the user still sees it, even
    // when the turn ultimately failed with partial output.
    if (outcome.text !== assistantContent.value) {
      assistantContent.value = outcome.text;
      await updateMessage({
        messageId,
        content: assistantContent.value,
        status: 'streaming',
      });
    }
    // A hard failure (nonzero exit / failure event) surfaces the turn as failed
    // even when partial assistant text streamed — the catch path marks the
    // message failed and appends the error event. This applies to every runtime,
    // so a nonzero-exit OpenCode turn with partial text also surfaces as failed.
    if (outcome.failure) {
      if (!assistantContent.value.trim()) {
        console.error(
          `${workspace.runtime} completed without producing an assistant response for job ${claim.job._id}.`,
        );
      }
      throw new Error(outcome.failure);
    }
    await updateMessage({
      messageId,
      status: 'completed',
      content: assistantContent.value,
    });
    workspace.agentTurnActive = false;
    console.log(
      `${workspace.runtime} turn completed for job ${claim.job._id}; response length=${assistantContent.value.length}`,
    );
    if (claim.job.jobType === 'maintenance_review') {
      const decision = parseMaintenanceDecision(assistantContent.value);
      if (decision) {
        await addArtifact({
          jobId: claim.job._id,
          kind: 'summary',
          title: 'Maintenance decision',
          content: JSON.stringify(decision, null, 2),
          contentType: 'application/json',
        });
        await applyMaintenanceDecision(claim.job._id, decision);
        await stopWorkspace(claim.job._id).catch((error: unknown) => {
          console.error(error);
        });
        return;
      } else {
        await updateStatus(claim.job._id, 'changes_ready', {
          summary:
            'The agent completed the review, but Spoon could not parse a structured maintenance decision.',
        });
      }
    }
    const diff = await getWorktreeDiff(workspace.repoDir, redact);
    await addArtifact({
      jobId: claim.job._id,
      kind: 'diff',
      title: 'Git diff',
      content: truncate(diff.output, 200_000),
      contentType: 'text/x-diff',
    });
    await recordChangedFiles(workspace, 'agent', diff.output);
  } catch (error) {
    workspace.agentTurnActive = false;
    workspace.resolveTurn?.();
    workspace.resolveTurn = undefined;
    const message = error instanceof Error ? error.message : String(error);
    console.error(`Agent turn failed for job ${claim.job._id}: ${message}`);
    await appendEvent(
      claim.job._id,
      'error',
      'cleanup',
      truncate(redact(message), 20_000),
    );
    if (assistantMessageId) {
      await updateMessage({
        messageId: assistantMessageId,
        status: 'failed',
        content: truncate(redact(message), 40_000),
      });
    } else {
      await appendMessage({
        jobId: claim.job._id,
        role: 'assistant',
        status: 'failed',
        content: truncate(redact(message), 40_000),
      });
    }
    throw error;
  }
};

const teardownActiveWorkspace = async (args: {
  jobId: string;
  workspace: ActiveWorkspace;
  abortAgent: boolean;
  appendCancellationEvent?: boolean;
  workspaceStatus?: 'stopped' | 'expired' | 'failed';
}) => {
  const { jobId, workspace } = args;
  if (args.abortAgent) workspace.cancelRequested = true;
  // The per-user box is shared across a user's threads and reference-counted by
  // the box registry, so teardown only aborts the turn and releases the box
  // handle — there is no per-job container to stop or agent session to close.
  return await teardownWorkspace({
    stopHeartbeat: () => stopHeartbeat(jobId),
    removeActive: () => activeWorkspaces.delete(jobId),
    appendEvent: args.appendCancellationEvent
      ? async () =>
          await appendEvent(
            workspace.claim.job._id,
            'warn',
            'cleanup',
            'Cancellation requested; stopping workspace.',
          )
      : undefined,
    abortAgent: args.abortAgent
      ? async () => await abortWorkspaceAgentForWorkspace(workspace)
      : undefined,
    removeMaterializedEnv: async () =>
      await removeMaterializedEnv(workspace.materializedEnvPath),
    markStopped: async () =>
      await markWorkspaceStopped(workspace.claim.job._id, args.workspaceStatus),
    release: () => workspace.boxHandle.release(),
  });
};

const reportTeardownErrors = (errors: unknown[]) => {
  for (const error of errors) console.error(error);
};

const cancelWorkspace = async (jobId: string) => {
  const workspace = activeWorkspaces.get(jobId);
  if (!workspace) {
    stopHeartbeat(jobId);
    return;
  }
  const result = await teardownActiveWorkspace({
    jobId,
    workspace,
    abortAgent: true,
    appendCancellationEvent: true,
  });
  reportTeardownErrors(result.errors);
};

export const openWorkspacePullRequest = async (jobId: string) => {
  const workspace = resolveWorkspace(jobId);
  const { claim, repoDir, redact } = workspace;
  await ensureNoEnvFilesStaged(workspace);
  const status = await getStatus(repoDir, redact);
  if (!status.output.trim()) {
    throw new Error('No changes are ready for a draft PR.');
  }
  const diff = await getWorktreeDiff(repoDir, redact);
  const settings = claim.agentSettings;
  const detected = await detectPackageCommands(repoDir);
  const installCommand = settings?.installCommand ?? detected.install;
  const checkCommand = settings?.checkCommand ?? detected.check;
  const testCommand = settings?.testCommand ?? detected.test;
  const prBody = buildPrBody({
    prompt: claim.job.prompt,
    summary: 'Interactive Spoon agent workspace changes.',
    commands: [installCommand, checkCommand, testCommand].filter(
      (command): command is string => Boolean(command),
    ),
    limitations: ['Review the draft PR before merging.'],
  });
  await addArtifact({
    jobId: claim.job._id,
    kind: 'diff',
    title: 'Final Git diff',
    content: truncate(diff.output, 200_000),
    contentType: 'text/x-diff',
  });
  await addArtifact({
    jobId: claim.job._id,
    kind: 'pr_body',
    title: 'Draft PR body',
    content: prBody,
    contentType: 'text/markdown',
  });
  const commitSha = await commitAndPush({
    repoDir,
    workBranch: claim.job.workBranch,
    message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
    redact,
    timeoutMs: env.jobTimeoutMs,
  });
  if (!claim.github.installationId) {
    throw new Error('GitHub installation ID is missing.');
  }
  const pullRequest = await openDraftPullRequest({
    installationId: claim.github.installationId,
    forkOwner: claim.job.forkOwner,
    forkRepo: claim.job.forkRepo,
    baseBranch: claim.job.baseBranch,
    workBranch: claim.job.workBranch,
    title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
    body: prBody,
  });
  await completeWithDraftPr({
    jobId: claim.job._id,
    commitSha,
    pullRequestUrl: pullRequest.html_url,
    pullRequestNumber: pullRequest.number,
    summary: 'Draft PR opened from interactive workspace.',
  });
  const teardown = await teardownActiveWorkspace({
    jobId,
    workspace,
    abortAgent: false,
  });
  if (teardown.errors.length > 0) {
    throw new AggregateError(teardown.errors, 'Workspace teardown failed.');
  }
  return {
    pullRequestUrl: pullRequest.html_url,
    pullRequestNumber: pullRequest.number,
  };
};

export const stopWorkspace = async (jobId: string) => {
  const workspace = resolveWorkspace(jobId);
  const teardown = await teardownActiveWorkspace({
    jobId,
    workspace,
    abortAgent: true,
  });
  if (teardown.errors.length > 0) {
    throw new AggregateError(teardown.errors, 'Workspace teardown failed.');
  }
  return { success: true };
};

export const getWorkerHealth = async () => {
  const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({
    jobId,
    runtimeMode: workspace.runtime,
    boxName: workspace.boxName,
    workdir: workspace.workdir,
    agentTurnActive: Boolean(workspace.agentTurnActive),
  }));
  const boxContainers = await listWorkspaceContainerNames('spoon-box-');
  return {
    ok: true,
    buildSha: env.buildSha,
    buildCreatedAt: env.buildCreatedAt,
    workerId: env.workerId,
    convexUrl: env.convexUrl,
    runtime: env.runtime,
    containerRuntime: env.containerRuntime,
    jobImage: env.jobImage,
    workdir: env.workdir,
    hostWorkdir: env.hostWorkdir,
    network: env.network,
    httpPort: env.httpPort,
    jobTimeoutMs: env.jobTimeoutMs,
    activeWorkspaceCount: active.length,
    activeWorkspaces: active,
    boxContainers,
  };
};

export const cleanupOrphanedWorkspaces = async () => {
  // Per-user boxes are shared and reference-counted, so cleanup no longer force
  // removes containers here — it only reclaims orphaned workdirs and reports the
  // live boxes. (Job containers no longer exist.)
  const activeWorkdirs = new Set(
    [...activeWorkspaces.values()].map((workspace) =>
      path.resolve(workspace.workdir),
    ),
  );

  const root = path.resolve(env.workdir);
  const removedWorkdirs: string[] = [];
  try {
    const rootDirents = await readdir(root, { withFileTypes: true });
    const rootEntries = rootDirents.map((d) => ({
      name: d.name,
      isDirectory: d.isDirectory(),
    }));
    const homesRoot = path.join(root, 'homes');
    const homesEntries: Record<
      string,
      { name: string; isDirectory: boolean }[]
    > = {};
    const codeLeaves: Record<string, string[]> = {};
    const homesDirents = await readdir(homesRoot, {
      withFileTypes: true,
    }).catch(() => []);
    for (const userDir of homesDirents) {
      if (!userDir.isDirectory()) continue;
      const username = userDir.name;
      const codeRoot = path.join(homesRoot, username, 'Code');
      homesEntries[username] = [];
      const leaves: string[] = [];
      const spoons = await readdir(codeRoot, { withFileTypes: true }).catch(
        () => [],
      );
      for (const spoonDir of spoons) {
        if (!spoonDir.isDirectory()) continue;
        const spoonRoot = path.join(codeRoot, spoonDir.name);
        const branches = await readdir(spoonRoot, {
          withFileTypes: true,
        }).catch(() => []);
        for (const branchDir of branches) {
          if (branchDir.isDirectory()) {
            leaves.push(path.join(spoonRoot, branchDir.name));
          }
        }
      }
      codeLeaves[username] = leaves;
    }
    const { removeDirs } = planWorkdirCleanup({
      root,
      rootEntries,
      homesEntries,
      codeLeaves,
      activeWorkdirs,
      runningBoxUsernames: runningBoxUsernames(),
    });
    for (const target of removeDirs) {
      await rm(target, { recursive: true, force: true });
      removedWorkdirs.push(target);
    }
  } catch (error) {
    const code = error && typeof error === 'object' ? 'code' in error : false;
    if (!code || (error as { code?: string }).code !== 'ENOENT') {
      throw error;
    }
  }

  const boxContainers = await listWorkspaceContainerNames('spoon-box-');
  return { success: true, removedWorkdirs, boxContainers };
};

export const startWorker = async () => {
  console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
  for (;;) {
    try {
      const claim = await client.action(api.agentJobsNode.claimNextForWorker, {
        workerId: env.workerId,
        workerToken: env.workerToken,
      });
      if (!claim) {
        await sleep(env.pollMs);
        continue;
      }
      // The generated `Doc<'agentJobs'>.runtime` still includes the legacy
      // `openai_direct` literal (queue-time validation now excludes it), so
      // narrow to the worker's codex/opencode/claude `Claim` shape here.
      await runClaim(claim as Claim);
    } catch (error) {
      console.error(error);
      await sleep(env.pollMs);
    }
  }
};
