@rallycry/conveyor-agent 10.0.0 → 10.0.1

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.
package/package.json CHANGED
@@ -1,20 +1,21 @@
1
1
  {
2
2
  "name": "@rallycry/conveyor-agent",
3
- "version": "10.0.0",
4
- "description": "Conveyor Agent Runner v8 - PTY harness for the task chat (SDK harness for audit/project-chat). Agent-as-User architecture with BaseService patterns. Works locally too.",
3
+ "version": "10.0.1",
4
+ "description": "Conveyor Agent Runner v10 - PTY harness for the task chat (SDK harness for audit/project-chat). Agent-as-User architecture with BaseService patterns. Works locally too.",
5
5
  "keywords": [
6
6
  "agent",
7
7
  "claude",
8
8
  "codespace",
9
9
  "conveyor",
10
- "v8"
10
+ "v10"
11
11
  ],
12
12
  "license": "MIT",
13
13
  "bin": {
14
14
  "conveyor-agent": "dist/cli.js"
15
15
  },
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "runtime"
18
19
  ],
19
20
  "type": "module",
20
21
  "main": "./dist/index.js",
@@ -39,6 +40,7 @@
39
40
  "@modelcontextprotocol/sdk": "^1.12.1",
40
41
  "node-pty": "^1.0.0",
41
42
  "socket.io-client": "^4.7.4",
43
+ "tar": "^7.5.19",
42
44
  "winston": "^3.11.0",
43
45
  "zod": "^3.25.76"
44
46
  },
