feat(scripts): add env isolation, incremental logging, and test timeouts

- Wrap all Claude CLI subprocess calls with `env -u CLAUDECODE` to prevent
  parent env var interference with child processes (17 sites across 7 files)
- Add `flush_log_to_repo()` to epic-execute.sh for incremental log persistence
  after each story completes or fails (prevents log loss on interruption)
- Add portable `run_with_timeout` utility to utils.sh and wrap all test
  invocations in epic-execute.sh and regression-gate.sh with configurable
  timeout (default 120s via REGRESSION_TEST_TIMEOUT)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Caleb 2026-02-14 05:19:53 -06:00
parent ae7d403ee0
commit a58fb564d1
7 changed files with 96 additions and 28 deletions

View File

@ -803,7 +803,7 @@ REPORT_GENERATED: $CHAIN_REPORT_FILE"
log "Invoking report generator..."
# Execute report generation
report_result=$(claude --dangerously-skip-permissions -p "$report_prompt" 2>&1) || true
report_result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$report_prompt" 2>&1) || true
echo "$report_result" >> "$LOG_FILE"

View File

@ -143,7 +143,7 @@ DESIGN COMPLETE: $story_id"
fi
local result
result=$(claude --dangerously-skip-permissions -p "$design_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$design_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"

View File

@ -126,9 +126,9 @@ init_regression_baseline() {
# Check if there's a test:json script for better parsing
if grep -q '"test:json"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
test_output=$(cd "$PROJECT_ROOT" && npm run test:json 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" npm run test:json) || true
else
test_output=$(cd "$PROJECT_ROOT" && npm test 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" npm test) || true
fi
BASELINE_PASSING_TESTS=$(extract_test_count "$test_output")
@ -152,14 +152,14 @@ init_regression_baseline() {
elif [ -f "$PROJECT_ROOT/Cargo.toml" ]; then
# Rust project
log "Capturing baseline test count (Rust)..."
test_output=$(cd "$PROJECT_ROOT" && cargo test 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" cargo test) || true
BASELINE_PASSING_TESTS=$(extract_test_count "$test_output")
log "Baseline passing tests: $BASELINE_PASSING_TESTS"
elif [ -f "$PROJECT_ROOT/go.mod" ]; then
# Go project
log "Capturing baseline test count (Go)..."
test_output=$(cd "$PROJECT_ROOT" && go test ./... -v 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" go test ./... -v) || true
BASELINE_PASSING_TESTS=$(extract_test_count "$test_output")
log "Baseline passing tests: $BASELINE_PASSING_TESTS"
@ -167,7 +167,7 @@ init_regression_baseline() {
# Python project
if command -v pytest >/dev/null 2>&1; then
log "Capturing baseline test count (Python)..."
test_output=$(cd "$PROJECT_ROOT" && pytest -v 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" pytest -v) || true
BASELINE_PASSING_TESTS=$(extract_test_count "$test_output")
log "Baseline passing tests: $BASELINE_PASSING_TESTS"
fi
@ -199,23 +199,23 @@ execute_regression_gate() {
if [ -f "$PROJECT_ROOT/package.json" ]; then
# Check if there's a test:json script for better parsing
if grep -q '"test:json"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
test_output=$(cd "$PROJECT_ROOT" && npm run test:json 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" npm run test:json) || true
else
test_output=$(cd "$PROJECT_ROOT" && npm test 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" npm test) || true
fi
current_tests=$(extract_test_count "$test_output")
elif [ -f "$PROJECT_ROOT/Cargo.toml" ]; then
test_output=$(cd "$PROJECT_ROOT" && cargo test 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" cargo test) || true
current_tests=$(extract_test_count "$test_output")
elif [ -f "$PROJECT_ROOT/go.mod" ]; then
test_output=$(cd "$PROJECT_ROOT" && go test ./... -v 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" go test ./... -v) || true
current_tests=$(extract_test_count "$test_output")
elif [ -f "$PROJECT_ROOT/requirements.txt" ] || [ -f "$PROJECT_ROOT/pyproject.toml" ]; then
if command -v pytest >/dev/null 2>&1; then
test_output=$(cd "$PROJECT_ROOT" && pytest -v 2>&1) || true
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" pytest -v) || true
current_tests=$(extract_test_count "$test_output")
fi
fi

View File

@ -169,7 +169,7 @@ After outputting the spec block:
fi
local result
result=$(claude --dangerously-skip-permissions -p "$spec_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$spec_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"
@ -315,7 +315,7 @@ After implementing the tests:
fi
local result
result=$(claude --dangerously-skip-permissions -p "$impl_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$impl_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"

View File

@ -12,6 +12,41 @@
# Usage: Sourced by epic-execute.sh
#
# =============================================================================
# Portable Timeout Wrapper
# =============================================================================
# Run a command with a timeout (portable, no coreutils needed)
# Usage: run_with_timeout <seconds> <command...>
# Returns: command output via stdout; exit code 0 on success, 1 on timeout
run_with_timeout() {
local timeout_secs="$1"; shift
local output_file
output_file=$(mktemp /tmp/bmad-timeout-XXXXXX)
( "$@" > "$output_file" 2>&1 ) &
local cmd_pid=$!
( sleep "$timeout_secs" && kill -TERM "$cmd_pid" 2>/dev/null && sleep 2 && kill -9 "$cmd_pid" 2>/dev/null ) &
local watchdog_pid=$!
wait "$cmd_pid" 2>/dev/null
local exit_code=$?
# Kill watchdog if command finished in time
kill "$watchdog_pid" 2>/dev/null
wait "$watchdog_pid" 2>/dev/null
cat "$output_file"
rm -f "$output_file"
# 143 = SIGTERM, 137 = SIGKILL (timed out)
if [ $exit_code -eq 143 ] || [ $exit_code -eq 137 ]; then
return 1
fi
return $exit_code
}
# =============================================================================
# M1: Retry Logic with Exponential Backoff
# =============================================================================
@ -102,7 +137,7 @@ execute_claude_with_retry() {
# Wrapper function for retry
_claude_invoke() {
timeout "$timeout" claude --dangerously-skip-permissions -p "$1" 2>&1
timeout "$timeout" env -u CLAUDECODE claude --dangerously-skip-permissions -p "$1" 2>&1
local code=$?
if [ $code -eq 124 ]; then
echo "TIMEOUT: Claude invocation timed out after ${timeout}s"
@ -591,7 +626,7 @@ execute_claude_verbose() {
# Execute with output tee'd to both terminal and log file
local result
result=$(timeout "$timeout" claude --dangerously-skip-permissions -p "$prompt" 2>&1 | tee -a "$LOG_FILE")
result=$(timeout "$timeout" env -u CLAUDECODE claude --dangerously-skip-permissions -p "$prompt" 2>&1 | tee -a "$LOG_FILE")
local exit_code=$?
if [ $exit_code -eq 124 ]; then
@ -605,7 +640,7 @@ execute_claude_verbose() {
else
# Non-verbose mode: capture output silently
local result
result=$(timeout "$timeout" claude --dangerously-skip-permissions -p "$prompt" 2>&1)
result=$(timeout "$timeout" env -u CLAUDECODE claude --dangerously-skip-permissions -p "$prompt" 2>&1)
local exit_code=$?
# Log to file only

View File

@ -249,6 +249,32 @@ save_log_to_repo() {
fi
}
# Flush the current log to the repo after each story completes/fails.
# Unlike save_log_to_repo (which runs once at exit), this overwrites
# the same file incrementally so the repo always has the latest progress.
flush_log_to_repo() {
# Skip if no log file or it's empty
if [ ! -f "$LOG_FILE" ] || [ ! -s "$LOG_FILE" ]; then
return 0
fi
# Create logs directory if needed
if [ -n "$LOGS_DIR" ]; then
mkdir -p "$LOGS_DIR" 2>/dev/null || true
else
return 0
fi
# Use a stable filename so each flush overwrites the previous snapshot
if [ -n "$EPIC_ID" ]; then
local flush_file="$LOGS_DIR/epic-${EPIC_ID}-latest.log"
else
return 0
fi
cp "$LOG_FILE" "$flush_file" 2>/dev/null || true
}
# =============================================================================
# Git Safety Functions
# =============================================================================
@ -1394,7 +1420,7 @@ Do NOT use 'git add -A' or 'git add .' - only stage files you created or modifie
# Execute in isolated context
local result
result=$(claude --dangerously-skip-permissions -p "$dev_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$dev_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"
@ -1534,7 +1560,7 @@ Stage any fixes with explicit file paths: git add <file1> <file2> ..."
# Execute in isolated context
local result
result=$(claude --dangerously-skip-permissions -p "$review_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$review_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"
@ -1782,7 +1808,7 @@ Address all review findings now. This is attempt $attempt_num of 3."
return 0
fi
result=$(claude --dangerously-skip-permissions -f "$temp_prompt_file" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -f "$temp_prompt_file" 2>&1) || true
rm -f "$temp_prompt_file"
else
if [ "$DRY_RUN" = true ]; then
@ -1791,7 +1817,7 @@ Address all review findings now. This is attempt $attempt_num of 3."
fi
# Execute in isolated context
result=$(claude --dangerously-skip-permissions -p "$fix_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$fix_prompt" 2>&1) || true
fi
echo "$result" >> "$LOG_FILE"
@ -1930,7 +1956,7 @@ $build_output
if grep -q '"test"' "$PROJECT_ROOT/package.json" 2>/dev/null; then
log "Running tests..."
local test_output
test_output=$(cd "$PROJECT_ROOT" && npm test 2>&1) || {
test_output=$(cd "$PROJECT_ROOT" && run_with_timeout "${REGRESSION_TEST_TIMEOUT:-120}" npm test) || {
local exit_code=$?
# Check if there are NEW failures (not just pre-existing baseline failures)
@ -2248,7 +2274,7 @@ Stage any fixes with: git add <file1> <file2> ..."
fi
local result
result=$(claude --dangerously-skip-permissions -p "$arch_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$arch_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"
@ -2325,7 +2351,7 @@ Stage any fixes with: git add <file1> <file2> ..."
fi
local result
result=$(claude --dangerously-skip-permissions -p "$quality_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$quality_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"
@ -2460,7 +2486,7 @@ Analyze traceability now. Read story files on-demand as needed."
fi
local result
result=$(claude --dangerously-skip-permissions -p "$trace_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$trace_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"
@ -2538,7 +2564,7 @@ Generate missing tests now."
fi
local result
result=$(claude --dangerously-skip-permissions -p "$fix_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$fix_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"
@ -2892,7 +2918,7 @@ Generate the UAT document now. Read story files on-demand as needed."
fi
local result
result=$(claude --dangerously-skip-permissions -p "$uat_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$uat_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"
@ -2988,6 +3014,8 @@ for story_file in "${STORIES[@]}"; do
if type save_checkpoint >/dev/null 2>&1; then
save_checkpoint "$STORY_INDEX" "$story_id" "$COMPLETED" "$FAILED" "$SKIPPED"
fi
# Flush log to repo after story failure
flush_log_to_repo
continue
fi
else
@ -3003,6 +3031,8 @@ for story_file in "${STORIES[@]}"; do
if type save_checkpoint >/dev/null 2>&1; then
save_checkpoint "$STORY_INDEX" "$story_id" "$COMPLETED" "$FAILED" "$SKIPPED"
fi
# Flush log to repo after story failure
flush_log_to_repo
continue
fi
fi
@ -3030,6 +3060,9 @@ for story_file in "${STORIES[@]}"; do
if type save_checkpoint >/dev/null 2>&1; then
save_checkpoint "$STORY_INDEX" "$story_id" "$COMPLETED" "$FAILED" "$SKIPPED"
fi
# Flush log to repo after each completed story
flush_log_to_repo
done
# =============================================================================

View File

@ -859,7 +859,7 @@ HUMAN_ACTION_NEEDED: {yes/no}"
# Execute in isolated context
local result
result=$(claude --dangerously-skip-permissions -p "$fix_prompt" 2>&1) || true
result=$(env -u CLAUDECODE claude --dangerously-skip-permissions -p "$fix_prompt" 2>&1) || true
echo "$result" >> "$LOG_FILE"