Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 56 additions & 28 deletions src/autocoder/qa_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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(./**)",
],
Expand All @@ -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=[],
)
)

Expand All @@ -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)

Expand Down
36 changes: 35 additions & 1 deletion src/autocoder/server/routers/parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

from autocoder.core.database import get_database


router = APIRouter(prefix="/api/projects/{project_name}/parallel", tags=["parallel"])


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
29 changes: 28 additions & 1 deletion ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 ?? []
Expand Down Expand Up @@ -908,6 +934,7 @@ function App() {
}
: null
}
idleDetail={idleDetail}
featureCounts={featureCounts}
onResolveBlockers={blockedNow > 0 ? () => setShowResolveBlockers(true) : undefined}
agentBadge={
Expand Down
15 changes: 14 additions & 1 deletion ui/src/components/ProgressDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface ProgressDashboardProps {
isConnected: boolean
agentStatus?: AgentStatus
agentActivity?: { active: number; total: number } | null
idleDetail?: string | null
featureCounts?: {
staged: number
pending: number
Expand All @@ -27,6 +28,7 @@ export function ProgressDashboard({
isConnected,
agentStatus,
agentActivity,
idleDetail,
featureCounts,
onResolveBlockers,
agentBadge,
Expand Down Expand Up @@ -140,7 +142,18 @@ export function ProgressDashboard({

<div className="flex flex-wrap items-center gap-2">
{statusText ? (
<span className={`neo-badge ${statusClass}`}>{statusText}</span>
<span className={`neo-badge ${statusClass}`} title={isIdle ? idleDetail || undefined : undefined}>
{statusText}
</span>
) : null}

{isIdle && idleDetail ? (
<span
className="neo-badge bg-[var(--color-neo-neutral-200)] text-[var(--color-neo-text-secondary)]"
title={idleDetail}
>
{idleDetail}
</span>
) : null}

<span
Expand Down
39 changes: 39 additions & 0 deletions ui/src/hooks/useParallelAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,28 @@ export interface ParallelAgentsStatus {
agents: ParallelAgentInfo[]
}

export interface ParallelQueueStateFeatureRef {
id: number
name: string
next_attempt_at: string | null
}

export interface ParallelQueueStateDepRef {
id: number
name: string
}

export interface ParallelQueueState {
pending_total: number
claimable_now: number
waiting_backoff: number
waiting_deps: number
staged_total: number
earliest_next_attempt_at: string | null
earliest_retry_feature: ParallelQueueStateFeatureRef | null
example_dep_blocked_feature: ParallelQueueStateDepRef | null
}

const API_BASE = '/api'

export function useParallelAgentsStatus(projectName: string | null, limit: number = 50) {
Expand All @@ -44,3 +66,20 @@ export function useParallelAgentsStatus(projectName: string | null, limit: numbe
})
}

export function useParallelQueueState(projectName: string | null) {
return useQuery({
queryKey: ['parallel-queue', 'state', projectName],
queryFn: async () => {
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<ParallelQueueState>
},
enabled: !!projectName,
refetchInterval: 5000,
})
}