diff --git a/shared/lib/agent-adapters.sh b/shared/lib/agent-adapters.sh index 6a9a5fe..0638213 100755 --- a/shared/lib/agent-adapters.sh +++ b/shared/lib/agent-adapters.sh @@ -1645,6 +1645,7 @@ agent_verify_launch() { local baseline_command="${5:-}" local baseline_children="${6:-}" local target="$session:$window" + local saw_probe_data=0 local attempts attempts=$(awk "BEGIN { v = $max_wait / $poll_interval; if (v < 1) v = 1; printf \"%d\", (v == int(v) ? v : int(v) + 1) }") @@ -1656,15 +1657,19 @@ agent_verify_launch() { current_command=$(_pane_current_command "$target") children=$(_pane_child_count "$target") - if [[ -z "$current_command" && -z "$children" ]]; then - _agent_log_debug "Launch verification unavailable for $target; accepting best-effort dispatch" + if (( attempt == 1 )) \ + && [[ -z "$baseline_command" ]] \ + && [[ -z "${baseline_children:-}" ]] \ + && [[ -z "$current_command" ]] \ + && [[ -z "$children" ]]; then + _agent_log_warn "Launch could not be verified: tmux pane metadata unavailable for $target; assuming dispatch succeeded" return 0 fi if [[ -n "$current_command" || -n "$children" ]]; then + saw_probe_data=1 introspection_available=1 fi - if [[ -n "$baseline_command" ]] || [[ -n "$baseline_children" ]]; then if [[ "$current_command" != "$baseline_command" ]] || [[ "$children" != "${baseline_children:-}" ]]; then state_changed=1 @@ -1689,6 +1694,11 @@ agent_verify_launch() { (( attempt += 1 )) done + if (( ! saw_probe_data )); then + _agent_log_warn "Launch could not be verified after retries: tmux pane metadata unavailable for $target; assuming dispatch succeeded" + return 0 + fi + _agent_log_warn "Launch not verified: pane $target remained at an idle shell for ${max_wait}s" return 1 } diff --git a/shared/lib/ready-stage.test.ts b/shared/lib/ready-stage.test.ts index 7b06e1b..ebc61c4 100644 --- a/shared/lib/ready-stage.test.ts +++ b/shared/lib/ready-stage.test.ts @@ -327,6 +327,21 @@ describe('ready-stage', () => { }); describe('checkCIStatus', () => { + it('suppresses gh stderr when fetching checks', () => { + let receivedCommand = ''; + const execMock = mock.method(readyStage.readyStageDeps, 'execShellCommand', (cmd: string) => { + receivedCommand = cmd; + return JSON.stringify([]); + }); + + try { + checkCIStatus(42, '/tmp/test'); + assert.match(receivedCommand, /gh pr checks '?42'? --json state,name 2>\/dev\/null$/); + } finally { + execMock.mock.restore(); + } + }); + it('returns pending for queued checks', () => { const execMock = mock.method(readyStage.readyStageDeps, 'execShellCommand', () => JSON.stringify([{ name: 'Shell and Unit Tests', state: 'QUEUED' }]) @@ -674,6 +689,59 @@ describe('ready-stage', () => { }); describe('runReadyStage - integration', () => { + it('suppresses gh stderr while gathering PR context CI metadata', async () => { + const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ready-stage-')); + await fs.writeFile( + path.join(repoDir, '.wavemill-config.json'), + JSON.stringify({ ready: { checks: [], requiredChecks: [] } }), + 'utf-8' + ); + + const commands: string[] = []; + const execMock = mock.method(readyStage.readyStageDeps, 'execShellCommand', (cmd: string) => { + commands.push(cmd); + + if (cmd.includes('gh pr view')) { + if (cmd.includes('mergeable,mergeStateStatus')) { + return JSON.stringify({ + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + }); + } + + return JSON.stringify({ + number: 42, + headRefName: 'feature-branch', + baseRefName: 'main', + url: 'https://github.com/test/repo/pull/42', + files: [], + }); + } + if (cmd.includes('gh pr diff')) { + return ''; + } + if (cmd.includes('gh pr checks')) { + return JSON.stringify([]); + } + return ''; + }); + + try { + await runReadyStage({ + prNumber: 42, + repoDir, + }); + + assert.ok( + commands.some((cmd) => /gh pr checks '?42'? --json state 2>\/dev\/null$/.test(cmd)), + 'expected gatherPRContext to suppress gh stderr' + ); + } finally { + execMock.mock.restore(); + await fs.rm(repoDir, { recursive: true, force: true }); + } + }); + it('includes merge conflict status independently from the readiness verdict', async () => { const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ready-stage-')); await fs.writeFile( diff --git a/shared/lib/ready-stage.ts b/shared/lib/ready-stage.ts index 8447cd7..a74073c 100644 --- a/shared/lib/ready-stage.ts +++ b/shared/lib/ready-stage.ts @@ -198,7 +198,7 @@ async function gatherPRContext(prNumber: number, repoDir: string): Promise/dev/null`, { encoding: 'utf-8', cwd: repoDir } ); const checks = JSON.parse(checksJson.toString()); @@ -537,7 +537,7 @@ function checkReleaseRequirements( export function checkCIStatus(prNumber: number, repoDir: string): ReadyCheck { try { const checksJson = readyStageDeps.execShellCommand( - `gh pr checks ${escapeShellArg(String(prNumber))} --json state,name`, + `gh pr checks ${escapeShellArg(String(prNumber))} --json state,name 2>/dev/null`, { encoding: 'utf-8', cwd: repoDir } ); const checks = JSON.parse(checksJson.toString()); diff --git a/shared/lib/wavemill-startup-runner.sh b/shared/lib/wavemill-startup-runner.sh index ef0c1d3..7f538b2 100755 --- a/shared/lib/wavemill-startup-runner.sh +++ b/shared/lib/wavemill-startup-runner.sh @@ -138,7 +138,8 @@ save_task_state() { | if $reviewerModel != "" then .tasks[$issue].reviewerModel = $reviewerModel else . end | if $planDepth != "" then .tasks[$issue].planDepth = $planDepth else . end | if $codeDepth != "" then .tasks[$issue].codeDepth = $codeDepth else . end - | if $reviewMode != "" then .tasks[$issue].reviewMode = $reviewMode else . end' \ + | if $reviewMode != "" then .tasks[$issue].reviewMode = $reviewMode else . end + | if $phase != "" then .tasks[$issue].phase = $phase else . end' \ "$STATE_FILE" > "$tmp" 2>/dev/null; then mv "$tmp" "$STATE_FILE" return 0 @@ -421,7 +422,6 @@ $details_context" # Persist launched tasks as active coding work in the initial state write so # downstream startup checks do not depend on a second jq update succeeding. local persisted_phase="coding" - if ! save_task_state "$issue" "$slug" "$branch" "$wt_dir" "" "" "$task_agent" "$linear_issue" "$challenge" "$challenge_pair" "$challenge_role" "$challenge_model" \ "$planner_model" "$coder_model" "$reviewer_model" "$plan_depth" "$code_depth" "$review_mode" "$persisted_phase"; then startup_log "✗ $issue FAILED at step [5/7]: saving workflow state" @@ -447,6 +447,16 @@ $details_context" startup_log "✗ $issue FAILED at step [6/7]: launching coding agent" return 1 fi + + # Re-persist the launched task after the pane handoff succeeds so the final + # workflow record reflects a fully launched coding session. + if ! save_task_state "$issue" "$slug" "$branch" "$wt_dir" "" "" "$task_agent" "$linear_issue" "$challenge" "$challenge_pair" "$challenge_role" "$challenge_model" \ + "$planner_model" "$coder_model" "$reviewer_model" "$plan_depth" "$code_depth" "$review_mode" "$persisted_phase"; then + [[ -n "${state_written:-}" ]] && remove_task_state "$issue" >/dev/null 2>&1 || true + tmux kill-window -t "$SESSION:$win" >/dev/null 2>&1 || true + startup_log "✗ $issue FAILED after step [6/7]: re-saving workflow state" + return 1 + fi startup_step "[6/7] Launching agent... ✓" if should_update_linear_for_task "$challenge_role"; then diff --git a/tests/launch-ready-phase.test.sh b/tests/launch-ready-phase.test.sh index d4cef58..494e067 100644 --- a/tests/launch-ready-phase.test.sh +++ b/tests/launch-ready-phase.test.sh @@ -169,7 +169,6 @@ run_launch_case() { ;; clean_with_stderr) printf "%s\n" "{\"prNumber\":304,\"branch\":\"task/fix-failing-ci-tests\",\"verdict\":\"pass\",\"checks\":[{\"name\":\"ci-status\",\"status\":\"pass\",\"message\":\"All CI checks passing\",\"details\":{\"totalChecks\":3}}],\"timestamp\":\"2026-04-16T14:12:00.431Z\",\"summary\":\"All checks passed\",\"mergeConflict\":{\"status\":\"CLEAN\",\"message\":\"No merge conflicts detected\",\"mergeable\":\"MERGEABLE\",\"mergeStateStatus\":\"CLEAN\",\"attempts\":1}}" - printf "%s\n" "⚠️ MERGE CONFLICT: PR #304 has conflicts with main" >&2 return 0 ;; fail_with_stderr) @@ -276,9 +275,8 @@ check_contains "pass after remediation writes completed stage" "$output" "|ready check_not_contains "pass after remediation clears remediation artifacts" "$output" "\"remediationAttempts\":" output="$(run_launch_case clean_with_stderr)" -check_contains "success stderr stays in debug logs" "$output" "debug [ready stderr] ⚠️ MERGE CONFLICT: PR #304 has conflicts with main" -check_not_contains "success stderr does not leak to terminal" "$output" $'\n⚠️ MERGE CONFLICT: PR #304 has conflicts with main\n' check_contains "success stderr is not treated as error" "$output" "error_count=0" +check_not_contains "success path does not log ready stderr" "$output" "[ready stderr] ⚠️ MERGE CONFLICT: PR #304 has conflicts with main" output="$(run_launch_case fail_with_stderr)" check_contains "failure stderr is logged as error" "$output" "error_payload= [ready stderr] TypeError: ready crashed" diff --git a/tools/ready.ts b/tools/ready.ts index e2b0cf6..590e06b 100644 --- a/tools/ready.ts +++ b/tools/ready.ts @@ -35,10 +35,6 @@ runTool({ const repoDir = args['repo-dir'] || process.cwd(); const result = await runReadyStage({ prNumber, repoDir }); - if (result.mergeConflict) { - printMergeConflictStatus(result); - } - // Output JSON for scripting console.log(JSON.stringify(result, null, 2)); @@ -66,21 +62,3 @@ function extractPrNumber(input: string): number { throw new Error(`Invalid PR number or URL: ${input}`); } - -function printMergeConflictStatus(result: Awaited>): void { - const status = result.mergeConflict?.status; - - switch (status) { - case 'CONFLICTED': - console.error(`⚠️ MERGE CONFLICT: PR #${result.prNumber} has conflicts with main`); - break; - case 'UNKNOWN': - console.error(`⏳ MERGE STATUS UNKNOWN: PR #${result.prNumber} - GitHub computing mergeability`); - break; - case 'ERROR': - console.error(`⚠️ MERGE STATUS ERROR: PR #${result.prNumber} - ${result.mergeConflict?.message}`); - break; - default: - break; - } -}