fix: agent retry loop for tool concurrency errors (#1546) [v3] (#1606)

* fix: implement agent retry loop for tool concurrency errors (#1546)

- Add exponential backoff retry logic (2s, 4s, 8s, 16s, 32s) for 400 tool concurrency errors
- Add is_tool_concurrency_error() detection in session.py
- Update run_agent_session to return 3-tuple (status, response, error_info)
- Track consecutive concurrency errors (max 5 retries before marking subtask as stuck)
- Add error context to agent prompt instructing it to use one tool at a time
- Fix: Reset first_run=True on planning concurrency errors to retry planning
- Update test mocks for run_agent_session 3-tuple return type
- Update test mocks for get_token_from_keychain config_dir parameter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix ruff format (line length)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use exception_type string instead of raw exception object

Avoids JSON serialization issues and potential internal leaks when
error_info is logged or sent via IPC.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review feedback for agent retry loop (#1546)

- Extract duplicated concurrency error reset logic into _reset_concurrency_state() helper
- Fix stale docstring referencing "exception" key (now "exception_type")
- Move `import os` to module level in simple_client.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix ruff format (nonlocal line length)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Ludlow
2026-01-29 15:37:46 -05:00
committed by GitHub
parent 4070a4c29c
commit 0aea4fb5e5
12 changed files with 296 additions and 49 deletions
+7
View File
@@ -13,3 +13,10 @@ logger = logging.getLogger(__name__)
# Configuration constants
AUTO_CONTINUE_DELAY_SECONDS = 3
HUMAN_INTERVENTION_FILE = "PAUSE"
# Retry configuration for 400 tool concurrency errors
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
INITIAL_RETRY_DELAY_SECONDS = (
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
)
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
+137 -6
View File
@@ -56,7 +56,13 @@ from ui import (
print_status,
)
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
from .base import (
AUTO_CONTINUE_DELAY_SECONDS,
HUMAN_INTERVENTION_FILE,
INITIAL_RETRY_DELAY_SECONDS,
MAX_CONCURRENCY_RETRIES,
MAX_RETRY_DELAY_SECONDS,
)
from .memory_manager import debug_memory_system_status, get_graphiti_context
from .session import post_session_processing, run_agent_session
from .utils import (
@@ -215,6 +221,21 @@ async def run_autonomous_agent(
# Main loop
iteration = 0
consecutive_concurrency_errors = 0 # Track consecutive 400 tool concurrency errors
current_retry_delay = INITIAL_RETRY_DELAY_SECONDS # Exponential backoff delay
concurrency_error_context: str | None = (
None # Context to pass to agent after concurrency error
)
def _reset_concurrency_state() -> None:
"""Reset concurrency error tracking state after a successful session or non-concurrency error."""
nonlocal \
consecutive_concurrency_errors, \
current_retry_delay, \
concurrency_error_context
consecutive_concurrency_errors = 0
current_retry_delay = INITIAL_RETRY_DELAY_SECONDS
concurrency_error_context = None
while True:
iteration += 1
@@ -405,6 +426,14 @@ async def run_autonomous_agent(
prompt += "\n\n" + graphiti_context
print_status("Graphiti memory context loaded", "success")
# Add concurrency error context if recovering from 400 error
if concurrency_error_context:
prompt += "\n\n" + concurrency_error_context
print_status(
f"Added tool concurrency error context (retry {consecutive_concurrency_errors}/{MAX_CONCURRENCY_RETRIES})",
"warning",
)
# Show what we're working on
print(f"Working on: {highlight(subtask_id)}")
print(f"Description: {next_subtask.get('description', 'No description')}")
@@ -419,7 +448,7 @@ async def run_autonomous_agent(
# Run session with async context manager
async with client:
status, response = await run_agent_session(
status, response, error_info = await run_agent_session(
client, prompt, spec_dir, verbose, phase=current_log_phase
)
@@ -512,6 +541,9 @@ async def run_autonomous_agent(
print_build_complete_banner(spec_dir)
status_manager.update(state=BuildState.COMPLETE)
# Reset error tracking on success
_reset_concurrency_state()
if task_logger:
task_logger.end_phase(
LogPhase.CODING,
@@ -526,6 +558,9 @@ async def run_autonomous_agent(
break
elif status == "continue":
# Reset error tracking on successful session
_reset_concurrency_state()
print(
muted(
f"\nAgent will auto-continue in {AUTO_CONTINUE_DELAY_SECONDS}s..."
@@ -556,10 +591,106 @@ async def run_autonomous_agent(
elif status == "error":
emit_phase(ExecutionPhase.FAILED, "Session encountered an error")
print_status("Session encountered an error", "error")
print(muted("Will retry with a fresh session..."))
status_manager.update(state=BuildState.ERROR)
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
# Check if this is a tool concurrency error (400)
is_concurrency_error = (
error_info and error_info.get("type") == "tool_concurrency"
)
if is_concurrency_error:
consecutive_concurrency_errors += 1
# Check if we've exceeded max retries (allow 5 retries with delays: 2s, 4s, 8s, 16s, 32s)
if consecutive_concurrency_errors > MAX_CONCURRENCY_RETRIES:
print_status(
f"Tool concurrency limit hit {consecutive_concurrency_errors} times consecutively",
"error",
)
print()
print("=" * 70)
print(" CRITICAL: Agent stuck in retry loop")
print("=" * 70)
print()
print(
"The agent is repeatedly hitting Claude API's tool concurrency limit."
)
print(
"This usually means the agent is trying to use too many tools at once."
)
print()
print("Possible solutions:")
print(" 1. The agent needs to reduce tool usage per request")
print(" 2. Break down the current subtask into smaller steps")
print(" 3. Manual intervention may be required")
print()
print(f"Error: {error_info.get('message', 'Unknown error')[:200]}")
print()
# Mark current subtask as stuck if we have one
if subtask_id:
recovery_manager.mark_subtask_stuck(
subtask_id,
f"Tool concurrency errors after {consecutive_concurrency_errors} retries",
)
print_status(f"Subtask {subtask_id} marked as STUCK", "error")
status_manager.update(state=BuildState.ERROR)
break # Exit the loop
# Exponential backoff: 2s, 4s, 8s, 16s, 32s
print_status(
f"Tool concurrency error (retry {consecutive_concurrency_errors}/{MAX_CONCURRENCY_RETRIES})",
"warning",
)
print(
muted(
f"Waiting {current_retry_delay}s before retry (exponential backoff)..."
)
)
print()
# Set context for next retry so agent knows to adjust behavior
error_context_message = (
"## CRITICAL: TOOL CONCURRENCY ERROR\n\n"
f"Your previous session hit Claude API's tool concurrency limit (HTTP 400).\n"
f"This is retry {consecutive_concurrency_errors}/{MAX_CONCURRENCY_RETRIES}.\n\n"
"**IMPORTANT: You MUST adjust your approach:**\n"
"1. Use ONE tool at a time - do NOT call multiple tools in parallel\n"
"2. Wait for each tool result before calling the next tool\n"
"3. Avoid starting with `pwd` or multiple Read calls at once\n"
"4. If you need to read multiple files, read them one by one\n"
"5. Take a more incremental, step-by-step approach\n\n"
"Start by focusing on ONE specific action for this subtask."
)
# If we're in planning phase, reset first_run to True so next iteration
# re-enters the planning branch (fix for issue #1565)
if current_log_phase == LogPhase.PLANNING:
first_run = True
planning_retry_context = error_context_message
print_status(
"Planning session failed - will retry planning", "warning"
)
else:
concurrency_error_context = error_context_message
status_manager.update(state=BuildState.ERROR)
await asyncio.sleep(current_retry_delay)
# Double the retry delay for next time (cap at MAX_RETRY_DELAY_SECONDS)
current_retry_delay = min(
current_retry_delay * 2, MAX_RETRY_DELAY_SECONDS
)
else:
# Other errors - use standard retry logic
print_status("Session encountered an error", "error")
print(muted("Will retry with a fresh session..."))
status_manager.update(state=BuildState.ERROR)
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
# Reset concurrency error tracking on non-concurrency errors
_reset_concurrency_state()
# Small delay between sessions
if max_iterations is None or iteration < max_iterations:
+1 -1
View File
@@ -111,7 +111,7 @@ async def run_followup_planner(
try:
# Run single planning session
async with client:
status, response = await run_agent_session(
status, response, error_info = await run_agent_session(
client, prompt, spec_dir, verbose, phase=LogPhase.PLANNING
)
+53 -9
View File
@@ -46,6 +46,28 @@ from .utils import (
logger = logging.getLogger(__name__)
def is_tool_concurrency_error(error: Exception) -> bool:
"""
Check if an error is a 400 tool concurrency error from Claude API.
Tool concurrency errors occur when too many tools are used simultaneously
in a single API request, hitting Claude's concurrent tool use limit.
Args:
error: The exception to check
Returns:
True if this is a tool concurrency error, False otherwise
"""
error_str = str(error).lower()
# Check for 400 status AND tool concurrency keywords
return "400" in error_str and (
("tool" in error_str and "concurrency" in error_str)
or "too many tools" in error_str
or "concurrent tool" in error_str
)
async def post_session_processing(
spec_dir: Path,
project_dir: Path,
@@ -317,7 +339,7 @@ async def run_agent_session(
spec_dir: Path,
verbose: bool = False,
phase: LogPhase = LogPhase.CODING,
) -> tuple[str, str]:
) -> tuple[str, str, dict]:
"""
Run a single agent session using Claude Agent SDK.
@@ -329,10 +351,13 @@ async def run_agent_session(
phase: Current execution phase for logging
Returns:
(status, response_text) where status is:
- "continue" if agent should continue working
- "complete" if all subtasks complete
- "error" if an error occurred
(status, response_text, error_info) where:
- status: "continue", "complete", or "error"
- response_text: Agent's response text
- error_info: Dict with error details (empty if no error):
- "type": "tool_concurrency" or "other"
- "message": Error message string
- "exception_type": Exception class name string
"""
debug_section("session", f"Agent Session - {phase.value}")
debug(
@@ -529,7 +554,7 @@ async def run_agent_session(
tool_count=tool_count,
response_length=len(response_text),
)
return "complete", response_text
return "complete", response_text, {}
debug_success(
"session",
@@ -538,17 +563,36 @@ async def run_agent_session(
tool_count=tool_count,
response_length=len(response_text),
)
return "continue", response_text
return "continue", response_text, {}
except Exception as e:
# Detect specific error types for better retry handling
is_concurrency = is_tool_concurrency_error(e)
error_type = "tool_concurrency" if is_concurrency else "other"
debug_error(
"session",
f"Session error: {e}",
exception_type=type(e).__name__,
error_category=error_type,
message_count=message_count,
tool_count=tool_count,
)
print(f"Error during agent session: {e}")
# Log concurrency errors prominently
if is_concurrency:
print("\n⚠️ Tool concurrency limit reached (400 error)")
print(" Claude API limits concurrent tool use in a single request")
print(f" Error: {str(e)[:200]}\n")
else:
print(f"Error during agent session: {e}")
if task_logger:
task_logger.log_error(f"Session error: {e}", phase)
return "error", str(e)
error_info = {
"type": error_type,
"message": str(e),
"exception_type": type(e).__name__,
}
return "error", str(e), error_info
+13 -4
View File
@@ -490,8 +490,16 @@ def create_client(
(see security.py for ALLOWED_COMMANDS)
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
"""
# Get OAuth token - Claude CLI handles token lifecycle internally
oauth_token = require_auth_token()
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, CLAUDE_CONFIG_DIR, etc.)
sdk_env = get_sdk_env_vars()
# Get the config dir for profile-specific credential lookup
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
oauth_token = require_auth_token(config_dir)
# Validate token is not encrypted before passing to SDK
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
@@ -499,10 +507,11 @@ def create_client(
validate_token_not_encrypted(oauth_token)
# Ensure SDK can access it via its expected env var
# This is required because the SDK doesn't know about per-profile Keychain naming
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
sdk_env = get_sdk_env_vars()
if config_dir:
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
# Debug: Log git-bash path detection on Windows
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
+13 -7
View File
@@ -22,6 +22,7 @@ Example usage:
"""
import logging
import os
from pathlib import Path
from agents.tools_pkg import get_agent_config, get_default_thinking_level
@@ -72,21 +73,26 @@ def create_simple_client(
Raises:
ValueError: If agent_type is not found in AGENT_CONFIGS
"""
# Get authentication
oauth_token = require_auth_token()
# Get environment variables for SDK (including CLAUDE_CONFIG_DIR if set)
sdk_env = get_sdk_env_vars()
# Get the config dir for profile-specific credential lookup
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
oauth_token = require_auth_token(config_dir)
# Validate token is not encrypted before passing to SDK
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
# If we still have an encrypted token here, it means decryption failed or was skipped
validate_token_not_encrypted(oauth_token)
import os
# Ensure SDK can access it via its expected env var
# This is required because the SDK doesn't know about per-profile Keychain naming
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
# Get environment variables for SDK
sdk_env = get_sdk_env_vars()
# Get agent configuration (raises ValueError if unknown type)
config = get_agent_config(agent_type)
+2 -1
View File
@@ -57,7 +57,7 @@ import { GitHubSetupModal } from './components/GitHubSetupModal';
import { useProjectStore, loadProjects, addProject, initializeProject, removeProject } from './stores/project-store';
import { useTaskStore, loadTasks } from './stores/task-store';
import { useSettingsStore, loadSettings, loadProfiles, saveSettings } from './stores/settings-store';
import { useClaudeProfileStore } from './stores/claude-profile-store';
import { useClaudeProfileStore, loadClaudeProfiles } from './stores/claude-profile-store';
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
import { initializeGitHubListeners } from './stores/github';
import { initDownloadProgressListener } from './stores/download-store';
@@ -184,6 +184,7 @@ export function App() {
loadProjects();
loadSettings();
loadProfiles();
loadClaudeProfiles();
// Initialize global GitHub listeners (PR reviews, etc.) so they persist across navigation
initializeGitHubListeners();
// Initialize global download progress listener for Ollama model downloads
@@ -23,6 +23,7 @@ import {
} from './ui/tooltip';
import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '../stores/settings-store';
import { useClaudeProfileStore } from '../stores/claude-profile-store';
import { detectProvider, getProviderLabel, getProviderBadgeColor, type ApiProvider } from '../../shared/utils/provider-detection';
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
@@ -49,8 +50,13 @@ const OAUTH_FALLBACK = {
} as const;
export function AuthStatusIndicator() {
// Subscribe to profile state from settings store
// Subscribe to profile state from settings store (API profiles)
const { profiles, activeProfileId } = useSettingsStore();
// Subscribe to Claude OAuth profile state
const claudeProfiles = useClaudeProfileStore((state) => state.profiles);
const activeClaudeProfileId = useClaudeProfileStore((state) => state.activeProfileId);
const { t } = useTranslation(['common']);
// Track usage data for warning badge
@@ -102,6 +108,7 @@ export function AuthStatusIndicator() {
// Compute auth status and provider detection using useMemo to avoid unnecessary re-renders
const authStatus = useMemo(() => {
// First check if user is using API profile auth (has active API profile)
if (activeProfileId) {
const activeProfile = profiles.find(p => p.id === activeProfileId);
if (activeProfile) {
@@ -119,12 +126,36 @@ export function AuthStatusIndicator() {
badgeColor: getProviderBadgeColor(provider)
};
}
// Profile ID set but profile not found - fallback to OAuth
return OAUTH_FALLBACK;
}
// No active profile - using OAuth
// No active API profile - check Claude OAuth profiles directly
if (activeClaudeProfileId && claudeProfiles.length > 0) {
const activeClaudeProfile = claudeProfiles.find(p => p.id === activeClaudeProfileId);
if (activeClaudeProfile) {
return {
type: 'oauth' as const,
name: activeClaudeProfile.email || activeClaudeProfile.name,
provider: 'anthropic' as const,
providerLabel: 'Anthropic',
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
};
}
}
// Fallback to usage data if Claude profiles aren't loaded yet
if (usage && (usage.profileName || usage.profileEmail)) {
return {
type: 'oauth' as const,
name: usage.profileEmail || usage.profileName,
provider: 'anthropic' as const,
providerLabel: 'Anthropic',
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
};
}
// No auth info available - fallback to generic OAuth
return OAUTH_FALLBACK;
}, [activeProfileId, profiles]);
}, [activeProfileId, profiles, activeClaudeProfileId, claudeProfiles, usage]);
// Helper function to truncate ID for display
const truncateId = (id: string): string => {
@@ -274,6 +305,22 @@ export function AuthStatusIndicator() {
</div>
</>
)}
{/* Account details for OAuth profiles */}
{isOAuth && authStatus.name && authStatus.name !== 'OAuth' && (
<>
<div className="pt-2 border-t space-y-2">
{/* Account name/email with icon */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lock className="h-3 w-3" />
<span className="text-[10px]">{t('common:usage.account')}</span>
</div>
<span className="font-medium text-[10px]">{authStatus.name}</span>
</div>
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
@@ -471,7 +471,8 @@
"clickToOpenSettings": "Click to open Settings →",
"sessionShort": "5-hour session usage",
"weeklyShort": "7-day weekly usage",
"swap": "Swap"
"swap": "Swap",
"account": "Account"
},
"oauth": {
"enterCode": "Manual Code Entry (Fallback)",
@@ -471,7 +471,8 @@
"clickToOpenSettings": "Cliquez pour ouvrir les Paramètres →",
"sessionShort": "Utilisation session 5 heures",
"weeklyShort": "Utilisation hebdomadaire 7 jours",
"swap": "Changer"
"swap": "Changer",
"account": "Compte"
},
"oauth": {
"enterCode": "Saisie manuelle du code (secours)",
+9 -9
View File
@@ -465,7 +465,7 @@ class TestEnsureClaudeCodeOAuthToken:
"""Doesn't set env var when no auth token is available."""
monkeypatch.setattr(platform, "system", lambda: "Linux")
# Ensure keychain returns None
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None)
ensure_claude_code_oauth_token()
@@ -646,7 +646,7 @@ class TestTokenDecryption:
from unittest.mock import patch
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "enc:testtoken123456789")
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None)
with patch("core.auth.decrypt_token") as mock_decrypt:
# Simulate decryption failure
@@ -669,7 +669,7 @@ class TestTokenDecryption:
decrypted_token = "sk-ant-oat01-decrypted-token"
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_token)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None)
with patch("core.auth.decrypt_token") as mock_decrypt:
mock_decrypt.return_value = decrypted_token
@@ -687,7 +687,7 @@ class TestTokenDecryption:
"""Verify plaintext tokens continue to work unchanged."""
token = "sk-ant-oat01-test"
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", token)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None)
monkeypatch.setattr("core.auth.get_token_from_keychain", lambda config_dir=None: None)
from core.auth import get_auth_token
@@ -990,7 +990,7 @@ class TestTokenDecryptionKeychain:
encrypted_token = "enc:keychaintoken1234"
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda: encrypted_token
"core.auth.get_token_from_keychain", lambda config_dir=None: encrypted_token
)
with patch("core.auth.decrypt_token") as mock_decrypt:
@@ -1012,7 +1012,7 @@ class TestTokenDecryptionKeychain:
decrypted_token = "sk-ant-oat01-from-keychain"
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda: encrypted_token
"core.auth.get_token_from_keychain", lambda config_dir=None: encrypted_token
)
with patch("core.auth.decrypt_token") as mock_decrypt:
@@ -1031,7 +1031,7 @@ class TestTokenDecryptionKeychain:
plaintext_token = "sk-ant-oat01-keychain-plaintext"
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda: plaintext_token
"core.auth.get_token_from_keychain", lambda config_dir=None: plaintext_token
)
with patch("core.auth.decrypt_token") as mock_decrypt:
@@ -1049,7 +1049,7 @@ class TestTokenDecryptionKeychain:
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", env_token)
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda: keychain_token
"core.auth.get_token_from_keychain", lambda config_dir=None: keychain_token
)
from core.auth import get_auth_token
@@ -1067,7 +1067,7 @@ class TestTokenDecryptionKeychain:
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_env)
monkeypatch.setattr(
"core.auth.get_token_from_keychain", lambda: keychain_token
"core.auth.get_token_from_keychain", lambda config_dir=None: keychain_token
)
with patch("core.auth.decrypt_token") as mock_decrypt:
+5 -5
View File
@@ -311,9 +311,9 @@ async def test_planner_session_does_not_trigger_post_session_processing_on_retry
_spec_dir: Path,
_verbose: bool = False,
phase: LogPhase = LogPhase.CODING,
) -> tuple[str, str]:
) -> tuple[str, str, dict]:
assert phase == LogPhase.PLANNING
return "error", "planner failed"
return "error", "planner failed", {}
monkeypatch.setattr("agents.coder.create_client", fake_create_client)
monkeypatch.setattr("agents.coder.get_graphiti_context", fake_get_graphiti_context)
@@ -374,7 +374,7 @@ async def test_worktree_planning_to_coding_sync_updates_source_phase_status(
spec_dir: Path,
_verbose: bool = False,
phase: LogPhase = LogPhase.CODING,
) -> tuple[str, str]:
) -> tuple[str, str, dict]:
if phase == LogPhase.PLANNING:
plan = {
"feature": "Test feature",
@@ -397,7 +397,7 @@ async def test_worktree_planning_to_coding_sync_updates_source_phase_status(
json.dumps(plan, indent=2),
encoding="utf-8",
)
return "continue", "planned"
return "continue", "planned", {}
# First coding session should see planning already completed in source spec logs
# Note: task_logs.json is created/synced by run_autonomous_agent; absence indicates a bug.
@@ -406,7 +406,7 @@ async def test_worktree_planning_to_coding_sync_updates_source_phase_status(
)
assert logs["phases"]["planning"]["status"] == "completed"
assert logs["phases"]["coding"]["status"] == "active"
return "complete", "done"
return "complete", "done", {}
monkeypatch.setattr("agents.coder.create_client", fake_create_client)
monkeypatch.setattr("agents.coder.get_graphiti_context", fake_get_graphiti_context)