#!/bin/bash
set -eo pipefail
export HOME=/home/conveyor
export PATH="/home/conveyor/.bun/bin:${PATH}"
# Suppress the "new major version of npm available" notice — it writes to
# stderr during `npm view`, and if we ever capture stderr into a version
# string it corrupts the semver and breaks the agent install.
export NO_UPDATE_NOTIFIER=1

exec > >(tee -a /tmp/claudespace-bootstrap.log) 2>&1

start_workspace_sshd() {
  local ssh_port="${CONVEYOR_WORKSPACE_SSH_PORT:-2222}"
  mkdir -p /home/conveyor/.ssh
  chmod 700 /home/conveyor/.ssh
  touch /home/conveyor/.ssh/authorized_keys
  chmod 600 /home/conveyor/.ssh/authorized_keys
  if command -v sudo >/dev/null 2>&1 && [ -x /usr/sbin/sshd ]; then
    sudo mkdir -p /run/sshd
    # Bind all interfaces: preview-router tunnels in from outside the pod to the
    # pod IP. Access is gated by the attach token + SSH public-key auth, not by
    # network reachability (the pod has no public ingress).
    if sudo /usr/sbin/sshd -o "ListenAddress=0.0.0.0" -o "Port=${ssh_port}"; then
      echo "[pool] Workspace SSHD listening on 0.0.0.0:${ssh_port}"
    else
      echo "[pool] WARN: workspace SSHD failed to start"
    fi
  else
    echo "[pool] WARN: workspace SSHD unavailable"
  fi
}

# ── Sidecar readiness is handled by the agent, not here ──
# The postgres / firebase wait moved into `conveyor-agent` (setup waitForSidecars())
# so it gates only the project setupCommand/startCommand and never the agent's
# thinking loop — even on-demand pods now launch the agent without blocking on the
# slow firebase emulator. The agent keeps separate per-target deadlines here
# (postgres 30s, firebase 60s).

