diff --git a/github-run-opencode/README.md b/github-run-opencode/README.md index 828f8c5..5d9c579 100644 --- a/github-run-opencode/README.md +++ b/github-run-opencode/README.md @@ -16,6 +16,8 @@ Part of [`sun-praise/opencode-actions`](https://github.com/sun-praise/opencode-a ## What it does - Installs and caches OpenCode (delegates to `setup-opencode`) +- Cleans up oversized `opencode.db` before running (prevents migration failures on self-hosted runners) +- Auto-recovers from SQLite migration errors by deleting the stale database and retrying - Runs `opencode github run` with optional retry logic for flaky GitHub network failures - Skips forked pull requests by default (no secrets exposed) - English / Chinese output controlled by the `language` input @@ -45,6 +47,8 @@ Part of [`sun-praise/opencode-actions`](https://github.com/sun-praise/opencode-a | `retry-profile` | `github-network` | Built-in retry preset for common GitHub failures | | `timeout-seconds` | `600` | Maximum execution time for `opencode github run`; `0` disables it | | `working-directory` | empty | Optional working directory before running OpenCode | +| `cleanup-db` | `true` | Delete `opencode.db` if it exceeds size threshold before running; `"true"` (50MB default), a number for custom MB threshold, `"0"` or `"false"` to disable. Note: `db-path` is validated against system directories; on macOS `/var` resolves to `/private/var` and is allowed | +| `db-path` | empty | Custom path to `opencode.db`; defaults to `~/.local/share/opencode/opencode.db` | All setup-related inputs from [`setup-opencode`](https://github.com/sun-praise/opencode-actions/tree/main/setup-opencode) (`install-url`, `install-dir`, `xdg-cache-home`, `cache`, `cache-key`, `install-attempts`, `allow-preinstalled`, `version`) are also accepted. diff --git a/github-run-opencode/action.yml b/github-run-opencode/action.yml index 57835a9..35dd445 100644 --- a/github-run-opencode/action.yml +++ b/github-run-opencode/action.yml @@ -153,6 +153,20 @@ inputs: blocked regardless of this setting. required: false default: "false" + cleanup-db: + description: >- + Delete opencode.db before running if it exceeds a size threshold. + Prevents migration failures on self-hosted runners with large stale databases. + Set to "true" to enable with the default 50MB threshold, or a number + (e.g. "100") for a custom threshold in MB, or "false" to disable. + required: false + default: "true" + db-path: + description: >- + Path to opencode.db. Defaults to ~/.local/share/opencode/opencode.db. + Used by the cleanup-db step and migration recovery logic to locate the database. + required: false + default: "" runs: using: composite @@ -234,6 +248,20 @@ runs: OPENCODE_MIN_VERSION: ${{ steps.version.outputs.version }} run: ${{ github.action_path }}/../setup-opencode/install-opencode.sh + - if: ${{ inputs.cleanup-db != 'false' && inputs.cleanup-db != '0' }} + shell: bash + env: + INPUT_CLEANUP_DB: ${{ inputs.cleanup-db }} + OPENCODE_DB_PATH: ${{ inputs.db-path }} + run: | + set -uo pipefail + cleanup_val="$INPUT_CLEANUP_DB" + export OPENCODE_DB_MAX_SIZE_MB="50" + if [[ "$cleanup_val" != "true" ]] && [[ "$cleanup_val" =~ ^[0-9]+$ ]]; then + export OPENCODE_DB_MAX_SIZE_MB="$cleanup_val" + fi + ${{ github.action_path }}/../shared/cleanup-db.sh + - shell: bash env: GITHUB_RUN_OPENCODE_WORKING_DIRECTORY: ${{ inputs.working-directory }} @@ -261,6 +289,7 @@ runs: GITHUB_RUN_OPENCODE_LANGUAGE: ${{ inputs.language }} GITHUB_RUN_OPENCODE_EXTRA_ENV: ${{ inputs.extra-env }} GITHUB_RUN_OPENCODE_EXTRA_ENV_ALLOW_SENSITIVE: ${{ inputs.extra-env-allow-sensitive }} + OPENCODE_DB_PATH: ${{ inputs.db-path }} run: | if ! command -v python3 >/dev/null 2>&1; then printf 'python3 is required but not installed on this runner\n' >&2 diff --git a/run-opencode/README.md b/run-opencode/README.md index 4a51be8..1ca1a2e 100644 --- a/run-opencode/README.md +++ b/run-opencode/README.md @@ -24,6 +24,7 @@ In the same-job case, `setup-opencode` already exports `opencode` to `PATH`, so - Runs `opencode` with space-delimited `args` and an optional `working-directory` - Built-in retry preset for common GitHub network failures (`retry-profile: github-network`) - Optional `retry-on-regex` for custom retry conditions +- Auto-recovers from SQLite migration errors (`duplicate column name`) by deleting the stale database and retrying once - Provider secrets and model selection are intentionally kept in workflow `env:` so the action stays generic - Sets `reasoning-effort` and `enable-thinking` for the model agent diff --git a/run-opencode/run-opencode.sh b/run-opencode/run-opencode.sh index e0731a5..a2a71f2 100755 --- a/run-opencode/run-opencode.sh +++ b/run-opencode/run-opencode.sh @@ -130,6 +130,7 @@ if [[ -n "$OPENCODE_ARGS" ]]; then fi attempt=1 +migration_recovery_done=false while [[ "$attempt" -le "$OPENCODE_ATTEMPTS" ]]; do log_file="$(mktemp)" @@ -143,6 +144,53 @@ while [[ "$attempt" -le "$OPENCODE_ATTEMPTS" ]]; do exit 0 fi + # Auto-recover from SQLite migration failures: delete the stale db and retry once. + # Recovery counts toward OPENCODE_ATTEMPTS — the attempt is incremented before + # continue so that recovery does not exceed the user-configured limit. + # Recovery skips OPENCODE_RETRY_DELAY_SECONDS: after deleting the stale db, + # retrying immediately is the correct behavior (no need to wait). + if [[ "$migration_recovery_done" == "false" ]] && grep -qim1 "duplicate column name" "$log_file" 2>/dev/null; then + _resolve_script="$(cd "$(dirname "$0")" && pwd)/../shared/resolve-db-path.sh" + db_path="" + if [[ -f "$_resolve_script" ]]; then + if ! source "$_resolve_script" 2>/dev/null || ! resolve_db_path 2>/dev/null; then + printf '::warning::migration recovery: resolve_db_path failed, attempting default path\n' + else + db_path="$RESOLVED_DB_PATH" + fi + else + printf '::warning::migration recovery: resolve-db-path.sh not found at %s\n' "$_resolve_script" + fi + if [[ -z "$db_path" ]]; then + # If user explicitly set OPENCODE_DB_PATH but resolve failed, don't guess — skip recovery. + if [[ -n "${OPENCODE_DB_PATH:-}" ]]; then + printf '::warning::migration recovery skipped: db-path validation failed for OPENCODE_DB_PATH=%s\n' "$OPENCODE_DB_PATH" + migration_recovery_done=true + rm -f "$log_file" + continue + fi + # Fallback: unset OPENCODE_DB_PATH so resolve_db_path uses its safe default, + # then re-run validation to get a sanitized path. + if [[ -f "$_resolve_script" ]]; then + OPENCODE_DB_PATH="" resolve_db_path 2>/dev/null && db_path="$RESOLVED_DB_PATH" + fi + if [[ -z "$db_path" ]]; then + migration_recovery_done=true + rm -f "$log_file" + continue + fi + fi + printf '::warning::opencode.db migration failed (duplicate column name), deleting %s and retrying\n' "$db_path" + rm -f -- "$db_path" "$db_path-wal" "$db_path-shm" "$db_path-journal" + migration_recovery_done=true + attempt="$((attempt + 1))" + rm -f "$log_file" + if [[ "$attempt" -gt "$OPENCODE_ATTEMPTS" ]]; then + exit "$status" + fi + continue + fi + if [[ -z "$OPENCODE_RETRY_ON_REGEX" ]] || ! grep -Eiq "$OPENCODE_RETRY_ON_REGEX" "$log_file"; then rm -f "$log_file" exit "$status" diff --git a/shared/README.md b/shared/README.md index e1a4a1e..57fb634 100644 --- a/shared/README.md +++ b/shared/README.md @@ -9,3 +9,15 @@ Plain-text prompt snippets used by multiple actions (e.g. `multi-review`, `githu Each file contains the prompt text without surrounding whitespace. These are the **single source of truth** — both TS (`multi-review/src/reviewers.ts` via `actionPath/../shared/prompts/`) and Python (`run-github-opencode.py` via `Path(__file__).parent.parent/shared/prompts/`) load from here at runtime. The path layout relies on the action checkout including the repository root (which is true for both `uses: ./multi-review` and `uses: org/repo/multi-review@vN` on GitHub Actions). If you add a new prompt file, update `tests/test_all.py` accordingly and run the Python test suite to verify consistency. + +## cleanup-db.sh + +A standalone shell script that checks the size of `~/.local/share/opencode/opencode.db` and deletes it if it exceeds a configurable threshold (default 50MB). Called from `github-run-opencode/action.yml` before the opencode run step. Controlled by the `cleanup-db` action input. + +Environment variables: +- `OPENCODE_DB_PATH` — path to the database file (default: `~/.local/share/opencode/opencode.db`); validated to reject system directories (`/etc`, `/usr`, etc.) +- `OPENCODE_DB_MAX_SIZE_MB` — max allowed size in MB before cleanup (default: `50`); `0` or negative disables cleanup + +## resolve-db-path.sh + +Shared helper that resolves `OPENCODE_DB_PATH` to an absolute path and validates it's not pointing into system directories. Sourced by `cleanup-db.sh`. The same path resolution logic is used inline in `run-opencode.sh` for migration recovery. diff --git a/shared/cleanup-db.sh b/shared/cleanup-db.sh new file mode 100755 index 0000000..c9bd8e1 --- /dev/null +++ b/shared/cleanup-db.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Cleanup opencode SQLite database to prevent migration failures. +# Called from github-run-opencode/action.yml before opencode runs. +# +# Behavior: +# 1. If the db file exceeds the size threshold → delete it. +# 2. Always: log what happened. +# +# Environment variables: +# OPENCODE_DB_PATH — path to opencode.db (default: ~/.local/share/opencode/opencode.db) +# OPENCODE_DB_MAX_SIZE_MB — max allowed size in MB before cleanup (required, set by action.yml) + +# Intentionally no -e: stat falls back via || chains, and we handle all +# error paths explicitly. Adding -e would break the || fallback pattern. +set -uo pipefail + +# Resolve and validate db path (shared with run-opencode.sh migration recovery) +# shellcheck source=resolve-db-path.sh +source "$(dirname "$0")/resolve-db-path.sh" +resolve_db_path || exit 1 +db_path="$RESOLVED_DB_PATH" + +max_mb="${OPENCODE_DB_MAX_SIZE_MB:-50}" +# Validate threshold is a non-negative integer +if ! [[ "$max_mb" =~ ^[0-9]+$ ]]; then + printf '::error::OPENCODE_DB_MAX_SIZE_MB must be a number, got: %s\n' "$max_mb" >&2 + exit 1 +fi +# Treat 0 as "disable cleanup" — user intent is likely to skip, not delete everything. +if [[ "$max_mb" -eq 0 ]]; then + printf 'cleanup-db: threshold is 0MB, treating as disabled\n' + exit 0 +fi + +if [[ ! -f "$db_path" ]]; then + exit 0 +fi + +# Capture size and inode atomically from a single stat call +# GNU stat: stat -c '%s %i'; BSD stat: stat -f '%z %i' +stat_line="$(stat -c '%s %i' -- "$db_path" 2>/dev/null || stat -f '%z %i' "$db_path" 2>/dev/null || true)" +if [[ -z "$stat_line" ]]; then + printf '::warning::cleanup-db: stat failed for %s, skipping\n' "$db_path" + exit 0 +fi +size_bytes="${stat_line%% *}" +inode_at_stat="${stat_line##* }" +if [[ "$size_bytes" -eq 0 ]]; then + printf 'cleanup-db: %s is 0 bytes, within threshold (%dMB)\n' "$db_path" "$max_mb" + exit 0 +fi +size_mb="$((size_bytes / 1024 / 1024))" + +if [[ "$size_mb" -ge "$max_mb" ]]; then + printf '::warning::opencode.db is %dMB (threshold %dMB), deleting to prevent migration failures\n' "$size_mb" "$max_mb" + # TOCTOU defense: verify inode hasn't changed since stat (file replaced by symlink) + inode_now="$(stat -c%i -- "$db_path" 2>/dev/null || stat -f%i "$db_path" 2>/dev/null || echo 0)" + if [[ "$inode_now" != "$inode_at_stat" ]]; then + printf '::warning::cleanup-db: file inode changed before deletion (TOCTOU), skipping\n' + exit 0 + fi + rm -f -- "$db_path" "$db_path-wal" "$db_path-shm" "$db_path-journal" + printf 'cleanup-db: deleted %s (%dMB)\n' "$db_path" "$size_mb" +else + printf 'cleanup-db: %s is %dMB, within threshold (%dMB)\n' "$db_path" "$size_mb" "$max_mb" +fi diff --git a/shared/resolve-db-path.sh b/shared/resolve-db-path.sh new file mode 100644 index 0000000..bc8dc6a --- /dev/null +++ b/shared/resolve-db-path.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +# Shared helper: resolve and validate the opencode database path. +# Sourced by cleanup-db.sh and run-opencode.sh migration recovery. +# This file is a source-only library, not meant to be executed directly. +# +# Public API: +# resolve_db_path() — resolves OPENCODE_DB_PATH, validates safety, exports result +# RESOLVED_DB_PATH — result variable set by resolve_db_path() +# +# Validation rules: +# - Must not be empty after resolution +# - Must not traverse into system or sensitive directories + +# Prevent direct execution +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + echo "error: resolve-db-path.sh is a source-only library" >&2 + exit 1 +fi + +# Default database path — single source of truth for all callers. +OPENCODE_DB_DEFAULT_PATH="$HOME/.local/share/opencode/opencode.db" + +resolve_db_path() { + local default_path="$OPENCODE_DB_DEFAULT_PATH" + local raw="${OPENCODE_DB_PATH:-$default_path}" + # Guard against empty string (e.g. action input unset → env var is "") + [[ -z "$raw" ]] && raw="$default_path" + + # Step 1: Normalize path using python3 (already a hard dependency). + # Falls back to shell-level .. stripping if python3 is unavailable. + local resolved + local normalized + normalized="$(python3 -c "import os,sys +try: + p = os.path.realpath(sys.argv[1]) + print(p if p else '') +except OSError: + pass" "$raw" 2>/dev/null || true)" + + if [[ -n "$normalized" ]]; then + resolved="$normalized" + else + # python3 unavailable — strip .. components at shell level as defense-in-depth. + local parts + IFS='/' read -ra parts <<< "$raw" + local stack=() + local part + for part in "${parts[@]}"; do + if [[ "$part" == ".." ]] && [[ ${#stack[@]} -gt 0 ]]; then + unset 'stack[${#stack[@]}-1]' + elif [[ -n "$part" && "$part" != "." ]]; then + stack+=("$part") + fi + done + # Join with / using IFS (handles paths with spaces correctly) + local IFS='/' + resolved="/${stack[*]}" + fi + + # Step 2: For non-python3 path, also resolve via cd if directory exists + if [[ -z "$normalized" ]] && [[ -d "$(dirname "$resolved")" ]]; then + local dir + dir="$(dirname "$resolved")" + resolved="$(cd "$dir" 2>/dev/null && pwd)/$(basename "$resolved")" + fi + + # Step 3: Reject dangerous paths (system dirs and sensitive locations). + # macOS symlinks: /etc → /private/etc, /tmp → /private/tmp, /var → /private/var. + # /private/etc and /private/tmp blocked to catch symlink-resolved paths. + # /private/var allowed (macOS CI temp dirs under /var/folders → /private/var/folders). + # /var blocked on Linux (contains logs, databases; /var/tmp is world-writable). + # Note: bare "/" intentionally NOT in list — it would match all absolute paths. + local -a forbidden=( + /etc /usr /bin /sbin /lib /boot /proc /sys /dev /opt /root + /mnt /media /srv /run /var /tmp + /private/etc /private/tmp + ) + local prefix + for prefix in "${forbidden[@]}"; do + if [[ "$resolved" == "$prefix"/* || "$resolved" == "$prefix" ]]; then + printf '::error::db-path must not point into %s, got: %s\n' "$prefix" "$resolved" >&2 + return 1 + fi + done + + RESOLVED_DB_PATH="$resolved" +} diff --git a/tests/test_all.py b/tests/test_all.py index e84f6bc..e0602d1 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -1320,5 +1320,210 @@ def test_python_script_uses_shared_prompts_dir(self): self.assertNotIn("using inline fallback", script) +class TestCleanupDb(unittest.TestCase): + """Tests for shared/cleanup-db.sh""" + + def setUp(self): + self.work_dir = Path(tempfile.mkdtemp(dir=os.environ.get("HOME", "/tmp"))) + self.db_path = self.work_dir / "opencode.db" + self.env = os.environ.copy() + self.env["OPENCODE_DB_PATH"] = str(self.db_path) + self.env.pop("OPENCODE_DB_MAX_SIZE_MB", None) + + def tearDown(self): + shutil.rmtree(self.work_dir, ignore_errors=True) + + def run_cleanup(self, **extra_env) -> subprocess.CompletedProcess: + env = self.env.copy() + env.update(extra_env) + return subprocess.run( + [str(REPO_ROOT / "shared" / "cleanup-db.sh")], + capture_output=True, + text=True, + env=env, + ) + + def test_no_db_file_is_noop(self): + """If db file does not exist, script exits 0 silently.""" + result = self.run_cleanup() + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout.strip(), "") + + def test_small_db_is_kept(self): + """Db under threshold is kept.""" + self.db_path.write_bytes(b"\x00" * 1024) # 1KB + result = self.run_cleanup(OPENCODE_DB_MAX_SIZE_MB="50") + self.assertEqual(result.returncode, 0) + self.assertTrue(self.db_path.exists()) + self.assertIn("within threshold", result.stdout) + + def test_large_db_is_deleted(self): + """Db over threshold is deleted.""" + # Write ~1MB to keep test fast + self.db_path.write_bytes(b"\x00" * (1024 * 1024)) + result = self.run_cleanup(OPENCODE_DB_MAX_SIZE_MB="1") + self.assertEqual(result.returncode, 0) + self.assertFalse(self.db_path.exists()) + self.assertIn("deleting", result.stdout) + + def test_zero_threshold_disables_cleanup(self): + """Threshold of 0 means disable cleanup — db is kept.""" + self.db_path.write_bytes(b"\x00" * (1024 * 1024)) + result = self.run_cleanup(OPENCODE_DB_MAX_SIZE_MB="0") + self.assertEqual(result.returncode, 0) + self.assertTrue(self.db_path.exists()) + self.assertIn("disabled", result.stdout) + + def test_custom_threshold_respected(self): + """Custom MB threshold is respected.""" + self.db_path.write_bytes(b"\x00" * (2 * 1024 * 1024)) # 2MB + result = self.run_cleanup(OPENCODE_DB_MAX_SIZE_MB="1") + self.assertEqual(result.returncode, 0) + self.assertFalse(self.db_path.exists()) + + def test_default_threshold_is_50(self): + """Default threshold is 50MB — a 5MB file is kept.""" + self.db_path.write_bytes(b"\x00" * (5 * 1024 * 1024)) + result = self.run_cleanup() + self.assertEqual(result.returncode, 0) + self.assertTrue(self.db_path.exists()) + self.assertIn("within threshold", result.stdout) + + def test_path_outside_home_is_rejected(self): + """db-path pointing into system dirs is rejected for safety.""" + result = self.run_cleanup(OPENCODE_DB_PATH="/etc/opencode/opencode.db") + self.assertNotEqual(result.returncode, 0) + self.assertIn("/etc", result.stderr) + + def test_dotdot_traversal_is_rejected(self): + """db-path with .. traversal into system dirs is rejected.""" + result = self.run_cleanup(OPENCODE_DB_PATH="/nonexistent/../etc/opencode/opencode.db") + self.assertNotEqual(result.returncode, 0) + + +class TestMigrationRecovery(unittest.TestCase): + """Tests for migration failure auto-recovery in run-opencode.sh.""" + + def setUp(self): + self.work_dir = Path(tempfile.mkdtemp(dir=os.environ.get("HOME", "/tmp"))) + self.db_dir = self.work_dir / "db" + self.db_dir.mkdir() + self.db_path = self.db_dir / "opencode.db" + self.db_path.write_text("fake db content") + + self.fake_opencode = self.work_dir / "opencode" + self.fake_opencode.write_text( + '#!/bin/bash\n' + 'echo "some output"\n' + 'echo "duplicate column name: foo" >&2\n' + 'exit 1\n' + ) + self.fake_opencode.chmod(0o755) + + self.env = os.environ.copy() + self.env["PATH"] = f"{self.work_dir}:{os.environ.get('PATH', '')}" + self.env["OPENCODE_ARGS"] = "github run" + self.env["OPENCODE_ATTEMPTS"] = "3" + self.env["OPENCODE_RETRY_DELAY_SECONDS"] = "0" + self.env["OPENCODE_RETRY_PROFILE"] = "" + self.env["OPENCODE_DB_PATH"] = str(self.db_path) + + def tearDown(self): + shutil.rmtree(self.work_dir, ignore_errors=True) + + def test_migration_error_triggers_recovery(self): + """duplicate column name error triggers db deletion and retry.""" + # Second invocation succeeds + attempt_file = self.work_dir / "attempt" + self.fake_opencode.write_text( + '#!/bin/bash\n' + 'afile="${FAKE_OPENCODE_ATTEMPT_FILE:?}"\n' + 'attempt=0\n' + 'if [[ -f "$afile" ]]; then attempt=$(<"$afile"); fi\n' + 'attempt=$((attempt + 1))\n' + 'printf "%s" "$attempt" >"$afile"\n' + 'if (( attempt == 1 )); then\n' + ' echo "duplicate column name: session_id" >&2\n' + ' exit 1\n' + 'fi\n' + 'echo "success"\n' + ) + self.env["FAKE_OPENCODE_ATTEMPT_FILE"] = str(attempt_file) + + result = subprocess.run( + [str(REPO_ROOT / "run-opencode" / "run-opencode.sh")], + capture_output=True, + text=True, + env=self.env, + ) + self.assertEqual(result.returncode, 0, f"stdout: {result.stdout}\nstderr: {result.stderr}") + self.assertFalse(self.db_path.exists(), "db should be deleted after migration error") + self.assertIn("success", result.stdout) + + def test_non_migration_error_does_not_trigger_recovery(self): + """Non-migration errors do not delete db.""" + self.fake_opencode.write_text( + '#!/bin/bash\n' + 'echo "some other error" >&2\n' + 'exit 1\n' + ) + result = subprocess.run( + [str(REPO_ROOT / "run-opencode" / "run-opencode.sh")], + capture_output=True, + text=True, + env=self.env, + ) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(self.db_path.exists(), "db should NOT be deleted for non-migration errors") + + def test_recovery_only_once(self): + """Recovery only happens once — second migration error exits normally.""" + self.fake_opencode.write_text( + '#!/bin/bash\n' + 'echo "duplicate column name: foo" >&2\n' + 'exit 1\n' + ) + result = subprocess.run( + [str(REPO_ROOT / "run-opencode" / "run-opencode.sh")], + capture_output=True, + text=True, + env=self.env, + ) + self.assertNotEqual(result.returncode, 0) + # Recovery warning appears exactly once (first failure triggers recovery, + # second failure exits normally without a second recovery). + warning_count = result.stdout.count("::warning::opencode.db migration failed") + self.assertEqual(warning_count, 1, f"expected exactly 1 recovery warning, got {warning_count}") + + + + def test_recovery_respects_attempts_limit(self): + """With attempts=1, recovery still counts and exits after the retry.""" + attempt_file = self.work_dir / "attempt" + self.fake_opencode.write_text( + '#!/bin/bash\n' + 'afile="${FAKE_OPENCODE_ATTEMPT_FILE:?}"\n' + 'attempt=0\n' + 'if [[ -f "$afile" ]]; then attempt=$(<"$afile"); fi\n' + 'attempt=$((attempt + 1))\n' + 'printf "%s" "$attempt" >"$afile"\n' + 'echo "duplicate column name: session_id" >&2\n' + 'exit 1\n' + ) + self.env["FAKE_OPENCODE_ATTEMPT_FILE"] = str(attempt_file) + self.env["OPENCODE_ATTEMPTS"] = "1" + + result = subprocess.run( + [str(REPO_ROOT / "run-opencode" / "run-opencode.sh")], + capture_output=True, + text=True, + env=self.env, + ) + self.assertNotEqual(result.returncode, 0) + # opencode should have been called at most 1 time (the initial attempt), + # since attempts=1 and recovery increments attempt to 2 which exceeds limit. + actual_attempts = int(attempt_file.read_text().strip()) + self.assertLessEqual(actual_attempts, 1, f"expected at most 1 attempt with OPENCODE_ATTEMPTS=1, got {actual_attempts}") + if __name__ == "__main__": unittest.main(verbosity=2)