Skip to content

agent auth failure is not session-fatal — it burns the entire queue in seconds #431

Open

Description

@bradtaylorsf

When the spawned agent CLI cannot authenticate (observed: Claude Code CLI returning Failed to authenticate: OAuth session expired and could not be refreshed), the loop treats each agent exit as an ordinary per-issue failure. Result on 2.4.0: it churned through the queue at ~13s/issue, relabeling every issue failed, opening a session PR titled "0/N succeeded" with zero code — instead of stopping at the first sign that no agent can run at all.

Suggested fixes:

  • Pre-flight agent liveness probe (e.g. echo ping | <agent> -p) before creating the session; abort with a clear auth error if it fails.
  • Treat consecutive instant agent failures (< a few seconds, identical auth error) as session-fatal: stop, restore labels, no session PR.

Found on alpha-loop 2.4.0, macOS, agent: claude, batch off, flat 5-issue run.

Activity

  1. added
    readyIssue is ready for automated agent processing
    in-progressAgent is actively working on this issue
    and removed
    readyIssue is ready for automated agent processing
    on Aug 24, 2026
  2. self-assigned this
    on Aug 24, 2026
  3. added
    in-reviewImplementation complete, PR under review
    and removed
    in-progressAgent is actively working on this issue
    on Aug 24, 2026
  4. bradtaylorsf commented on Aug 24, 2026

    @bradtaylorsf
    OwnerAuthor

    Automated implementation complete.

    PR: #436
    Tests: PASSING
    Review: Attached to PR body.


    Processed by alpha-loop in 1872s

  5. added a commit that references this issue on Aug 24, 2026
    28f5fd5
  6. bradtaylorsf commented on Aug 24, 2026

    @bradtaylorsf
    OwnerAuthor

    AI Implementation Notes

    warning: --full-auto is deprecated; use --sandbox workspace-write instead.
    Reading prompt from stdin...
    2026-08-24T15:16:43.094180Z ERROR codex_core::session::session: failed to load skill /Users/bradtaylor/Documents/GitHub/alpha-loop/.worktrees/session-epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue/.agents/skills/playwright-testing/SKILL.md: missing YAML frontmatter delimited by ---
    OpenAI Codex v0.144.1

    workdir: /Users/bradtaylor/Documents/GitHub/alpha-loop/.worktrees/session-epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue
    model: gpt-5.6-sol
    provider: openai
    approval: never
    sandbox: workspace-write [workdir, /tmp, $TMPDIR, /Users/bradtaylor/Documents/GitHub/alpha-loop/.git] (network access enabled)
    reasoning effort: high
    reasoning summaries: none
    session id: 01a03458-6396-7b20-bf4a-081b9b2d3ffd

    user
    You just implemented GitHub issue #431: agent auth failure is not session-fatal — it burns the entire queue in seconds

    Original Requirements

    When the spawned agent CLI cannot authenticate (observed: Claude Code CLI returning Failed to authenticate: OAuth session expired and could not be refreshed), the loop treats each agent exit as an ordinary per-issue failure. Result on 2.4.0: it churned through the queue at ~13s/issue, relabeling every issue failed, opening a session PR titled "0/N succeeded" with zero code — instead of stopping at the first sign that no agent can run at all.

    Suggested fixes:

    • Pre-flight agent liveness probe (e.g. echo ping | <agent> -p) before creating the session; abort with a clear auth error if it fails.
    • Treat consecutive instant agent failures (< a few seconds, identical auth error) as session-fatal: stop, restore labels, no session PR.

    Found on alpha-loop 2.4.0, macOS, agent: claude, batch off, flat 5-issue run.

    Code Changes (first 5000 chars)

    diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
    index e443cd4..667183a 100644
    --- a/.github/workflows/test.yml
    +++ b/.github/workflows/test.yml
    @@ -4,7 +4,7 @@ on:
    push:
    branches: [master]
    pull_request:

    • branches: [master]
    • branches: [master, 'session/', 'agent/']

    jobs:
    type-check:
    diff --git a/CLAUDE.md b/CLAUDE.md
    index df3bf3f..a870ce5 100644
    --- a/CLAUDE.md
    +++ b/CLAUDE.md
    @@ -49,6 +49,8 @@ pnpm test # Run all tests
    pnpm build # Build TypeScript to dist/

    
    +Every non-dry `alpha-loop run` checks the selected agent CLI with a short, text-only liveness prompt before session creation. Agent authentication failure exits before session/git/GitHub mutation; authentication loss during processing requeues affected work and stops the remaining queue without an empty session PR.
    +
    ## Directory Structure
    
    

    diff --git a/README.md b/README.md
    index 7cf55b8..c25d262 100644
    --- a/README.md
    +++ b/README.md
    @@ -82,6 +82,8 @@ Milestones answer "when should this epic ship?" The epic checklist answers "what

    Alpha Loop implements a 12-step pipeline for each issue:

    +Before creating a session, every non-dry run checks the configured agent binary and sends a short, text-only liveness prompt. Authentication, spawn, or timeout failures exit with a clear diagnostic before any session directory, branch, worktree, issue label change, or session PR is created. If authentication is lost during an active run, the affected issue or batch is restored to ready/Todo and the remaining queue stops; an empty 0/N succeeded session PR is never opened.
    +

    1. Status Update — Labels issue in-progress, assigns to you, updates project board
    2. Worktree — Creates an isolated git worktree so work doesn't conflict with other issues
    3. Plan — Agent analyzes the issue and enriches it with implementation details
      diff --git a/review-issue-431.json b/review-issue-431.json
      new file mode 100644
      index 0000000..2508263
      --- /dev/null
      +++ b/review-issue-431.json
      @@ -0,0 +1,12 @@
      +{
    • "passed": true,
    • "summary": "Preflight and mid-session agent authentication failures now stop safely without burning the remaining queue or opening an empty session PR.",
    • "findings": [
    • {
    •  "severity": "critical",
      
    •  "description": "Authentication loss during test repair, code review, review repair, live verification, verification repair, or batch review was still treated as an ordinary issue failure, allowing later queued issues to run.",
      
    •  "fixed": true,
      
    •  "file": "src/lib/pipeline.ts"
      
    • }
    • ]
      +}
      diff --git a/src/commands/run.ts b/src/commands/run.ts
      index 40b971b..1413bd1 100644
      --- a/src/commands/run.ts
      +++ b/src/commands/run.ts
      @@ -6,7 +6,7 @@ import { spawn } from 'node:child_process';
      import * as readline from 'node:readline';
      import { log } from '../lib/logger.js';
      import { exec, shellQuote } from '../lib/shell.js';
      -import { loadConfig, assertSafeShellArg, resolveStepConfig, type Config } from '../lib/config.js';
      +import { loadConfig, assertSafeShellArg, resolveRoutingStage, resolveStepConfig, type Config } from '../lib/config.js';
      import {
      pollIssues, listMilestones, listEpics, getEpicSubIssues, getIssueWithComments,
      getMergedPRForIssue, updateEpicChecklist, commentIssue, closeIssue, labelIssue,
      @@ -47,7 +47,14 @@ import { runPreflight } from '../lib/preflight.js';
      import { syncAgentAssets, resolveHarnesses } from './sync.js';
      import { saveCapturedCase, detectFailureStep } from '../lib/eval.js';
      import { readGateResult, formatGateFindings } from '../lib/pipeline.js';
      -import { spawnAgent } from '../lib/agent.js';
      +import {
    • agentCliCommand,
    • buildEndpointEnv,
    • probeAgentLiveness,
    • spawnAgent,
    • type AgentFailure,
    • type AgentType,
      +} from '../lib/agent.js';
      import { buildSessionReviewPrompt, type EpicPromptContext } from '../lib/prompts.js';
      import { writeTraceToSubdir } from '../lib/traces.js';
      import { validateGeneratedMarkdownForCommit } from '../lib/scan-validation.js';
      @@ -141,7 +148,8 @@ export type EpicExecutionFailureCode =
      | 'epic-run-error'
      | 'session-test-failed'
      | 'session-review-failed'
    • | 'quick-finalize-failed';
    • | 'quick-finalize-failed'
    • | 'agent-unavailable';

    export type EpicExecutionFailure = {
    code: EpicExecutionFailureCode;
    @@ -388,17 +396,18 @@ async function pauseIssueForAutomationPolicy(args: {

    • const agentUrl = AGENT_INSTALL_URLS[config.agent] ?? '';
    • const agentMsg =

    Review Summary

    Preflight and mid-session agent authentication failures now stop safely without burning the remaining queue or opening an empty session PR.

    Analyze the implementation and list any assumptions or decisions you had to make where the requirements were ambiguous or incomplete. Output ONLY a markdown document with this structure:

    Assumptions

    • (list each assumption made, e.g. "Assumed the date format should be ISO 8601 since it wasn't specified")
    • If no assumptions were needed, write "None — requirements were fully specified"

    Decisions

    • (list each design/implementation decision where multiple valid approaches existed, e.g. "Chose to validate on the server side rather than client side for security")
    • If no notable decisions, write "None — implementation was straightforward"

    Items to Validate

    • (list specific things the user should check, e.g. "Verify the error message wording matches your team's style guide")
    • If nothing needs validation, write "None"

    Keep it concise. Only include genuinely ambiguous items, not obvious implementation choices.
    codex
    I’m using the code-review skill to inspect the implementation’s actual behavior and isolate only genuinely ambiguous assumptions and design choices.
    exec
    /bin/zsh -lc "sed -n '1,240p' .agents/skills/code-review/SKILL.md" in /Users/bradtaylor/Documents/GitHub/alpha-loop/.worktrees/session-epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue
    succeeded in 0ms:

    name: code-review
    description: Code review standards for automated and manual reviews. Use when reviewing code changes.
    auto_load: true
    priority: high

    Code Review Skill

    Trigger

    When reviewing code changes (PR review, post-implementation review).

    Review Checklist

    1. Requirements Compliance

    • All acceptance criteria met
    • No scope creep (unnecessary changes)
    • Edge cases handled

    2. Code Quality

    • Follows project conventions (check CLAUDE.md)
    • No any types in TypeScript
    • Functions are small and focused
    • No dead code or commented-out code
    • No console.log left in production code

    3. Security (OWASP Top 10)

    • No SQL injection (use parameterized queries)
    • No XSS (sanitize user input)
    • No command injection (validate shell args)
    • Auth/authz checks in place
    • No secrets in code

    4. Testing

    • New code has tests
    • Tests cover happy path AND error cases
    • Tests are deterministic (no flaky tests)
    • Test names describe behavior, not implementation

    5. Performance

    • No N+1 queries
    • No unnecessary re-renders
    • Large lists paginated

    Action on Findings

    • CRITICAL: Fix immediately before merge
    • WARNING: Fix now if possible, create issue if not
    • SUGGESTION: Note for future improvement

    Output Format

    ### Review Summary
    **Status**: PASS | FAIL
    **Issues fixed**: N
    **Issues deferred**: N (with issue links)

    exec
    /bin/zsh -lc 'git status --short && git diff --stat && git diff -- src/lib/agent.ts src/commands/run.ts src/lib/pipeline.ts tests README.md CLAUDE.md .github/workflows/test.yml' in /Users/bradtaylor/Documents/GitHub/alpha-loop/.worktrees/session-epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue
    succeeded in 0ms:
    ?? .alpha-loop/learnings/issue-429-20260824-073503.md
    ?? .alpha-loop/learnings/issue-430-20260824-074500.md

    exec
    /bin/zsh -lc 'git log --oneline --decorate -8 && git show --stat --oneline --decorate HEAD && git branch --show-current && git merge-base HEAD master' in /Users/bradtaylor/Documents/GitHub/alpha-loop/.worktrees/session-epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue
    succeeded in 0ms:
    28f5fd5 (HEAD -> session/epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue, origin/session/epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue) feat: agent auth failure is not session-fatal — it burns the entire queue in seconds (closes #431) (#436)
    847dae3 feat: worktree setup unconditionally runs pnpm install, breaking zero-dependency repos (closes #430) (#435)
    93976f6 feat: fix: CI never runs on issue PRs (session-branch base), so #404's merge gate will stall every bundle (closes #429) (#434)
    9af431d chore: start session session/epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue
    b69a5be (origin/master, origin/HEAD, release-staging, master) Checkpoint before M3 migration (2026-08-19)
    613d641 fix: make epic verification evidence authoritative (#398) (#399)
    1f59697 Merge pull request #393 from bradtaylorsf/fix/GH-392-fail-closed-review-gates
    fca7824 (origin/fix/GH-392-fail-closed-review-gates, fix/GH-392-fail-closed-review-gates) fix: fail closed on missing review gates (#392)
    28f5fd5 (HEAD -> session/epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue, origin/session/epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue) feat: agent auth failure is not session-fatal — it burns the entire queue in seconds (closes #431) (#436)
    CLAUDE.md | 2 +
    README.md | 2 +
    review-issue-431.json | 12 ++
    src/commands/run.ts | 171 ++++++++++++++++++++++--
    src/lib/agent.ts | 160 +++++++++++++++++++++-
    src/lib/pipeline.ts | 208 +++++++++++++++++++++++++++--
    src/lib/traces.ts | 2 +-
    tests/commands/epic-first-workflow.test.ts | 5 +
    tests/commands/run.test.ts | 201 +++++++++++++++++++++++++++-
    tests/lib/agent.test.ts | 152 +++++++++++++++++++++
    tests/lib/pipeline.test.ts | 182 +++++++++++++++++++++++++
    11 files changed, 1073 insertions(+), 24 deletions(-)
    session/epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue
    b69a5be

    exec
    /bin/zsh -lc 'git show --format=fuller --find-renames 28f5fd5 -- src/lib/agent.ts tests/lib/agent.test.ts' in /Users/bradtaylor/Documents/GitHub/alpha-loop/.worktrees/session-epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue
    succeeded in 0ms:
    commit 28f5fd5
    Author: Brad Taylor bradtaylorsf@gmail.com
    AuthorDate: Mon Aug 24 08:16:27 2026 -0700
    Commit: GitHub noreply@github.com
    CommitDate: Mon Aug 24 08:16:27 2026 -0700

    feat: agent auth failure is not session-fatal — it burns the entire queue in seconds (closes #431) (#436)
    
    * feat: agent auth failure is not session-fatal — it burns the entire queue in seconds (closes #431)
    
    * fix: address review findings for #431
    

    diff --git a/src/lib/agent.ts b/src/lib/agent.ts
    index fbab5e8..79e9e64 100644
    --- a/src/lib/agent.ts
    +++ b/src/lib/agent.ts
    @@ -2,6 +2,7 @@

    • Agent Runner — spawn AI agents with real-time output streaming.
      /
      import { spawn } from 'node:child_process';
      +import { createHash } from 'node:crypto';
      import { createWriteStream, type WriteStream } from 'node:fs';
      import { resolve, relative, isAbsolute } from 'node:path';
      import { log } from './logger.js';
      @@ -36,6 +37,27 @@ export type AgentResult = {
      resultSubtype?: string;
      /
      * Whether the Claude stream-json result reported an error. */
      resultIsError?: boolean;
    • /** Sanitized classification for non-zero CLI exits. */
    • failure?: AgentFailure;
      +};

    +export type AgentFailureKind = 'authentication' | 'spawn' | 'timeout' | 'execution';
    +
    +/**

      • Safe-to-log agent failure metadata. The fingerprint is deterministic but
      • never contains raw CLI output, which can include account or endpoint data.
    • */
      +export type AgentFailure = {
    • kind: AgentFailureKind;
    • fingerprint: string;
    • diagnostic: string;
    • durationMs: number;
      +};

    +export type AgentProbeResult = {

    • ok: boolean;
    • duration: number;
    • failure?: AgentFailure;
      };

    /**
    @@ -127,8 +149,26 @@ export type AgentOptions = {
    * frontier stage does not inherit a local-endpoint env from a prior stage.
    */
    env?: Record<string, string>;

    • /** Restrict the invocation to a stdout-only/read-only liveness check. */
    • textOnly?: boolean;
      };

    +/** Resolve aliases to the CLI binary actually spawned. */
    +export function agentCliCommand(agent: AgentType): string {

    • switch (agent) {
    • case 'claude':
    • case 'lmstudio':
    •  return 'claude';
      
    • case 'codex':
    • case 'ollama':
    •  return 'codex';
      
    • case 'opencode':
    •  return 'opencode';
      
    • default:
    •  throw new Error(`Unknown agent type: ${agent}`);
      
    • }
      +}

    /**

    • Extra sandbox config the codex CLI needs when running inside a linked git
    • worktree: the worktree's index and metadata live in the PARENT repo's .git
      @@ -177,6 +217,10 @@ export function buildAgentArgs(options: AgentOptions): { command: string; args:
      if (options.resume) args.push('--continue');
      args.push('-p');
      if (options.model) args.push('--model', options.model);
    •  if (options.textOnly) {
      
    •    args.push('--allowedTools', '', '--output-format', 'text');
      
    •    return { command: agentCliCommand(options.agent), args };
      
    •  }
       args.push(
         '--dangerously-skip-permissions',
         '--verbose',
      

    @@ -185,7 +229,7 @@ export function buildAgentArgs(options: AgentOptions): { command: string; args:
    if (options.maxTurns) {
    args.push('--max-turns', String(options.maxTurns));
    }

    •  return { command: 'claude', args };
      
    •  return { command: agentCliCommand(options.agent), args };
      
      }
      case 'codex':
      case 'ollama': {
      @@ -196,20 +240,103 @@ export function buildAgentArgs(options: AgentOptions): { command: string; args:
      args.push('exec');
      }
      if (options.model) args.push('--model', options.model);
    •  if (options.textOnly) {
      
    •    args.push('--sandbox', 'read-only');
      
    •    return { command: agentCliCommand(options.agent), args };
      
    •  }
       args.push('--full-auto');
       args.push(...codexSandboxArgs(options.cwd));
      
    •  return { command: 'codex', args };
      
    •  return { command: agentCliCommand(options.agent), args };
      
      }
      case 'opencode': {
      const args = ['run'];
      if (options.model) args.push('--model', options.model);
    •  return { command: 'opencode', args };
      
    •  return { command: agentCliCommand(options.agent), args };
      
      }
      default:
      throw new Error(Unknown agent type: ${options.agent});
      }
      }

    +const AUTH_FAILURES: Array<{

    • pattern: RegExp;
    • fingerprint: string;
    • diagnostic: string;
      +}> = [
    • {
    • pattern: /oauth session expired(?:.|\n)*could not be refreshed/i,
    • fingerprint: 'authentication:oauth-session-expired',
    • diagnostic: 'OAuth session expired and could not be refreshed',
    • },
    • {
    • pattern: /failed to authenticate/i,
    • fingerprint: 'authentication:failed',
    • diagnostic: 'Agent CLI authentication failed',
    • },
    • {
    • pattern: /(?:not logged in|please (?:run )?(?:the )?login|authentication required|unauthorized|invalid api key|api key (?:is )?(?:invalid|missing))/i,
    • fingerprint: 'authentication:credentials-required',
    • diagnostic: 'Agent CLI credentials are missing or invalid',
    • },
      +];

    +/**

      • Normalize a non-zero agent exit into structured metadata without copying
      • raw diagnostics (which may contain usernames, tokens, or endpoint URLs).
    • */
      +export function classifyAgentFailure(
    • exitCode: number,
    • output: string,
    • durationMs: number,
      +): AgentFailure | undefined {
    • if (exitCode === 0) return undefined;
    • const tail = output.slice(-4000);
    • for (const authFailure of AUTH_FAILURES) {
    • if (authFailure.pattern.test(tail)) {
    •  return {
      
    •    kind: 'authentication',
      
    •    fingerprint: authFailure.fingerprint,
      
    •    diagnostic: authFailure.diagnostic,
      
    •    durationMs,
      
    •  };
      
    • }
    • }
    • if (/failed to spawn\b/i.test(tail)) {
    • return {
    •  kind: 'spawn',
      
    •  fingerprint: 'spawn:failed',
      
    •  diagnostic: 'Agent CLI could not be started',
      
    •  durationMs,
      
    • };
    • }
    • if (tail.includes('[TIMEOUT]')) {
    • return {
    •  kind: 'timeout',
      
    •  fingerprint: 'timeout:agent',
      
    •  diagnostic: 'Agent CLI did not respond before the timeout',
      
    •  durationMs,
      
    • };
    • }
    • const normalized = tail
    • .toLowerCase()
    • .replace(/https?://\S+/g, '')
    • .replace(/[\w.+-]+@[\w.-]+/g, '')
    • .replace(/\b\d+\b/g, '')
    • .replace(/\s+/g, ' ')
    • .trim();
    • const digest = createHash('sha256').update(normalized || exit:${exitCode}).digest('hex').slice(0, 12);
    • return {
    • kind: 'execution',
    • fingerprint: execution:${digest},
    • diagnostic: Agent CLI exited with code ${exitCode},
    • durationMs,
    • };
      +}

    /**

    • Build a shell command string for one-shot agent prompts (scan, vision).
    • Reads prompt from stdin. Returns the command to pipe into.
      @@ -555,6 +682,8 @@ export async function spawnAgent(options: AgentOptions): Promise {
      toolCalls: toolUseCount,
      toolErrors: toolErrorsFinal,
      };
    •  const failure = classifyAgentFailure(exitCode, finalOutput, duration);
      
    •  if (failure) result.failure = failure;
       if (parsedResultSubtype !== undefined) result.resultSubtype = parsedResultSubtype;
       if (parsedResultIsError) result.resultIsError = true;
       if (logStream) {
      

    @@ -589,3 +718,28 @@ export async function spawnAgent(options: AgentOptions): Promise {
    });
    });
    }
    +
    +/**

      • Run a short, text-only prompt before session creation to prove the selected
      • CLI can authenticate and answer without gaining write-capable tools.
    • */
      +export async function probeAgentLiveness(
    • options: Pick<AgentOptions, 'agent' | 'model' | 'cwd' | 'env'> & { timeout?: number },
      +): Promise {
    • const result = await spawnAgent({
    • ...options,
    • prompt: 'Reply with exactly: ping. Do not use tools.',
    • textOnly: true,
    • timeout: options.timeout ?? 10_000,
    • });
    • if (result.exitCode === 0) {
    • return { ok: true, duration: result.duration };
    • }
    • return {
    • ok: false,
    • duration: result.duration,
    • failure: result.failure ?? classifyAgentFailure(result.exitCode, result.output, result.duration),
    • };
      +}
      diff --git a/tests/lib/agent.test.ts b/tests/lib/agent.test.ts
      index 7b50a8c..0d58257 100644
      --- a/tests/lib/agent.test.ts
      +++ b/tests/lib/agent.test.ts
      @@ -2,7 +2,9 @@ import {
      buildAgentArgs,
      buildEndpointEnv,
      buildOneShotCommand,
    • classifyAgentFailure,
      codexSandboxArgs,
    • probeAgentLiveness,
      spawnAgent,
      DEFAULT_LMSTUDIO_BASE_URL,
      DEFAULT_OLLAMA_BASE_URL,
      @@ -52,6 +54,30 @@ beforeEach(() => {
      });

    describe('buildAgentArgs', () => {

    • test('constructs text-only probe args without write permissions', () => {
    • expect(buildAgentArgs({
    •  agent: 'claude',
      
    •  model: 'opus',
      
    •  prompt: 'ping',
      
    •  cwd: '/tmp',
      
    •  textOnly: true,
      
    • })).toEqual({
    •  command: 'claude',
      
    •  args: ['-p', '--model', 'opus', '--allowedTools', '', '--output-format', 'text'],
      
    • });
    • expect(buildAgentArgs({
    •  agent: 'ollama',
      
    •  model: 'llama3.1:70b',
      
    •  prompt: 'ping',
      
    •  cwd: '/tmp',
      
    •  textOnly: true,
      
    • })).toEqual({
    •  command: 'codex',
      
    •  args: ['exec', '--model', 'llama3.1:70b', '--sandbox', 'read-only'],
      
    • });
    • });
    • test('constructs correct args for claude agent', () => {
      const result = buildAgentArgs({
      agent: 'claude',
      @@ -180,6 +206,132 @@ describe('buildAgentArgs', () => {
      });
      });

    +describe('classifyAgentFailure', () => {

    • test('normalizes Claude expired OAuth diagnostics without retaining raw output', () => {
    • const failure = classifyAgentFailure(
    •  1,
      
    •  'Failed to authenticate: OAuth session expired and could not be refreshed\naccount: brad@example.com',
      
    •  412,
      
    • );
    • expect(failure).toEqual({
    •  kind: 'authentication',
      
    •  fingerprint: 'authentication:oauth-session-expired',
      
    •  diagnostic: 'OAuth session expired and could not be refreshed',
      
    •  durationMs: 412,
      
    • });
    • expect(JSON.stringify(failure)).not.toContain('brad@example.com');
    • });
    • test('classifies spawn and timeout failures separately', () => {
    • expect(classifyAgentFailure(1, 'Failed to spawn claude: command not found', 5)).toEqual(
    •  expect.objectContaining({ kind: 'spawn', fingerprint: 'spawn:failed' }),
      
    • );
    • expect(classifyAgentFailure(1, '[TIMEOUT] Agent killed after exceeding time limit.', 10_000)).toEqual(
    •  expect.objectContaining({ kind: 'timeout', fingerprint: 'timeout:agent' }),
      
    • );
    • });
      +});

    +describe('probeAgentLiveness', () => {

    • test('uses a text-only invocation and reports success', async () => {
    • mockChild.on.mockImplementation((event: string, cb: Function) => {
    •  if (event === 'close') queueMicrotask(() => cb(0));
      
    • });
    • const result = await probeAgentLiveness({
    •  agent: 'claude',
      
    •  model: 'opus',
      
    •  cwd: '/project',
      
    • });
    • expect(result.ok).toBe(true);
    • expect(spawn).toHaveBeenCalledWith(
    •  'claude',
      
    •  ['-p', '--model', 'opus', '--allowedTools', '', '--output-format', 'text'],
      
    •  expect.objectContaining({ cwd: '/project' }),
      
    • );
    • expect(mockStdin.write).toHaveBeenCalledWith(expect.stringContaining('ping'));
    • });
    • test('returns a structured authentication failure', async () => {
    • let stderrHandler!: (data: Buffer) => void;
    • mockStderr.on.mockImplementation((event: string, cb: (data: Buffer) => void) => {
    •  if (event === 'data') stderrHandler = cb;
      
    • });
    • mockChild.on.mockImplementation((event: string, cb: Function) => {
    •  if (event === 'close') {
      
    •    queueMicrotask(() => {
      
    •      stderrHandler(Buffer.from('Failed to authenticate: OAuth session expired and could not be refreshed'));
      
    •      cb(1);
      
    •    });
      
    •  }
      
    • });
    • const result = await probeAgentLiveness({
    •  agent: 'claude',
      
    •  model: 'opus',
      
    •  cwd: '/project',
      
    • });
    • expect(result).toEqual(expect.objectContaining({
    •  ok: false,
      
    •  failure: expect.objectContaining({
      
    •    kind: 'authentication',
      
    •    fingerprint: 'authentication:oauth-session-expired',
      
    •  }),
      
    • }));
    • });
    • test('reports spawn failures from the probe', async () => {
    • mockChild.on.mockImplementation((event: string, cb: Function) => {
    •  if (event === 'error') queueMicrotask(() => cb(new Error('command not found')));
      
    • });
    • const result = await probeAgentLiveness({
    •  agent: 'claude',
      
    •  model: 'opus',
      
    •  cwd: '/project',
      
    • });
    • expect(result).toEqual(expect.objectContaining({
    •  ok: false,
      
    •  failure: expect.objectContaining({ kind: 'spawn', fingerprint: 'spawn:failed' }),
      
    • }));
    • });
    • test('times out a probe without a real delay', async () => {
    • jest.useFakeTimers();
    • try {
    •  let closeHandler!: (code: number | null) => void;
      
    •  mockChild.on.mockImplementation((event: string, cb: (code: number | null) => void) => {
      
    •    if (event === 'close') closeHandler = cb;
      
    •  });
      
    •  mockChild.kill.mockImplementation(() => {
      
    •    queueMicrotask(() => closeHandler(null));
      
    •    return true;
      
    •  });
      
    •  const resultPromise = probeAgentLiveness({
      
    •    agent: 'claude',
      
    •    model: 'opus',
      
    •    cwd: '/project',
      
    •    timeout: 100,
      
    •  });
      
    •  await jest.advanceTimersByTimeAsync(100);
      
    •  const result = await resultPromise;
      
    •  expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM');
      
    •  expect(result).toEqual(expect.objectContaining({
      
    •    ok: false,
      
    •    failure: expect.objectContaining({ kind: 'timeout', fingerprint: 'timeout:agent' }),
      
    •  }));
      
    • } finally {
    •  jest.useRealTimers();
      
    • }
    • });
      +});

    describe('buildOneShotCommand', () => {
    test('builds claude command with model', () => {
    const cmd = buildOneShotCommand('claude', 'opus');

    exec
    /bin/zsh -lc 'git show --format= --find-renames 28f5fd5 -- src/commands/run.ts tests/commands/run.test.ts tests/commands/epic-first-workflow.test.ts' in /Users/bradtaylor/Documents/GitHub/alpha-loop/.worktrees/session-epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue
    succeeded in 0ms:
    diff --git a/src/commands/run.ts b/src/commands/run.ts
    index 40b971b..1413bd1 100644
    --- a/src/commands/run.ts
    +++ b/src/commands/run.ts
    @@ -6,7 +6,7 @@ import { spawn } from 'node:child_process';
    import * as readline from 'node:readline';
    import { log } from '../lib/logger.js';
    import { exec, shellQuote } from '../lib/shell.js';
    -import { loadConfig, assertSafeShellArg, resolveStepConfig, type Config } from '../lib/config.js';
    +import { loadConfig, assertSafeShellArg, resolveRoutingStage, resolveStepConfig, type Config } from '../lib/config.js';
    import {
    pollIssues, listMilestones, listEpics, getEpicSubIssues, getIssueWithComments,
    getMergedPRForIssue, updateEpicChecklist, commentIssue, closeIssue, labelIssue,
    @@ -47,7 +47,14 @@ import { runPreflight } from '../lib/preflight.js';
    import { syncAgentAssets, resolveHarnesses } from './sync.js';
    import { saveCapturedCase, detectFailureStep } from '../lib/eval.js';
    import { readGateResult, formatGateFindings } from '../lib/pipeline.js';
    -import { spawnAgent } from '../lib/agent.js';
    +import {

    • agentCliCommand,
    • buildEndpointEnv,
    • probeAgentLiveness,
    • spawnAgent,
    • type AgentFailure,
    • type AgentType,
      +} from '../lib/agent.js';
      import { buildSessionReviewPrompt, type EpicPromptContext } from '../lib/prompts.js';
      import { writeTraceToSubdir } from '../lib/traces.js';
      import { validateGeneratedMarkdownForCommit } from '../lib/scan-validation.js';
      @@ -141,7 +148,8 @@ export type EpicExecutionFailureCode =
      | 'epic-run-error'
      | 'session-test-failed'
      | 'session-review-failed'
    • | 'quick-finalize-failed';
    • | 'quick-finalize-failed'
    • | 'agent-unavailable';

    export type EpicExecutionFailure = {
    code: EpicExecutionFailureCode;
    @@ -388,17 +396,18 @@ async function pauseIssueForAutomationPolicy(args: {

    • const agentUrl = AGENT_INSTALL_URLS[config.agent] ?? '';
    • const agentMsg = ${config.agent} CLI not found.${agentUrl ? Install: ${agentUrl} : ''};
    • const agentUrl = AGENT_INSTALL_URLS[selectedAgent] ?? '';
    • const agentMsg = ${selectedAgent} CLI not found.${agentUrl ? Install: ${agentUrl} : ''};
    • const safeAgent = assertSafeShellArg(config.agent, 'agent');
    • const cliCommand = agentCliCommand(selectedAgent as AgentType);

    • const safeAgent = assertSafeShellArg(cliCommand, 'agent');

      const tools = [
      { name: 'gh', message: 'GitHub CLI not found. Install: https://cli.github.com/' },
      @@ -430,6 +439,34 @@ function checkPrerequisites(config: Config): void {
      }
      }

    +async function assertAgentAvailable(config: Config): Promise {

    • const implementStep = resolveStepConfig(config, 'implement');
    • checkPrerequisites(config, implementStep.agent);
    • const routedBuild = resolveRoutingStage(config, 'build');
    • const model = routedBuild?.model ?? implementStep.model;
    • const env = routedBuild?.endpoint
    • ? buildEndpointEnv(routedBuild.endpoint, model)
    • : undefined;
    • log.step(Checking ${implementStep.agent} agent authentication);
    • const probe = await probeAgentLiveness({
    • agent: implementStep.agent as AgentType,
    • model,
    • cwd: process.cwd(),
    • env,
    • });
    • if (probe.ok) return;
    • const diagnostic = probe.failure?.diagnostic ?? 'Agent CLI did not complete the liveness probe';
    • const message = Agent liveness probe failed for ${implementStep.agent} (${model || 'default model'}): ${diagnostic}. +
    • 'Re-authenticate or repair the configured agent CLI, then retry.';
    • throw new CommandExitError({
    • code: 'agent-unavailable',
    • message,
    • exitCode: 1,
    • });
      +}

    /**

    • Print the startup banner showing all configuration.
      */
      @@ -912,6 +949,12 @@ async function runIssueSession(
      log.info(Filtering issues by milestone: ${activeMilestone});
      }
    • // This boundary must remain before createSession: a dead or unauthenticated
    • // CLI must not create session directories, branches, worktrees, or PR state.
    • if (!config.dryRun) {
    • await assertAgentAvailable(config);
    • }
    • // Create session (named after epic or milestone if selected). Session names
      // are deterministic per epic/milestone, so createSession locks the session
      // directory — a concurrently started run of the same target fails fast here
      @@ -1118,9 +1161,6 @@ async function executeSessionRun(
      };
      }
    • // Check prerequisites
    • checkPrerequisites(config);
    • // Track active worktree for cleanup on signal
      let activeIssueNum: number | null = null;
      const runAbortController = new AbortController();
      @@ -1351,6 +1391,7 @@ async function executeSessionRun(
      // must not add a second entry for each of them.
      const demotedQuickIssueNums = new Set();
      let epicVerificationSummary: string | null = null;
    • let agentUnavailableFailure: AgentFailure | undefined;

      if (issues.length === 0) {
      log.info('No issues found. Nothing to do.');
      @@ -1495,6 +1536,17 @@ async function executeSessionRun(
      if (runAbortController.signal.aborted) break;
      session.results.push(...results);

    •      const unavailableResult = results.find((result) => (
      
    •        result.failureReason === 'agent-unavailable'
      
    •        && result.agentFailure !== undefined
      
    •        && !isRecoveredRunResult(result)
      
    •      ));
      
    •      if (unavailableResult?.agentFailure) {
      
    •        agentUnavailableFailure = unavailableResult.agentFailure;
      
    •        log.error(`Agent unavailable — stopping session: ${agentUnavailableFailure.diagnostic}`);
      
    •        break;
      
    •      }
      
    •      // Flip epic checklist for each successful sub-issue.
           // Skipped in dry-run: `processIssue` returns status='success' when tests are
           // stubbed in dry-run, which would otherwise mutate the live epic body.
      

    @@ -1608,6 +1660,15 @@ async function executeSessionRun(
    );
    if (runAbortController.signal.aborted) break;
    session.results.push(result);

    •      if (
      
    •        result.failureReason === 'agent-unavailable'
      
    •        && result.agentFailure
      
    •        && !isRecoveredRunResult(result)
      
    •      ) {
      
    •        agentUnavailableFailure = result.agentFailure;
      
    •        log.error(`Agent unavailable — stopping session: ${agentUnavailableFailure.diagnostic}`);
      
    •        break;
      
    •      }
           if (quickWorktree && result.status === 'success' && !isRecoveredRunResult(result)) {
             quickProcessed.push({ number: issue.number, title: issue.title });
           }
      

    @@ -1758,6 +1819,95 @@ async function executeSessionRun(
    };
    }

    • if (agentUnavailableFailure) {
    • const affectedResult = session.results.find((result) => (
    •  result.failureReason === 'agent-unavailable' && !isRecoveredRunResult(result)
      
    • ));
    • const message = Agent became unavailable during the session: ${agentUnavailableFailure.diagnostic};
    • failures.push({
    •  code: 'agent-unavailable',
      
    •  message,
      
    •  issueNum: affectedResult?.issueNum,
      
    •  exitCode: 1,
      
    • });
    • epicAbort = true;
    • const successfulResults = session.results.filter((result) => (
    •  result.status === 'success' && !isRecoveredRunResult(result)
      
    • ));
    • if (successfulResults.length === 0) {
    •  recordSessionError(session, {
      
    •    issueNum: affectedResult?.issueNum,
      
    •    stage: 'failed',
      
    •    message,
      
    •  });
      
    •  transitionSessionStatus(session, 'failed', 'failed');
      
    •  await emitLifecycleEvent({
      
    •    config,
      
    •    type: 'session.failed',
      
    •    session,
      
    •    context: {
      
    •      error: message,
      
    •      metadata: {
      
    •        failure: {
      
    •          kind: agentUnavailableFailure.kind,
      
    •          fingerprint: agentUnavailableFailure.fingerprint,
      
    •        },
      
    •        successCount: 0,
      
    •        issueCount: session.results.length,
      
    •      },
      
    •    },
      
    •  });
      
    •  log.error('Session stopped before any issue succeeded; skipping empty-session finalization');
      
    •  process.off('SIGINT', handleSigint);
      
    •  process.off('SIGTERM', handleSigterm);
      
    •  return {
      
    •    session,
      
    •    sessionPrUrl: null,
      
    •    failures,
      
    •    verificationClosedEpic,
      
    •    waiting: false,
      
    •  };
      
    • }
    • // Preserve already-shipped work, but do not invoke any more agent-backed
    • // review, repair, learning, or verification stages after liveness is lost.
    • await drainSessionBackgroundTasks(session);
    • const finalizedPrUrl = await finalizeSession(session, config);
    • const sessionPrUrl = finalizedPrUrl ?? session.sessionPrUrl ?? null;
    • transitionSessionStatus(session, 'failed', 'failed', {
    •  prUrl: sessionPrUrl,
      
    •  sessionPrUrl,
      
    • });
    • await emitLifecycleEvent({
    •  config,
      
    •  type: 'session.failed',
      
    •  session,
      
    •  context: {
      
    •    prUrl: sessionPrUrl,
      
    •    error: message,
      
    •    metadata: {
      
    •      failure: {
      
    •        kind: agentUnavailableFailure.kind,
      
    •        fingerprint: agentUnavailableFailure.fingerprint,
      
    •      },
      
    •      successCount: successfulResults.length,
      
    •      issueCount: session.results.length,
      
    •    },
      
    •  },
      
    • });
    • log.error(Session stopped after ${successfulResults.length} issue(s) succeeded; remaining work was not attempted);
    • process.off('SIGINT', handleSigint);
    • process.off('SIGTERM', handleSigterm);
    • return {
    •  session,
      
    •  sessionPrUrl,
      
    •  failures,
      
    •  verificationClosedEpic,
      
    •  waiting: false,
      
    • };
    • }
    • // Changed scope keeps per-issue runs focused, so require one aggregate full
      // suite pass before epic verification, post-session review, and finalization.
      // Quick mode already runs this same gate inside finalizeQuickRun.
      @@ -1832,6 +1982,7 @@ async function executeSessionRun(
      // quick-finalize-failed entry — a per-issue duplicate is manifest noise.
      if (demotedQuickIssueNums.has(result.issueNum)) continue;
      if (result.status === 'failure' && !isRecoveredRunResult(result)) {
    •  if (result.failureReason === 'agent-unavailable') continue;
       const transient = result.failureReason === 'transient';
       failures.push({
         code: transient ? 'transient-stop' : 'pipeline-failure',
      

    diff --git a/tests/commands/epic-first-workflow.test.ts b/tests/commands/epic-first-workflow.test.ts
    index b424e99..b7628b3 100644
    --- a/tests/commands/epic-first-workflow.test.ts
    +++ b/tests/commands/epic-first-workflow.test.ts
    @@ -71,10 +71,15 @@ jest.mock('../../src/lib/config', () => ({
    ...overrides,
    })),
    assertSafeShellArg: jest.fn((val: string) => val),

    • resolveStepConfig: jest.fn((config: any) => ({ agent: config.agent, model: config.model })),
    • resolveRoutingStage: jest.fn(),
      }));

    jest.mock('../../src/lib/agent', () => ({
    buildOneShotCommand: jest.fn(() => 'claude -p --output-format text'),

    • agentCliCommand: jest.fn((agent: string) => agent),
    • buildEndpointEnv: jest.fn(() => ({})),
    • probeAgentLiveness: jest.fn().mockResolvedValue({ ok: true, duration: 1 }),
      spawnAgent: jest.fn(),
      }));

    diff --git a/tests/commands/run.test.ts b/tests/commands/run.test.ts
    index f422e92..a833b05 100644
    --- a/tests/commands/run.test.ts
    +++ b/tests/commands/run.test.ts
    @@ -22,7 +22,11 @@ jest.mock('../../src/lib/logger', () => ({
    jest.mock('../../src/lib/config', () => ({
    loadConfig: jest.fn(),
    assertSafeShellArg: jest.fn((value: string) => value),

    • resolveStepConfig: jest.fn((config: any) => ({ agent: config.agent, model: config.model })),
    • resolveStepConfig: jest.fn((config: any, step: string) => ({
    • agent: config.pipeline?.[step]?.agent ?? config.agent,
    • model: config.pipeline?.[step]?.model ?? config.model,
    • })),
    • resolveRoutingStage: jest.fn(),
      }));

    jest.mock('../../src/lib/github', () => ({
    @@ -49,6 +53,9 @@ jest.mock('../../src/lib/pipeline', () => ({

    jest.mock('../../src/lib/agent', () => ({
    spawnAgent: jest.fn(),

    • buildEndpointEnv: jest.fn(() => ({})),
    • agentCliCommand: jest.fn((agent: string) => agent === 'lmstudio' ? 'claude' : agent === 'ollama' ? 'codex' : agent),
    • probeAgentLiveness: jest.fn(),
      }));

    jest.mock('../../src/lib/session', () => ({
    @@ -151,7 +158,7 @@ import { syncAgentAssets } from '../../src/commands/sync';
    import { existsSync, readFileSync, writeFileSync } from 'node:fs';
    import { spawn } from 'node:child_process';
    import { emitLifecycleEvent } from '../../src/lib/events';
    -import { spawnAgent } from '../../src/lib/agent';
    +import { probeAgentLiveness, spawnAgent } from '../../src/lib/agent';

    const mockExec = exec as jest.MockedFunction;
    const mockLog = log as jest.Mocked;
    @@ -190,6 +197,7 @@ const mockExistsSync = existsSync as jest.MockedFunction;
    const mockReadFileSync = readFileSync as jest.MockedFunction;
    const mockEmitLifecycleEvent = emitLifecycleEvent as jest.MockedFunction;
    const mockSpawnAgent = spawnAgent as jest.MockedFunction;
    +const mockProbeAgentLiveness = probeAgentLiveness as jest.MockedFunction;
    const mockSpawn = spawn as jest.MockedFunction;

    function makeConfig(overrides: Record<string, unknown> = {}) {
    @@ -273,6 +281,7 @@ beforeEach(() => {
    });
    mockCleanupWorktree.mockResolvedValue({ status: 'removed', path: '/tmp/session-worktree' });
    mockSpawnAgent.mockResolvedValue({ exitCode: 0, output: 'Review passed', duration: 1 });

    • mockProbeAgentLiveness.mockResolvedValue({ ok: true, duration: 1 });
      mockPollIssues.mockReturnValue([]);
      mockListEpics.mockReturnValue([]);
      mockGetEpicSubIssues.mockReturnValue([]);
      @@ -2447,6 +2456,194 @@ Coordinate hosted work.
      expect(mockExec).toHaveBeenCalledWith('command -v "claude"');
      });

    • test('aborts on expired agent OAuth before creating any session state', async () => {

    • mockProbeAgentLiveness.mockResolvedValue({

    •  ok: false,
      
    •  duration: 250,
      
    •  failure: {
      
    •    kind: 'authentication',
      
    •    fingerprint: 'authentication:oauth-session-expired',
      
    •    diagnostic: 'OAuth session expired and could not be refreshed',
      
    •    durationMs: 250,
      
    •  },
      
    • });

    • await runCommand({ skipEpic: true });

    • expect(process.exitCode).toBe(1);

    • expect(mockLog.error).toHaveBeenCalledWith(expect.stringContaining('OAuth session expired'));

    • expect(mockCreateSession).not.toHaveBeenCalled();

    • expect(mockEnsureSessionWorktree).not.toHaveBeenCalled();

    • expect(mockPollIssues).not.toHaveBeenCalled();

    • expect(mockProcessIssue).not.toHaveBeenCalled();

    • expect(mockFinalizeSession).not.toHaveBeenCalled();

    • expect(mockExec.mock.invocationCallOrder.at(-1)).toBeLessThan(

    •  mockProbeAgentLiveness.mock.invocationCallOrder[0],
      
    • );

    • });

    • test('dry runs do not invoke the agent liveness probe', async () => {

    • await runCommand({ skipEpic: true, dryRun: true });

    • expect(mockProbeAgentLiveness).not.toHaveBeenCalled();

    • });

    • test('checks the backing CLI for local-agent aliases', async () => {

    • mockLoadConfig.mockImplementation((overrides: any = {}) => makeConfig({

    •  agent: 'lmstudio',
      
    •  model: 'local-model',
      
    •  ...overrides,
      
    • }) as any);

    • await runCommand({ skipEpic: true });

    • expect(mockExec).toHaveBeenCalledWith('command -v "claude"');

    • expect(mockExec).not.toHaveBeenCalledWith('command -v "lmstudio"');

    • expect(mockProbeAgentLiveness).toHaveBeenCalledWith(expect.objectContaining({

    •  agent: 'lmstudio',
      
    •  model: 'local-model',
      
    • }));

    • });

    • test('checks and probes the effective implementation agent override', async () => {

    • mockLoadConfig.mockImplementation((overrides: any = {}) => makeConfig({

    •  agent: 'claude',
      
    •  model: 'opus',
      
    •  pipeline: { implement: { agent: 'codex', model: 'gpt-5.4' } },
      
    •  ...overrides,
      
    • }) as any);

    • await runCommand({ skipEpic: true });

    • expect(mockExec).toHaveBeenCalledWith('command -v "codex"');

    • expect(mockExec).not.toHaveBeenCalledWith('command -v "claude"');

    • expect(mockProbeAgentLiveness).toHaveBeenCalledWith(expect.objectContaining({

    •  agent: 'codex',
      
    •  model: 'gpt-5.4',
      
    • }));

    • });

    • test('stops the sequential queue and suppresses a zero-success session PR when auth is lost', async () => {

    • mockPollIssues.mockReturnValue([

    •  { number: 42, title: 'First issue', body: 'Body', labels: ['ready'] },
      
    •  { number: 43, title: 'Second issue', body: 'Body', labels: ['ready'] },
      
    • ]);

    • mockProcessIssue.mockResolvedValue({

    •  issueNum: 42,
      
    •  title: 'First issue',
      
    •  status: 'failure',
      
    •  failureReason: 'agent-unavailable',
      
    •  agentFailure: {
      
    •    kind: 'authentication',
      
    •    fingerprint: 'authentication:oauth-session-expired',
      
    •    diagnostic: 'OAuth session expired and could not be refreshed',
      
    •    durationMs: 300,
      
    •  },
      
    •  testsPassing: false,
      
    •  verifyPassing: false,
      
    •  verifySkipped: false,
      
    •  duration: 0,
      
    •  filesChanged: 0,
      
    • });

    • await runCommand({ skipEpic: true });

    • expect(mockProcessIssue).toHaveBeenCalledTimes(1);

    • expect(mockProcessIssue).toHaveBeenCalledWith(42, expect.anything(), expect.anything(), expect.anything(), expect.anything(), expect.anything());

    • expect(mockFinalizeSession).not.toHaveBeenCalled();

    • expect(mockRecordSessionError).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({

    •  issueNum: 42,
      
    •  message: expect.stringContaining('OAuth session expired'),
      
    • }));

    • expect(process.exitCode).toBe(1);

    • });

    • test('stops the batch queue and suppresses empty-session finalization when auth is lost', async () => {

    • mockLoadConfig.mockImplementation((overrides: any = {}) => makeConfig({

    •  batch: true,
      
    •  batchSize: 1,
      
    •  ...overrides,
      
    • }) as any);

    • mockPollIssues.mockReturnValue([

    •  { number: 42, title: 'First issue', body: 'Body', labels: ['ready'] },
      
    •  { number: 43, title: 'Second issue', body: 'Body', labels: ['ready'] },
      
    • ]);

    • mockProcessBatch.mockResolvedValue([{

    •  issueNum: 42,
      
    •  title: 'First issue',
      
    •  status: 'failure',
      
    •  failureReason: 'agent-unavailable',
      
    •  agentFailure: {
      
    •    kind: 'authentication',
      
    •    fingerprint: 'authentication:oauth-session-expired',
      
    •    diagnostic: 'OAuth session expired and could not be refreshed',
      
    •    durationMs: 300,
      
    •  },
      
    •  testsPassing: false,
      
    •  verifyPassing: false,
      
    •  verifySkipped: false,
      
    •  duration: 0,
      
    •  filesChanged: 0,
      
    • }]);

    • await runCommand({ skipEpic: true, batch: true, batchSize: 1 });

    • expect(mockProcessBatch).toHaveBeenCalledTimes(1);

    • expect(mockFinalizeSession).not.toHaveBeenCalled();

    • expect(process.exitCode).toBe(1);

    • });

    • test('finalizes prior successes without invoking more agents when auth is lost mid-session', async () => {

    • mockPollIssues.mockReturnValue([

    •  { number: 42, title: 'First issue', body: 'Body', labels: ['ready'] },
      
    •  { number: 43, title: 'Second issue', body: 'Body', labels: ['ready'] },
      
    •  { number: 44, title: 'Unattempted issue', body: 'Body', labels: ['ready'] },
      
    • ]);

    • mockProcessIssue

    •  .mockResolvedValueOnce({
      
    •    issueNum: 42,
      
    •    title: 'First issue',
      
    •    status: 'success',
      
    •    testsPassing: true,
      
    •    verifyPassing: true,
      
    •    verifySkipped: false,
      
    •    duration: 60,
      
    •    filesChanged: 2,
      
    •  })
      
    •  .mockResolvedValueOnce({
      
    •    issueNum: 43,
      
    •    title: 'Second issue',
      
    •    status: 'failure',
      
    •    failureReason: 'agent-unavailable',
      
    •    agentFailure: {
      
    •      kind: 'authentication',
      
    •      fingerprint: 'authentication:oauth-session-expired',
      
    •      diagnostic: 'OAuth session expired and could not be refreshed',
      
    •      durationMs: 300,
      
    •    },
      
    •    testsPassing: false,
      
    •    verifyPassing: false,
      
    •    verifySkipped: false,
      
    •    duration: 0,
      
    •    filesChanged: 0,
      
    •  });
      
    • mockFinalizeSession.mockResolvedValue('https://github.com/owner/repo/pull/500');

    • await runCommand({ skipEpic: true });

    • expect(mockProcessIssue).toHaveBeenCalledTimes(2);

    • expect(mockFinalizeSession).toHaveBeenCalledTimes(1);

    • expect(mockSpawnAgent).not.toHaveBeenCalled();

    • expect(mockEmitLifecycleEvent).toHaveBeenCalledWith(expect.objectContaining({

    •  type: 'session.failed',
      
    •  context: expect.objectContaining({
      
    •    prUrl: 'https://github.com/owner/repo/pull/500',
      
    •    metadata: expect.objectContaining({ successCount: 1, issueCount: 2 }),
      
    •  }),
      
    • }));

    • expect(process.exitCode).toBe(1);

    • });

    • test('passes compact parent epic context to sub-issue processing for --epic runs', async () => {
      const epicBody = `## Goal
      Ship the epic as a coordinated set of sub-issues.

    exec
    /bin/zsh -lc 'git show --format= 28f5fd5 -- src/lib/pipeline.ts tests/lib/pipeline.test.ts src/lib/traces.ts' in /Users/bradtaylor/Documents/GitHub/alpha-loop/.worktrees/session-epic-432-epic-make-an-unattended-run-safe-no-checks-on-issue-prs-install-aborts-dep-free-repos-auth-failure-burns-the-queue
    succeeded in 0ms:
    diff --git a/src/lib/pipeline.ts b/src/lib/pipeline.ts
    index b5a6e9b..49c2b24 100644
    --- a/src/lib/pipeline.ts
    +++ b/src/lib/pipeline.ts
    @@ -5,7 +5,7 @@ import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from '
    import { basename, join, relative } from 'node:path';
    import { log } from './logger.js';
    import { exec, shellQuote } from './shell.js';
    -import { spawnAgent, buildEndpointEnv } from './agent.js';
    +import { spawnAgent, buildEndpointEnv, classifyAgentFailure } from './agent.js';
    import type { AgentOptions } from './agent.js';
    import {
    classifyToolError,
    @@ -91,7 +91,7 @@ import {
    import type { StepCost, PipelineResultForScores } from './traces.js';
    import { estimateCost, getFallbackPolicy, resolveRoutingStage, resolveStepConfig, selectRoutingProfile } from './config.js';
    import type { Config, PipelineStepName, RoutingStageName, RoutingEndpointType } from './config.js';
    -import type { AgentResult } from './agent.js';
    +import type { AgentFailure, AgentResult } from './agent.js';
    import type { SessionContext } from './session.js';
    import { buildStageTelemetry, writeStageTelemetry } from './telemetry.js';
    import { emitLifecycleEvent } from './events.js';
    @@ -469,8 +469,10 @@ export type PipelineResult = {
    consoleErrors?: string[];
    /** Failed network requests captured during web/app verification. */
    networkErrors?: string[];

    • /** Why the issue failed — 'transient' means re-queue (e.g. usage limit), 'permanent' means label failed. */
    • failureReason?: 'transient' | 'permanent';
    • /** Why the issue failed; unavailable agents are re-queued and stop the owning session. */
    • failureReason?: 'transient' | 'permanent' | 'agent-unavailable';
    • /** Sanitized CLI failure metadata used by the owning session's fatal-error guard. */
    • agentFailure?: AgentFailure;
      prUrl?: string;
      testsPassing: boolean;
      verifyPassing: boolean;
      @@ -522,6 +524,33 @@ function throwIfPipelineAborted(signal?: AbortSignal): void {
      throw error;
      }

    +function unavailableAgentFailure(result: AgentResult): AgentFailure | undefined {

    • if (result.exitCode === 0) return undefined;
    • const failure = agentFailureForResult(result);
    • return failure?.kind === 'authentication' || failure?.kind === 'spawn'
    • ? failure
    • : undefined;
      +}

    +const INSTANT_AGENT_FAILURE_MS = 5_000;
    +
    +function agentFailureForResult(result: AgentResult): AgentFailure | undefined {

    • return result.exitCode === 0
    • ? undefined
    • : result.failure ?? classifyAgentFailure(result.exitCode, result.output, result.duration);
      +}

    +function matchingInstantAgentFailure(

    • previous: AgentFailure | undefined,
    • current: AgentFailure | undefined,
      +): AgentFailure | undefined {
    • if (!previous || !current) return undefined;
    • if (previous.durationMs >= INSTANT_AGENT_FAILURE_MS || current.durationMs >= INSTANT_AGENT_FAILURE_MS) {
    • return undefined;
    • }
    • return previous.fingerprint === current.fingerprint ? current : undefined;
      +}

    const PAUSE_REQUEST_FILE = 'alpha-loop-pause-request.json';

    type PauseRequestType = 'human_input' | 'qa' | 'feedback' | 'new_scope';
    @@ -1572,11 +1601,53 @@ export async function processIssue(
    });
    };

    • const stopForAgentUnavailable = async (

    • failure: AgentFailure,

    • stage: 'plan' | 'implement' | 'test' | 'review' | 'verify',

    • ): Promise => {

    • log.error(Agent unavailable during ${stage} for #${issueNum}: ${failure.diagnostic});

    • try {

    •  requeueIssue(config, issueNum);
      
    • } catch (err) {

    •  log.warn(`Could not fully re-queue issue #${issueNum} after agent failure: ${err instanceof Error ? err.message : err}`);
      
    • }

    • if (!quickWorktree) {

    •  try {
      
    •    const cleanup = await cleanupWorktree({
      
    •      issueNum,
      
    •      projectDir,
      
    •      autoCleanup: config.autoCleanup,
      
    •      preserveIfCommits: true,
      
    •      worktreePath,
      
    •    });
      
    •    recordSessionCleanup(session, {
      
    •      status: cleanup.status,
      
    •      worktreePath: cleanup.path,
      
    •      reason: cleanup.reason,
      
    •      at: new Date().toISOString(),
      
    •    });
      
    •  } catch (err) {
      
    •    log.warn(`Could not clean up issue #${issueNum} after agent failure: ${err instanceof Error ? err.message : err}`);
      
    •  }
      
    • }

    • try {

    •  recordSessionIssue(session, issueNum, {
      
    •    status: 'failure',
      
    •    stage,
      
    •    failureReason: 'agent-unavailable',
      
    •  });
      
    • } catch (err) {

    •  log.warn(`Could not record agent failure for issue #${issueNum}: ${err instanceof Error ? err.message : err}`);
      
    • }

    • return failureResult(issueNum, title, startTime, 'agent-unavailable', failure);

    • };

    • try {
      // --- Step 3: Plan (structured JSON — controls test/verify steps) ---
      currentStep = 'plan';
      recordSessionStage(session, 'plan');
      log.step('Step 3: Planning');

    • let priorInstantAgentFailure: AgentFailure | undefined;
      let plan: IssuePlan = resumeVerificationOnly
      ? {
      ...DEFAULT_PLAN,
      @@ -1628,6 +1699,11 @@ export async function processIssue(
      stepCosts.push(buildStepCost('plan', issueNum, planResult, config));
      recordStageTelemetry(session, issueNum, 'plan', planResult, config, planCtx);

    •  const planAgentFailure = unavailableAgentFailure(planResult);
      
    •  if (planAgentFailure) {
      
    •    return stopForAgentUnavailable(planAgentFailure, 'plan');
      
    •  }
      
    •  // Detect transient errors (usage limits) during planning
       if (planResult.exitCode !== 0 && isTransientError(planResult.output)) {
         log.warn(`Agent hit a transient error during planning for #${issueNum} — re-queuing`);
      

    @@ -1644,6 +1720,9 @@ export async function processIssue(
    recordSessionIssue(session, issueNum, { status: 'failure', stage: 'plan', failureReason: 'transient' });
    return failureResult(issueNum, title, startTime, 'transient');
    }

    •  if (planResult.exitCode !== 0) {
      
    •    priorInstantAgentFailure = agentFailureForResult(planResult);
      
    •  }
      
       const pauseResult = await pauseIfRequested('plan');
       if (pauseResult) return pauseResult;
      

    @@ -1750,6 +1829,10 @@ export async function processIssue(
    stepCosts.push(buildStepCost('implement', issueNum, implResult, config));
    recordStageTelemetry(session, issueNum, 'implement', implResult, config, implCtx);

    • const classifiedImplementationFailure = agentFailureForResult(implResult);
    • const implementationAgentFailure = unavailableAgentFailure(implResult)
    •  ?? matchingInstantAgentFailure(priorInstantAgentFailure, classifiedImplementationFailure);
      
    • // Commit any work the agent left uncommitted BEFORE honoring pause
      // requests or failure handling. A sandboxed agent that cannot write the
      // parent repo's git metadata (linked worktrees keep their index under the
      @@ -1760,7 +1843,7 @@ export async function processIssue(
      worktreePath,
      feat: implement issue #${issueNum} - ${title},
      );
    • } else if (!isTransientError(implResult.output)) {
    • } else if (!implementationAgentFailure && !isTransientError(implResult.output)) {
      // Permanent failures keep a wip commit so the worktree is preserved for
      // recovery. Transient failures (rate limits) are re-queued for a fresh
      // retry — a wip commit here would pin the worktree via preserveIfCommits
      @@ -1775,6 +1858,9 @@ export async function processIssue(
      if (pauseResult) return pauseResult;

      if (implResult.exitCode !== 0) {

    •  if (implementationAgentFailure) {
      
    •    return stopForAgentUnavailable(implementationAgentFailure, 'implement');
      
    •  }
       if (isTransientError(implResult.output)) {
         log.warn(`Agent hit a transient error during implementation for #${issueNum} — re-queuing`);
         requeueIssue(config, issueNum);
      

    @@ -1804,7 +1890,7 @@ export async function processIssue(
    });
    }
    recordSessionIssue(session, issueNum, { status: 'failure', stage: 'implement', failureReason: 'permanent' });

    •  return failureResult(issueNum, title, startTime, 'permanent');
      
    •  return failureResult(issueNum, title, startTime, 'permanent', classifiedImplementationFailure);
      

      }

      stepsCompleted.push('implement');
      @@ -1926,6 +2012,10 @@ export async function processIssue(
      traceOutput(session, issueNum, fix-${attempt}, fixResult.output);
      stepCosts.push(buildStepCost('test_fix', issueNum, fixResult, config));
      recordStageTelemetry(session, issueNum, 'test_fix', fixResult, config, fixCtx);

    •    const fixAgentFailure = unavailableAgentFailure(fixResult);
      
    •    if (fixAgentFailure) {
      
    •      return stopForAgentUnavailable(fixAgentFailure, 'test');
      
    •    }
         stepsCompleted.push(`fix-${attempt}`);
      
         // Auto-commit fixes before the pause check so sandboxed agents that
      

    @@ -2057,6 +2147,11 @@ export async function processIssue(
    stepCosts.push(buildStepCost('review', issueNum, reviewResult, config));
    recordStageTelemetry(session, issueNum, 'review', reviewResult, config, reviewCtx);

    •    const reviewAgentFailure = unavailableAgentFailure(reviewResult);
      
    •    if (reviewAgentFailure) {
      
    •      return stopForAgentUnavailable(reviewAgentFailure, 'review');
      
    •    }
      
    •    reviewOutput = reviewResult.output;
       } catch (err) {
         throwIfPipelineAborted(pipelineOptions.signal);
      

    @@ -2112,6 +2207,11 @@ export async function processIssue(
    stepCosts.push(buildStepCost('review', issueNum, reviewFixResult, config));
    recordStageTelemetry(session, issueNum, 'review_fix', reviewFixResult, config, reviewFixCtx);

    •    const reviewFixAgentFailure = unavailableAgentFailure(reviewFixResult);
      
    •    if (reviewFixAgentFailure) {
      
    •      return stopForAgentUnavailable(reviewFixAgentFailure, 'review');
      
    •    }
      
    •    // Auto-commit before the pause check so sandboxed agents that cannot
         // commit never strand review fixes uncommitted.
         autoCommitDirtyWorktree(worktreePath, `fix(#${issueNum}): address review findings (attempt ${attempt})`);
      

    @@ -2226,6 +2326,12 @@ export async function processIssue(
    ...epicOption,

    ... (body truncated, see full log)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

in-reviewImplementation complete, PR under review

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions