@rallycry/conveyor-agent 10.13.14 → 10.13.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/boot-NMXFM4GK.js +1268 -0
  2. package/dist/boot-NMXFM4GK.js.map +1 -0
  3. package/dist/{server-TVIXWCAI.js → chunk-5UYMDSAE.js} +8 -3
  4. package/dist/chunk-5UYMDSAE.js.map +1 -0
  5. package/dist/chunk-6GS5ADIY.js +11 -0
  6. package/dist/chunk-6GS5ADIY.js.map +1 -0
  7. package/dist/{chunk-RSIW2UCR.js → chunk-JG54UJ6V.js} +2 -2
  8. package/dist/{chunk-VKZ5W2VO.js → chunk-JIGG755T.js} +1 -1
  9. package/dist/chunk-JIGG755T.js.map +1 -0
  10. package/dist/{chunk-J4UH5TOO.js → chunk-OW2TIDWZ.js} +71 -114
  11. package/dist/chunk-OW2TIDWZ.js.map +1 -0
  12. package/dist/{chunk-LZM5OKAC.js → chunk-UDSRAHKP.js} +19 -2
  13. package/dist/chunk-UDSRAHKP.js.map +1 -0
  14. package/dist/chunk-UZTJJD7Y.js +254 -0
  15. package/dist/chunk-UZTJJD7Y.js.map +1 -0
  16. package/dist/cli.js +30 -47
  17. package/dist/cli.js.map +1 -1
  18. package/dist/{client-VQIBGPE6.js → client-BU4XA7CV.js} +3 -3
  19. package/dist/index.d.ts +0 -1
  20. package/dist/index.js +5 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/{protocol-IHRTO5C4.js → protocol-QLVS5W6O.js} +2 -2
  23. package/dist/server-CO5O7G4U.js +9 -0
  24. package/dist/server-CO5O7G4U.js.map +1 -0
  25. package/package.json +1 -1
  26. package/runtime/entrypoint.sh +8 -975
  27. package/dist/chunk-J4UH5TOO.js.map +0 -1
  28. package/dist/chunk-LZM5OKAC.js.map +0 -1
  29. package/dist/chunk-VKZ5W2VO.js.map +0 -1
  30. package/dist/server-TVIXWCAI.js.map +0 -1
  31. /package/dist/{chunk-RSIW2UCR.js.map → chunk-JG54UJ6V.js.map} +0 -0
  32. /package/dist/{client-VQIBGPE6.js.map → client-BU4XA7CV.js.map} +0 -0
  33. /package/dist/{protocol-IHRTO5C4.js.map → protocol-QLVS5W6O.js.map} +0 -0
@@ -1,978 +1,11 @@
1
- #!/bin/bash
2
- set -eo pipefail
1
+ #!/bin/sh
2
+ # vnext shim — ALL boot logic lives in `conveyor-agent boot`
3
+ # (packages/conveyor-agent/src/boot/). Keep this file dumb: env floor,
4
+ # best-effort self-update preflight, exec. The preflight runs baked code and
5
+ # only installs; the exec picks up whichever global bin is now current.
6
+ set -eu
3
7
  export HOME=/home/conveyor
4
8
  export PATH="/home/conveyor/.bun/bin:${PATH}"
5
- # Suppress the "new major version of npm available" notice — it writes to
6
- # stderr during `npm view`, and if we ever capture stderr into a version
7
- # string it corrupts the semver and breaks the agent install.
8
9
  export NO_UPDATE_NOTIFIER=1
