diff --git a/src/autocoder/qa_worker.py b/src/autocoder/qa_worker.py index 1e9282d9..cb2ac6a9 100644 --- a/src/autocoder/qa_worker.py +++ b/src/autocoder/qa_worker.py @@ -33,8 +33,9 @@ from datetime import datetime from pathlib import Path -from autocoder.core.cli_defaults import get_codex_cli_defaults from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + +from autocoder.core.cli_defaults import get_codex_cli_defaults from autocoder.core.database import get_database from autocoder.core.knowledge_files import build_knowledge_bundle @@ -319,11 +320,13 @@ def _fix_prompt_diff_only(*, repo: Path, project_dir: Path, failure: str, diff: knowledge = build_knowledge_bundle(project_dir, max_total_chars=8000) base = ( "You are a software engineer fixing a CI failure.\n" - "Output ONLY a git-apply-compatible UNIFIED DIFF.\n" + "Make changes by editing files in the repository.\n" + "Do NOT output a patch/diff.\n" + "When finished, reply with a short status like 'DONE' (no markdown fences).\n" "Rules:\n" "- Only fix the failing verification (tests/lint/typecheck). No new features.\n" "- Keep the patch minimal.\n" - "- Do NOT output JSON, markdown fences, or explanations.\n" + "- Do NOT output JSON or markdown fences.\n" "- Do NOT output '*** Begin Patch' or any other wrapper format.\n\n" f"Attempt: {attempt}\n\n" "Gatekeeper failure excerpt:\n" @@ -388,11 +391,13 @@ def _implement_prompt_diff_only( return ( "You are a software engineer implementing a feature.\n" - "Output ONLY a git-apply-compatible UNIFIED DIFF.\n" + "Make changes by editing files in the repository.\n" + "Do NOT output a patch/diff.\n" + "When finished, reply with a short status like 'DONE' (no markdown fences).\n" "Rules:\n" "- Implement ONLY this feature. Avoid unrelated refactors.\n" "- Keep changes minimal but complete.\n" - "- Do NOT output JSON, markdown fences, or explanations.\n" + "- Do NOT output JSON or markdown fences.\n" "- Do NOT output '*** Begin Patch' or any other wrapper format.\n\n" f"Attempt: {attempt}\n\n" "Feature:\n" @@ -587,13 +592,15 @@ async def _run_claude_patch(repo: Path, *, prompt: str, timeout_s: int) -> tuple or "sonnet" ).strip() - # Read-only settings file (no writes). Use a temp file so it never gets committed by the worker. + # Tight sandbox for the patch worker. Allow file edits, but no Bash and no network. security_settings = { "sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True}, "permissions": { "defaultMode": "reject", "allow": [ "Read(./**)", + "Write(./**)", + "Edit(./**)", "Glob(./**)", "Grep(./**)", ], @@ -607,17 +614,19 @@ async def _run_claude_patch(repo: Path, *, prompt: str, timeout_s: int) -> tuple options=ClaudeAgentOptions( model=model, cli_path=_claude_cli_path(use_custom_api), - allowed_tools=["Read", "Glob", "Grep"], + allowed_tools=["Read", "Write", "Edit", "Glob", "Grep"], + permission_mode="acceptEdits", system_prompt=( - "You are generating a unified diff patch.\n" - "Output ONLY a git-apply-compatible unified diff.\n" - "No explanations, no markdown fences, no JSON.\n" - "Do not use the AutoCoder '*** Begin Patch' format." + "You are editing a git repository to implement a requested change.\n" + "Use read/search tools to inspect the repo, and edit files to implement the request.\n" + "Do not run shell commands.\n" + "Do not output a patch/diff unless explicitly asked.\n" ), cwd=str(repo), settings=str(settings_path), max_turns=4, - setting_sources=["project"], + # Do not load user/project Claude settings for workers; only use the explicit sandbox above. + setting_sources=[], ) ) @@ -631,31 +640,50 @@ async def _collect_one(query: str) -> str: text += block.text return text + def _status_porcelain() -> str: + with contextlib.suppress(Exception): + return (_git(repo, ["status", "--porcelain"], check=True, timeout_s=60).stdout or "").strip() + return "" + + def _harvest_working_tree_patch() -> str: + # Include untracked files in the diff. + with contextlib.suppress(Exception): + _git(repo, ["add", "-N", "."], check=False, timeout_s=60) + out = "" + with contextlib.suppress(Exception): + out = _git(repo, ["diff"], check=False, timeout_s=120).stdout or "" + out = _strip_fences(out) + out = _trim_to_diff_start(out) + if not _looks_like_unified_diff(out): + return "" + return out + + def _reset_clean() -> None: + with contextlib.suppress(Exception): + _git(repo, ["reset", "--hard", "HEAD"], check=False, timeout_s=120) + with contextlib.suppress(Exception): + # Never delete AutoCoder runtime artifacts (feature plans, logs, etc). + _git(repo, ["clean", "-fd", "-e", ".autocoder/"], check=False, timeout_s=120) + try: + if _status_porcelain(): + return False, "", "claude_patch worktree was not clean; refusing to run" + async with client: text = await asyncio.wait_for(_collect_one(prompt), timeout=timeout_s) - candidate = _strip_fences(text) - candidate = _trim_to_diff_start(candidate) - if not _looks_like_unified_diff(candidate): - retry_prompt = ( - "Your previous response was not a patch.\n" - "Output ONLY a unified diff that `git apply` can apply.\n" - "Start with `diff --git` when possible; otherwise include `--- a/...` and `+++ b/...` with hunks.\n" - "No explanations.\n" - ) - text2 = await asyncio.wait_for(_collect_one(retry_prompt), timeout=timeout_s) - candidate = _trim_to_diff_start(_strip_fences(text2)) - - if not _looks_like_unified_diff(candidate): - preview = (candidate.strip().splitlines()[0] if candidate.strip() else "(empty)").strip() - return False, "", f"claude_patch returned non-diff output: {preview[:200]}" + patch = _harvest_working_tree_patch() + if patch: + _reset_clean() + return True, patch, "" - return True, candidate, "" + preview = (text.strip().splitlines()[0] if text.strip() else "(empty)").strip() + return False, "", f"claude_patch produced no git diff (assistant said: {preview[:200]})" except asyncio.TimeoutError: return False, "", "claude patch timed out" except Exception as e: return False, "", str(e) finally: + _reset_clean() with contextlib.suppress(Exception): os.unlink(settings_path) diff --git a/src/autocoder/server/routers/parallel.py b/src/autocoder/server/routers/parallel.py index 4ff081bc..9f70163b 100644 --- a/src/autocoder/server/routers/parallel.py +++ b/src/autocoder/server/routers/parallel.py @@ -18,7 +18,6 @@ from autocoder.core.database import get_database - router = APIRouter(prefix="/api/projects/{project_name}/parallel", tags=["parallel"]) @@ -57,6 +56,28 @@ class ParallelAgentsStatusResponse(BaseModel): agents: list[ParallelAgentInfo] +class _QueueStateFeatureRef(BaseModel): + id: int + name: str + next_attempt_at: str | None = None + + +class _QueueStateDepRef(BaseModel): + id: int + name: str + + +class ParallelQueueStateResponse(BaseModel): + pending_total: int + claimable_now: int + waiting_backoff: int + waiting_deps: int + staged_total: int + earliest_next_attempt_at: str | None = None + earliest_retry_feature: _QueueStateFeatureRef | None = None + example_dep_blocked_feature: _QueueStateDepRef | None = None + + @router.get("/agents", response_model=ParallelAgentsStatusResponse) async def get_parallel_agents(project_name: str, limit: int = 50): project_name = _validate_project_name(project_name) @@ -114,3 +135,16 @@ async def get_parallel_agents(project_name: str, limit: int = 50): active_count=active_count, agents=agents, ) + + +@router.get("/queue-state", response_model=ParallelQueueStateResponse) +async def get_parallel_queue_state(project_name: str): + project_name = _validate_project_name(project_name) + project_dir = _get_project_path(project_name).resolve() + + if not project_dir.exists(): + raise HTTPException(status_code=404, detail="Project directory not found") + + db = get_database(str(project_dir)) + state = db.get_pending_queue_state() or {} + return ParallelQueueStateResponse(**state) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index cd1772d9..597113ac 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -14,7 +14,7 @@ import { useFeatureSound } from './hooks/useFeatureSound' import { useCelebration } from './hooks/useCelebration' import { useAdvancedSettings } from './hooks/useAdvancedSettings' import { useBlockersSummary } from './hooks/useBlockers' -import { useParallelAgentsStatus } from './hooks/useParallelAgents' +import { useParallelAgentsStatus, useParallelQueueState } from './hooks/useParallelAgents' import { ProjectSelector } from './components/ProjectSelector' import { KanbanBoard } from './components/KanbanBoard' @@ -454,6 +454,32 @@ function App() { ? selectedProject : null const parallelAgentsQuery = useParallelAgentsStatus(parallelStatusProject) + const parallelQueueStateQuery = useParallelQueueState(parallelStatusProject) + + const idleDetail = useMemo(() => { + if (!parallelStatusProject) return null + if (String(wsState.agentStatus || '').toLowerCase() !== 'running') return null + if (!agentStatusData?.parallel_mode) return null + if ((parallelAgentsQuery.data?.active_count ?? 0) > 0) return null + + const qs = parallelQueueStateQuery.data + if (!qs) return null + + const parts: string[] = [] + if (qs.waiting_deps > 0) parts.push(`deps ${qs.waiting_deps}`) + if (qs.waiting_backoff > 0) parts.push(`retry ${qs.waiting_backoff}`) + + const base = parts.length > 0 ? `Waiting: ${parts.join(', ')}` : qs.pending_total > 0 ? 'No claimable work' : null + if (!base) return null + + return qs.earliest_next_attempt_at ? `${base} (next: ${qs.earliest_next_attempt_at})` : base + }, [ + parallelStatusProject, + wsState.agentStatus, + agentStatusData?.parallel_mode, + parallelAgentsQuery.data?.active_count, + parallelQueueStateQuery.data, + ]) const openMostRelevantWorkerLog = () => { const agents = parallelAgentsQuery.data?.agents ?? [] @@ -908,6 +934,7 @@ function App() { } : null } + idleDetail={idleDetail} featureCounts={featureCounts} onResolveBlockers={blockedNow > 0 ? () => setShowResolveBlockers(true) : undefined} agentBadge={ diff --git a/ui/src/components/ProgressDashboard.tsx b/ui/src/components/ProgressDashboard.tsx index cccf67fb..bd6cd6c2 100644 --- a/ui/src/components/ProgressDashboard.tsx +++ b/ui/src/components/ProgressDashboard.tsx @@ -8,6 +8,7 @@ interface ProgressDashboardProps { isConnected: boolean agentStatus?: AgentStatus agentActivity?: { active: number; total: number } | null + idleDetail?: string | null featureCounts?: { staged: number pending: number @@ -27,6 +28,7 @@ export function ProgressDashboard({ isConnected, agentStatus, agentActivity, + idleDetail, featureCounts, onResolveBlockers, agentBadge, @@ -140,7 +142,18 @@ export function ProgressDashboard({
{statusText ? ( - {statusText} + + {statusText} + + ) : null} + + {isIdle && idleDetail ? ( + + {idleDetail} + ) : null} { + const response = await fetch( + `${API_BASE}/projects/${encodeURIComponent(projectName!)}/parallel/queue-state` + ) + if (!response.ok) { + const err = await response.json().catch(() => ({ detail: 'Unknown error' })) + throw new Error(err.detail || `HTTP ${response.status}`) + } + return response.json() as Promise + }, + enabled: !!projectName, + refetchInterval: 5000, + }) +}