@@ -0,0 +1,627 @@
1
+ #!/bin/bash
2
+ set -eo pipefail
3
+ export HOME=/home/conveyor
4
+ 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
+ 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 the same per-target deadlines learned
38
+ # here (postgres 30s, firebase 60s). See:
39
+ # docs/superpowers/specs/2026-06-28-agent-launch-before-sidecar-setup-design.md
40
+
41
+ # ── Refresh agent runner to latest before launch (payload-independent) ──
42
+ # The image version can be stale vs. what the API expects (protocol/contract
43
+ # drift causes instant exit-1 crashes). Only install if the registry's `latest`
44
+ # is *strictly greater* than what's already in the image. If the image version
45
+ # is ahead (e.g. a dev pod image from an unreleased commit), never downgrade.
46
+ # `npm view` is a metadata-only fetch (~1s) vs. a full install (~30-60s). Hoisted
47
+ # into pre-warm so a claimed warm pod never pays this on the critical path.
48
+ refresh_agent_version() {
49
+ local _installed_agent _npm_view_stderr _npm_view_output _npm_view_rc
50
+ local _npm_view_err_content _latest_agent_raw _latest_agent _higher
51
+ _installed_agent=$(conveyor-agent --version 2>/dev/null | tr -d '[:space:]' || echo "")
52
+ # The image's /home/conveyor/.npmrc pins `@rallycry:registry=https://npm.pkg.github.com/`
53
+ # for other private @rallycry packages, but conveyor-agent itself is published
54
+ # to public npmjs.org. Override the scope on the command line so this lookup
55
+ # hits the right registry.
56
+ #
57
+ # Capture stdout and stderr SEPARATELY. Mixing them (2>&1) was a disaster:
58
+ # npm's update notifier (+ random future notices) write to stderr, and after
59
+ # `tr -d '[:space:]'` the version and the notice ran together into a garbage
60
+ # string like "7.0.12npmnoticenpmnotice..." — which then got fed to
61
+ # `npm install @rallycry/conveyor-agent@<garbage>`, which failed partway
62
+ # through and left the pod with NO conveyor-agent binary at all (crashloop).
63
+ # NO_UPDATE_NOTIFIER=1 is set at the top of this script as belt-and-suspenders,
64
+ # but don't rely on it.
65
+ #
66
+ # The if/else form on the assignment is required because `set -e` aborts on
67
+ # a failing command in assignment context, and a trailing `|| true` would
68
+ # mask the real exit code.
69
+ # The baked /home/conveyor/.npm cache dir is owned by root (npm ran as root
70
+ # during the image build), but the entrypoint runs as uid 1001 — so a non-sudo
71
+ # `npm view` fails with EACCES trying to mkdir its _cacache. Point every npm
72
+ # invocation here at a world-writable cache dir so the version lookup works.
73
+ local _npm_cache=/tmp/npm-cache
74
+ _npm_view_stderr=$(mktemp 2>/dev/null || echo "/tmp/npmview.$$.err")
75
+ if _npm_view_output=$(npm view --cache "$_npm_cache" \
76
+ --@rallycry:registry=https://registry.npmjs.org/ \
77
+ @rallycry/conveyor-agent version 2>"$_npm_view_stderr"); then
78
+ _npm_view_rc=0
79
+ else
80
+ _npm_view_rc=$?
81
+ fi
82
+ _npm_view_err_content=$(cat "$_npm_view_stderr" 2>/dev/null || echo "")
83
+ rm -f "$_npm_view_stderr" 2>/dev/null || true
84
+ _latest_agent_raw=$(printf '%s' "${_npm_view_output}" | tr -d '[:space:]')
85
+ # Validate the captured value looks like a semver (x.y.z with optional
86
+ # prerelease/build metadata). Anything else → treat as a failed lookup and
87
+ # keep the image version. This is the safety net that prevents us from ever
88
+ # running `npm install @rallycry/conveyor-agent@<corrupted>` again.
89
+ if [ $_npm_view_rc -eq 0 ] && [[ "${_latest_agent_raw}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][A-Za-z0-9.-]+)?$ ]]; then
90
+ _latest_agent="${_latest_agent_raw}"
91
+ else
92
+ _latest_agent=""
93
+ fi
94
+ if [ -z "${_latest_agent}" ]; then
95
+ echo "[pool] WARNING: failed to get valid semver from npm for @rallycry/conveyor-agent (rc=${_npm_view_rc}), keeping image version ${_installed_agent:-unknown}"
96
+ echo "[pool] npm view stdout: ${_npm_view_output}"
97
+ echo "[pool] npm view stderr: ${_npm_view_err_content}"
98
+ echo "[pool] raw value after whitespace strip: '${_latest_agent_raw}'"
99
+ echo "[pool] npm binary: $(command -v npm || echo 'not found'), node: $(command -v node || echo 'not found'), HOME=${HOME:-unset}"
100
+ elif [ -z "${_installed_agent}" ]; then
101
+ echo "[pool] No agent in image, installing @rallycry/conveyor-agent@${_latest_agent}..."
102
+ sudo npm install -g --cache "$_npm_cache" --silent "@rallycry/conveyor-agent@${_latest_agent}" 2>&1 \
103
+ || echo "[pool] WARNING: agent install failed"
104
+ echo "[pool] Agent version: $(conveyor-agent --version 2>&1 || echo unknown)"
105
+ elif [ "${_installed_agent}" = "${_latest_agent}" ]; then
106
+ echo "[pool] Agent version: ${_installed_agent} (matches published, skipping install)"
107
+ else
108
+ # Semver compare via `sort -V`. Highest version is the last line.
109
+ _higher=$(printf '%s\n%s\n' "${_installed_agent}" "${_latest_agent}" | sort -V | tail -n1)
110
+ if [ "${_higher}" = "${_installed_agent}" ]; then
111
+ echo "[pool] Agent version: ${_installed_agent} (ahead of published ${_latest_agent}, skipping install)"
112
+ else
113
+ echo "[pool] Updating @rallycry/conveyor-agent ${_installed_agent} → ${_latest_agent}..."
114
+ sudo npm install -g --cache "$_npm_cache" --silent "@rallycry/conveyor-agent@${_latest_agent}" 2>&1 \
115
+ || echo "[pool] WARNING: agent update failed, falling back to image version ${_installed_agent}"
116
+ echo "[pool] Agent version: $(conveyor-agent --version 2>&1 || echo unknown)"
117
+ fi
118
+ fi
119
+ }
120
+
121
+ start_workspace_sshd
122
+
123
+ # ── Required env vars (injected by pod spec — v3: exactly these two) ──
124
+ : "${CONVEYOR_API_URL:?CONVEYOR_API_URL is required}"
125
+ : "${POD_BOOTSTRAP_TOKEN:?POD_BOOTSTRAP_TOKEN is required}"
126
+
127
+ # Pod name is the instance identifier
128
+ INSTANCE_NAME="${HOSTNAME}"
129
+ export CLAUDESPACE_NAME="${INSTANCE_NAME}"
130
+
131
+ # ═══════════════════════════════════════════════════════════════════════════
132
+ # PRE-BIND PHASE — payload-independent work only. v3 pods do not have repo,
133
+ # GitHub, or task credentials until the bootstrap endpoint returns 200.
134
+ # ═══════════════════════════════════════════════════════════════════════════
135
+ PREWARM_DEV_LOOP_PID=""
136
+ refresh_agent_version
137
+
138
+ # ═══════════════════════════════════════════════════════════════════════════
139
+ # PULL-BASED BOOTSTRAP — poll until the reconciler binds this pod to a
140
+ # Workspace. 204 = still unbound; 200 = full bundle. No HMAC, no hostname
141
+ # derivation, no exec-push, no /tmp/assignment.json.
142
+ # ═══════════════════════════════════════════════════════════════════════════
143
+ # Bash runs as PID 1: without a trap, SIGTERM is ignored during standby and
144
+ # every pool drain/scale-down rides the full terminationGracePeriod (180s) to
145
+ # SIGKILL. The agent-phase trap installed later replaces this one.
146
+ trap 'echo "[boot] SIGTERM during standby — exiting."; exit 0' TERM INT
147
+ echo "[boot] Entering standby — polling for bootstrap bind..."
148
+ POLL_COUNTER=0
149
+ BOOTSTRAP_JSON=""
150
+ while true; do
151
+ # The response body carries credentials — pre-create it owner-only so no
152
+ # window exists where another uid could read it.
153
+ rm -f /tmp/bootstrap-response.json
154
+ (umask 077 && touch /tmp/bootstrap-response.json)
155
+ # No `-f` on curl: with -f an HTTP-error response still prints the -w
156
+ # write-out AND exits 22, so `|| echo "000"` produced "401000" — the 401
157
+ # fail-fast branch below could never match and a deleted pod polled forever.
158
+ # Without -f curl exits 0 on any HTTP response and -w yields the clean
159
+ # status; the `|| echo "000"` fires only on pure network errors.
160
+ HTTP_STATUS=$(curl -s -o /tmp/bootstrap-response.json -w '%{http_code}' \
161
+ -H "Authorization: Bearer ${POD_BOOTSTRAP_TOKEN}" \
162
+ "${CONVEYOR_API_URL}/api/v3/pods/bootstrap" 2>/dev/null || echo "000")
163
+
164
+ if [ "${HTTP_STATUS}" = "200" ]; then
165
+ BOOTSTRAP_JSON=$(cat /tmp/bootstrap-response.json)
166
+ rm -f /tmp/bootstrap-response.json
167
+ echo "[boot] Bound — bootstrap bundle received!"
168
+ break
169
+ elif [ "${HTTP_STATUS}" = "401" ]; then
170
+ echo "[boot] ERROR: bootstrap token rejected (401) — pod identity invalid, exiting." >&2
171
+ exit 1
172
+ fi
173
+ rm -f /tmp/bootstrap-response.json
174
+
175
+ POLL_COUNTER=$((POLL_COUNTER + 1))
176
+ if [ $((POLL_COUNTER % 30)) -eq 0 ]; then
177
+ echo "[boot] Still waiting for bind (poll #${POLL_COUNTER})..."
178
+ fi
179
+ sleep 2
180
+ done
181
+
182
+ # `// empty` on every extraction: bare `.field` renders a missing/null field
183
+ # as the literal string "null", which then flows into git URLs and env vars.
184
+ export CONVEYOR_TASK_TOKEN=$(echo "${BOOTSTRAP_JSON}" | jq -r '.sessionJwt // empty')
185
+ export CONVEYOR_GITHUB_TOKEN=$(echo "${BOOTSTRAP_JSON}" | jq -r '.githubToken // empty')
186
+ REPO_OWNER=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.repoOwner // empty')
187
+ REPO_NAME=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.repoName // empty')
188
+ BRANCH=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.branch // empty')
189
+ BASE_BRANCH=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.baseBranch // empty')
190
+ CHECKOUT_REF=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gitPlan.checkoutRef // empty')
191
+ export REPO_OWNER REPO_NAME BRANCH
192
+
193
+ # Decode non-secret session identity fields from the JWT payload so the
194
+ # existing agent startup contract remains unchanged.
195
+ 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 "{}")
196
+ # CONVEYOR_TASK_ID is exported ONLY when the claim exists: a task-less PROJECT
197
+ # session JWT has no taskId claim, and the agent keys its project-runner mode
198
+ # off the var being ABSENT (never an empty-string export).
199
+ CONVEYOR_TASK_ID=$(echo "${SESSION_CLAIMS}" | jq -r '.taskId // empty' 2>/dev/null || true)
200
+ if [ -n "${CONVEYOR_TASK_ID}" ]; then
201
+ export CONVEYOR_TASK_ID
202
+ else
203
+ unset CONVEYOR_TASK_ID
204
+ fi
205
+ # projectId claim is present only on task-less project session JWTs.
206
+ CONVEYOR_PROJECT_ID_CLAIM=$(echo "${SESSION_CLAIMS}" | jq -r '.projectId // empty' 2>/dev/null || true)
207
+ if [ -n "${CONVEYOR_PROJECT_ID_CLAIM}" ]; then
208
+ export CONVEYOR_PROJECT_ID="${CONVEYOR_PROJECT_ID_CLAIM}"
209
+ fi
210
+ export CONVEYOR_SESSION_ID=$(echo "${SESSION_CLAIMS}" | jq -r '.sessionId // empty')
211
+ export CONVEYOR_WORKSPACE_ID=$(echo "${SESSION_CLAIMS}" | jq -r '.workspaceId // empty')
212
+ SESSION_ROLE=$(echo "${SESSION_CLAIMS}" | jq -r '.role // empty')
213
+ unset SESSION_CLAIMS
214
+ if [ "${SESSION_ROLE}" = "reader" ]; then
215
+ export CONVEYOR_MODE="review"
216
+ elif [ -z "${CONVEYOR_TASK_ID:-}" ] && [ -n "${CONVEYOR_PROJECT_ID_CLAIM}" ]; then
217
+ # Task-less project pod: the agent boots the project runner in pm mode
218
+ # (see conveyor-agent setup/project-identity.ts — pm is required).
219
+ export CONVEYOR_MODE="pm"
220
+ fi
221
+
222
+ # ── Export bundle env vars (secrets, OAuth tokens, project config) ──
223
+ ENV_KEYS=$(echo "${BOOTSTRAP_JSON}" | jq -r '.envVars // {} | keys[]' 2>/dev/null)
224
+ if [ -n "${ENV_KEYS}" ]; then
225
+ ENV_COUNT=0
226
+ while IFS= read -r key; do
227
+ # A bundle key that isn't a valid shell identifier would make `export`
228
+ # eval arbitrary content, and a handful of names would hijack the boot
229
+ # itself (PATH swaps every binary below; LD_PRELOAD injects code into
230
+ # them). Project env is user-supplied — validate, never trust.
231
+ if ! [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
232
+ echo "[boot] WARN: skipping invalid env key from bundle: '${key}'"
233
+ continue
234
+ fi
235
+ case "${key}" in
236
+ PATH|HOME|LD_PRELOAD|SHELL)
237
+ echo "[boot] WARN: skipping denylisted env key from bundle: '${key}'"
238
+ continue
239
+ ;;
240
+ esac
241
+ value=$(echo "${BOOTSTRAP_JSON}" | jq -r --arg k "${key}" '.envVars[$k] // empty')
242
+ export "${key}=${value}"
243
+ ENV_COUNT=$((ENV_COUNT + 1))
244
+ done <<< "${ENV_KEYS}"
245
+ echo "[boot] Injected ${ENV_COUNT} env vars from bootstrap bundle"
246
+ fi
247
+ export ANTHROPIC_API_KEY=$(echo "${BOOTSTRAP_JSON}" | jq -r '.anthropicKey // empty')
248
+ export CLOUDSDK_AUTH_ACCESS_TOKEN=$(echo "${BOOTSTRAP_JSON}" | jq -r '.gcpToken // empty')
249
+ # WorkPreservation GCS tier (spec §Durability & snapshots): the upload URL is
250
+ # long-lived (used for the pod's whole lifetime of periodic captures + sleep
251
+ # finalize); the download URL is present only when a snapshot exists and is
252
+ # consumed once by restoreOnBoot.
253
+ CONVEYOR_SNAPSHOT_UPLOAD_URL=$(echo "${BOOTSTRAP_JSON}" | jq -r '.snapshotUploadUrl // empty')
254
+ CONVEYOR_SNAPSHOT_URL=$(echo "${BOOTSTRAP_JSON}" | jq -r '.snapshotUrl // empty')
255
+ export CONVEYOR_SNAPSHOT_UPLOAD_URL CONVEYOR_SNAPSHOT_URL
256
+ unset BOOTSTRAP_JSON
257
+ # POD_BOOTSTRAP_TOKEN is deliberately NOT unset: it is the credential-refresh
258
+ # key. The agent re-polls GET /api/v3/pods/bootstrap with it to swap in a
259
+ # fresh githubToken/sessionJwt in place (see agent-connection refresh path).
260
+
261
+ CONVEYOR_USER_ID="${CONVEYOR_USER_ID:-}"
262
+ CONVEYOR_PROJECT_ID_FROM_BUNDLE="${CONVEYOR_PROJECT_ID:-${PROJECT_ID:-}}"
263
+ export CONVEYOR_USER_ID
264
+
265
+ # ── Wire per-user Claude state from the GCS FUSE mount ──
266
+ # When the pod has /mnt/conveyor-users mounted (GCS FUSE CSI), symlink
267
+ # ~/.claude, ~/.claude.json, and ~/.config/claude to the user's subdir so
268
+ # Claude state persists across all of the user's codespaces without any
269
+ # tar/upload/download round-trip. Uses projectId if present, else falls
270
+ # back to PROJECT_ID from the pod env.
271
+ USER_HOME_MOUNT="/mnt/conveyor-users"
272
+ USER_HOME_PROJECT_ID="${CONVEYOR_PROJECT_ID_FROM_BUNDLE:-${PROJECT_ID:-}}"
273
+ if [ -n "${CONVEYOR_USER_ID}" ] && [ -n "${USER_HOME_PROJECT_ID}" ] && [ -d "${USER_HOME_MOUNT}" ]; then
274
+ USER_HOME_ROOT="${USER_HOME_MOUNT}/users/${CONVEYOR_USER_ID}/${USER_HOME_PROJECT_ID}"
275
+ echo "[pool] Linking Claude state to ${USER_HOME_ROOT}"
276
+ mkdir -p "${USER_HOME_ROOT}/.claude" "${USER_HOME_ROOT}/.config/claude" 2>/dev/null || true
277
+ # Seed .claude.json on first use. Claude Code treats a 0-byte file as
278
+ # corrupted ("Unexpected end of JSON input") and exits code 1, killing the
279
+ # very first query on any fresh user+project mount. We pre-complete onboarding
280
+ # and pick a default theme so the spawned Claude Code TUI doesn't stop at the
281
+ # first-run theme picker / login. Only seed when the file is missing/empty or a
282
+ # bare "{}" placeholder — never overwrite a real config carried over via the
283
+ # GCS-FUSE user-home mount from a prior pod session.
284
+ CLAUDE_JSON="${USER_HOME_ROOT}/.claude.json"
285
+ # Read defensively: on a first-time user+project mount the file does not
286
+ # exist yet. `< "${CLAUDE_JSON}"` on a missing file is a SHELL-level
287
+ # redirection error (the `2>/dev/null` only silences tr, not bash), which
288
+ # under `set -eo pipefail` aborts the whole entrypoint with exit 1 — the
289
+ # agent container then dies seconds after start and the pod is reaped
290
+ # ("deleted externally"). Guard on existence and rescue any read error so a
291
+ # missing/unreadable file falls through to the seed below instead of crashing.
292
+ if [ -f "${CLAUDE_JSON}" ]; then
293
+ CLAUDE_JSON_CURRENT="$(tr -d '[:space:]' < "${CLAUDE_JSON}" 2>/dev/null || true)"
294
+ else
295
+ CLAUDE_JSON_CURRENT=""
296
+ fi
297
+ if [ -z "${CLAUDE_JSON_CURRENT}" ] || [ "${CLAUDE_JSON_CURRENT}" = "{}" ]; then
298
+ echo '{"hasCompletedOnboarding":true,"theme":"dark"}' > "${CLAUDE_JSON}" 2>/dev/null || true
299
+ fi
300
+
301
+ # Replace any image defaults with live symlinks into the mount.
302
+ rm -rf /home/conveyor/.claude /home/conveyor/.claude.json /home/conveyor/.config/claude 2>/dev/null || true
303
+ mkdir -p /home/conveyor/.config 2>/dev/null || true
304
+ ln -sfn "${USER_HOME_ROOT}/.claude" /home/conveyor/.claude
305
+ ln -sfn "${USER_HOME_ROOT}/.claude.json" /home/conveyor/.claude.json
306
+ ln -sfn "${USER_HOME_ROOT}/.config/claude" /home/conveyor/.config/claude
307
+ else
308
+ 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))"
309
+ fi
310
+
311
+ # ═══════════════════════════════════════════════════════════════════════════
312
+ # WORKSPACE GIT — moved OFF the pre-launch critical path (Claudespace v3).
313
+ #
314
+ # The agent is launched IMMEDIATELY (below) so the card lights up and setup
315
+ # output streams while git runs in the BACKGROUND. `prepare_workspace_git`
316
+ # does the full fetch/checkout/merge (task repo) then clones reference repos,
317
+ # and signals completion by writing exactly ONE marker file:
318
+ #
319
+ # GIT_READY_MARKER — task repo is up to date; the agent may spawn Claude.
320
+ # GIT_FAILED_MARKER — git preparation errored; the agent surfaces the error
321
+ # and shuts down WITHOUT operating on a broken/stale repo.
322
+ #
323
+ # This function runs backgrounded (`prepare_workspace_git &`). It therefore
324
+ # must NEVER `exit` (that only kills the subshell, leaving the agent to wait
325
+ # on a marker that never arrives) — every error path does
326
+ # `echo >&2; printf ... > "$GIT_FAILED_MARKER"; return 1` instead. The
327
+ # fail-loud-on-stale-image philosophy is preserved: a failure writes the failed
328
+ # marker (was: exit 1), which the agent treats as fatal.
329
+ #
330
+ # `set -e` interaction: fallible git commands keep the existing
331
+ # `if ! _out=$(...); then` guard so a non-zero rc reaches our marker write
332
+ # rather than aborting the subshell before it. Every exit path writes exactly
333
+ # one marker.
334
+ # ═══════════════════════════════════════════════════════════════════════════
335
+ GIT_READY_MARKER="/workspaces/.conveyor-git-ready"
336
+ GIT_FAILED_MARKER="/workspaces/.conveyor-git-failed"
337
+
338
+ # Shared by both the pod-image branch and the repo-present-non-pod-image branch
339
+ # of prepare_workspace_git: refresh the remote token, fetch the base branch,
340
+ # fetch/checkout the task branch (creating it from base if it doesn't exist on
341
+ # origin yet), and merge latest base into it. On success the repo is left
342
+ # checked out on the task branch, up to date with base. On any failure it
343
+ # writes GIT_FAILED_MARKER and returns 1 — callers must not fall through to
344
+ # `: > "$GIT_READY_MARKER"` in that case.
345
+ sync_task_branch_to_repo() {
346
+ # Guarded so a set-url failure writes the failed marker instead of aborting
347
+ # the backgrounded subshell (which would leave the agent waiting forever).
348
+ 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
349
+ echo "[pool] ERROR: remote set-url failed for pod-image repo" >&2
350
+ printf '%s' "pod-image remote set-url failed" > "$GIT_FAILED_MARKER"
351
+ return 1
352
+ fi
353
+ # Unshallow the baked repo if needed to prevent "refusing to merge unrelated histories"
354
+ if [ "$(git -C repo rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then
355
+ echo "[pool] Unshallowing baked repo..."
356
+ if ! _unshallow_out=$(git -C repo fetch --unshallow origin 2>&1); then
357
+ echo "[pool] WARN: unshallow failed: ${_unshallow_out}" >&2
358
+ fi
359
+ fi
360
+ # Always fetch latest base so downstream steps (and the agent's
361
+ # syncWithBaseBranch) have an up-to-date origin/<base> to merge from.
362
+ DEV_BRANCH="${BASE_BRANCH}"
363
+ if ! _fetch_dev_out=$(git -C repo fetch origin "+refs/heads/${DEV_BRANCH}:refs/remotes/origin/${DEV_BRANCH}" 2>&1); then
364
+ echo "[pool] WARN: fetch origin/${DEV_BRANCH} failed: ${_fetch_dev_out}" >&2
365
+ fi
366
+
367
+ if [ -n "${CHECKOUT_REF}" ]; then
368
+ echo "[pool] Fetching checkout ref ${CHECKOUT_REF} for review branch ${BRANCH}..."
369
+ if ! _fetch_checkout_out=$(git -C repo fetch origin "+${CHECKOUT_REF}:refs/remotes/origin/pr-checkout" 2>&1); then
370
+ echo "[pool] ERROR: Repo fetch failed for checkout ref '${CHECKOUT_REF}': ${_fetch_checkout_out}" >&2
371
+ printf '%s' "fetch checkout ref ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
372
+ return 1
373
+ fi
374
+ if ! _checkout_out=$(git -C repo checkout -B "${BRANCH}" "refs/remotes/origin/pr-checkout" 2>&1); then
375
+ echo "[pool] ERROR: Repo checkout of ${CHECKOUT_REF} failed: ${_checkout_out}" >&2
376
+ printf '%s' "checkout of ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
377
+ return 1
378
+ fi
379
+ else
380
+ # Fetch the task branch into a local ref explicitly so we can check it out.
381
+ # If the branch doesn't exist on origin, create it from the base branch.
382
+ # Note: if BRANCH equals DEV_BRANCH (deferred-branch case), the fetch above
383
+ # already populated the ref and this is a no-op.
384
+ if ! _fetch_out=$(git -C repo fetch origin "+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}" 2>&1); then
385
+ # Check if the error is specifically due to the remote ref not existing
386
+ if echo "${_fetch_out}" | grep -q "couldn't find remote ref"; then
387
+ echo "[pool] Branch missing on origin — creating from base '${DEV_BRANCH}'"
388
+ # Create the branch locally from the base branch
389
+ if ! _create_out=$(git -C repo checkout -B "${BRANCH}" "origin/${DEV_BRANCH}" 2>&1); then
390
+ echo "[pool] ERROR: Failed to create branch '${BRANCH}' from '${DEV_BRANCH}': ${_create_out}" >&2
391
+ printf '%s' "create branch ${BRANCH} from ${DEV_BRANCH} failed" > "$GIT_FAILED_MARKER"
392
+ return 1
393
+ fi
394
+ # Push the new branch to origin with -u to set up tracking
395
+ if ! _push_out=$(git -C repo push -u origin "${BRANCH}" 2>&1); then
396
+ echo "[pool] ERROR: Failed to push new branch '${BRANCH}': ${_push_out}" >&2
397
+ printf '%s' "push new branch ${BRANCH} failed" > "$GIT_FAILED_MARKER"
398
+ return 1
399
+ fi
400
+ echo "[pool] Created and pushed new branch '${BRANCH}' from '${DEV_BRANCH}'"
401
+ else
402
+ # Other git error (auth, network, etc.) — fail loud as before
403
+ echo "[pool] ERROR: Repo fetch failed for branch '${BRANCH}': ${_fetch_out}" >&2
404
+ printf '%s' "fetch branch ${BRANCH} failed" > "$GIT_FAILED_MARKER"
405
+ return 1
406
+ fi
407
+ fi
408
+ # Use `checkout -B` to create or reset the local branch tracking origin.
409
+ # This leaves HEAD attached to a real branch (not detached) so `git status`,
410
+ # `git push`, and any tool that keys off branch state work correctly.
411
+ if ! _checkout_out=$(git -C repo checkout -B "${BRANCH}" "origin/${BRANCH}" 2>&1); then
412
+ echo "[pool] ERROR: Repo checkout of ${BRANCH} failed: ${_checkout_out}" >&2
413
+ printf '%s' "checkout of ${BRANCH} failed" > "$GIT_FAILED_MARKER"
414
+ return 1
415
+ fi
416
+ fi
417
+
418
+ # Merge latest dev into the task branch so the agent starts on up-to-date
419
+ # base code. Skip if the branch already contains origin/dev (fast-path)
420
+ # or if BRANCH is the dev branch itself.
421
+ if [ "${BRANCH}" != "${DEV_BRANCH}" ] && git -C repo rev-parse "origin/${DEV_BRANCH}" >/dev/null 2>&1; then
422
+ if git -C repo merge-base --is-ancestor "origin/${DEV_BRANCH}" HEAD; then
423
+ echo "[pool] Branch already up-to-date with origin/${DEV_BRANCH}"
424
+ elif ! _merge_out=$(git -C repo merge "origin/${DEV_BRANCH}" --no-edit 2>&1); then
425
+ echo "[pool] WARN: merge origin/${DEV_BRANCH} failed, aborting: ${_merge_out}" >&2
426
+ git -C repo merge --abort 2>/dev/null || true
427
+ else
428
+ echo "[pool] Merged origin/${DEV_BRANCH} into ${BRANCH}"
429
+ fi
430
+ fi
431
+ echo "[pool] Repo updated to ${BRANCH}@$(git -C repo rev-parse --short HEAD)"
432
+ return 0
433
+ }
434
+
435
+ prepare_workspace_git() {
436
+ # Update remote URL with fresh token, or clone if pre-clone failed
437
+ cd /workspaces
438
+ if [ "${CONVEYOR_POD_IMAGE}" = "1" ] && [ -d "repo/.git" ] && [ -n "${CONVEYOR_GITHUB_TOKEN}" ]; then
439
+ # Pod image: repo already exists (and pre-warm already fetched origin/dev, so
440
+ # the fetch below is a near-instant fast-forward). Just refresh the remote with
441
+ # the assignment's fresh token and bring the task branch up to date.
442
+ # IMPORTANT: Do NOT silently fall through to the image snapshot when the
443
+ # requested branch can't be fetched/checked out. A stale image repo has
444
+ # bitten us before (old load-env.sh, old scripts, wrong deps) and the
445
+ # symptoms are very hard to diagnose from pod logs. Fail loud instead —
446
+ # now via the failed marker rather than exit 1.
447
+ echo "[pool] Pod image — updating repo to latest (branch=${BRANCH})..."
448
+ if ! sync_task_branch_to_repo; then
449
+ return 1
450
+ fi
451
+ # Task repo is ready HERE — release the gate. The agent (already launched)
452
+ # is polling for this marker before it spawns Claude / runs setup. Reference
453
+ # repos are cloned AFTER this so they never block Claude.
454
+ : > "$GIT_READY_MARKER"
455
+ elif [ -d "repo/.git" ] && [ -n "${CONVEYOR_GITHUB_TOKEN}" ]; then
456
+ # Non-image but repo already present (e.g. a baked image running without
457
+ # CONVEYOR_POD_IMAGE set). Must NOT settle for a bare remote-URL refresh —
458
+ # that would leave the agent running on whatever branch the repo snapshot
459
+ # happened to be on (e.g. dev), silently committing/pushing to the wrong
460
+ # branch. Run the exact same fetch/checkout/merge flow as the pod-image
461
+ # branch so this path ends up in the same checked-out-on-task-branch state.
462
+ echo "[pool] Repo present (non-pod-image) — updating repo to latest (branch=${BRANCH})..."
463
+ if ! sync_task_branch_to_repo; then
464
+ return 1
465
+ fi
466
+ : > "$GIT_READY_MARKER"
467
+ elif [ -n "${CONVEYOR_GITHUB_TOKEN}" ] && [ -n "${REPO_OWNER}" ] && [ -n "${REPO_NAME}" ] && [ -n "${BRANCH}" ]; then
468
+ echo "[pool] Cloning repo post-assignment (pre-clone was missing)..."
469
+ # Guard each clone/fetch/checkout: under `set -e` a bare failing clone would
470
+ # abort the subshell before we can write the failed marker.
471
+ if [ -n "${CHECKOUT_REF}" ]; then
472
+ if ! _clone_out=$(git clone --depth 1 --single-branch --branch "${BASE_BRANCH}" \
473
+ "https://x-access-token:${CONVEYOR_GITHUB_TOKEN}@github.com/${REPO_OWNER}/${REPO_NAME}.git" \
474
+ repo 2>&1); then
475
+ echo "[pool] ERROR: post-assignment clone failed: ${_clone_out}" >&2
476
+ printf '%s' "post-assignment clone failed" > "$GIT_FAILED_MARKER"
477
+ return 1
478
+ fi
479
+ if ! _fetch_out=$(git -C repo fetch origin "+${CHECKOUT_REF}:refs/remotes/origin/pr-checkout" 2>&1); then
480
+ echo "[pool] ERROR: post-assignment fetch of ${CHECKOUT_REF} failed: ${_fetch_out}" >&2
481
+ printf '%s' "post-assignment fetch of ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
482
+ return 1
483
+ fi
484
+ if ! _checkout_out=$(git -C repo checkout -B "${BRANCH}" "refs/remotes/origin/pr-checkout" 2>&1); then
485
+ echo "[pool] ERROR: post-assignment checkout of ${CHECKOUT_REF} failed: ${_checkout_out}" >&2
486
+ printf '%s' "post-assignment checkout of ${CHECKOUT_REF} failed" > "$GIT_FAILED_MARKER"
487
+ return 1
488
+ fi
489
+ else
490
+ # Clone the BASE branch — it always exists, unlike a brand-new task
491
+ # branch. A naive `clone --branch <task>` dies with "Remote branch not
492
+ # found" when the task branch has never been pushed to origin, which
493
+ # crash-loops the pod on every fresh task. sync_task_branch_to_repo below
494
+ # resolves/creates ${BRANCH} from the clone identically to the pod-image
495
+ # path. Full depth (no --depth 1) so its base-merge never hits "refusing
496
+ # to merge unrelated histories".
497
+ if ! _clone_out=$(git clone --single-branch --branch "${BASE_BRANCH}" \
498
+ "https://x-access-token:${CONVEYOR_GITHUB_TOKEN}@github.com/${REPO_OWNER}/${REPO_NAME}.git" \
499
+ repo 2>&1); then
500
+ echo "[pool] ERROR: post-assignment clone of base '${BASE_BRANCH}' failed: ${_clone_out}" >&2
501
+ printf '%s' "post-assignment clone failed" > "$GIT_FAILED_MARKER"
502
+ return 1
503
+ fi
504
+ if ! sync_task_branch_to_repo; then
505
+ return 1
506
+ fi
507
+ fi
508
+ : > "$GIT_READY_MARKER"
509
+ else
510
+ # No git plan (e.g. task-less pod, or no token/repo). Nothing to prepare —
511
+ # the repo dir already exists (created before launch) and the agent's own
512
+ # git-sync fallback (guarded by CONVEYOR_GIT_READY) never runs on this pod
513
+ # path anyway. Signal ready so the agent doesn't wait out the timeout.
514
+ echo "[pool] No git plan to prepare — marking git ready."
515
+ : > "$GIT_READY_MARKER"
516
+ fi
517
+
518
+ # ── Clone reference repos into /workspaces/references (best-effort) ──
519
+ # Populated from REFERENCE_REPOS_JSON (injected via envVars by
520
+ # injectReferenceRepos). Each repo is shallow-cloned as read-only context for
521
+ # the agent. Failures are non-fatal — the task must still proceed even if a
522
+ # reference project's GitHub App is uninstalled or the token mint failed.
523
+ # This runs AFTER the ready marker: references are supplementary context and
524
+ # must NOT block Claude from spawning.
525
+ if [ -n "${REFERENCE_REPOS_JSON:-}" ]; then
526
+ mkdir -p /workspaces/references
527
+ echo "${REFERENCE_REPOS_JSON}" | jq -c '.[]' 2>/dev/null | while IFS= read -r ref; do
528
+ REF_SLUG=$(echo "${ref}" | jq -r '.slug')
529
+ REF_OWNER=$(echo "${ref}" | jq -r '.owner')
530
+ REF_NAME=$(echo "${ref}" | jq -r '.name')
531
+ REF_BRANCH=$(echo "${ref}" | jq -r '.branch // "main"')
532
+ REF_TOKEN=$(echo "${ref}" | jq -r '.token // empty')
533
+ if [ -z "${REF_TOKEN}" ] || [ -z "${REF_SLUG}" ] || [ -z "${REF_OWNER}" ] || [ -z "${REF_NAME}" ]; then
534
+ continue
535
+ fi
536
+ if [ -d "/workspaces/references/${REF_SLUG}/.git" ]; then
537
+ continue
538
+ fi
539
+ if git clone --depth 1 --single-branch --branch "${REF_BRANCH}" \
540
+ "https://x-access-token:${REF_TOKEN}@github.com/${REF_OWNER}/${REF_NAME}.git" \
541
+ "/workspaces/references/${REF_SLUG}" 2>/dev/null; then
542
+ # Strip the token from the cloned remote so it never surfaces via
543
+ # `git remote -v` when the agent inspects the reference repo.
544
+ git -C "/workspaces/references/${REF_SLUG}" remote set-url origin \
545
+ "https://github.com/${REF_OWNER}/${REF_NAME}.git" 2>/dev/null || true
546
+ echo "[pool] cloned reference ${REF_SLUG} (${REF_OWNER}/${REF_NAME}@${REF_BRANCH})"
547
+ else
548
+ echo "[pool] WARN: reference clone failed: ${REF_SLUG}"
549
+ fi
550
+ done
551
+ unset REFERENCE_REPOS_JSON
552
+ fi
553
+ return 0
554
+ }
555
+
556
+ # Preview traffic is now proxied directly via k8s API pod proxy —
557
+ # no tunnel client needed. The API routes subdomain requests through
558
+ # the k8s API to reach the pod's ports directly.
559
+
560
+ # ── Clear stale markers, then background git and launch the agent early ──
561
+ # A leftover ready marker from a PRIOR pod on the baked image would be
562
+ # catastrophic: the agent would spawn Claude before THIS pod's git runs. Clear
563
+ # both before starting the background prep.
564
+ rm -f "$GIT_READY_MARKER" "$GIT_FAILED_MARKER"
565
+ # Ensure the repo dir exists for launch on BOTH paths (the background clone
566
+ # populates an empty dir on the non-image path; the pod-image path already has
567
+ # repo/.git).
568
+ mkdir -p /workspaces/repo
569
+
570
+ # Run the full git prep in the background so the agent can connect (card lights
571
+ # up) and stream setup output while git finishes.
572
+ prepare_workspace_git &
573
+
574
+ # Signal to the agent that bash owns the git block (skip the agent's OWN
575
+ # git-sync fallback) and that it must await the ready marker before spawning
576
+ # Claude / running setup.
577
+ export CONVEYOR_GIT_READY=1
578
+ export CONVEYOR_GIT_READY_MARKER="$GIT_READY_MARKER"
579
+ export CONVEYOR_GIT_FAILED_MARKER="$GIT_FAILED_MARKER"
580
+ # Target the repo regardless of when the background clone lands.
581
+ export CONVEYOR_WORKSPACE=/workspaces/repo
582
+
583
+ # Launch agent — exit-code-aware restart loop.
584
+ # Exit 0 = clean shutdown (idle timeout, task complete) — pod dies.
585
+ # Non-zero = crash — retry after a brief pause.
586
+ cd /workspaces/repo
587
+ echo "[pool] Launching agent..."
588
+ set +e
589
+ # Belt-and-braces alongside the pod's preStop hook (which pkills the agent
590
+ # directly because bash as PID 1 does not forward signals): if a SIGTERM does
591
+ # reach this shell (manual kill, future spec changes), forward it to the agent
592
+ # so flushGitOnShutdown still runs, then exit cleanly within the grace period.
593
+ trap 'echo "[pool] SIGTERM received, forwarding to agent..."; pkill -TERM -f conveyor-agent; wait; exit 0' TERM
594
+ # Bounded retry for a failed git prep. Without this, a transient git-prep
595
+ # failure writes GIT_FAILED_MARKER once and the agent's git gate then exits
596
+ # nonzero on every single relaunch forever (the marker never clears itself),
597
+ # crashlooping the pod every ~10s. Retry git prep itself, up to a small cap,
598
+ # before each relaunch; once the cap is exhausted stop hammering and fall
599
+ # back to the existing behavior (agent surfaces the failure) with a wider
600
+ # sleep so the pod idles instead of spinning.
601
+ GIT_PREP_MAX_RETRIES=3
602
+ _git_prep_attempts=0
603
+ while true; do
604
+ if [ -f "$GIT_FAILED_MARKER" ]; then
605
+ if [ "$_git_prep_attempts" -lt "$GIT_PREP_MAX_RETRIES" ]; then
606
+ _git_prep_attempts=$((_git_prep_attempts + 1))
607
+ echo "[pool] git prep previously failed — retrying (attempt ${_git_prep_attempts}/${GIT_PREP_MAX_RETRIES})..."
608
+ rm -f "$GIT_READY_MARKER" "$GIT_FAILED_MARKER"
609
+ prepare_workspace_git &
610
+ else
611
+ echo "[pool] git prep failed ${GIT_PREP_MAX_RETRIES} times — giving up on retries, letting agent surface the failure."
612
+ fi
613
+ fi
614
+ conveyor-agent 2>&1 | tee -a /tmp/claudespace-agent.log
615
+ _exit_code=${PIPESTATUS[0]}
616
+ if [ "$_exit_code" -eq 0 ]; then
617
+ echo "[pool] agent exited cleanly (code 0), shutting down pod."
618
+ exit 0
619
+ fi
620
+ if [ -f "$GIT_FAILED_MARKER" ] && [ "$_git_prep_attempts" -ge "$GIT_PREP_MAX_RETRIES" ]; then
621
+ echo "[pool] agent crashed (code $_exit_code) after git prep exhausted retries, backing off (60s)..."
622
+ sleep 60
623
+ else
624
+ echo "[pool] agent crashed (code $_exit_code), retrying in 10s..."
625
+ sleep 10
626
+ fi
627
+ done