From 9bbca26bccc5cca541d0dc07f806082e8e88688d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 18 May 2026 04:16:26 +0200 Subject: [PATCH] feat(fleet): [SI-9] CODEX_FLEET_WORKER_CWD override at worker boot Resolve worker cwd by precedence in claude-worker.sh before spawning the Claude CLI: CODEX_FLEET_WORKER_CWD > active-plan-meta writable_roots[0] > $REPO. Forward the env var through claude-spawn.sh, document the precedence in worker-prompt.md, add a yaml example in accounts.example.yml, and ship test/run-worker-cwd.sh covering the four precedence scenarios. --- scripts/codex-fleet/accounts.example.yml | 8 ++ scripts/codex-fleet/claude-spawn.sh | 5 + scripts/codex-fleet/claude-worker.sh | 48 +++++++- scripts/codex-fleet/test/run-worker-cwd.sh | 122 +++++++++++++++++++++ scripts/codex-fleet/worker-prompt.md | 19 ++++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100755 scripts/codex-fleet/test/run-worker-cwd.sh diff --git a/scripts/codex-fleet/accounts.example.yml b/scripts/codex-fleet/accounts.example.yml index 6fc7c08..d374f57 100644 --- a/scripts/codex-fleet/accounts.example.yml +++ b/scripts/codex-fleet/accounts.example.yml @@ -25,6 +25,11 @@ # specialty [list of plan-slug prefixes] (default [] = generalist) # Pane prefers tasks whose `plan_slug` starts with one of # these prefixes. Empty list = accept all plans. +# worker_cwd (optional, SI-9) absolute path to cd into before the +# worker REPL starts. Exported as CODEX_FLEET_WORKER_CWD. +# Use when this account is dedicated to a project tree +# outside the codex-fleet repo. Precedence: +# worker_cwd > active-plan-meta writable_roots[0] > $REPO accounts: - id: research @@ -40,6 +45,9 @@ accounts: rate_limit_tier: standard tier: medium specialty: ["codex-fleet", "recodee"] + # Pin this pane to the polymarket-cli tree so its first action is not + # `cd /home/deadpool/Documents/polymarket-cli` every wake. See SI-9. + worker_cwd: /home/deadpool/Documents/polymarket-cli - id: review email: admin@mite.hu diff --git a/scripts/codex-fleet/claude-spawn.sh b/scripts/codex-fleet/claude-spawn.sh index 3521289..2f81a0b 100755 --- a/scripts/codex-fleet/claude-spawn.sh +++ b/scripts/codex-fleet/claude-spawn.sh @@ -263,6 +263,11 @@ build_pane_cmd() { local env_str env_str="CLAUDE_FLEET_AGENT_NAME='$agent' CLAUDE_FLEET_ACCOUNT_LABEL='$label' CLAUDE_FLEET_TIER='$TIER' CLAUDE_FLEET_SPECIALTY='$SPECIALTY' CLAUDE_FLEET_MODEL='$MODEL'" env_str="$env_str CODEX_HOME='$CODEX_HOME'" + # SI-9: forward per-pane worker cwd override into the wrapper env so + # claude-worker.sh's resolve_worker_cwd picks it up before the main loop. + if [ -n "${CODEX_FLEET_WORKER_CWD:-}" ]; then + env_str="$env_str CODEX_FLEET_WORKER_CWD='$CODEX_FLEET_WORKER_CWD'" + fi if [ -n "$ACCOUNT_EMAIL" ]; then env_str="$env_str ACCOUNT_EMAIL='$ACCOUNT_EMAIL'" fi diff --git a/scripts/codex-fleet/claude-worker.sh b/scripts/codex-fleet/claude-worker.sh index 5d36513..6b67c6d 100755 --- a/scripts/codex-fleet/claude-worker.sh +++ b/scripts/codex-fleet/claude-worker.sh @@ -38,6 +38,45 @@ set -u +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="${CODEX_FLEET_REPO_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}" + +# Resolve the worker's initial cwd by precedence: +# 1. CODEX_FLEET_WORKER_CWD from env (e.g. set in accounts.yml per-pane) +# 2. task.metadata.worker_cwd (only after a task is claimed — the wake +# prompt is responsible for cd'ing there; out of scope for this fn) +# 3. priority plan's metadata.writable_roots[0] read from +# $REPO/.codex-fleet/active-plan-meta.json if present +# 4. fallback: $REPO (back-compat with pre-SI-9 behavior) +# +# TODO(SI-3/SI-5/SI-11): the bringup hardening PR is the right place to +# write $REPO/.codex-fleet/active-plan-meta.json from the priority plan's +# metadata. Until then case (3) is a no-op for live fleets and we fall +# through to (4) — which keeps things working exactly as before. +resolve_worker_cwd() { + if [ -n "${CODEX_FLEET_WORKER_CWD:-}" ]; then + if [ -d "$CODEX_FLEET_WORKER_CWD" ] && [ -w "$CODEX_FLEET_WORKER_CWD" ]; then + echo "$CODEX_FLEET_WORKER_CWD"; return + fi + fi + local meta_file="$REPO/.codex-fleet/active-plan-meta.json" + if [ -f "$meta_file" ] && command -v jq >/dev/null 2>&1; then + local wr + wr=$(jq -r '.metadata.writable_roots[0] // empty' "$meta_file" 2>/dev/null) + if [ -n "$wr" ] && [ -d "$wr" ] && [ -w "$wr" ]; then + echo "$wr"; return + fi + fi + echo "$REPO" +} + +# When sourced by test/run-worker-cwd.sh we want resolve_worker_cwd +# defined but the rest of the script (env validation, banner, main loop) +# skipped. The test sets CLAUDE_WORKER_SOURCE_ONLY=1 before sourcing. +if [ "${CLAUDE_WORKER_SOURCE_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + AGENT="${CLAUDE_FLEET_AGENT_NAME:-}" if [ -z "$AGENT" ]; then echo "[claude-worker] fatal: CLAUDE_FLEET_AGENT_NAME unset" >&2 @@ -56,13 +95,18 @@ mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/claude-worker-$AGENT.log" STOP_FILE="${STOP_FILE:-$LOG_DIR/claude-worker-$AGENT.stop}" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WAKE="$SCRIPT_DIR/claude-wake-prompt.md" if [ ! -f "$WAKE" ]; then echo "[claude-worker] fatal: wake-prompt missing at $WAKE" >&2 exit 2 fi +worker_cwd="$(resolve_worker_cwd)" +cd "$worker_cwd" || { + echo "[claude-worker] fatal: cannot cd to resolved worker cwd: $worker_cwd" >&2 + exit 2 +} + ADD_DIR_FLAGS=( --add-dir "/home/deadpool/Documents/recodee" --add-dir "/home/deadpool/Documents/codex-fleet" @@ -97,6 +141,8 @@ fi "$AGENT" "$LABEL" "$TIER" "$SPECIALTY" "$MODEL" printf 'wake=%s log=%s\n' "$WAKE" "$LOG_FILE" printf 'stop=%s (touch this to break the loop)\n' "$STOP_FILE" + printf 'cwd=%s (resolved via CODEX_FLEET_WORKER_CWD precedence; fallback $REPO=%s)\n' \ + "$worker_cwd" "$REPO" printf 'add-dir: %s\n' "${ADD_DIR_FLAGS[*]}" printf '========================================\n\n' } | tee -a "$LOG_FILE" diff --git a/scripts/codex-fleet/test/run-worker-cwd.sh b/scripts/codex-fleet/test/run-worker-cwd.sh new file mode 100755 index 0000000..38df61b --- /dev/null +++ b/scripts/codex-fleet/test/run-worker-cwd.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# +# run-worker-cwd.sh — SI-9 unit tests for claude-worker.sh's +# resolve_worker_cwd precedence helper. +# +# Sources claude-worker.sh with CLAUDE_WORKER_SOURCE_ONLY=1 in a subshell +# so the function is defined without firing the main loop, then exercises +# four scenarios: +# +# 1. CODEX_FLEET_WORKER_CWD set + writable → echoes that path. +# 2. CODEX_FLEET_WORKER_CWD unset, active-plan-meta.json points at a +# writable dir → echoes that dir. +# 3. Both unset → echoes $REPO. +# 4. CODEX_FLEET_WORKER_CWD points at non-existent path → falls +# through to plan-meta (if usable) or $REPO. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +WORKER_SH="$ROOT/scripts/codex-fleet/claude-worker.sh" + +[ -f "$WORKER_SH" ] || { echo "FAIL: missing $WORKER_SH" >&2; exit 1; } + +TMP="$(mktemp -d -t si9-worker-cwd-XXXXXX)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +FAIL=0 + +fail() { + printf 'FAIL %s\n' "$1" >&2 + FAIL=$((FAIL + 1)) +} + +pass() { + printf 'PASS %s\n' "$1" + PASS=$((PASS + 1)) +} + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + pass "$label" + else + fail "$label (expected='$expected' actual='$actual')" + fi +} + +# Helper: invoke resolve_worker_cwd in a clean subshell with a forced +# REPO and a chosen $HOME-like sandbox so each test is isolated. +# +# Args: +# $1 fake REPO root (must exist; we control whether +# .codex-fleet/active-plan-meta.json sits inside it) +# env: CODEX_FLEET_WORKER_CWD optional pass-through +run_resolver() { + local fake_repo="$1" + CLAUDE_WORKER_SOURCE_ONLY=1 \ + CODEX_FLEET_REPO_ROOT="$fake_repo" \ + CODEX_FLEET_WORKER_CWD="${CODEX_FLEET_WORKER_CWD:-}" \ + bash -c " + set -u + # shellcheck disable=SC1090 + source '$WORKER_SH' + resolve_worker_cwd + " +} + +# Scenario 1: CODEX_FLEET_WORKER_CWD set + writable → echoes it. +foo="$TMP/foo" +mkdir -p "$foo" +fake_repo_1="$TMP/repo1" +mkdir -p "$fake_repo_1" +got="$(CODEX_FLEET_WORKER_CWD="$foo" run_resolver "$fake_repo_1")" +assert_eq "1 env override returns explicit path" "$foo" "$got" + +# Scenario 2: env unset, active-plan-meta points at /tmp/bar. +bar="$TMP/bar" +mkdir -p "$bar" +fake_repo_2="$TMP/repo2" +mkdir -p "$fake_repo_2/.codex-fleet" +cat >"$fake_repo_2/.codex-fleet/active-plan-meta.json" </dev/null 2>&1; then + got="$(run_resolver "$fake_repo_2")" + assert_eq "2 plan-meta writable_roots[0] used" "$bar" "$got" +else + printf 'SKIP 2 plan-meta test (jq not on PATH)\n' +fi + +# Scenario 3: both unset → echoes $REPO. +fake_repo_3="$TMP/repo3" +mkdir -p "$fake_repo_3" +unset CODEX_FLEET_WORKER_CWD +got="$(run_resolver "$fake_repo_3")" +assert_eq "3 fallback to \$REPO" "$fake_repo_3" "$got" + +# Scenario 4: CODEX_FLEET_WORKER_CWD points at non-existent path. +# With no plan-meta, must fall through to $REPO. Confirms the env path +# does NOT clobber the resolution when the target is unusable. +fake_repo_4="$TMP/repo4" +mkdir -p "$fake_repo_4" +got="$(CODEX_FLEET_WORKER_CWD="$TMP/does-not-exist" run_resolver "$fake_repo_4")" +assert_eq "4 non-existent env path falls through to \$REPO" "$fake_repo_4" "$got" + +# Scenario 4b: non-existent env path WITH valid plan-meta → plan-meta wins. +baz="$TMP/baz" +mkdir -p "$baz" +fake_repo_4b="$TMP/repo4b" +mkdir -p "$fake_repo_4b/.codex-fleet" +cat >"$fake_repo_4b/.codex-fleet/active-plan-meta.json" </dev/null 2>&1; then + got="$(CODEX_FLEET_WORKER_CWD="$TMP/still-missing" run_resolver "$fake_repo_4b")" + assert_eq "4b non-existent env path falls through to plan-meta" "$baz" "$got" +fi + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/scripts/codex-fleet/worker-prompt.md b/scripts/codex-fleet/worker-prompt.md index 17441f2..b710252 100644 --- a/scripts/codex-fleet/worker-prompt.md +++ b/scripts/codex-fleet/worker-prompt.md @@ -5,6 +5,25 @@ You are pane `$CODEX_FLEET_AGENT_NAME` (Colony agent id) under account plus the `force-claim` + `claim-release-supervisor` daemons. Your job: pull → preflight → execute → report. Do not propose tasks. Do not chat. +## Worker cwd precedence (SI-9) + +`claude-worker.sh` cd's into the resolved working directory *before* +spawning the Claude CLI, so your first turn already lands in the right +repo and you do not waste an action on `cd`. Precedence (highest wins): + +1. `CODEX_FLEET_WORKER_CWD` (env, e.g. set per-account in `accounts.yml`) + — used when this account is dedicated to a single project tree. +2. `task.metadata.worker_cwd` (per-task override; honoured inside the + wake loop after claim, not at boot). +3. Priority plan's `metadata.writable_roots[0]` read from + `$REPO/.codex-fleet/active-plan-meta.json` (written by the bringup + hardening PR; absent today → falls through). +4. `$REPO` — the codex-fleet repo root, back-compat fallback. + +If your task's `touches_files` live elsewhere, cd into the appropriate +worktree explicitly. Do NOT shell out to `cd "$REPO"` reflexively at +the start of every turn. + ## Token discipline - Less word, same proof. No commentary, no narration of your reasoning.