# ── Refresh agent runner to latest before launch (payload-independent) ──
# The image version can be stale vs. what the API expects (protocol/contract
# drift causes instant exit-1 crashes). Only install if the registry's `latest`
# is *strictly greater* than what's already in the image. If the image version
# is ahead (e.g. a dev pod image from an unreleased commit), never downgrade.
# `npm view` is a metadata-only fetch (~1s) vs. a full install (~30-60s). Hoisted
# into pre-warm so a claimed warm pod never pays this on the critical path.
refresh_agent_version() {
  local _installed_agent _npm_view_stderr _npm_view_output _npm_view_rc
  local _npm_view_err_content _latest_agent_raw _latest_agent _higher
  _installed_agent=$(conveyor-agent --version 2>/dev/null | tr -d '[:space:]' || echo "")
  # The image's /home/conveyor/.npmrc pins `@rallycry:registry=https://npm.pkg.github.com/`
  # for other private @rallycry packages, but conveyor-agent itself is published
  # to public npmjs.org. Override the scope on the command line so this lookup
  # hits the right registry.
  #
  # Capture stdout and stderr SEPARATELY. Mixing them (2>&1) was a disaster:
  # npm's update notifier (+ random future notices) write to stderr, and after
  # `tr -d '[:space:]'` the version and the notice ran together into a garbage
  # string like "7.0.12npmnoticenpmnotice..." — which then got fed to
  # `npm install @rallycry/conveyor-agent@<garbage>`, which failed partway
  # through and left the pod with NO conveyor-agent binary at all (crashloop).
  # NO_UPDATE_NOTIFIER=1 is set at the top of this script as belt-and-suspenders,
  # but don't rely on it.
  #
  # The if/else form on the assignment is required because `set -e` aborts on
  # a failing command in assignment context, and a trailing `|| true` would
  # mask the real exit code.
  # The baked /home/conveyor/.npm cache dir is owned by root (npm ran as root
  # during the image build), but the entrypoint runs as uid 1001 — so a non-sudo
  # `npm view` fails with EACCES trying to mkdir its _cacache. Point every npm
  # invocation here at a world-writable cache dir so the version lookup works.
  local _npm_cache=/tmp/npm-cache
  _npm_view_stderr=$(mktemp 2>/dev/null || echo "/tmp/npmview.$$.err")
  if _npm_view_output=$(npm view --cache "$_npm_cache" \
      --@rallycry:registry=https://registry.npmjs.org/ \
      @rallycry/conveyor-agent version 2>"$_npm_view_stderr"); then
    _npm_view_rc=0
  else
    _npm_view_rc=$?
  fi
  _npm_view_err_content=$(cat "$_npm_view_stderr" 2>/dev/null || echo "")
  rm -f "$_npm_view_stderr" 2>/dev/null || true
  _latest_agent_raw=$(printf '%s' "${_npm_view_output}" | tr -d '[:space:]')
  # Validate the captured value looks like a semver (x.y.z with optional
  # prerelease/build metadata). Anything else → treat as a failed lookup and
  # keep the image version. This is the safety net that prevents us from ever
  # running `npm install @rallycry/conveyor-agent@<corrupted>` again.
  if [ $_npm_view_rc -eq 0 ] && [[ "${_latest_agent_raw}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][A-Za-z0-9.-]+)?$ ]]; then
    _latest_agent="${_latest_agent_raw}"
  else
    _latest_agent=""
  fi
  if [ -z "${_latest_agent}" ]; then
    echo "[pool] WARNING: failed to get valid semver from npm for @rallycry/conveyor-agent (rc=${_npm_view_rc}), keeping image version ${_installed_agent:-unknown}"
    echo "[pool]   npm view stdout: ${_npm_view_output}"
    echo "[pool]   npm view stderr: ${_npm_view_err_content}"
    echo "[pool]   raw value after whitespace strip: '${_latest_agent_raw}'"
    echo "[pool]   npm binary: $(command -v npm || echo 'not found'), node: $(command -v node || echo 'not found'), HOME=${HOME:-unset}"
  elif [ -z "${_installed_agent}" ]; then
    echo "[pool] No agent in image, installing @rallycry/conveyor-agent@${_latest_agent}..."
    sudo npm install -g --cache "$_npm_cache" --silent "@rallycry/conveyor-agent@${_latest_agent}" 2>&1 \
      || echo "[pool] WARNING: agent install failed"
    echo "[pool] Agent version: $(conveyor-agent --version 2>&1 || echo unknown)"
  elif [ "${_installed_agent}" = "${_latest_agent}" ]; then
    echo "[pool] Agent version: ${_installed_agent} (matches published, skipping install)"
  else
    # Semver compare via `sort -V`. Highest version is the last line.
    _higher=$(printf '%s\n%s\n' "${_installed_agent}" "${_latest_agent}" | sort -V | tail -n1)
    if [ "${_higher}" = "${_installed_agent}" ]; then
      echo "[pool] Agent version: ${_installed_agent} (ahead of published ${_latest_agent}, skipping install)"
    else
      echo "[pool] Updating @rallycry/conveyor-agent ${_installed_agent} → ${_latest_agent}..."
      sudo npm install -g --cache "$_npm_cache" --silent "@rallycry/conveyor-agent@${_latest_agent}" 2>&1 \
        || echo "[pool] WARNING: agent update failed, falling back to image version ${_installed_agent}"
      echo "[pool] Agent version: $(conveyor-agent --version 2>&1 || echo unknown)"
    fi
  fi
}

# ── Reclaim any root-owned $HOME config/state entries (bake defense-in-depth) ──
# Bake steps that run as root while ENV HOME points at /home/conveyor can leave
# root-owned entries under ~/.config / ~/.local (a uv install receipt in
# ~/.config once bricked every pod boot fleet-wide). This reclaims them so a
# baked ownership slip can't EACCES the entrypoint under `set -e`.
#
# CONDITIONAL, not `chown -R`: a `find … ! -user conveyor` walk is stat-only when
# the tree is already clean (the common case since the bake-side `HOME=/root`
# fix in 9815c8915 — current images carry ZERO root-owned files here), so it
# triggers no overlayfs copy-up. The former unconditional `chown -R` copied up
# ALL ~3.9k baked files (~63MB, dominated by ~/.config/opencode's 3,652 files)
# on EVERY boot even when nothing was root-owned — ~16s of I/O-bound
# uninterruptible-disk-sleep that reclaimed nothing, and the true owner of the
# "GCS-FUSE" boot gap. Runs in the PRE-BIND phase below (payload-independent, off
# the time-to-agent critical path) and before any mount symlinks exist, so it
# never follows into the FUSE mount. See
# docs/investigations/gcs-fuse-user-home-boot-cost-2026-07-15.md.
reclaim_home_ownership() {
  local d
  for d in /home/conveyor/.config /home/conveyor/.local; do
    [ -d "${d}" ] || continue
    sudo -n find "${d}" ! -user conveyor -exec chown conveyor:conveyor {} + 2>/dev/null || true
  done
}

# ── Container role (workbench split) ──
# Split-mode pods run TWO app containers from this same entrypoint:
#   agent     — supervisor + conveyor-agent only (protected, restartPolicy
#               Never semantics); no repo, no sshd, no git prep.
#   workbench — the workspace: git prep, sshd, claude + all workloads behind
#               the launcher daemon (native sidecar, restartPolicy Always).
# Unset = today's monolith container; every guard below must be a no-op then.
CONTAINER_ROLE="${CONVEYOR_CONTAINER_ROLE:-}"

# Split-mode pods: SSH attach lands in the WORKBENCH container (the workspace
# lives there); the agent container skips sshd.
if [ "${CONTAINER_ROLE}" != "agent" ]; then
  start_workspace_sshd
fi

# ── Required env vars (injected by pod spec — v3: exactly these two) ──
: "${CONVEYOR_API_URL:?CONVEYOR_API_URL is required}"
: "${POD_BOOTSTRAP_TOKEN:?POD_BOOTSTRAP_TOKEN is required}"

# Pod name is the instance identifier
INSTANCE_NAME="${HOSTNAME}"
export CLAUDESPACE_NAME="${INSTANCE_NAME}"

# ═══════════════════════════════════════════════════════════════════════════
# PRE-BIND PHASE — payload-independent work only. v3 pods do not have repo,
# GitHub, or task credentials until the bootstrap endpoint returns 200.
# ═══════════════════════════════════════════════════════════════════════════
PREWARM_DEV_LOOP_PID=""
refresh_agent_version
# Reclaim root-owned $HOME entries here, during standby, so the common (clean)
# case costs a stat-only walk off the critical path instead of a ~16s copy-up
# `chown -R` after the bundle arrives.
reclaim_home_ownership

# ═══════════════════════════════════════════════════════════════════════════
# PULL-BASED BOOTSTRAP — poll until the reconciler binds this pod to a
# Workspace. 204 = still unbound; 200 = full bundle.
# ═══════════════════════════════════════════════════════════════════════════
# Bash runs as PID 1: without a trap, SIGTERM is ignored during standby and
# every pool drain/scale-down rides the full terminationGracePeriod (180s) to
# SIGKILL. The agent-phase trap installed later replaces this one.
trap 'echo "[boot] SIGTERM during standby — exiting."; exit 0' TERM INT
echo "[boot] Entering standby — polling for bootstrap bind..."
POLL_COUNTER=0
BOOTSTRAP_JSON=""
while true; do
  # The response body carries credentials — pre-create it owner-only so no
  # window exists where another uid could read it.
  rm -f /tmp/bootstrap-response.json
  (umask 077 && touch /tmp/bootstrap-response.json)
  # No `-f` on curl: with -f an HTTP-error response still prints the -w
  # write-out AND exits 22, so `|| echo "000"` produced "401000" — the 401
  # fail-fast branch below could never match and a deleted pod polled forever.
  # Without -f curl exits 0 on any HTTP response and -w yields the clean
  # status; the `|| echo "000"` fires only on pure network errors.
  HTTP_STATUS=$(curl -s -o /tmp/bootstrap-response.json -w '%{http_code}' \
    -H "Authorization: Bearer ${POD_BOOTSTRAP_TOKEN}" \
    "${CONVEYOR_API_URL}/api/v3/pods/bootstrap" 2>/dev/null || echo "000")

  if [ "${HTTP_STATUS}" = "200" ]; then
    BOOTSTRAP_JSON=$(cat /tmp/bootstrap-response.json)
    rm -f /tmp/bootstrap-response.json
    echo "[boot] Bound — bootstrap bundle received!"
    break
  elif [ "${HTTP_STATUS}" = "401" ]; then
    echo "[boot] ERROR: bootstrap token rejected (401) — pod identity invalid, exiting." >&2
    exit 1
  fi
  rm -f /tmp/bootstrap-response.json

  POLL_COUNTER=$((POLL_COUNTER + 1))
  if [ $((POLL_COUNTER % 30)) -eq 0 ]; then
    echo "[boot] Still waiting for bind (poll #${POLL_COUNTER})..."
  fi
  sleep 2
done

# `// empty` on every extraction: bare `.field` renders a missing/null field
# as the literal string "null", which then flows into git URLs and env vars.
export CONVEYOR_TASK_TOKEN=$(echo "${BOOTSTRAP_JSON}" | jq -r '.sessionJwt // empty')
export CONVEYOR_GITHUB_TOKEN=$(echo "${BOOTSTRAP_JSON}" | jq -r '.githubToken // empty')
# gh CLI auth (baked into the base image). GH_TOKEN (not GITHUB_TOKEN — that
# name leaks into too many third-party tools) points gh at the same role-scoped
# installation token git uses. Caveat: installation tokens live ~1h and the
# agent's refresh path updates the git remote URL, not this env — a long-lived
# session's gh calls can 401 after expiry; agents should treat that as
# "re-check via MCP tools", not an auth bug to debug.
export GH_TOKEN="${CONVEYOR_GITHUB_TOKEN}"
REPO_OWNER=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.repoOwner // empty')
REPO_NAME=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.repoName // empty')
BRANCH=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.branch // empty')
BASE_BRANCH=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.baseBranch // empty')
CHECKOUT_REF=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.checkoutRef // empty')
export REPO_OWNER REPO_NAME BRANCH

# Decode non-secret session identity fields from the JWT payload so the
# existing agent startup contract remains unchanged.
SESSION_CLAIMS=$(node -e 'const t=process.argv[1].split(".")[1]||""; const s=t.replace(/-/g,"+").replace(/_/g,"/"); const p=s+"=".repeat((4-s.length%4)%4); process.stdout.write(Buffer.from(p,"base64").toString("utf8"));' "${CONVEYOR_TASK_TOKEN}" 2>/dev/null || echo "{}")
# CONVEYOR_TASK_ID is exported ONLY when the claim exists: a task-less PROJECT
# session JWT has no taskId claim, and the agent keys its project-runner mode
# off the var being ABSENT (never an empty-string export).
CONVEYOR_TASK_ID=$(echo "${SESSION_CLAIMS}" | jq -r '.taskId // empty' 2>/dev/null || true)
if [ -n "${CONVEYOR_TASK_ID}" ]; then
  export CONVEYOR_TASK_ID
else
  unset CONVEYOR_TASK_ID
fi
# projectId claim is present only on task-less project session JWTs.
CONVEYOR_PROJECT_ID_CLAIM=$(echo "${SESSION_CLAIMS}" | jq -r '.projectId // empty' 2>/dev/null || true)
if [ -n "${CONVEYOR_PROJECT_ID_CLAIM}" ]; then
  export CONVEYOR_PROJECT_ID="${CONVEYOR_PROJECT_ID_CLAIM}"
fi
export CONVEYOR_SESSION_ID=$(echo "${SESSION_CLAIMS}" | jq -r '.sessionId // empty')
export CONVEYOR_WORKSPACE_ID=$(echo "${SESSION_CLAIMS}" | jq -r '.workspaceId // empty')
SESSION_MODE=$(echo "${SESSION_CLAIMS}" | jq -r '.mode // empty')
unset SESSION_CLAIMS
if [ "${SESSION_MODE}" = "review" ]; then
  # Review sessions run the PR-review runner. Keyed on the session MODE claim,
  # not the role: review sessions are writers (they need push access to the PR
  # branch), so a role check can't identify them — gating on role=reader here
  # is what let review pods fall through to the default task runner and boot
  # the parent's task agent in discovery mode. The agent CLI's RunnerMode is
  # spelled "code-review" (cli.ts validates task|pm|code-review|adhoc|pack); the bare
  # "review" is the agentMode/tag axis, NOT a runner mode — passing it here
  # made the agent exit "Invalid CONVEYOR_MODE" and crash-loop every 10s.
  export CONVEYOR_MODE="code-review"
elif [ "${SESSION_MODE}" = "pack" ]; then
  # Parent-card orchestrator (task-bound — CONVEYOR_TASK_ID is set): the agent
  # runs the autonomous pack loop (start ready children, merge child PRs,
  # complete the parent) instead of building code. Same task-mode lifecycle,
  # different prompt/tool surface — see conveyor-agent pack-runner-prompt.ts.
  export CONVEYOR_MODE="pack"
elif [ "${SESSION_MODE}" = "adhoc" ]; then
  # Task-less USER SCRATCH pod (Sessions view): the agent runs an interactive
  # `claude` TUI relayed to the web terminal — no autonomous loop, no task. Must
  # be checked BEFORE the project branch below: an adhoc session is also
  # task-less with a projectId claim, but it must NOT boot the pm project runner.
  export CONVEYOR_MODE="adhoc"
elif [ -z "${CONVEYOR_TASK_ID:-}" ] && [ -n "${CONVEYOR_PROJECT_ID_CLAIM}" ]; then
  # Task-less project pod: the agent boots the project runner in pm mode
  # (see conveyor-agent setup/project-identity.ts — pm is required).
  export CONVEYOR_MODE="pm"
fi

# ── Export bundle env vars (secrets, OAuth tokens, project config) ──
ENV_KEYS=$(echo "${BOOTSTRAP_JSON}" | jq -r '.envVars // {} | keys[]' 2>/dev/null)
if [ -n "${ENV_KEYS}" ]; then
  ENV_COUNT=0
  while IFS= read -r key; do
    # A bundle key that isn't a valid shell identifier would make `export`
    # eval arbitrary content, and a handful of names would hijack the boot
    # itself (PATH swaps every binary below; LD_PRELOAD injects code into
    # them). Project env is user-supplied — validate, never trust.
    if ! [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
      echo "[boot] WARN: skipping invalid env key from bundle: '${key}'"
      continue
    fi
    case "${key}" in
      PATH|HOME|LD_PRELOAD|SHELL)
        echo "[boot] WARN: skipping denylisted env key from bundle: '${key}'"
        continue
        ;;
    esac
    value=$(echo "${BOOTSTRAP_JSON}" | jq -r --arg k "${key}" '.envVars[$k] // empty')
    export "${key}=${value}"
    ENV_COUNT=$((ENV_COUNT + 1))
  done <<< "${ENV_KEYS}"
  echo "[boot] Injected ${ENV_COUNT} env vars from bootstrap bundle"
fi

# ── Boot timing instrumentation (env-inject → agent-launch breakdown) ──
# A ~16s window between "Injected env vars" and "Linking Claude state" was
# root-caused (2026-07-16) to the unconditional root-owned-$HOME `chown -R` that
# used to run here — NOT GCS-FUSE (the mount is idle during the gap). That chown
# is now a conditional pre-bind reclaim (see reclaim_home_ownership). These marks
# stay as a regression probe: they attribute the gap to each sub-step —
# mount-readiness stat vs first metadata write vs symlink wiring — so if the
# total ever balloons again a canary boot shows WHERE. EPOCHREALTIME is
# bash-native (no per-mark subprocess); the `//[.,]/` strip tolerates
# comma-decimal locales. Grep the boot log for `[boot-timing]`. See
# docs/investigations/gcs-fuse-user-home-boot-cost-2026-07-15.md.
BOOT_MARK_T0_US="${EPOCHREALTIME//[.,]/}"
boot_mark() {
  if [ -z "${EPOCHREALTIME:-}" ]; then echo "[boot-timing] $1"; return 0; fi
  local now_us="${EPOCHREALTIME//[.,]/}"
  local delta_us=$(( now_us - BOOT_MARK_T0_US ))
  printf '[boot-timing] +%d.%03ds %s\n' "$(( delta_us / 1000000 ))" "$(( (delta_us / 1000) % 1000 ))" "$1"
}
boot_mark "env injected — entering user-home wiring"

export ANTHROPIC_API_KEY=$(echo "${BOOTSTRAP_JSON}" | jq -r '.anthropicKey // empty')
export CLOUDSDK_AUTH_ACCESS_TOKEN=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gcpToken // empty')
# WorkPreservation GCS tier (spec §Durability & snapshots): the upload URL is
# long-lived (used for the pod's whole lifetime of periodic captures + sleep
# finalize); the download URL is present only when a snapshot exists and is
# consumed once by restoreOnBoot.
CONVEYOR_SNAPSHOT_UPLOAD_URL=$(echo "${BOOTSTRAP_JSON}" | jq -r '.snapshotUploadUrl // empty')
CONVEYOR_SNAPSHOT_URL=$(echo "${BOOTSTRAP_JSON}" | jq -r '.snapshotUrl // empty')
export CONVEYOR_SNAPSHOT_UPLOAD_URL CONVEYOR_SNAPSHOT_URL
unset BOOTSTRAP_JSON
# POD_BOOTSTRAP_TOKEN is deliberately NOT unset: it is the credential-refresh
# key. The agent re-polls GET /api/v3/pods/bootstrap with it to swap in a
# fresh githubToken/sessionJwt in place (see agent-connection refresh path).

CONVEYOR_USER_ID="${CONVEYOR_USER_ID:-}"
CONVEYOR_PROJECT_ID_FROM_BUNDLE="${CONVEYOR_PROJECT_ID:-${PROJECT_ID:-}}"
export CONVEYOR_USER_ID

# ── Seed Claude Code's first-run gates ──
# Pre-answer every interactive gate the CLI shows on a fresh (or partially
# initialized) config: the onboarding wizard (theme picker), the
# bypass-permissions warning, and the folder-trust dialog for the repo
# workspace. Any one of these parks a headless TUI forever — seen live
# 2026-07-02 as code reviews stuck at "Is this a project you trust?" until the
# server's cap burned the attempt. conveyor-agent also seeds the same keys before
# each spawn (ensureClaudeOnboarding), but this shell seed is version-independent
# (pods can run older baked agents) and lands before the CLI's first read — and
# it also covers an interactive `claude` opened over an SSH tunnel, which the
# agent seed never runs for. Merge via jq, never clobber: CLI-owned keys (cache
# keys, firstStartTime, other projects' trust entries) survive; a missing or
# corrupt file becomes a fresh seed — Claude Code treats a 0-byte/unparseable
# file as fatal ("Unexpected end of JSON input"), so replacing is a repair. Theme
# only seeds fresh configs (a user-picked theme is never overridden). Every step
# is rescued: config seeding must never kill the entrypoint under `set -eo pipefail`.
seed_claude_json() {
  SEED_TARGET="$1"
  SEED_GATES='{"hasCompletedOnboarding":true,"bypassPermissionsModeAccepted":true,"projects":{"/workspaces/repo":{"hasTrustDialogAccepted":true}}}'
  # Fable is entitlement-gated in the CLI's /model picker via
  # additionalModelOptionsCache, normally populated by a bootstrap fetch the
  # pods' synthesized credentials don't satisfy — without this row the picker
  # never offers Fable in a pod. Appended only when no fable-valued entry
  # exists, so a CLI-written entitlement cache is never clobbered.
  SEED_FABLE_OPTION='{"value":"claude-fable-5[1m]","label":"Fable","description":"Fable 5 - most capable for your hardest and longest-running tasks"}'
  SEED_FABLE_FILTER='if ([.additionalModelOptionsCache[]? | .value? | strings | ascii_downcase | select(contains("fable"))] | length) > 0 then . else .additionalModelOptionsCache = ((.additionalModelOptionsCache // []) + [$fable]) end'
  if [ -f "${SEED_TARGET}" ] && jq -e . "${SEED_TARGET}" >/dev/null 2>&1; then
    if SEED_MERGED="$(jq -c --argjson gates "${SEED_GATES}" --argjson fable "${SEED_FABLE_OPTION}" ". * \$gates | ${SEED_FABLE_FILTER}" "${SEED_TARGET}" 2>/dev/null)" && [ -n "${SEED_MERGED}" ]; then
      printf '%s' "${SEED_MERGED}" > "${SEED_TARGET}" 2>/dev/null || true
    fi
  else
    printf '%s' "${SEED_GATES}" | jq -c --argjson fable "${SEED_FABLE_OPTION}" '. + {theme:"dark", additionalModelOptionsCache: [$fable]}' > "${SEED_TARGET}" 2>/dev/null || true
  fi
}

# ── Wire per-user Claude state from the GCS FUSE mount ──
# When the pod has /mnt/conveyor-users mounted (GCS FUSE CSI), symlink
# ~/.claude, ~/.claude.json, and ~/.config/claude to the user's subdir so
# Claude state persists across all of the user's codespaces without any
# tar/upload/download round-trip. Uses projectId if present, else falls
# back to PROJECT_ID from the pod env.
USER_HOME_MOUNT="/mnt/conveyor-users"
USER_HOME_PROJECT_ID="${CONVEYOR_PROJECT_ID_FROM_BUNDLE:-${PROJECT_ID:-}}"

# Root-owned $HOME reclaim moved OFF this critical path into the pre-bind phase
# (reclaim_home_ownership, called after refresh_agent_version) and made
# conditional — the former unconditional `chown -R` here cost ~16s of copy-up on
# every boot and was the real owner of this "GCS-FUSE" gap. See
# docs/investigations/gcs-fuse-user-home-boot-cost-2026-07-15.md.

# The `[ -d "${USER_HOME_MOUNT}" ]` test below is the first stat against the FUSE
# mount — if the gcsfuse sidecar is still handshaking it blocks here, so the
# mark straddles the readiness probe.
boot_mark "probing user-home mount presence"
if [ -n "${CONVEYOR_USER_ID}" ] && [ -n "${USER_HOME_PROJECT_ID}" ] && [ -d "${USER_HOME_MOUNT}" ]; then
  USER_HOME_ROOT="${USER_HOME_MOUNT}/users/${CONVEYOR_USER_ID}/${USER_HOME_PROJECT_ID}"
  boot_mark "user-home mount ready — linking Claude state"
  echo "[pool] Linking Claude state to ${USER_HOME_ROOT}"
  mkdir -p "${USER_HOME_ROOT}/.claude" "${USER_HOME_ROOT}/.config/claude" 2>/dev/null || true
  boot_mark "mkdir into mount done (first metadata write)"

  # Replace any image defaults with live symlinks into the mount for the SHARED,
  # append-mostly Claude state: transcripts/plans/memory under ~/.claude and
  # ~/.config/claude. These persist across every pod the user runs, and --resume
  # + session history depend on them (see claude-session-persistence.md).
  #
  # ~/.claude.json is deliberately NOT symlinked — it is POD-LOCAL (below). The
  # Claude CLI rewrites that file wholesale at startup and during runs, so a
  # burst of concurrent pods for the same user+project (seen live 2026-07-12: 7
  # pods in 10 minutes) racing one shared file produces last-writer-wins lost
  # updates — the true root cause behind the read-back mismatch that parked a
  # TUI at a startup dialog, and the vector that let one poisoned
  # customApiKeyResponses write brick every future pod. Nothing load-bearing
  # lives only in ~/.claude.json: credentials are in ~/.claude/.credentials.json
  # (pod-local via symlink, see below) and transcripts in ~/.claude/projects.
  # The agent's ensureClaudeOnboarding + the seed below rebuild every first-run
  # gate this file needs, fresh, on each boot.
  rm -rf /home/conveyor/.claude /home/conveyor/.config/claude 2>/dev/null || true
  mkdir -p /home/conveyor/.config 2>/dev/null || true
  ln -sfn "${USER_HOME_ROOT}/.claude"        /home/conveyor/.claude
  ln -sfn "${USER_HOME_ROOT}/.config/claude" /home/conveyor/.config/claude
  boot_mark "~/.claude + ~/.config/claude symlinks wired"

  # Pod-local ~/.claude.json: clear any symlink/file a prior boot or the image
  # baked in, then seed a fresh real file on the pod's OWN disk (local
  # read-after-write is consistent, so no cross-pod race and no read-back skew).
  rm -rf /home/conveyor/.claude.json 2>/dev/null || true
  seed_claude_json /home/conveyor/.claude.json

  # Pod-local ~/.claude/.credentials.json: the TUI and the usage probe
  # authenticate from this file, while the server attributes usage samples to
  # the key stamped on THIS pod's session. As a shared file (it lives inside
  # the symlinked ~/.claude), concurrent pods booted under different keys
  # clobbered it last-writer-wins, so every pod probed whichever ACCOUNT booted
  # most recently — cross-wiring usage attribution between the user's keys
  # (seen live 2026-07-14). Same cure as ~/.claude.json above, except the CLI
  # derives this path from ~/.claude, so the shared dir keeps a SYMLINK to an
  # absolute pod-local path — which resolves per-pod. Racing pods all write the
  # identical symlink value, so the ln itself cannot lose data. Best-effort: on
  # failure the file stays shared and the agent's sampler identity guard still
  # blocks wrong-account attribution.
  CRED_SHARED="${USER_HOME_ROOT}/.claude/.credentials.json"
  CRED_POD_LOCAL="/home/conveyor/.claude-credentials.pod.json"
  # Split-mode pods: the agent container WRITES credentials (harness auth) and
  # claude in the workbench READS them through the shared-dir symlink — so the
  # pod-local file must live on the shared emptyDir. Still pod-local, so the
  # concurrent-pods clobber fix above is preserved.
  if [ -n "${CONVEYOR_SHARED_DIR:-}" ]; then
    CRED_POD_LOCAL="${CONVEYOR_SHARED_DIR}/claude-credentials.pod.json"
  fi
  if [ -f "${CRED_SHARED}" ] && [ ! -L "${CRED_SHARED}" ]; then
    # Legacy shared regular file: carry its contents into this pod so auth
    # survives the cutover; the agent re-synthesizes from its own token at
    # spawn anyway.
    cp "${CRED_SHARED}" "${CRED_POD_LOCAL}" 2>/dev/null || true
  fi
  ln -sfn "${CRED_POD_LOCAL}" "${CRED_SHARED}" 2>/dev/null || true
  chmod 600 "${CRED_POD_LOCAL}" 2>/dev/null || true
  boot_mark "~/.claude.json seeded + credentials repointed"

  # OpenCode state (sessions + config) persists the same way as ~/.claude.
  mkdir -p "${USER_HOME_ROOT}/.local/share/opencode" "${USER_HOME_ROOT}/.config/opencode" 2>/dev/null || true
  rm -rf /home/conveyor/.local/share/opencode /home/conveyor/.config/opencode 2>/dev/null || true
  mkdir -p /home/conveyor/.local/share 2>/dev/null || true
  ln -sfn "${USER_HOME_ROOT}/.local/share/opencode" /home/conveyor/.local/share/opencode
  ln -sfn "${USER_HOME_ROOT}/.config/opencode"      /home/conveyor/.config/opencode
  boot_mark "opencode state linked — user-home wiring complete"
else
  echo "[pool] Skipping user-home symlink (userId='${CONVEYOR_USER_ID}', projectId='${USER_HOME_PROJECT_ID}', mount present: $([ -d "${USER_HOME_MOUNT}" ] && echo yes || echo no))"
  # No persistent home — the CLI reads the pod-local config; seed it there so
  # non-FUSE pods get the same first-run gate suppression as the mounted path.
  seed_claude_json /home/conveyor/.claude.json
  # Split-mode pods without a persistent user-home: ~/.claude must still cross
  # the container boundary (claude writes transcripts in the workbench; the
  # agent's tailer reads them), so it lives on the shared emptyDir.
  if [ -n "${CONVEYOR_SHARED_DIR:-}" ]; then
    mkdir -p "${CONVEYOR_SHARED_DIR}/claude-home/.claude" 2>/dev/null || true
    rm -rf /home/conveyor/.claude 2>/dev/null || true
    ln -sfn "${CONVEYOR_SHARED_DIR}/claude-home/.claude" /home/conveyor/.claude
  fi
  boot_mark "no user-home mount — pod-local seed only"
fi

# ── Wire published graphify bundles from the shared user-home mount ──
# A locally published graph lives in the same GCS-FUSE user-home bucket as
# Claude state, under users/_shared/graphify/<repo>/latest. Export the path
# contract before the agent starts, then bind the files into graphify-out after
# git has prepared the workspace but before the ready marker is released.
configure_graphify_env() {
  if [ "${CONVEYOR_GRAPHIFY_DISABLE:-}" = "1" ]; then
    return 0
  fi

  local slug="${CONVEYOR_GRAPHIFY_SLUG:-${REPO_NAME:-}}"
  if [ -z "${slug}" ]; then
    return 0
  fi

  local primary_root="${USER_HOME_MOUNT}/users/_shared/graphify"
  local legacy_root="${USER_HOME_MOUNT}/_shared/graphify"
  local shared_root="${CONVEYOR_GRAPHIFY_SHARED_ROOT:-}"
  if [ -z "${shared_root}" ]; then
    if [ -d "${primary_root}" ] || [ ! -d "${legacy_root}" ]; then
      shared_root="${primary_root}"
    else
      shared_root="${legacy_root}"
    fi
  fi

  export CONVEYOR_GRAPHIFY_SLUG="${slug}"
  export CONVEYOR_GRAPHIFY_SHARED_ROOT="${shared_root}"
  export CONVEYOR_GRAPHIFY_DIR="${CONVEYOR_GRAPHIFY_DIR:-${shared_root}/${slug}/latest}"
  export CONVEYOR_GRAPHIFY_GRAPH="${CONVEYOR_GRAPHIFY_GRAPH:-${CONVEYOR_GRAPHIFY_DIR}/graph.json}"
}

bind_graphify_bundle() {
  if [ "${CONVEYOR_GRAPHIFY_DISABLE:-}" = "1" ]; then
    echo "[pool] Graphify bind disabled."
    return 0
  fi

  if [ -z "${CONVEYOR_GRAPHIFY_SLUG:-}" ]; then
    return 0
  fi

  local workspace="${CONVEYOR_WORKSPACE:-/workspaces/repo}"
  local source_dir="${CONVEYOR_GRAPHIFY_DIR:-}"
  local graph_file="${CONVEYOR_GRAPHIFY_GRAPH:-}"

  if [ -z "${source_dir}" ] || [ -z "${graph_file}" ]; then
    return 0
  fi

  if [ ! -f "${graph_file}" ]; then
    echo "[pool] Graphify bundle not found for '${CONVEYOR_GRAPHIFY_SLUG}' at ${source_dir}"
    return 0
  fi

  local target_dir="${workspace}/graphify-out"
  if ! mkdir -p "${target_dir}" 2>/dev/null; then
    echo "[pool] WARN: unable to create graphify-out at ${target_dir}"
    return 0
  fi

  local rel
  for rel in graph.json GRAPH_REPORT.md manifest.json .graphify_analysis.json .graphify_labels.json publish-manifest.json cost.json; do
    if [ ! -e "${source_dir}/${rel}" ]; then
      continue
    fi
    if [ -e "${target_dir}/${rel}" ] || [ -L "${target_dir}/${rel}" ]; then
      continue
    fi
    ln -s "${source_dir}/${rel}" "${target_dir}/${rel}" 2>/dev/null || true
  done

  echo "[pool] Bound graphify bundle '${CONVEYOR_GRAPHIFY_SLUG}' into ${target_dir}"
}

# ── Link shared Grimoire skills into the repo's project skill dir ──
# The prebake links grimoire skills into the BAKED image's ~/.claude/skills,
# but the user-home wiring above replaces /home/conveyor/.claude with the
# per-user GCS mount on every mounted pod boot — wiping those links before any
# agent runs (observed fleet-wide 2026-07-10: pods saw only repo-tracked
# skills, none of the shared rc-* set). Link repo-locally instead: Claude
# loads project skills from <workspace>/.claude/skills regardless of where
# $HOME points, the links are pod-local (no writes to the shared mount, no
# cross-pod races), and .gitignore covers them so `git status` stays clean.
# Runs inside mark_git_ready — after checkout is final, before the agent may
# spawn Claude — alongside the graphify bundle bind. Both helpers are
# best-effort: a grimoire failure must never block the git-ready gate.
ensure_grimoire_submodule() {
  local workspace="${CONVEYOR_WORKSPACE:-/workspaces/repo}"
  [ -f "${workspace}/.gitmodules" ] || return 0
  git -C "${workspace}" config --file .gitmodules --get-regexp 'submodule\..*\.path' 2>/dev/null \
    | grep -q '\.claude/grimoire$' || return 0
  # Already materialized (pod-image bake ran conveyor-prebake successfully).
  [ -d "${workspace}/.claude/grimoire/skills" ] && return 0
  if [ -z "${CONVEYOR_GITHUB_TOKEN:-}" ]; then
    echo "[pool] WARN: grimoire submodule absent and no token to fetch it"
    return 0
  fi
  # insteadOf injects the installation token for the submodule's https URL the
  # same way sync_task_branch_to_repo authenticates the main repo remote.
  if _grim_out=$(git -C "${workspace}" \
      -c url."https://x-access-token:${CONVEYOR_GITHUB_TOKEN}@github.com/".insteadOf="https://github.com/" \
      submodule update --init .claude/grimoire 2>&1); then
    echo "[pool] Initialized grimoire submodule"
  else
    echo "[pool] WARN: grimoire submodule init failed (baked pods may lack rc-* skills): ${_grim_out}"
  fi
  return 0
}

link_grimoire_skills() {
  local workspace="${CONVEYOR_WORKSPACE:-/workspaces/repo}"
  local source_dir="${workspace}/.claude/grimoire/skills"
  local target_dir="${workspace}/.claude/skills"
  [ -d "${source_dir}" ] || return 0
  if ! mkdir -p "${target_dir}" 2>/dev/null; then
    echo "[pool] WARN: unable to create ${target_dir}; skipping grimoire skill links"
    return 0
  fi
  local skill_dir name linked=0
  for skill_dir in "${source_dir}"/*/; do
    [ -d "${skill_dir}" ] || continue
    name="$(basename "${skill_dir}")"
    # Never clobber a real (repo-tracked) skill dir with a link.
    if [ -e "${target_dir}/${name}" ] && [ ! -L "${target_dir}/${name}" ]; then
      echo "[pool] WARN: ${target_dir}/${name} exists and is not a symlink; skipping"
      continue
    fi
    ln -sfn "../grimoire/skills/${name}" "${target_dir}/${name}" 2>/dev/null && linked=$((linked + 1))
  done
  echo "[pool] Linked ${linked} grimoire skills into ${target_dir}"
  return 0
}

mark_git_ready() {
  bind_graphify_bundle
  ensure_grimoire_submodule
  link_grimoire_skills
  : > "$GIT_READY_MARKER"
}

configure_graphify_env

# ═══════════════════════════════════════════════════════════════════════════
# WORKSPACE GIT — moved OFF the pre-launch critical path (Claudespace v3).
#
# The agent is launched IMMEDIATELY (below) so the card lights up and setup
# output streams while git runs in the BACKGROUND. `prepare_workspace_git`
# does the full fetch/checkout/merge (task repo) then clones reference repos,
# and signals completion by writing exactly ONE marker file:
#
#   GIT_READY_MARKER  — task repo is up to date; the agent may spawn Claude.
#   GIT_FAILED_MARKER — git preparation errored; the agent surfaces the error
#                       and shuts down WITHOUT operating on a broken/stale repo.
#
# This function runs backgrounded (`prepare_workspace_git &`). It therefore
# must NEVER `exit` (that only kills the subshell, leaving the agent to wait
# on a marker that never arrives) — every error path does
# `echo >&2; printf ... > "$GIT_FAILED_MARKER"; return 1` instead. The
# fail-loud-on-stale-image philosophy is preserved: a failure writes the failed
# marker (was: exit 1), which the agent treats as fatal.
#
# `set -e` interaction: fallible git commands keep the existing
# `if ! _out=$(...); then` guard so a non-zero rc reaches our marker write
# rather than aborting the subshell before it. Every exit path writes exactly
# one marker.
# ═══════════════════════════════════════════════════════════════════════════
GIT_READY_MARKER="/workspaces/.conveyor-git-ready"
GIT_FAILED_MARKER="/workspaces/.conveyor-git-failed"
# Split-mode pods: the markers live on the shared emptyDir so the workbench's
# git prep is visible to the agent container (which never sees /workspaces).
if [ -n "${CONVEYOR_SHARED_DIR:-}" ]; then
  GIT_READY_MARKER="${CONVEYOR_SHARED_DIR}/git-ready"
  GIT_FAILED_MARKER="${CONVEYOR_SHARED_DIR}/git-failed"
fi

reset_tracked_repo_changes_before_assignment_checkout() {
  # The baked/pooled repo is not user-owned until this git gate succeeds. Reset
  # stale tracked image-generated dirt so assignment checkout can move to the
  # requested branch/ref instead of being blocked by files like dependency
  # stamps. Do not `git clean`: untracked prebake artifacts may be intentional.
  # The assignment checkouts additionally pass -f: an UNTRACKED bake artifact
  # can collide with a path the TARGET ref tracks (e.g. a dependency stamp
  # committed on an older PR branch), which this reset cannot clear — checkout
  # then refuses with "untracked working tree files would be overwritten".
  # Forcing is safe here for the same not-user-owned reason.
  if ! _reset_out=$(git -C repo reset --hard HEAD 2>&1); then
    echo "[pool] ERROR: failed to clean tracked repo changes before checkout: ${_reset_out}" >&2
    printf '%s' "pre-checkout reset failed" > "$GIT_FAILED_MARKER"
    return 1
  fi
  return 0
}

# Shared by both the pod-image branch and the repo-present-non-pod-image branch
# of prepare_workspace_git: refresh the remote token, fetch the base branch,
# fetch/checkout the task branch (creating it from base if it doesn't exist on
# origin yet), and merge latest base into it. On success the repo is left
# checked out on the task branch, up to date with base. On any failure it
# writes GIT_FAILED_MARKER and returns 1 — callers must not fall through to
# `: > "$GIT_READY_MARKER"` in that case.
sync_task_branch_to_repo() {
  # Guarded so a set-url failure writes the failed marker instead of aborting
  # the backgrounded subshell (which would leave the agent waiting forever).
  if ! git -C repo remote set-url origin "https://x-access-token:${CONVEYOR_GITHUB_TOKEN}@github.com/${REPO_OWNER}/${REPO_NAME}.git" 2>/dev/null; then
    echo "[pool] ERROR: remote set-url failed for pod-image repo" >&2
    printf '%s' "pod-image remote set-url failed" > "$GIT_FAILED_MARKER"
    return 1
  fi
  # Always fetch latest base so downstream steps (and the agent's
  # syncWithBaseBranch) have an up-to-date origin/<base> to merge from.
  DEV_BRANCH="${BASE_BRANCH}"
  if ! _fetch_dev_out=$(git -C repo fetch origin "+refs/heads/${DEV_BRANCH}:refs/remotes/origin/${DEV_BRANCH}" 2>&1); then
    echo "[pool] WARN: fetch origin/${DEV_BRANCH} failed: ${_fetch_dev_out}" >&2
  fi

  if ! reset_tracked_repo_changes_before_assignment_checkout; then
    return 1
  fi

  if [ -n "${CHECKOUT_REF}" ]; then
    echo "[pool] Fetching checkout ref ${CHECKOUT_REF} for review branch ${BRANCH}..."
    if ! _fetch_checkout_out=$(git -C repo fetch origin "+${CHECKOUT_REF}:refs/remotes/origin/pr-checkout" 2>&1); then
      echo "[pool] ERROR: Repo fetch failed for checkout ref '${CHECKOUT_REF}': ${_fetch_checkout_out}" >&2
      printf '%s' "fetch checkout ref ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
      return 1
    fi
    if ! _checkout_out=$(git -C repo checkout -f -B "${BRANCH}" "refs/remotes/origin/pr-checkout" 2>&1); then
      echo "[pool] ERROR: Repo checkout of ${CHECKOUT_REF} failed: ${_checkout_out}" >&2
      printf '%s' "checkout of ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
      return 1
    fi
  else
    # Fetch the task branch into a local ref explicitly so we can check it out.
    # If the branch doesn't exist on origin, create it from the base branch.
    # Note: if BRANCH equals DEV_BRANCH (deferred-branch case), the fetch above
    # already populated the ref and this is a no-op.
    if ! _fetch_out=$(git -C repo fetch origin "+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>&1); then
      # Check if the error is specifically due to the remote ref not existing
      if echo "${_fetch_out}" | grep -q "couldn't find remote ref"; then
        echo "[pool] Branch missing on origin — creating from base '${DEV_BRANCH}'"
        # Create the branch locally from the base branch
        if ! _create_out=$(git -C repo checkout -f -B "${BRANCH}" "origin/${DEV_BRANCH}" 2>&1); then
          echo "[pool] ERROR: Failed to create branch '${BRANCH}' from '${DEV_BRANCH}': ${_create_out}" >&2
          printf '%s' "create branch ${BRANCH} from ${DEV_BRANCH} failed" > "$GIT_FAILED_MARKER"
          return 1
        fi
        # Push the new branch to origin with -u to set up tracking
        if ! _push_out=$(git -C repo push -u origin "${BRANCH}" 2>&1); then
          echo "[pool] ERROR: Failed to push new branch '${BRANCH}': ${_push_out}" >&2
          printf '%s' "push new branch ${BRANCH} failed" > "$GIT_FAILED_MARKER"
          return 1
        fi
        echo "[pool] Created and pushed new branch '${BRANCH}' from '${DEV_BRANCH}'"
      else
        # Other git error (auth, network, etc.) — fail loud as before
        echo "[pool] ERROR: Repo fetch failed for branch '${BRANCH}': ${_fetch_out}" >&2
        printf '%s' "fetch branch ${BRANCH} failed" > "$GIT_FAILED_MARKER"
        return 1
      fi
    fi
    # Use `checkout -B` to create or reset the local branch tracking origin.
    # This leaves HEAD attached to a real branch (not detached) so `git status`,
    # `git push`, and any tool that keys off branch state work correctly.
    if ! _checkout_out=$(git -C repo checkout -f -B "${BRANCH}" "origin/${BRANCH}" 2>&1); then
      echo "[pool] ERROR: Repo checkout of ${BRANCH} failed: ${_checkout_out}" >&2
      printf '%s' "checkout of ${BRANCH} failed" > "$GIT_FAILED_MARKER"
      return 1
    fi
  fi

  # Merge latest dev into the task branch so the agent starts on up-to-date
  # base code. Skip if the branch already contains origin/dev (fast-path)
  # or if BRANCH is the dev branch itself.
  if [ "${BRANCH}" != "${DEV_BRANCH}" ] && git -C repo rev-parse "origin/${DEV_BRANCH}" >/dev/null 2>&1; then
    if git -C repo merge-base --is-ancestor "origin/${DEV_BRANCH}" HEAD; then
      echo "[pool] Branch already up-to-date with origin/${DEV_BRANCH}"
    elif ! _merge_out=$(git -C repo merge "origin/${DEV_BRANCH}" --no-edit 2>&1); then
      echo "[pool] WARN: merge origin/${DEV_BRANCH} failed, aborting: ${_merge_out}" >&2
      git -C repo merge --abort 2>/dev/null || true
    else
      echo "[pool] Merged origin/${DEV_BRANCH} into ${BRANCH}"
    fi
  fi
  echo "[pool] Repo updated to ${BRANCH}@$(git -C repo rev-parse --short HEAD)"
  return 0
}

prepare_workspace_git() {
  # Update remote URL with fresh token, or clone if pre-clone failed
  cd /workspaces
  if [ "${CONVEYOR_POD_IMAGE}" = "1" ] && [ -d "repo/.git" ] && [ -n "${CONVEYOR_GITHUB_TOKEN}" ]; then
    # Pod image: repo already exists (and pre-warm already fetched origin/dev, so
    # the fetch below is a near-instant fast-forward). Just refresh the remote with
    # the assignment's fresh token and bring the task branch up to date.
    # IMPORTANT: Do NOT silently fall through to the image snapshot when the
    # requested branch can't be fetched/checked out. A stale image repo has
    # bitten us before (old load-env.sh, old scripts, wrong deps) and the
    # symptoms are very hard to diagnose from pod logs. Fail loud instead —
    # now via the failed marker rather than exit 1.
    echo "[pool] Pod image — updating repo to latest (branch=${BRANCH})..."
    if ! sync_task_branch_to_repo; then
      return 1
    fi
    # Task repo is ready HERE — release the gate. The agent (already launched)
    # is polling for this marker before it spawns Claude / runs setup. Reference
    # repos are cloned AFTER this so they never block Claude.
    mark_git_ready
  elif [ -d "repo/.git" ] && [ -n "${CONVEYOR_GITHUB_TOKEN}" ]; then
    # Non-image but repo already present (e.g. a baked image running without
    # CONVEYOR_POD_IMAGE set). Must NOT settle for a bare remote-URL refresh —
    # that would leave the agent running on whatever branch the repo snapshot
    # happened to be on (e.g. dev), silently committing/pushing to the wrong
    # branch. Run the exact same fetch/checkout/merge flow as the pod-image
    # branch so this path ends up in the same checked-out-on-task-branch state.
    echo "[pool] Repo present (non-pod-image) — updating repo to latest (branch=${BRANCH})..."
    if ! sync_task_branch_to_repo; then
      return 1
    fi
    mark_git_ready
  elif [ -n "${CONVEYOR_GITHUB_TOKEN}" ] && [ -n "${REPO_OWNER}" ] && [ -n "${REPO_NAME}" ] && [ -n "${BRANCH}" ]; then
    echo "[pool] Cloning repo post-assignment (pre-clone was missing)..."
    # Guard each clone/fetch/checkout: under `set -e` a bare failing clone would
    # abort the subshell before we can write the failed marker.
    if [ -n "${CHECKOUT_REF}" ]; then
      if ! _clone_out=$(git clone --depth 1 --single-branch --branch "${BASE_BRANCH}" \
          "https://x-access-token:${CONVEYOR_GITHUB_TOKEN}@github.com/${REPO_OWNER}/${REPO_NAME}.git" \
          repo 2>&1); then
        echo "[pool] ERROR: post-assignment clone failed: ${_clone_out}" >&2
        printf '%s' "post-assignment clone failed" > "$GIT_FAILED_MARKER"
        return 1
      fi
      if ! _fetch_out=$(git -C repo fetch origin "+${CHECKOUT_REF}:refs/remotes/origin/pr-checkout" 2>&1); then
        echo "[pool] ERROR: post-assignment fetch of ${CHECKOUT_REF} failed: ${_fetch_out}" >&2
        printf '%s' "post-assignment fetch of ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
        return 1
      fi
      if ! _checkout_out=$(git -C repo checkout -f -B "${BRANCH}" "refs/remotes/origin/pr-checkout" 2>&1); then
        echo "[pool] ERROR: post-assignment checkout of ${CHECKOUT_REF} failed: ${_checkout_out}" >&2
        printf '%s' "post-assignment checkout of ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
        return 1
      fi
    else
      # Clone the BASE branch — it always exists, unlike a brand-new task
      # branch. A naive `clone --branch <task>` dies with "Remote branch not
      # found" when the task branch has never been pushed to origin, which
      # crash-loops the pod on every fresh task. sync_task_branch_to_repo below
      # resolves/creates ${BRANCH} from the clone identically to the pod-image
      # path. Full depth (no --depth 1) so its base-merge never hits "refusing
      # to merge unrelated histories".
      if ! _clone_out=$(git clone --single-branch --branch "${BASE_BRANCH}" \
          "https://x-access-token:${CONVEYOR_GITHUB_TOKEN}@github.com/${REPO_OWNER}/${REPO_NAME}.git" \
          repo 2>&1); then
        echo "[pool] ERROR: post-assignment clone of base '${BASE_BRANCH}' failed: ${_clone_out}" >&2
        printf '%s' "post-assignment clone failed" > "$GIT_FAILED_MARKER"
        return 1
      fi
      if ! sync_task_branch_to_repo; then
        return 1
      fi
    fi
    mark_git_ready
  else
    # No git plan (e.g. task-less pod, or no token/repo). Nothing to prepare —
    # the repo dir already exists (created before launch) and the agent's own
    # git-sync fallback (guarded by CONVEYOR_GIT_READY) never runs on this pod
    # path anyway. Signal ready so the agent doesn't wait out the timeout.
    echo "[pool] No git plan to prepare — marking git ready."
    mark_git_ready
  fi

  # ── Clone reference repos into /workspaces/references (best-effort) ──
  # Populated from REFERENCE_REPOS_JSON (injected via envVars by
  # injectReferenceRepos). Each repo is shallow-cloned as read-only context for
  # the agent. Failures are non-fatal — the task must still proceed even if a
  # reference project's GitHub App is uninstalled or the token mint failed.
  # This runs AFTER the ready marker: references are supplementary context and
  # must NOT block Claude from spawning.
  if [ -n "${REFERENCE_REPOS_JSON:-}" ]; then
    mkdir -p /workspaces/references
    echo "${REFERENCE_REPOS_JSON}" | jq -c '.[]' 2>/dev/null | while IFS= read -r ref; do
      REF_SLUG=$(echo "${ref}" | jq -r '.slug')
      REF_OWNER=$(echo "${ref}" | jq -r '.owner')
      REF_NAME=$(echo "${ref}" | jq -r '.name')
      REF_BRANCH=$(echo "${ref}" | jq -r '.branch // "main"')
      REF_TOKEN=$(echo "${ref}" | jq -r '.token // empty')
      if [ -z "${REF_TOKEN}" ] || [ -z "${REF_SLUG}" ] || [ -z "${REF_OWNER}" ] || [ -z "${REF_NAME}" ]; then
        continue
      fi
      if [ -d "/workspaces/references/${REF_SLUG}/.git" ]; then
        continue
      fi
      if git clone --depth 1 --single-branch --branch "${REF_BRANCH}" \
          "https://x-access-token:${REF_TOKEN}@github.com/${REF_OWNER}/${REF_NAME}.git" \
          "/workspaces/references/${REF_SLUG}" 2>/dev/null; then
        # Strip the token from the cloned remote so it never surfaces via
        # `git remote -v` when the agent inspects the reference repo.
        git -C "/workspaces/references/${REF_SLUG}" remote set-url origin \
          "https://github.com/${REF_OWNER}/${REF_NAME}.git" 2>/dev/null || true
        echo "[pool] cloned reference ${REF_SLUG} (${REF_OWNER}/${REF_NAME}@${REF_BRANCH})"
      else
        echo "[pool] WARN: reference clone failed: ${REF_SLUG}"
      fi
    done
    unset REFERENCE_REPOS_JSON
  fi
  return 0
}

# Preview traffic is now proxied directly via k8s API pod proxy —
# no tunnel client needed. The API routes subdomain requests through
# the k8s API to reach the pod's ports directly.

# ── Clear stale markers, then background git and launch the agent early ──
# A leftover ready marker from a PRIOR pod on the baked image would be
# catastrophic: the agent would spawn Claude before THIS pod's git runs. Clear
# both before starting the background prep.
#
# Split-mode agent container: the WORKBENCH owns the repo and the git prep and
# writes the shared markers; this container only reads them. It must not clear
# them either — the workbench may have already written ready.
if [ "${CONTAINER_ROLE}" != "agent" ]; then
  rm -f "$GIT_READY_MARKER" "$GIT_FAILED_MARKER"
  # Ensure the repo dir exists for launch on BOTH paths (the background clone
  # populates an empty dir on the non-image path; the pod-image path already has
  # repo/.git).
  mkdir -p /workspaces/repo

  # Run the full git prep in the background so the agent can connect (card lights
  # up) and stream setup output while git finishes.
  prepare_workspace_git &
else
  echo "[pool] agent role — skipping git prep (workbench owns the workspace)"
fi

# Signal to the agent that bash owns the git block (skip the agent's OWN
# git-sync fallback) and that it must await the ready marker before spawning
# Claude / running setup.
export CONVEYOR_GIT_READY=1
export CONVEYOR_GIT_READY_MARKER="$GIT_READY_MARKER"
export CONVEYOR_GIT_FAILED_MARKER="$GIT_FAILED_MARKER"
# Target the repo regardless of when the background clone lands.
export CONVEYOR_WORKSPACE=/workspaces/repo

# ── Workbench container (split-mode): exec the launcher daemon as PID 1 ──
# No crash loop here — the workbench is a native sidecar (restartPolicy
# Always); the kubelet owns restarts. Git prep above ran in THIS container
# (the repo lives here); the daemon serves the agent container's exec/pty/
# snapshot/file operations over loopback (token-authed, see workbench/server).
if [ "${CONTAINER_ROLE}" = "workbench" ]; then
  # Bounded git-prep retry, backgrounded so the daemon starts immediately
  # (the startupProbe gates pod readiness on the daemon listening). The repo
  # lives HERE, so the retry must run here — the agent container's launch-loop
  # retry is role-guarded off (found live 2026-07-16: an unguarded agent-side
  # retry cloned the repo into the agent's own dead overlay).
  (
    _wb_git_attempts=0
    while true; do
      sleep 10
      [ -f "$GIT_READY_MARKER" ] && break
      if [ -f "$GIT_FAILED_MARKER" ]; then
        if [ "$_wb_git_attempts" -ge 3 ]; then
          echo "[pool] workbench git prep failed 3 times — giving up (agent surfaces the failure)."
          break
        fi
        _wb_git_attempts=$((_wb_git_attempts + 1))
        echo "[pool] workbench git prep failed — retrying (attempt ${_wb_git_attempts}/3)..."
        rm -f "$GIT_READY_MARKER" "$GIT_FAILED_MARKER"
        prepare_workspace_git
      fi
    done
  ) &
  cd /workspaces/repo
  boot_mark "launching workbench daemon (env-inject → daemon-launch total)"
  echo "[pool] Launching workbench launcher daemon..."
  export CONVEYOR_MODE=workbench
  exec conveyor-agent
fi

# Launch agent — exit-code-aware restart loop.
# Exit 0 = clean shutdown (idle timeout, task complete) — pod dies.
# Non-zero = crash — retry after a brief pause.
#
# Split-mode agent container: /workspaces/repo in THIS container is a stale
# baked overlay, never the live tree — cwd must not resolve into it.
if [ "${CONTAINER_ROLE}" = "agent" ]; then
  cd /home/conveyor
else
  cd /workspaces/repo
fi
boot_mark "launching agent (env-inject → agent-launch total)"
echo "[pool] Launching agent..."
set +e
# Belt-and-braces alongside the pod's preStop hook (which pkills the agent
# directly because bash as PID 1 does not forward signals): if a SIGTERM does
# reach this shell (manual kill, future spec changes), forward it to the agent
# so flushGitOnShutdown still runs, then exit cleanly within the grace period.
trap 'echo "[pool] SIGTERM received, forwarding to agent..."; pkill -TERM -f conveyor-agent; wait; exit 0' TERM
# Bounded retry for a failed git prep. Without this, a transient git-prep
# failure writes GIT_FAILED_MARKER once and the agent's git gate then exits
# nonzero on every single relaunch forever (the marker never clears itself),
# crashlooping the pod every ~10s. Retry git prep itself, up to a small cap,
# before each relaunch; once the cap is exhausted stop hammering and fall
# back to the existing behavior (agent surfaces the failure) with a wider
# sleep so the pod idles instead of spinning.
GIT_PREP_MAX_RETRIES=3
_git_prep_attempts=0
# Bounded agent-crash supervision. An agent that keeps dying is a pod that keeps
# burning; cap the restarts, report every attempt to the API (the report posts to
# the task's activity log, which is also what keeps the pod alive through
# recovery), and exit for good once the cap is hit.
AGENT_CRASH_ATTEMPTS=0
AGENT_CRASH_MAX=3

report_agent_crash() {
  # $1 = exit code, $2 = attempt, $3 = final (true/false). Best-effort.
  curl -s -m 10 -X POST \
    -H "Authorization: Bearer ${POD_BOOTSTRAP_TOKEN}" \
    -H "Content-Type: application/json" \
    -d "{\"exitCode\":${1},\"attempt\":${2},\"final\":${3}}" \
    "${CONVEYOR_API_URL}/api/v3/pods/agent-crash" >/dev/null 2>&1 || true
}

while true; do
  # Split-mode agent container: the workbench owns git prep AND its retry —
  # re-running prep here would clone into this container's dead overlay.
  if [ -f "$GIT_FAILED_MARKER" ] && [ "${CONTAINER_ROLE}" != "agent" ]; then
    if [ "$_git_prep_attempts" -lt "$GIT_PREP_MAX_RETRIES" ]; then
      _git_prep_attempts=$((_git_prep_attempts + 1))
      echo "[pool] git prep previously failed — retrying (attempt ${_git_prep_attempts}/${GIT_PREP_MAX_RETRIES})..."
      rm -f "$GIT_READY_MARKER" "$GIT_FAILED_MARKER"
      prepare_workspace_git &
    else
      echo "[pool] git prep failed ${GIT_PREP_MAX_RETRIES} times — giving up on retries, letting agent surface the failure."
    fi
  fi
  conveyor-agent 2>&1 | tee -a /tmp/claudespace-agent.log
  _exit_code=${PIPESTATUS[0]}
  if [ "$_exit_code" -eq 0 ]; then
    echo "[pool] agent exited cleanly (code 0), shutting down pod."
    exit 0
  fi
  AGENT_CRASH_ATTEMPTS=$((AGENT_CRASH_ATTEMPTS + 1))
  if [ "$AGENT_CRASH_ATTEMPTS" -ge "$AGENT_CRASH_MAX" ]; then
    echo "[pool] agent crashed (code $_exit_code) — attempt cap ${AGENT_CRASH_MAX} reached, giving up."
    report_agent_crash "$_exit_code" "$AGENT_CRASH_ATTEMPTS" true
    exit 1
  fi
  report_agent_crash "$_exit_code" "$AGENT_CRASH_ATTEMPTS" false
  if [ -f "$GIT_FAILED_MARKER" ] && [ "$_git_prep_attempts" -ge "$GIT_PREP_MAX_RETRIES" ]; then
    echo "[pool] agent crashed (code $_exit_code) after git prep exhausted retries, backing off (60s, attempt ${AGENT_CRASH_ATTEMPTS}/${AGENT_CRASH_MAX})..."
    sleep 60
  else
    echo "[pool] agent crashed (code $_exit_code), retrying in 10s (attempt ${AGENT_CRASH_ATTEMPTS}/${AGENT_CRASH_MAX})..."
    sleep 10
  fi
done
