diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh index 567b432..c35bccb 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -285,13 +285,84 @@ auto_commit_worktree_changes() { local default_message="Auto-finish: ${TASK_NAME}" local commit_message="${MUSAFETY_CODEX_AUTO_COMMIT_MESSAGE:-$default_message}" + local commit_output="" - if ! git -C "$wt" commit -m "$commit_message" >/dev/null 2>&1; then - echo "[codex-agent] Auto-commit failed in sandbox. Keeping branch for manual review: $branch" >&2 + if commit_output="$(git -C "$wt" commit -m "$commit_message" 2>&1)"; then + echo "[codex-agent] Auto-committed sandbox changes on '${branch}'." + return 0 + fi + + if auto_sync_for_commit_retry "$wt" "$branch"; then + claim_changed_files "$wt" "$branch" + git -C "$wt" add -A + if commit_output="$(git -C "$wt" commit -m "$commit_message" 2>&1)"; then + echo "[codex-agent] Auto-committed sandbox changes on '${branch}' after sync retry." + return 0 + fi + fi + + echo "[codex-agent] Auto-commit failed in sandbox. Keeping branch for manual review: $branch" >&2 + if [[ -n "$commit_output" ]]; then + printf '%s\n' "$commit_output" >&2 + fi + return 1 +} + +auto_sync_for_commit_retry() { + local wt="$1" + local branch="$2" + + if ! has_origin_remote; then + return 1 + fi + + local base_branch + base_branch="$(resolve_worktree_base_branch "$wt")" + if [[ -z "$base_branch" ]]; then return 1 fi - echo "[codex-agent] Auto-committed sandbox changes on '${branch}'." + if ! git -C "$wt" fetch origin "$base_branch" --quiet; then + return 1 + fi + + if ! git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then + return 1 + fi + + local behind_count + behind_count="$(git -C "$wt" rev-list --left-right --count "HEAD...origin/${base_branch}" 2>/dev/null | awk '{print $2}')" + behind_count="${behind_count:-0}" + if [[ "$behind_count" -le 0 ]]; then + return 1 + fi + + echo "[codex-agent] Auto-commit retry: '${branch}' is behind origin/${base_branch} by ${behind_count} commit(s). Syncing and retrying..." + + local stash_ref="" + local stash_output="" + if worktree_has_changes "$wt"; then + if ! stash_output="$(git -C "$wt" stash push --include-untracked -m "codex-agent-autocommit-sync-${branch}-$(date +%s)" 2>&1)"; then + return 1 + fi + stash_ref="$(printf '%s\n' "$stash_output" | grep -o 'stash@{[0-9]\+}' | head -n 1 || true)" + fi + + if ! git -C "$wt" rebase "origin/${base_branch}" >/dev/null 2>&1; then + git -C "$wt" rebase --abort >/dev/null 2>&1 || true + if [[ -n "$stash_ref" ]]; then + git -C "$wt" stash pop "$stash_ref" >/dev/null 2>&1 || true + fi + return 1 + fi + + if [[ -n "$stash_ref" ]]; then + if ! git -C "$wt" stash pop "$stash_ref" >/dev/null 2>&1; then + echo "[codex-agent] Auto-commit retry could not re-apply local changes after sync. Manual resolution required in: $wt" >&2 + return 1 + fi + fi + return 0 } diff --git a/test/install.test.js b/test/install.test.js index f9a8451..d9f69cf 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -1483,6 +1483,152 @@ exit 1 assert.match(launchedArgs, /--model gpt-5\.4-mini/); }); +test('codex-agent still auto-finishes when base branch advances during task run', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + const originPath = attachOriginRemote(repoDir); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['push', 'origin', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd('git', ['config', 'multiagent.sync.requireBeforeCommit', 'true'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['config', 'multiagent.sync.maxBehindCommits', '0'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const fakeCodexBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-retry-')); + const fakeCodexPath = path.join(fakeCodexBin, 'codex'); + fs.writeFileSync( + fakeCodexPath, + `#!/usr/bin/env bash\n` + + `set -e\n` + + `pwd > "${'${MUSAFETY_TEST_CODEX_CWD}'}"\n` + + `echo "$@" > "${'${MUSAFETY_TEST_CODEX_ARGS}'}"\n` + + `echo "retry" > codex-autocommit-retry.txt\n` + + `clone_dir="${'${MUSAFETY_TEST_ORIGIN_ADVANCE_CLONE}'}"\n` + + `rm -rf "$clone_dir"\n` + + `git clone "${'${MUSAFETY_TEST_ORIGIN_PATH}'}" "$clone_dir" >/dev/null 2>&1\n` + + `git -C "$clone_dir" config user.email "bot@example.com"\n` + + `git -C "$clone_dir" config user.name "Bot"\n` + + `git -C "$clone_dir" checkout dev >/dev/null 2>&1\n` + + `echo "advance base" > "$clone_dir/base-advance.txt"\n` + + `git -C "$clone_dir" add base-advance.txt\n` + + `git -C "$clone_dir" commit -m "advance base during codex run" >/dev/null 2>&1\n` + + `git -C "$clone_dir" push origin dev >/dev/null 2>&1\n`, + 'utf8', + ); + fs.chmodSync(fakeCodexPath, 0o755); + + const { fakePath: fakeGhPath } = createFakeGhScript(` +if [[ "$1" == "pr" && "$2" == "create" ]]; then + exit 0 +fi +if [[ "$1" == "pr" && "$2" == "view" ]]; then + if [[ " $* " == *" --json state,mergedAt,url "* ]]; then + printf 'MERGED\\t2026-04-13T00:00:00Z\\thttps://example.test/pr/autocommit-retry\\n' + exit 0 + fi + if [[ " $* " == *" --json url "* ]]; then + echo "https://example.test/pr/autocommit-retry" + exit 0 + fi + echo "unexpected gh pr view args: $*" >&2 + exit 1 +fi +if [[ "$1" == "pr" && "$2" == "merge" ]]; then + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +`); + + const cwdMarker = path.join(repoDir, '.codex-agent-cwd-autocommit-retry'); + const argsMarker = path.join(repoDir, '.codex-agent-args-autocommit-retry'); + const originAdvanceClone = path.join(repoDir, '.origin-advance-clone'); + const launch = runCmd( + 'bash', + ['scripts/codex-agent.sh', 'autocommit-retry-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], + repoDir, + { + PATH: `${fakeCodexBin}:${process.env.PATH}`, + MUSAFETY_TEST_CODEX_CWD: cwdMarker, + MUSAFETY_TEST_CODEX_ARGS: argsMarker, + MUSAFETY_TEST_ORIGIN_PATH: originPath, + MUSAFETY_TEST_ORIGIN_ADVANCE_CLONE: originAdvanceClone, + MUSAFETY_GH_BIN: fakeGhPath, + MUSAFETY_FINISH_WAIT_TIMEOUT_SECONDS: '60', + MUSAFETY_FINISH_WAIT_POLL_SECONDS: '0', + }, + ); + assert.equal(launch.status, 0, launch.stderr || launch.stdout); + const sawCommitRetry = /Auto-commit retry: .*behind origin\/dev/.test(launch.stdout); + const sawFinishSync = /\[agent-sync-guard\] Auto-syncing .* onto origin\/dev before finish/.test(launch.stdout); + assert.equal( + sawCommitRetry || sawFinishSync, + true, + `expected sync retry evidence in output, got:\n${launch.stdout}`, + ); + assert.match(launch.stdout, /\[codex-agent\] Auto-finish completed for/); + assert.match(launch.stdout, /\[codex-agent\] Auto-cleaned sandbox worktree:/); + + const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); + assert.equal(fs.existsSync(launchedCwd), false, 'auto-finished sandbox should be cleaned by default'); +}); + +test('codex-agent surfaces commit-hook failures so unfinished sandboxes are actionable', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + attachOriginRemote(repoDir); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['push', 'origin', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + fs.writeFileSync( + path.join(repoDir, '.githooks', 'pre-commit'), + '#!/usr/bin/env bash\nset -euo pipefail\necho "forced pre-commit failure for test" >&2\nexit 1\n', + 'utf8', + ); + fs.chmodSync(path.join(repoDir, '.githooks', 'pre-commit'), 0o755); + result = runCmd('git', ['config', 'core.hooksPath', `${repoDir}/.githooks`], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const fakeCodexBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-hookfail-')); + const fakeCodexPath = path.join(fakeCodexBin, 'codex'); + fs.writeFileSync(fakeCodexPath, '#!/usr/bin/env bash\nset -e\necho "hook-fail" > codex-hook-fail.txt\n', 'utf8'); + fs.chmodSync(fakeCodexPath, 0o755); + + const launch = runCmd( + 'bash', + ['scripts/codex-agent.sh', 'hook-fail-task', 'planner', 'dev'], + repoDir, + { + PATH: `${fakeCodexBin}:${process.env.PATH}`, + MUSAFETY_CODEX_WAIT_FOR_MERGE: 'false', + MUSAFETY_FINISH_WAIT_TIMEOUT_SECONDS: '30', + MUSAFETY_FINISH_WAIT_POLL_SECONDS: '0', + }, + ); + assert.notEqual(launch.status, 0, launch.stderr || launch.stdout); + assert.match(launch.stderr, /Auto-commit failed in sandbox/); + assert.match(launch.stderr, /forced pre-commit failure for test/); +}); + test('sync command rebases current agent branch onto latest origin/dev', () => { const repoDir = initRepo(); seedCommit(repoDir);