9
-
10
- exec > >(tee -a /tmp/claudespace-bootstrap.log) 2>&1
11
-
12
- start_workspace_sshd() {
13
- local ssh_port="${CONVEYOR_WORKSPACE_SSH_PORT:-2222}"
14
- mkdir -p /home/conveyor/.ssh
15
- chmod 700 /home/conveyor/.ssh
16
- touch /home/conveyor/.ssh/authorized_keys
17
- chmod 600 /home/conveyor/.ssh/authorized_keys
18
- if command -v sudo >/dev/null 2>&1 && [ -x /usr/sbin/sshd ]; then
19
- sudo mkdir -p /run/sshd
20
- # Bind all interfaces: preview-router tunnels in from outside the pod to the
21
- # pod IP. Access is gated by the attach token + SSH public-key auth, not by
22
- # network reachability (the pod has no public ingress).
23
- if sudo /usr/sbin/sshd -o "ListenAddress=0.0.0.0" -o "Port=${ssh_port}"; then
24
- echo "[pool] Workspace SSHD listening on 0.0.0.0:${ssh_port}"
25
- else
26
- echo "[pool] WARN: workspace SSHD failed to start"
27
- fi
28
- else
29
- echo "[pool] WARN: workspace SSHD unavailable"
30
- fi
31
- }
32
-
33
- # ── Sidecar readiness is handled by the agent, not here ──
34
- # The postgres / firebase wait moved into `conveyor-agent` (setup waitForSidecars())
35
- # so it gates only the project setupCommand/startCommand and never the agent's
36
- # thinking loop — even on-demand pods now launch the agent without blocking on the
37
- # slow firebase emulator. The agent keeps separate per-target deadlines here
38
- # (postgres 30s, firebase 60s).
39
-
40
- # ── Refresh agent runner to latest before launch (payload-independent) ──
41
- # The image version can be stale vs. what the API expects (protocol/contract
42
- # drift causes instant exit-1 crashes). Only install if the registry's `latest`
43
- # is *strictly greater* than what's already in the image. If the image version
44
- # is ahead (e.g. a dev pod image from an unreleased commit), never downgrade.
45
- # `npm view` is a metadata-only fetch (~1s) vs. a full install (~30-60s). Hoisted
46
- # into pre-warm so a claimed warm pod never pays this on the critical path.
47
- refresh_agent_version() {
48
- local _installed_agent _npm_view_stderr _npm_view_output _npm_view_rc
49
- local _npm_view_err_content _latest_agent_raw _latest_agent _higher
50
- _installed_agent=$(conveyor-agent --version 2>/dev/null | tr -d '[:space:]' || echo "")
51
- # The image's /home/conveyor/.npmrc pins `@rallycry:registry=https://npm.pkg.github.com/`
52
- # for other private @rallycry packages, but conveyor-agent itself is published
53
- # to public npmjs.org. Override the scope on the command line so this lookup
54
- # hits the right registry.
55
- #
56
- # Capture stdout and stderr SEPARATELY. Mixing them (2>&1) was a disaster:
57
- # npm's update notifier (+ random future notices) write to stderr, and after
58
- # `tr -d '[:space:]'` the version and the notice ran together into a garbage
59
- # string like "7.0.12npmnoticenpmnotice..." — which then got fed to
60
- # `npm install @rallycry/conveyor-agent@<garbage>`, which failed partway
61
- # through and left the pod with NO conveyor-agent binary at all (crashloop).
62
- # NO_UPDATE_NOTIFIER=1 is set at the top of this script as belt-and-suspenders,
63
- # but don't rely on it.
64
- #
65
- # The if/else form on the assignment is required because `set -e` aborts on
66
- # a failing command in assignment context, and a trailing `|| true` would
67
- # mask the real exit code.
68
- # The baked /home/conveyor/.npm cache dir is owned by root (npm ran as root
69
- # during the image build), but the entrypoint runs as uid 1001 — so a non-sudo
70
- # `npm view` fails with EACCES trying to mkdir its _cacache. Point every npm
71
- # invocation here at a world-writable cache dir so the version lookup works.
72
- local _npm_cache=/tmp/npm-cache
73
- _npm_view_stderr=$(mktemp 2>/dev/null || echo "/tmp/npmview.$$.err")
74
- if _npm_view_output=$(npm view --cache "$_npm_cache" \
75
- --@rallycry:registry=https://registry.npmjs.org/ \
76
- @rallycry/conveyor-agent version 2>"$_npm_view_stderr"); then
77
- _npm_view_rc=0
78
- else
79
- _npm_view_rc=$?
80
- fi
81
- _npm_view_err_content=$(cat "$_npm_view_stderr" 2>/dev/null || echo "")
82
- rm -f "$_npm_view_stderr" 2>/dev/null || true
83
- _latest_agent_raw=$(printf '%s' "${_npm_view_output}" | tr -d '[:space:]')
84
- # Validate the captured value looks like a semver (x.y.z with optional
85
- # prerelease/build metadata). Anything else → treat as a failed lookup and
86
- # keep the image version. This is the safety net that prevents us from ever
87
- # running `npm install @rallycry/conveyor-agent@<corrupted>` again.
88
- if [ $_npm_view_rc -eq 0 ] && [[ "${_latest_agent_raw}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][A-Za-z0-9.-]+)?$ ]]; then
89
- _latest_agent="${_latest_agent_raw}"
90
- else
91
- _latest_agent=""
92
- fi
93
- if [ -z "${_latest_agent}" ]; then
94
- echo "[pool] WARNING: failed to get valid semver from npm for @rallycry/conveyor-agent (rc=${_npm_view_rc}), keeping image version ${_installed_agent:-unknown}"
95
- echo "[pool] npm view stdout: ${_npm_view_output}"
96
- echo "[pool] npm view stderr: ${_npm_view_err_content}"
97
- echo "[pool] raw value after whitespace strip: '${_latest_agent_raw}'"
98
- echo "[pool] npm binary: $(command -v npm || echo 'not found'), node: $(command -v node || echo 'not found'), HOME=${HOME:-unset}"
99
- elif [ -z "${_installed_agent}" ]; then
100
- echo "[pool] No agent in image, installing @rallycry/conveyor-agent@${_latest_agent}..."
101
- sudo npm install -g --cache "$_npm_cache" --silent "@rallycry/conveyor-agent@${_latest_agent}" 2>&1 \
102
- || echo "[pool] WARNING: agent install failed"
103
- echo "[pool] Agent version: $(conveyor-agent --version 2>&1 || echo unknown)"
104
- elif [ "${_installed_agent}" = "${_latest_agent}" ]; then
105
- echo "[pool] Agent version: ${_installed_agent} (matches published, skipping install)"
106
- else
107
- # Semver compare via `sort -V`. Highest version is the last line.
108
- _higher=$(printf '%s\n%s\n' "${_installed_agent}" "${_latest_agent}" | sort -V | tail -n1)
109
- if [ "${_higher}" = "${_installed_agent}" ]; then
110
- echo "[pool] Agent version: ${_installed_agent} (ahead of published ${_latest_agent}, skipping install)"
111
- else
112
- echo "[pool] Updating @rallycry/conveyor-agent ${_installed_agent} → ${_latest_agent}..."
113
- sudo npm install -g --cache "$_npm_cache" --silent "@rallycry/conveyor-agent@${_latest_agent}" 2>&1 \
114
- || echo "[pool] WARNING: agent update failed, falling back to image version ${_installed_agent}"
115
- echo "[pool] Agent version: $(conveyor-agent --version 2>&1 || echo unknown)"
116
- fi
117
- fi
118
- }
119
-
120
- # ── Reclaim any root-owned $HOME config/state entries (bake defense-in-depth) ──
121
- # Bake steps that run as root while ENV HOME points at /home/conveyor can leave
122
- # root-owned entries under ~/.config / ~/.local (a uv install receipt in
123
- # ~/.config once bricked every pod boot fleet-wide). This reclaims them so a
124
- # baked ownership slip can't EACCES the entrypoint under `set -e`.
125
- #
126
- # CONDITIONAL, not `chown -R`: a `find … ! -user conveyor` walk is stat-only when
127
- # the tree is already clean (the common case since the bake-side `HOME=/root`
128
- # fix in 9815c8915 — current images carry ZERO root-owned files here), so it
129
- # triggers no overlayfs copy-up. The former unconditional `chown -R` copied up
130
- # ALL ~3.9k baked files (~63MB, dominated by ~/.config/opencode's 3,652 files)
131
- # on EVERY boot even when nothing was root-owned — ~16s of I/O-bound
132
- # uninterruptible-disk-sleep that reclaimed nothing, and the true owner of the
133
- # "GCS-FUSE" boot gap. Runs in the PRE-BIND phase below (payload-independent, off
134
- # the time-to-agent critical path) and before any mount symlinks exist, so it
135
- # never follows into the FUSE mount. See
136
- # docs/investigations/gcs-fuse-user-home-boot-cost-2026-07-15.md.
137
- reclaim_home_ownership() {
138
- local d
139
- for d in /home/conveyor/.config /home/conveyor/.local; do
140
- [ -d "${d}" ] || continue
141
- sudo -n find "${d}" ! -user conveyor -exec chown conveyor:conveyor {} + 2>/dev/null || true
142
- done
143
- }
144
-
145
- # ── Container role (workbench split) ──
146
- # Split-mode pods run TWO app containers from this same entrypoint:
147
- # agent — supervisor + conveyor-agent only (protected, restartPolicy
148
- # Never semantics); no repo, no sshd, no git prep.
149
- # workbench — the workspace: git prep, sshd, claude + all workloads behind
150
- # the launcher daemon (native sidecar, restartPolicy Always).
151
- # Unset = today's monolith container; every guard below must be a no-op then.
152
- CONTAINER_ROLE="${CONVEYOR_CONTAINER_ROLE:-}"
153
-
154
- # Split-mode pods: SSH attach lands in the WORKBENCH container (the workspace
155
- # lives there); the agent container skips sshd.
156
- if [ "${CONTAINER_ROLE}" != "agent" ]; then
157
- start_workspace_sshd
158
- fi
159
-
160
- # ── Required env vars (injected by pod spec — v3: exactly these two) ──
161
- : "${CONVEYOR_API_URL:?CONVEYOR_API_URL is required}"
162
- : "${POD_BOOTSTRAP_TOKEN:?POD_BOOTSTRAP_TOKEN is required}"
163
-
164
- # Pod name is the instance identifier
165
- INSTANCE_NAME="${HOSTNAME}"
166
- export CLAUDESPACE_NAME="${INSTANCE_NAME}"
167
-
168
- # ═══════════════════════════════════════════════════════════════════════════
169
- # PRE-BIND PHASE — payload-independent work only. v3 pods do not have repo,
170
- # GitHub, or task credentials until the bootstrap endpoint returns 200.
171
- # ═══════════════════════════════════════════════════════════════════════════
172
- PREWARM_DEV_LOOP_PID=""
173
- refresh_agent_version
174
- # Reclaim root-owned $HOME entries here, during standby, so the common (clean)
175
- # case costs a stat-only walk off the critical path instead of a ~16s copy-up
176
- # `chown -R` after the bundle arrives.
177
- reclaim_home_ownership
178
-
179
- # ═══════════════════════════════════════════════════════════════════════════
180
- # PULL-BASED BOOTSTRAP — poll until the reconciler binds this pod to a
181
- # Workspace. 204 = still unbound; 200 = full bundle.
182
- # ═══════════════════════════════════════════════════════════════════════════
183
- # Bash runs as PID 1: without a trap, SIGTERM is ignored during standby and
184
- # every pool drain/scale-down rides the full terminationGracePeriod (180s) to
185
- # SIGKILL. The agent-phase trap installed later replaces this one.
186
- trap 'echo "[boot] SIGTERM during standby — exiting."; exit 0' TERM INT
187
- echo "[boot] Entering standby — polling for bootstrap bind..."
188
- POLL_COUNTER=0
189
- BOOTSTRAP_JSON=""
190
- while true; do
191
- # The response body carries credentials — pre-create it owner-only so no
192
- # window exists where another uid could read it.
193
- rm -f /tmp/bootstrap-response.json
194
- (umask 077 && touch /tmp/bootstrap-response.json)
195
- # No `-f` on curl: with -f an HTTP-error response still prints the -w
196
- # write-out AND exits 22, so `|| echo "000"` produced "401000" — the 401
197
- # fail-fast branch below could never match and a deleted pod polled forever.
198
- # Without -f curl exits 0 on any HTTP response and -w yields the clean
199
- # status; the `|| echo "000"` fires only on pure network errors.
200
- HTTP_STATUS=$(curl -s -o /tmp/bootstrap-response.json -w '%{http_code}' \
201
- -H "Authorization: Bearer ${POD_BOOTSTRAP_TOKEN}" \
202
- "${CONVEYOR_API_URL}/api/v3/pods/bootstrap" 2>/dev/null || echo "000")
203
-
204
- if [ "${HTTP_STATUS}" = "200" ]; then
205
- BOOTSTRAP_JSON=$(cat /tmp/bootstrap-response.json)
206
- rm -f /tmp/bootstrap-response.json
207
- echo "[boot] Bound — bootstrap bundle received!"
208
- break
209
- elif [ "${HTTP_STATUS}" = "401" ]; then
210
- echo "[boot] ERROR: bootstrap token rejected (401) — pod identity invalid, exiting." >&2
211
- exit 1
212
- fi
213
- rm -f /tmp/bootstrap-response.json
214
-
215
- POLL_COUNTER=$((POLL_COUNTER + 1))
216
- if [ $((POLL_COUNTER % 30)) -eq 0 ]; then
217
- echo "[boot] Still waiting for bind (poll #${POLL_COUNTER})..."
218
- fi
219
- sleep 2
220
- done
221
-
222
- # `// empty` on every extraction: bare `.field` renders a missing/null field
223
- # as the literal string "null", which then flows into git URLs and env vars.
224
- export CONVEYOR_TASK_TOKEN=$(echo "${BOOTSTRAP_JSON}" | jq -r '.sessionJwt // empty')
225
- export CONVEYOR_GITHUB_TOKEN=$(echo "${BOOTSTRAP_JSON}" | jq -r '.githubToken // empty')
226
- # gh CLI auth (baked into the base image). GH_TOKEN (not GITHUB_TOKEN — that
227
- # name leaks into too many third-party tools) points gh at the same role-scoped
228
- # installation token git uses. Caveat: installation tokens live ~1h and the
229
- # agent's refresh path updates the git remote URL, not this env — a long-lived
230
- # session's gh calls can 401 after expiry; agents should treat that as
231
- # "re-check via MCP tools", not an auth bug to debug.
232
- export GH_TOKEN="${CONVEYOR_GITHUB_TOKEN}"
233
- REPO_OWNER=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.repoOwner // empty')
234
- REPO_NAME=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.repoName // empty')
235
- BRANCH=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.branch // empty')
236
- BASE_BRANCH=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.baseBranch // empty')
237
- CHECKOUT_REF=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.checkoutRef // empty')
238
- export REPO_OWNER REPO_NAME BRANCH
239
-
240
- # Decode non-secret session identity fields from the JWT payload so the
241
- # existing agent startup contract remains unchanged.
242
- 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 "{}")
243
- # CONVEYOR_TASK_ID is exported ONLY when the claim exists: a task-less PROJECT
244
- # session JWT has no taskId claim, and the agent keys its project-runner mode
245
- # off the var being ABSENT (never an empty-string export).
246
- CONVEYOR_TASK_ID=$(echo "${SESSION_CLAIMS}" | jq -r '.taskId // empty' 2>/dev/null || true)
247
- if [ -n "${CONVEYOR_TASK_ID}" ]; then
248
- export CONVEYOR_TASK_ID
249
- else
250
- unset CONVEYOR_TASK_ID
251
- fi
252
- # projectId claim is present only on task-less project session JWTs.
253
- CONVEYOR_PROJECT_ID_CLAIM=$(echo "${SESSION_CLAIMS}" | jq -r '.projectId // empty' 2>/dev/null || true)
254
- if [ -n "${CONVEYOR_PROJECT_ID_CLAIM}" ]; then
255
- export CONVEYOR_PROJECT_ID="${CONVEYOR_PROJECT_ID_CLAIM}"
256
- fi
257
- export CONVEYOR_SESSION_ID=$(echo "${SESSION_CLAIMS}" | jq -r '.sessionId // empty')
258
- export CONVEYOR_WORKSPACE_ID=$(echo "${SESSION_CLAIMS}" | jq -r '.workspaceId // empty')
259
- SESSION_MODE=$(echo "${SESSION_CLAIMS}" | jq -r '.mode // empty')
260
- unset SESSION_CLAIMS
261
- if [ "${SESSION_MODE}" = "review" ]; then
262
- # Review sessions run the PR-review runner. Keyed on the session MODE claim,
263
- # not the role: review sessions are writers (they need push access to the PR
264
- # branch), so a role check can't identify them — gating on role=reader here
265
- # is what let review pods fall through to the default task runner and boot
266
- # the parent's task agent in discovery mode. The agent CLI's RunnerMode is
267
- # spelled "code-review" (cli.ts validates task|pm|code-review|adhoc|pack); the bare
268
- # "review" is the agentMode/tag axis, NOT a runner mode — passing it here
269
- # made the agent exit "Invalid CONVEYOR_MODE" and crash-loop every 10s.
270
- export CONVEYOR_MODE="code-review"
271
- elif [ "${SESSION_MODE}" = "pack" ]; then
272
- # Parent-card orchestrator (task-bound — CONVEYOR_TASK_ID is set): the agent
273
- # runs the autonomous pack loop (start ready children, merge child PRs,
274
- # complete the parent) instead of building code. Same task-mode lifecycle,
275
- # different prompt/tool surface — see conveyor-agent pack-runner-prompt.ts.
276
- export CONVEYOR_MODE="pack"
277
- elif [ "${SESSION_MODE}" = "adhoc" ]; then
278
- # Task-less USER SCRATCH pod (Sessions view): the agent runs an interactive
279
- # `claude` TUI relayed to the web terminal — no autonomous loop, no task. Must
280
- # be checked BEFORE the project branch below: an adhoc session is also
281
- # task-less with a projectId claim, but it must NOT boot the pm project runner.
282
- export CONVEYOR_MODE="adhoc"
283
- elif [ -z "${CONVEYOR_TASK_ID:-}" ] && [ -n "${CONVEYOR_PROJECT_ID_CLAIM}" ]; then
284
- # Task-less project pod: the agent boots the project runner in pm mode
285
- # (see conveyor-agent setup/project-identity.ts — pm is required).
286
- export CONVEYOR_MODE="pm"
287
- fi
288
-
289
- # ── Export bundle env vars (secrets, OAuth tokens, project config) ──
290
- ENV_KEYS=$(echo "${BOOTSTRAP_JSON}" | jq -r '.envVars // {} | keys[]' 2>/dev/null)
291
- if [ -n "${ENV_KEYS}" ]; then
292
- ENV_COUNT=0
293
- while IFS= read -r key; do
294
- # A bundle key that isn't a valid shell identifier would make `export`
295
- # eval arbitrary content, and a handful of names would hijack the boot
296
- # itself (PATH swaps every binary below; LD_PRELOAD injects code into
297
- # them). Project env is user-supplied — validate, never trust.
298
- if ! [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
299
- echo "[boot] WARN: skipping invalid env key from bundle: '${key}'"
300
- continue
301
- fi
302
- case "${key}" in
303
- PATH|HOME|LD_PRELOAD|SHELL)
304
- echo "[boot] WARN: skipping denylisted env key from bundle: '${key}'"
305
- continue
306
- ;;
307
- esac
308
- value=$(echo "${BOOTSTRAP_JSON}" | jq -r --arg k "${key}" '.envVars[$k] // empty')
309
- export "${key}=${value}"
310
- ENV_COUNT=$((ENV_COUNT + 1))
311
- done <<< "${ENV_KEYS}"
312
- echo "[boot] Injected ${ENV_COUNT} env vars from bootstrap bundle"
313
- fi
314
-
315
- # ── Boot timing instrumentation (env-inject → agent-launch breakdown) ──
316
- # A ~16s window between "Injected env vars" and "Linking Claude state" was
317
- # root-caused (2026-07-16) to the unconditional root-owned-$HOME `chown -R` that
318
- # used to run here — NOT GCS-FUSE (the mount is idle during the gap). That chown
319
- # is now a conditional pre-bind reclaim (see reclaim_home_ownership). These marks
320
- # stay as a regression probe: they attribute the gap to each sub-step —
321
- # mount-readiness stat vs first metadata write vs symlink wiring — so if the
322
- # total ever balloons again a canary boot shows WHERE. EPOCHREALTIME is
323
- # bash-native (no per-mark subprocess); the `//[.,]/` strip tolerates
324
- # comma-decimal locales. Grep the boot log for `[boot-timing]`. See
325
- # docs/investigations/gcs-fuse-user-home-boot-cost-2026-07-15.md.
326
- BOOT_MARK_T0_US="${EPOCHREALTIME//[.,]/}"
327
- boot_mark() {
328
- if [ -z "${EPOCHREALTIME:-}" ]; then echo "[boot-timing] $1"; return 0; fi
329
- local now_us="${EPOCHREALTIME//[.,]/}"
330
- local delta_us=$(( now_us - BOOT_MARK_T0_US ))
331
- printf '[boot-timing] +%d.%03ds %s\n' "$(( delta_us / 1000000 ))" "$(( (delta_us / 1000) % 1000 ))" "$1"
332
- }
333
- boot_mark "env injected — entering user-home wiring"
334
-
335
- export ANTHROPIC_API_KEY=$(echo "${BOOTSTRAP_JSON}" | jq -r '.anthropicKey // empty')
336
- export CLOUDSDK_AUTH_ACCESS_TOKEN=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gcpToken // empty')
337
- unset BOOTSTRAP_JSON
338
- # POD_BOOTSTRAP_TOKEN is deliberately NOT unset: it is the credential-refresh
339
- # key. The agent re-polls GET /api/v3/pods/bootstrap with it to swap in a
340
- # fresh githubToken/sessionJwt in place (see agent-connection refresh path).
341
-
342
- CONVEYOR_USER_ID="${CONVEYOR_USER_ID:-}"
343
- CONVEYOR_PROJECT_ID_FROM_BUNDLE="${CONVEYOR_PROJECT_ID:-${PROJECT_ID:-}}"
344
- export CONVEYOR_USER_ID
345
-
346
- # ── Seed Claude Code's first-run gates ──
347
- # Pre-answer every interactive gate the CLI shows on a fresh (or partially
348
- # initialized) config: the onboarding wizard (theme picker), the
349
- # bypass-permissions warning, and the folder-trust dialog for the repo
350
- # workspace. Any one of these parks a headless TUI forever — seen live
351
- # 2026-07-02 as code reviews stuck at "Is this a project you trust?" until the
352
- # server's cap burned the attempt. conveyor-agent also seeds the same keys before
353
- # each spawn (ensureClaudeOnboarding), but this shell seed is version-independent
354
- # (pods can run older baked agents) and lands before the CLI's first read — and
355
- # it also covers an interactive `claude` opened over an SSH tunnel, which the
356
- # agent seed never runs for. Merge via jq, never clobber: CLI-owned keys (cache
357
- # keys, firstStartTime, other projects' trust entries) survive; a missing or
358
- # corrupt file becomes a fresh seed — Claude Code treats a 0-byte/unparseable
359
- # file as fatal ("Unexpected end of JSON input"), so replacing is a repair. Theme
360
- # only seeds fresh configs (a user-picked theme is never overridden). Every step
361
- # is rescued: config seeding must never kill the entrypoint under `set -eo pipefail`.
362
- seed_claude_json() {
363
- SEED_TARGET="$1"
364
- SEED_GATES='{"hasCompletedOnboarding":true,"bypassPermissionsModeAccepted":true,"projects":{"/workspaces/repo":{"hasTrustDialogAccepted":true}}}'
365
- # Fable is entitlement-gated in the CLI's /model picker via
366
- # additionalModelOptionsCache, normally populated by a bootstrap fetch the
367
- # pods' synthesized credentials don't satisfy — without this row the picker
368
- # never offers Fable in a pod. Appended only when no fable-valued entry
369
- # exists, so a CLI-written entitlement cache is never clobbered.
370
- SEED_FABLE_OPTION='{"value":"claude-fable-5[1m]","label":"Fable","description":"Fable 5 - most capable for your hardest and longest-running tasks"}'
371
- SEED_FABLE_FILTER='if ([.additionalModelOptionsCache[]? | .value? | strings | ascii_downcase | select(contains("fable"))] | length) > 0 then . else .additionalModelOptionsCache = ((.additionalModelOptionsCache // []) + [$fable]) end'
372
- if [ -f "${SEED_TARGET}" ] && jq -e . "${SEED_TARGET}" >/dev/null 2>&1; then
373
- 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
374
- printf '%s' "${SEED_MERGED}" > "${SEED_TARGET}" 2>/dev/null || true
375
- fi
376
- else
377
- printf '%s' "${SEED_GATES}" | jq -c --argjson fable "${SEED_FABLE_OPTION}" '. + {theme:"dark", additionalModelOptionsCache: [$fable]}' > "${SEED_TARGET}" 2>/dev/null || true
378
- fi
379
- }
380
-
381
- # ── Wire per-user Claude state from the GCS FUSE mount ──
382
- # When the pod has /mnt/conveyor-users mounted (GCS FUSE CSI), symlink
383
- # ~/.claude, ~/.claude.json, and ~/.config/claude to the user's subdir so
384
- # Claude state persists across all of the user's codespaces without any
385
- # tar/upload/download round-trip. Uses projectId if present, else falls
386
- # back to PROJECT_ID from the pod env.
387
- USER_HOME_MOUNT="/mnt/conveyor-users"
388
- USER_HOME_PROJECT_ID="${CONVEYOR_PROJECT_ID_FROM_BUNDLE:-${PROJECT_ID:-}}"
389
-
390
- # Root-owned $HOME reclaim moved OFF this critical path into the pre-bind phase
391
- # (reclaim_home_ownership, called after refresh_agent_version) and made
392
- # conditional — the former unconditional `chown -R` here cost ~16s of copy-up on
393
- # every boot and was the real owner of this "GCS-FUSE" gap. See
394
- # docs/investigations/gcs-fuse-user-home-boot-cost-2026-07-15.md.
395
-
396
- # The `[ -d "${USER_HOME_MOUNT}" ]` test below is the first stat against the FUSE
397
- # mount — if the gcsfuse sidecar is still handshaking it blocks here, so the
398
- # mark straddles the readiness probe.
399
- boot_mark "probing user-home mount presence"
400
- if [ -n "${CONVEYOR_USER_ID}" ] && [ -n "${USER_HOME_PROJECT_ID}" ] && [ -d "${USER_HOME_MOUNT}" ]; then
401
- USER_HOME_ROOT="${USER_HOME_MOUNT}/users/${CONVEYOR_USER_ID}/${USER_HOME_PROJECT_ID}"
402
- boot_mark "user-home mount ready — linking Claude state"
403
- echo "[pool] Linking Claude state to ${USER_HOME_ROOT}"
404
- mkdir -p "${USER_HOME_ROOT}/.claude" "${USER_HOME_ROOT}/.config/claude" 2>/dev/null || true
405
- boot_mark "mkdir into mount done (first metadata write)"
406
-
407
- # Replace any image defaults with live symlinks into the mount for the SHARED,
408
- # append-mostly Claude state: transcripts/plans/memory under ~/.claude and
409
- # ~/.config/claude. These persist across every pod the user runs, and --resume
410
- # + session history depend on them (see claude-session-persistence.md).
411
- #
412
- # ~/.claude.json is deliberately NOT symlinked — it is POD-LOCAL (below). The
413
- # Claude CLI rewrites that file wholesale at startup and during runs, so a
414
- # burst of concurrent pods for the same user+project (seen live 2026-07-12: 7
415
- # pods in 10 minutes) racing one shared file produces last-writer-wins lost
416
- # updates — the true root cause behind the read-back mismatch that parked a
417
- # TUI at a startup dialog, and the vector that let one poisoned
418
- # customApiKeyResponses write brick every future pod. Nothing load-bearing
419
- # lives only in ~/.claude.json: credentials are in ~/.claude/.credentials.json
420
- # (pod-local via symlink, see below) and transcripts in ~/.claude/projects.
421
- # The agent's ensureClaudeOnboarding + the seed below rebuild every first-run
422
- # gate this file needs, fresh, on each boot.
423
- rm -rf /home/conveyor/.claude /home/conveyor/.config/claude 2>/dev/null || true
424
- mkdir -p /home/conveyor/.config 2>/dev/null || true
425
- ln -sfn "${USER_HOME_ROOT}/.claude" /home/conveyor/.claude
426
- ln -sfn "${USER_HOME_ROOT}/.config/claude" /home/conveyor/.config/claude
427
- boot_mark "~/.claude + ~/.config/claude symlinks wired"
428
-
429
- # Pod-local ~/.claude.json: clear any symlink/file a prior boot or the image
430
- # baked in, then seed a fresh real file on the pod's OWN disk (local
431
- # read-after-write is consistent, so no cross-pod race and no read-back skew).
432
- rm -rf /home/conveyor/.claude.json 2>/dev/null || true
433
- seed_claude_json /home/conveyor/.claude.json
434
-
435
- # Pod-local ~/.claude/.credentials.json: the TUI and the usage probe
436
- # authenticate from this file, while the server attributes usage samples to
437
- # the key stamped on THIS pod's session. As a shared file (it lives inside
438
- # the symlinked ~/.claude), concurrent pods booted under different keys
439
- # clobbered it last-writer-wins, so every pod probed whichever ACCOUNT booted
440
- # most recently — cross-wiring usage attribution between the user's keys
441
- # (seen live 2026-07-14). Same cure as ~/.claude.json above, except the CLI
442
- # derives this path from ~/.claude, so the shared dir keeps a SYMLINK to an
443
- # absolute pod-local path — which resolves per-pod. Racing pods all write the
444
- # identical symlink value, so the ln itself cannot lose data. Best-effort: on
445
- # failure the file stays shared and the agent's sampler identity guard still
446
- # blocks wrong-account attribution.
447
- CRED_SHARED="${USER_HOME_ROOT}/.claude/.credentials.json"
448
- CRED_POD_LOCAL="/home/conveyor/.claude-credentials.pod.json"
449
- # Split-mode pods: the agent container WRITES credentials (harness auth) and
450
- # claude in the workbench READS them through the shared-dir symlink — so the
451
- # pod-local file must live on the shared emptyDir. Still pod-local, so the
452
- # concurrent-pods clobber fix above is preserved.
453
- if [ -n "${CONVEYOR_SHARED_DIR:-}" ]; then
454
- CRED_POD_LOCAL="${CONVEYOR_SHARED_DIR}/claude-credentials.pod.json"
455
- fi
456
- if [ -f "${CRED_SHARED}" ] && [ ! -L "${CRED_SHARED}" ]; then
457
- # Legacy shared regular file: carry its contents into this pod so auth
458
- # survives the cutover; the agent re-synthesizes from its own token at
459
- # spawn anyway.
460
- cp "${CRED_SHARED}" "${CRED_POD_LOCAL}" 2>/dev/null || true
461
- fi
462
- ln -sfn "${CRED_POD_LOCAL}" "${CRED_SHARED}" 2>/dev/null || true
463
- chmod 600 "${CRED_POD_LOCAL}" 2>/dev/null || true
464
- boot_mark "~/.claude.json seeded + credentials repointed"
465
-
466
- # OpenCode state (sessions + config) persists the same way as ~/.claude.
467
- mkdir -p "${USER_HOME_ROOT}/.local/share/opencode" "${USER_HOME_ROOT}/.config/opencode" 2>/dev/null || true
468
- rm -rf /home/conveyor/.local/share/opencode /home/conveyor/.config/opencode 2>/dev/null || true
469
- mkdir -p /home/conveyor/.local/share 2>/dev/null || true
470
- ln -sfn "${USER_HOME_ROOT}/.local/share/opencode" /home/conveyor/.local/share/opencode
471
- ln -sfn "${USER_HOME_ROOT}/.config/opencode" /home/conveyor/.config/opencode
472
- boot_mark "opencode state linked — user-home wiring complete"
473
- else
474
- 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))"
475
- # No persistent home — the CLI reads the pod-local config; seed it there so
476
- # non-FUSE pods get the same first-run gate suppression as the mounted path.
477
- seed_claude_json /home/conveyor/.claude.json
478
- # Split-mode pods without a persistent user-home: ~/.claude must still cross
479
- # the container boundary (claude writes transcripts in the workbench; the
480
- # agent's tailer reads them), so it lives on the shared emptyDir.
481
- if [ -n "${CONVEYOR_SHARED_DIR:-}" ]; then
482
- mkdir -p "${CONVEYOR_SHARED_DIR}/claude-home/.claude" 2>/dev/null || true
483
- rm -rf /home/conveyor/.claude 2>/dev/null || true
484
- ln -sfn "${CONVEYOR_SHARED_DIR}/claude-home/.claude" /home/conveyor/.claude
485
- fi
486
- boot_mark "no user-home mount — pod-local seed only"
487
- fi
488
-
489
- # ── Wire published graphify bundles from the shared user-home mount ──
490
- # A locally published graph lives in the same GCS-FUSE user-home bucket as
491
- # Claude state, under users/_shared/graphify/<repo>/latest. Export the path
492
- # contract before the agent starts, then bind the files into graphify-out after
493
- # git has prepared the workspace but before the ready marker is released.
494
- configure_graphify_env() {
495
- if [ "${CONVEYOR_GRAPHIFY_DISABLE:-}" = "1" ]; then
496
- return 0
497
- fi
498
-
499
- local slug="${CONVEYOR_GRAPHIFY_SLUG:-${REPO_NAME:-}}"
500
- if [ -z "${slug}" ]; then
501
- return 0
502
- fi
503
-
504
- local primary_root="${USER_HOME_MOUNT}/users/_shared/graphify"
505
- local legacy_root="${USER_HOME_MOUNT}/_shared/graphify"
506
- local shared_root="${CONVEYOR_GRAPHIFY_SHARED_ROOT:-}"
507
- if [ -z "${shared_root}" ]; then
508
- if [ -d "${primary_root}" ] || [ ! -d "${legacy_root}" ]; then
509
- shared_root="${primary_root}"
510
- else
511
- shared_root="${legacy_root}"
512
- fi
513
- fi
514
-
515
- export CONVEYOR_GRAPHIFY_SLUG="${slug}"
516
- export CONVEYOR_GRAPHIFY_SHARED_ROOT="${shared_root}"
517
- export CONVEYOR_GRAPHIFY_DIR="${CONVEYOR_GRAPHIFY_DIR:-${shared_root}/${slug}/latest}"
518
- export CONVEYOR_GRAPHIFY_GRAPH="${CONVEYOR_GRAPHIFY_GRAPH:-${CONVEYOR_GRAPHIFY_DIR}/graph.json}"
519
- }
520
-
521
- bind_graphify_bundle() {
522
- if [ "${CONVEYOR_GRAPHIFY_DISABLE:-}" = "1" ]; then
523
- echo "[pool] Graphify bind disabled."
524
- return 0
525
- fi
526
-
527
- if [ -z "${CONVEYOR_GRAPHIFY_SLUG:-}" ]; then
528
- return 0
529
- fi
530
-
531
- local workspace="${CONVEYOR_WORKSPACE:-/workspaces/repo}"
532
- local source_dir="${CONVEYOR_GRAPHIFY_DIR:-}"
533
- local graph_file="${CONVEYOR_GRAPHIFY_GRAPH:-}"
534
-
535
- if [ -z "${source_dir}" ] || [ -z "${graph_file}" ]; then
536
- return 0
537
- fi
538
-
539
- if [ ! -f "${graph_file}" ]; then
540
- echo "[pool] Graphify bundle not found for '${CONVEYOR_GRAPHIFY_SLUG}' at ${source_dir}"
541
- return 0
542
- fi
543
-
544
- local target_dir="${workspace}/graphify-out"
545
- if ! mkdir -p "${target_dir}" 2>/dev/null; then
546
- echo "[pool] WARN: unable to create graphify-out at ${target_dir}"
547
- return 0
548
- fi
549
-
550
- local rel
551
- for rel in graph.json GRAPH_REPORT.md manifest.json .graphify_analysis.json .graphify_labels.json publish-manifest.json cost.json; do
552
- if [ ! -e "${source_dir}/${rel}" ]; then
553
- continue
554
- fi
555
- if [ -e "${target_dir}/${rel}" ] || [ -L "${target_dir}/${rel}" ]; then
556
- continue
557
- fi
558
- ln -s "${source_dir}/${rel}" "${target_dir}/${rel}" 2>/dev/null || true
559
- done
560
-
561
- echo "[pool] Bound graphify bundle '${CONVEYOR_GRAPHIFY_SLUG}' into ${target_dir}"
562
- }
563
-
564
- # ── Link shared Grimoire skills into the repo's project skill dir ──
565
- # The prebake links grimoire skills into the BAKED image's ~/.claude/skills,
566
- # but the user-home wiring above replaces /home/conveyor/.claude with the
567
- # per-user GCS mount on every mounted pod boot — wiping those links before any
568
- # agent runs (observed fleet-wide 2026-07-10: pods saw only repo-tracked
569
- # skills, none of the shared rc-* set). Link repo-locally instead: Claude
570
- # loads project skills from <workspace>/.claude/skills regardless of where
571
- # $HOME points, the links are pod-local (no writes to the shared mount, no
572
- # cross-pod races), and .gitignore covers them so `git status` stays clean.
573
- # Runs inside mark_git_ready — after checkout is final, before the agent may
574
- # spawn Claude — alongside the graphify bundle bind. Both helpers are
575
- # best-effort: a grimoire failure must never block the git-ready gate.
576
- ensure_grimoire_submodule() {
577
- local workspace="${CONVEYOR_WORKSPACE:-/workspaces/repo}"
578
- [ -f "${workspace}/.gitmodules" ] || return 0
579
- git -C "${workspace}" config --file .gitmodules --get-regexp 'submodule\..*\.path' 2>/dev/null \
580
- | grep -q '\.claude/grimoire$' || return 0
581
- # Already materialized (pod-image bake ran conveyor-prebake successfully).
582
- [ -d "${workspace}/.claude/grimoire/skills" ] && return 0
583
- if [ -z "${CONVEYOR_GITHUB_TOKEN:-}" ]; then
584
- echo "[pool] WARN: grimoire submodule absent and no token to fetch it"
585
- return 0
586
- fi
587
- # insteadOf injects the installation token for the submodule's https URL the
588
- # same way sync_task_branch_to_repo authenticates the main repo remote.
589
- if _grim_out=$(git -C "${workspace}" \
590
- -c url."https://x-access-token:${CONVEYOR_GITHUB_TOKEN}@github.com/".insteadOf="https://github.com/" \
591
- submodule update --init .claude/grimoire 2>&1); then
592
- echo "[pool] Initialized grimoire submodule"
593
- else
594
- echo "[pool] WARN: grimoire submodule init failed (baked pods may lack rc-* skills): ${_grim_out}"
595
- fi
596
- return 0
597
- }
598
-
599
- link_grimoire_skills() {
600
- local workspace="${CONVEYOR_WORKSPACE:-/workspaces/repo}"
601
- local source_dir="${workspace}/.claude/grimoire/skills"
602
- local target_dir="${workspace}/.claude/skills"
603
- [ -d "${source_dir}" ] || return 0
604
- if ! mkdir -p "${target_dir}" 2>/dev/null; then
605
- echo "[pool] WARN: unable to create ${target_dir}; skipping grimoire skill links"
606
- return 0
607
- fi
608
- local skill_dir name linked=0
609
- for skill_dir in "${source_dir}"/*/; do
610
- [ -d "${skill_dir}" ] || continue
611
- name="$(basename "${skill_dir}")"
612
- # Never clobber a real (repo-tracked) skill dir with a link.
613
- if [ -e "${target_dir}/${name}" ] && [ ! -L "${target_dir}/${name}" ]; then
614
- echo "[pool] WARN: ${target_dir}/${name} exists and is not a symlink; skipping"
615
- continue
616
- fi
617
- ln -sfn "../grimoire/skills/${name}" "${target_dir}/${name}" 2>/dev/null && linked=$((linked + 1))
618
- done
619
- echo "[pool] Linked ${linked} grimoire skills into ${target_dir}"
620
- return 0
621
- }
622
-
623
- mark_git_ready() {
624
- bind_graphify_bundle
625
- ensure_grimoire_submodule
626
- link_grimoire_skills
627
- : > "$GIT_READY_MARKER"
628
- }
629
-
630
- configure_graphify_env
631
-
632
- # ═══════════════════════════════════════════════════════════════════════════
633
- # WORKSPACE GIT — moved OFF the pre-launch critical path (Claudespace v3).
634
- #
635
- # The agent is launched IMMEDIATELY (below) so the card lights up and setup
636
- # output streams while git runs in the BACKGROUND. `prepare_workspace_git`
637
- # does the full fetch/checkout/merge (task repo) then clones reference repos,
638
- # and signals completion by writing exactly ONE marker file:
639
- #
640
- # GIT_READY_MARKER — task repo is up to date; the agent may spawn Claude.
641
- # GIT_FAILED_MARKER — git preparation errored; the agent surfaces the error
642
- # and shuts down WITHOUT operating on a broken/stale repo.
643
- #
644
- # This function runs backgrounded (`prepare_workspace_git &`). It therefore
645
- # must NEVER `exit` (that only kills the subshell, leaving the agent to wait
646
- # on a marker that never arrives) — every error path does
647
- # `echo >&2; printf ... > "$GIT_FAILED_MARKER"; return 1` instead. The
648
- # fail-loud-on-stale-image philosophy is preserved: a failure writes the failed
649
- # marker (was: exit 1), which the agent treats as fatal.
650
- #
651
- # `set -e` interaction: fallible git commands keep the existing
652
- # `if ! _out=$(...); then` guard so a non-zero rc reaches our marker write
653
- # rather than aborting the subshell before it. Every exit path writes exactly
654
- # one marker.
655
- # ═══════════════════════════════════════════════════════════════════════════
656
- GIT_READY_MARKER="/workspaces/.conveyor-git-ready"
657
- GIT_FAILED_MARKER="/workspaces/.conveyor-git-failed"
658
- # Split-mode pods: the markers live on the shared emptyDir so the workbench's
659
- # git prep is visible to the agent container (which never sees /workspaces).
660
- if [ -n "${CONVEYOR_SHARED_DIR:-}" ]; then
661
- GIT_READY_MARKER="${CONVEYOR_SHARED_DIR}/git-ready"
662
- GIT_FAILED_MARKER="${CONVEYOR_SHARED_DIR}/git-failed"
663
- fi
664
-
665
- reset_tracked_repo_changes_before_assignment_checkout() {
666
- # The baked/pooled repo is not user-owned until this git gate succeeds. Reset
667
- # stale tracked image-generated dirt so assignment checkout can move to the
668
- # requested branch/ref instead of being blocked by files like dependency
669
- # stamps. Do not `git clean`: untracked prebake artifacts may be intentional.
670
- # The assignment checkouts additionally pass -f: an UNTRACKED bake artifact
671
- # can collide with a path the TARGET ref tracks (e.g. a dependency stamp
672
- # committed on an older PR branch), which this reset cannot clear — checkout
673
- # then refuses with "untracked working tree files would be overwritten".
674
- # Forcing is safe here for the same not-user-owned reason.
675
- if ! _reset_out=$(git -C repo reset --hard HEAD 2>&1); then
676
- echo "[pool] ERROR: failed to clean tracked repo changes before checkout: ${_reset_out}" >&2
677
- printf '%s' "pre-checkout reset failed" > "$GIT_FAILED_MARKER"
678
- return 1
679
- fi
680
- return 0
681
- }
682
-
683
- # Shared by both the pod-image branch and the repo-present-non-pod-image branch
684
- # of prepare_workspace_git: refresh the remote token and warm origin/<base>.
685
- # The agent (ensureOnTaskBranch, Tasks 4-5) now owns the task-branch
686
- # fetch/checkout/create-from-base and the merge of base into the task branch —
687
- # this function no longer touches BRANCH or CHECKOUT_REF at all. On success the
688
- # repo is left on a clean tree with an up-to-date origin/<base> remote-tracking
689
- # ref. On any failure it writes GIT_FAILED_MARKER and returns 1 — callers must
690
- # not fall through to `: > "$GIT_READY_MARKER"` in that case.
691
- sync_task_branch_to_repo() {
692
- # Guarded so a set-url failure writes the failed marker instead of aborting
693
- # the backgrounded subshell (which would leave the agent waiting forever).
694
- 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
695
- echo "[pool] ERROR: remote set-url failed" >&2
696
- printf '%s' "remote set-url failed" > "$GIT_FAILED_MARKER"
697
- return 1
698
- fi
699
- # Warm origin/<base> so the agent's checkout/fetch is a fast-forward.
700
- if ! _fetch_out=$(git -C repo fetch origin "+refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}" 2>&1); then
701
- echo "[pool] WARN: fetch origin/${BASE_BRANCH} failed: ${_fetch_out}" >&2
702
- fi
703
- # Leave the tree clean; the agent (ensureOnTaskBranch) does the authoritative
704
- # checkout + conveyor-wip restore. No task-branch checkout, no dev merge here.
705
- reset_tracked_repo_changes_before_assignment_checkout || return 1
706
- echo "[pool] Repo remote ready; agent will checkout ${BRANCH}"
707
- return 0
708
- }
709
-
710
- prepare_workspace_git() {
711
- # Update remote URL with fresh token, or clone if pre-clone failed
712
- cd /workspaces
713
- if [ "${CONVEYOR_POD_IMAGE}" = "1" ] && [ -d "repo/.git" ] && [ -n "${CONVEYOR_GITHUB_TOKEN}" ]; then
714
- # Pod image: repo already exists (and pre-warm already fetched origin/dev, so
715
- # the fetch below is a near-instant fast-forward). Just refresh the remote with
716
- # the assignment's fresh token and bring the task branch up to date.
717
- # IMPORTANT: Do NOT silently fall through to the image snapshot when the
718
- # requested branch can't be fetched/checked out. A stale image repo has
719
- # bitten us before (old load-env.sh, old scripts, wrong deps) and the
720
- # symptoms are very hard to diagnose from pod logs. Fail loud instead —
721
- # now via the failed marker rather than exit 1.
722
- echo "[pool] Pod image — updating repo to latest (branch=${BRANCH})..."
723
- if ! sync_task_branch_to_repo; then
724
- return 1
725
- fi
726
- # Task repo is ready HERE — release the gate. The agent (already launched)
727
- # is polling for this marker before it spawns Claude / runs setup. Reference
728
- # repos are cloned AFTER this so they never block Claude.
729
- mark_git_ready
730
- elif [ -d "repo/.git" ] && [ -n "${CONVEYOR_GITHUB_TOKEN}" ]; then
731
- # Non-image but repo already present (e.g. a baked image running without
732
- # CONVEYOR_POD_IMAGE set). Run the exact same remote-refresh + base-warm
733
- # flow as the pod-image branch so this path ends up in the same
734
- # clean-tree, remote-ready state; the agent (ensureOnTaskBranch) does the
735
- # actual task-branch checkout regardless of which branch got it here.
736
- echo "[pool] Repo present (non-pod-image) — updating repo to latest (branch=${BRANCH})..."
737
- if ! sync_task_branch_to_repo; then
738
- return 1
739
- fi
740
- mark_git_ready
741
- elif [ -n "${CONVEYOR_GITHUB_TOKEN}" ] && [ -n "${REPO_OWNER}" ] && [ -n "${REPO_NAME}" ] && [ -n "${BRANCH}" ]; then
742
- echo "[pool] Cloning repo post-assignment (pre-clone was missing)..."
743
- # Guard each clone/fetch/checkout: under `set -e` a bare failing clone would
744
- # abort the subshell before we can write the failed marker.
745
- if [ -n "${CHECKOUT_REF}" ]; then
746
- if ! _clone_out=$(git clone --depth 1 --single-branch --branch "${BASE_BRANCH}" \
747
- "https://x-access-token:${CONVEYOR_GITHUB_TOKEN}@github.com/${REPO_OWNER}/${REPO_NAME}.git" \
748
- repo 2>&1); then
749
- echo "[pool] ERROR: post-assignment clone failed: ${_clone_out}" >&2
750
- printf '%s' "post-assignment clone failed" > "$GIT_FAILED_MARKER"
751
- return 1
752
- fi
753
- if ! _fetch_out=$(git -C repo fetch origin "+${CHECKOUT_REF}:refs/remotes/origin/pr-checkout" 2>&1); then
754
- echo "[pool] ERROR: post-assignment fetch of ${CHECKOUT_REF} failed: ${_fetch_out}" >&2
755
- printf '%s' "post-assignment fetch of ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
756
- return 1
757
- fi
758
- if ! _checkout_out=$(git -C repo checkout -f -B "${BRANCH}" "refs/remotes/origin/pr-checkout" 2>&1); then
759
- echo "[pool] ERROR: post-assignment checkout of ${CHECKOUT_REF} failed: ${_checkout_out}" >&2
760
- printf '%s' "post-assignment checkout of ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
761
- return 1
762
- fi
763
- else
764
- # Clone the BASE branch — it always exists, unlike a brand-new task
765
- # branch. A naive `clone --branch <task>` dies with "Remote branch not
766
- # found" when the task branch has never been pushed to origin, which
767
- # crash-loops the pod on every fresh task. sync_task_branch_to_repo below
768
- # only refreshes the remote and warms origin/<base> from this clone; the
769
- # agent (ensureOnTaskBranch) resolves/creates ${BRANCH} from it. Full
770
- # depth (no --depth 1) so the agent's base-merge never hits "refusing to
771
- # merge unrelated histories".
772
- if ! _clone_out=$(git clone --single-branch --branch "${BASE_BRANCH}" \
773
- "https://x-access-token:${CONVEYOR_GITHUB_TOKEN}@github.com/${REPO_OWNER}/${REPO_NAME}.git" \
774
- repo 2>&1); then
775
- echo "[pool] ERROR: post-assignment clone of base '${BASE_BRANCH}' failed: ${_clone_out}" >&2
776
- printf '%s' "post-assignment clone failed" > "$GIT_FAILED_MARKER"
777
- return 1
778
- fi
779
- if ! sync_task_branch_to_repo; then
780
- return 1
781
- fi
782
- fi
783
- mark_git_ready
784
- else
785
- # No git plan (e.g. task-less pod, or no token/repo). Nothing to prepare —
786
- # the repo dir already exists (created before launch) and the agent's own
787
- # git-sync fallback (guarded by CONVEYOR_GIT_READY) never runs on this pod
788
- # path anyway. Signal ready so the agent doesn't wait out the timeout.
789
- echo "[pool] No git plan to prepare — marking git ready."
790
- mark_git_ready
791
- fi
792
-
793
- # ── Clone reference repos into /workspaces/references (best-effort) ──
794
- # Populated from REFERENCE_REPOS_JSON (injected via envVars by
795
- # injectReferenceRepos). Each repo is shallow-cloned as read-only context for
796
- # the agent. Failures are non-fatal — the task must still proceed even if a
797
- # reference project's GitHub App is uninstalled or the token mint failed.
798
- # This runs AFTER the ready marker: references are supplementary context and
799
- # must NOT block Claude from spawning.
800
- if [ -n "${REFERENCE_REPOS_JSON:-}" ]; then
801
- mkdir -p /workspaces/references
802
- echo "${REFERENCE_REPOS_JSON}" | jq -c '.[]' 2>/dev/null | while IFS= read -r ref; do
803
- REF_SLUG=$(echo "${ref}" | jq -r '.slug')
804
- REF_OWNER=$(echo "${ref}" | jq -r '.owner')
805
- REF_NAME=$(echo "${ref}" | jq -r '.name')
806
- REF_BRANCH=$(echo "${ref}" | jq -r '.branch // "main"')
807
- REF_TOKEN=$(echo "${ref}" | jq -r '.token // empty')
808
- if [ -z "${REF_TOKEN}" ] || [ -z "${REF_SLUG}" ] || [ -z "${REF_OWNER}" ] || [ -z "${REF_NAME}" ]; then
809
- continue
810
- fi
811
- if [ -d "/workspaces/references/${REF_SLUG}/.git" ]; then
812
- continue
813
- fi
814
- if git clone --depth 1 --single-branch --branch "${REF_BRANCH}" \
815
- "https://x-access-token:${REF_TOKEN}@github.com/${REF_OWNER}/${REF_NAME}.git" \
816
- "/workspaces/references/${REF_SLUG}" 2>/dev/null; then
817
- # Strip the token from the cloned remote so it never surfaces via
818
- # `git remote -v` when the agent inspects the reference repo.
819
- git -C "/workspaces/references/${REF_SLUG}" remote set-url origin \
820
- "https://github.com/${REF_OWNER}/${REF_NAME}.git" 2>/dev/null || true
821
- echo "[pool] cloned reference ${REF_SLUG} (${REF_OWNER}/${REF_NAME}@${REF_BRANCH})"
822
- else
823
- echo "[pool] WARN: reference clone failed: ${REF_SLUG}"
824
- fi
825
- done
826
- unset REFERENCE_REPOS_JSON
827
- fi
828
- return 0
829
- }
830
-
831
- # Preview traffic is now proxied directly via k8s API pod proxy —
832
- # no tunnel client needed. The API routes subdomain requests through
833
- # the k8s API to reach the pod's ports directly.
834
-
835
- # ── Clear stale markers, then background git and launch the agent early ──
836
- # A leftover ready marker from a PRIOR pod on the baked image would be
837
- # catastrophic: the agent would spawn Claude before THIS pod's git runs. Clear
838
- # both before starting the background prep.
839
- #
840
- # Split-mode agent container: the WORKBENCH owns the repo and the git prep and
841
- # writes the shared markers; this container only reads them. It must not clear
842
- # them either — the workbench may have already written ready.
843
- if [ "${CONTAINER_ROLE}" != "agent" ]; then
844
- rm -f "$GIT_READY_MARKER" "$GIT_FAILED_MARKER"
845
- # Ensure the repo dir exists for launch on BOTH paths (the background clone
846
- # populates an empty dir on the non-image path; the pod-image path already has
847
- # repo/.git).
848
- mkdir -p /workspaces/repo
849
-
850
- # Run the full git prep in the background so the agent can connect (card lights
851
- # up) and stream setup output while git finishes.
852
- prepare_workspace_git &
853
- else
854
- echo "[pool] agent role — skipping git prep (workbench owns the workspace)"
855
- fi
856
-
857
- # Signal to the agent that bash owns the git block (skip the agent's OWN
858
- # git-sync fallback) and that it must await the ready marker before spawning
859
- # Claude / running setup.
860
- export CONVEYOR_GIT_READY=1
861
- export CONVEYOR_GIT_READY_MARKER="$GIT_READY_MARKER"
862
- export CONVEYOR_GIT_FAILED_MARKER="$GIT_FAILED_MARKER"
863
- # Target the repo regardless of when the background clone lands.
864
- export CONVEYOR_WORKSPACE=/workspaces/repo
865
-
866
- # ── Workbench container (split-mode): exec the launcher daemon as PID 1 ──
867
- # No crash loop here — the workbench is a native sidecar (restartPolicy
868
- # Always); the kubelet owns restarts. Git prep above ran in THIS container
869
- # (the repo lives here); the daemon serves the agent container's exec/pty/
870
- # snapshot/file operations over loopback (token-authed, see workbench/server).
871
- if [ "${CONTAINER_ROLE}" = "workbench" ]; then
872
- # Bounded git-prep retry, backgrounded so the daemon starts immediately
873
- # (the startupProbe gates pod readiness on the daemon listening). The repo
874
- # lives HERE, so the retry must run here — the agent container's launch-loop
875
- # retry is role-guarded off (found live 2026-07-16: an unguarded agent-side
876
- # retry cloned the repo into the agent's own dead overlay).
877
- (
878
- _wb_git_attempts=0
879
- while true; do
880
- sleep 10
881
- [ -f "$GIT_READY_MARKER" ] && break
882
- if [ -f "$GIT_FAILED_MARKER" ]; then
883
- if [ "$_wb_git_attempts" -ge 3 ]; then
884
- echo "[pool] workbench git prep failed 3 times — giving up (agent surfaces the failure)."
885
- break
886
- fi
887
- _wb_git_attempts=$((_wb_git_attempts + 1))
888
- echo "[pool] workbench git prep failed — retrying (attempt ${_wb_git_attempts}/3)..."
889
- rm -f "$GIT_READY_MARKER" "$GIT_FAILED_MARKER"
890
- prepare_workspace_git
891
- fi
892
- done
893
- ) &
894
- cd /workspaces/repo
895
- boot_mark "launching workbench daemon (env-inject → daemon-launch total)"
896
- echo "[pool] Launching workbench launcher daemon..."
897
- export CONVEYOR_MODE=workbench
898
- exec conveyor-agent
899
- fi
900
-
901
- # Launch agent — exit-code-aware restart loop.
902
- # Exit 0 = clean shutdown (idle timeout, task complete) — pod dies.
903
- # Non-zero = crash — retry after a brief pause.
904
- #
905
- # Split-mode agent container: /workspaces/repo in THIS container is a stale
906
- # baked overlay, never the live tree — cwd must not resolve into it.
907
- if [ "${CONTAINER_ROLE}" = "agent" ]; then
908
- cd /home/conveyor
909
- else
910
- cd /workspaces/repo
911
- fi
912
- boot_mark "launching agent (env-inject → agent-launch total)"
913
- echo "[pool] Launching agent..."
914
- set +e
915
- # Belt-and-braces alongside the pod's preStop hook (which pkills the agent
916
- # directly because bash as PID 1 does not forward signals): if a SIGTERM does
917
- # reach this shell (manual kill, future spec changes), forward it to the agent
918
- # so flushGitOnShutdown still runs, then exit cleanly within the grace period.
919
- trap 'echo "[pool] SIGTERM received, forwarding to agent..."; pkill -TERM -f conveyor-agent; wait; exit 0' TERM
920
- # Bounded retry for a failed git prep. Without this, a transient git-prep
921
- # failure writes GIT_FAILED_MARKER once and the agent's git gate then exits
922
- # nonzero on every single relaunch forever (the marker never clears itself),
923
- # crashlooping the pod every ~10s. Retry git prep itself, up to a small cap,
924
- # before each relaunch; once the cap is exhausted stop hammering and fall
925
- # back to the existing behavior (agent surfaces the failure) with a wider
926
- # sleep so the pod idles instead of spinning.
927
- GIT_PREP_MAX_RETRIES=3
928
- _git_prep_attempts=0
929
- # Bounded agent-crash supervision. An agent that keeps dying is a pod that keeps
930
- # burning; cap the restarts, report every attempt to the API (the report posts to
931
- # the task's activity log, which is also what keeps the pod alive through
932
- # recovery), and exit for good once the cap is hit.
933
- AGENT_CRASH_ATTEMPTS=0
934
- AGENT_CRASH_MAX=3
935
-
936
- report_agent_crash() {
937
- # $1 = exit code, $2 = attempt, $3 = final (true/false). Best-effort.
938
- curl -s -m 10 -X POST \
939
- -H "Authorization: Bearer ${POD_BOOTSTRAP_TOKEN}" \
940
- -H "Content-Type: application/json" \
941
- -d "{\"exitCode\":${1},\"attempt\":${2},\"final\":${3}}" \
942
- "${CONVEYOR_API_URL}/api/v3/pods/agent-crash" >/dev/null 2>&1 || true
943
- }
944
-
945
- while true; do
946
- # Split-mode agent container: the workbench owns git prep AND its retry —
947
- # re-running prep here would clone into this container's dead overlay.
948
- if [ -f "$GIT_FAILED_MARKER" ] && [ "${CONTAINER_ROLE}" != "agent" ]; then
949
- if [ "$_git_prep_attempts" -lt "$GIT_PREP_MAX_RETRIES" ]; then
950
- _git_prep_attempts=$((_git_prep_attempts + 1))
951
- echo "[pool] git prep previously failed — retrying (attempt ${_git_prep_attempts}/${GIT_PREP_MAX_RETRIES})..."
952
- rm -f "$GIT_READY_MARKER" "$GIT_FAILED_MARKER"
953
- prepare_workspace_git &
954
- else
955
- echo "[pool] git prep failed ${GIT_PREP_MAX_RETRIES} times — giving up on retries, letting agent surface the failure."
956
- fi
957
- fi
958
- conveyor-agent 2>&1 | tee -a /tmp/claudespace-agent.log
959
- _exit_code=${PIPESTATUS[0]}
960
- if [ "$_exit_code" -eq 0 ]; then
961
- echo "[pool] agent exited cleanly (code 0), shutting down pod."
962
- exit 0
963
- fi
964
- AGENT_CRASH_ATTEMPTS=$((AGENT_CRASH_ATTEMPTS + 1))
965
- if [ "$AGENT_CRASH_ATTEMPTS" -ge "$AGENT_CRASH_MAX" ]; then
966
- echo "[pool] agent crashed (code $_exit_code) — attempt cap ${AGENT_CRASH_MAX} reached, giving up."
967
- report_agent_crash "$_exit_code" "$AGENT_CRASH_ATTEMPTS" true
968
- exit 1
969
- fi
970
- report_agent_crash "$_exit_code" "$AGENT_CRASH_ATTEMPTS" false
971
- if [ -f "$GIT_FAILED_MARKER" ] && [ "$_git_prep_attempts" -ge "$GIT_PREP_MAX_RETRIES" ]; then
972
- echo "[pool] agent crashed (code $_exit_code) after git prep exhausted retries, backing off (60s, attempt ${AGENT_CRASH_ATTEMPTS}/${AGENT_CRASH_MAX})..."
973
- sleep 60
974
- else
975
- echo "[pool] agent crashed (code $_exit_code), retrying in 10s (attempt ${AGENT_CRASH_ATTEMPTS}/${AGENT_CRASH_MAX})..."
976
- sleep 10
977
- fi
978
- done
10
+ conveyor-agent boot --preflight-update || echo "[shim] preflight failed; booting baked agent"
11
+ exec conveyor-agent boot