1124 lines
45 KiB
Python
Executable file
1124 lines
45 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Dispatch active agent-task PLAN/CODE_REVIEW pairs until completion or a hard blocker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
SEP = "-" * 42
|
|
PLAN_RE = re.compile(r"^PLAN-(local|cloud)-G(0[1-9]|10)\.md$")
|
|
REVIEW_RE = re.compile(r"^CODE_REVIEW-(local|cloud)-G(0[1-9]|10)\.md$")
|
|
SUBTASK_RE = re.compile(r"^(?P<index>\d{2})(?:\+(?P<deps>\d{2}(?:,\d{2})*))?_[a-z0-9_]+$")
|
|
VERDICT_RE = re.compile(r"종합 판정\s*:\s*(PASS|WARN|FAIL)")
|
|
PLAN_IDENTITY_RE = re.compile(
|
|
r"<!--\s+task=(?P<task>\S+)\s+plan=(?P<plan>\d+)\s+tag=(?P<tag>\S+)\s+-->"
|
|
)
|
|
FINAL_CHECK_RE = re.compile(r"^-\s+\[[xX]\]\s+CODE_REVIEW-\*-G\?\?\.md", re.MULTILINE)
|
|
PROMOTABLE_PATTERNS = {
|
|
"context-limit": [
|
|
r"context (?:length|window)", r"maximum context", r"prompt is too long",
|
|
r"too many tokens", r"token limit", r"exceeded.{0,40}token",
|
|
r"output (?:token )?limit", r"maximum output", r"\bmax_tokens\b",
|
|
r"response (?:is )?too long",
|
|
],
|
|
"provider-quota": [
|
|
r"rate.?limit", r"\bquota\b", r"resource_exhausted", r"\b429\b",
|
|
r"usage limit", r"capacity limit",
|
|
],
|
|
"model-unavailable": [
|
|
r"model.{0,40}(?:not found|unavailable)", r"overloaded",
|
|
r"temporarily unavailable",
|
|
],
|
|
}
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def banner(event: str, task: str, lines: list[str] | None = None) -> None:
|
|
display_task = task.rsplit("/", 1)[-1]
|
|
print(SEP, flush=True)
|
|
print(f"{event}: {display_task}", flush=True)
|
|
print(SEP, flush=True)
|
|
if display_task != task:
|
|
print(f"task={task}", flush=True)
|
|
for line in lines or []:
|
|
print(line, flush=True)
|
|
|
|
|
|
def sha256_file(path: Path | None) -> str:
|
|
if path is None or not path.exists():
|
|
return "none"
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(65536), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def plan_identity(path: Path | None) -> str:
|
|
if path is None or not path.exists():
|
|
return "none"
|
|
text = path.read_text(encoding="utf-8", errors="replace")[:1024]
|
|
match = PLAN_IDENTITY_RE.search(text)
|
|
if not match:
|
|
return sha256_file(path)
|
|
identity = "\0".join(match.group(name) for name in ("task", "plan", "tag"))
|
|
return "meta:" + hashlib.sha256(identity.encode()).hexdigest()
|
|
|
|
|
|
def write_json(path: Path, value: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
temporary.replace(path)
|
|
|
|
|
|
def safe_name(value: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9_.-]+", "__", value).strip("_") or "task"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentSpec:
|
|
cli: str
|
|
model: str
|
|
display: str
|
|
local_pi: bool = False
|
|
|
|
|
|
@dataclass
|
|
class Task:
|
|
name: str
|
|
directory: Path
|
|
plan: Path | None
|
|
review: Path | None
|
|
user_review: Path | None
|
|
recovery: bool
|
|
errors: list[str] = field(default_factory=list)
|
|
index: int = 0
|
|
deps: tuple[str, ...] = ()
|
|
write_set: set[str] = field(default_factory=set)
|
|
write_set_known: bool = False
|
|
plan_hash: str = "none"
|
|
lane: str | None = None
|
|
grade: int | None = None
|
|
|
|
|
|
class StateStore:
|
|
def __init__(self, workspace: Path):
|
|
workspace_id = hashlib.sha256(str(workspace).encode()).hexdigest()[:16]
|
|
git_marker = workspace / ".git"
|
|
git_directory: Path | None = None
|
|
if git_marker.is_dir():
|
|
git_directory = git_marker
|
|
elif git_marker.is_file():
|
|
marker = git_marker.read_text(encoding="utf-8", errors="replace").strip()
|
|
if marker.startswith("gitdir:"):
|
|
candidate = Path(marker.split(":", 1)[1].strip())
|
|
git_directory = candidate if candidate.is_absolute() else (workspace / candidate).resolve()
|
|
candidates = []
|
|
if git_directory is not None:
|
|
candidates.append(git_directory / "agent-task-dispatcher")
|
|
state_base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state")))
|
|
candidates.append(state_base / "agent-task-dispatcher" / workspace_id)
|
|
self.root = candidates[-1]
|
|
last_error: OSError | None = None
|
|
for candidate in candidates:
|
|
try:
|
|
candidate.mkdir(parents=True, exist_ok=True)
|
|
self.root = candidate
|
|
last_error = None
|
|
break
|
|
except OSError as exc:
|
|
last_error = exc
|
|
if last_error is not None:
|
|
raise RuntimeError(f"dispatcher state 디렉터리를 만들 수 없다: {candidates}") from last_error
|
|
self.path = self.root / "state.json"
|
|
self.runs = self.root / "runs"
|
|
self.runs.mkdir(exist_ok=True)
|
|
self.lock_stream = (self.root / "dispatcher.lock").open("a+", encoding="utf-8")
|
|
try:
|
|
fcntl.flock(self.lock_stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError as exc:
|
|
raise RuntimeError(f"같은 workspace의 dispatcher가 이미 실행 중이다: {self.root}") from exc
|
|
if self.path.exists():
|
|
try:
|
|
self.data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
self.data = {"tasks": {}, "attempt_counters": {}}
|
|
else:
|
|
self.data = {"tasks": {}, "attempt_counters": {}}
|
|
|
|
def save(self) -> None:
|
|
write_json(self.path, self.data)
|
|
|
|
def task_state(self, task: Task) -> dict[str, Any]:
|
|
tasks = self.data.setdefault("tasks", {})
|
|
current = tasks.get(task.name)
|
|
if not current or current.get("plan_hash") != task.plan_hash:
|
|
current = {
|
|
"plan_hash": task.plan_hash,
|
|
"worker_done": False,
|
|
"worker_cli": None,
|
|
"worker_model": None,
|
|
"selfcheck_done": False,
|
|
"blocked": None,
|
|
"review_no_progress": 0,
|
|
}
|
|
tasks[task.name] = current
|
|
self.save()
|
|
return current
|
|
|
|
def update_task(self, task: Task, **values: Any) -> None:
|
|
state = self.task_state(task)
|
|
state.update(values)
|
|
self.save()
|
|
|
|
def next_attempt(self, task: Task, role: str) -> int:
|
|
key = f"{task.name}|{task.plan_hash}|{role}"
|
|
counters = self.data.setdefault("attempt_counters", {})
|
|
number = int(counters.get(key, 0))
|
|
counters[key] = number + 1
|
|
self.save()
|
|
return number
|
|
|
|
def clear_blocked(self) -> None:
|
|
for value in self.data.get("tasks", {}).values():
|
|
value["blocked"] = None
|
|
self.save()
|
|
|
|
|
|
def parse_route(plan: Path | None) -> tuple[str | None, int | None]:
|
|
if plan is None:
|
|
return None, None
|
|
match = PLAN_RE.match(plan.name)
|
|
if not match:
|
|
return None, None
|
|
return match.group(1), int(match.group(2))
|
|
|
|
|
|
def parse_task_name(task_root: Path, directory: Path) -> str:
|
|
return directory.relative_to(task_root).as_posix()
|
|
|
|
|
|
def extract_write_set(plan: Path | None) -> tuple[set[str], bool]:
|
|
if plan is None or not plan.exists():
|
|
return set(), False
|
|
text = plan.read_text(encoding="utf-8", errors="replace")
|
|
match = re.search(r"^## 수정 파일 요약\s*$([\s\S]*?)(?=^##\s|\Z)", text, re.MULTILINE)
|
|
if not match:
|
|
return set(), False
|
|
result: set[str] = set()
|
|
for line in match.group(1).splitlines():
|
|
if not line.lstrip().startswith("|"):
|
|
continue
|
|
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
if not cells:
|
|
continue
|
|
for value in re.findall(r"`([^`]+)`", cells[0]):
|
|
normalized = re.sub(r":\d+(?::\d+)?$", "", value.strip())
|
|
if normalized and not normalized.startswith(("http://", "https://")):
|
|
result.add(normalized)
|
|
return result, bool(result)
|
|
|
|
|
|
def latest_verdict_log(directory: Path) -> Path | None:
|
|
logs = sorted(directory.glob("code_review_*.log"), key=lambda p: p.stat().st_mtime)
|
|
if not logs:
|
|
return None
|
|
newest = logs[-1]
|
|
text = newest.read_text(encoding="utf-8", errors="replace")
|
|
return newest if VERDICT_RE.search(text) else None
|
|
|
|
|
|
def scan_tasks(workspace: Path, task_group: str | None) -> list[Task]:
|
|
task_root = workspace / "agent-task"
|
|
if not task_root.is_dir():
|
|
raise RuntimeError(f"agent-task 디렉터리가 없다: {task_root}")
|
|
directories: list[Path] = []
|
|
groups = [task_root / task_group] if task_group else sorted(
|
|
p for p in task_root.iterdir() if p.is_dir() and p.name != "archive"
|
|
)
|
|
for group in groups:
|
|
if not group.is_dir():
|
|
continue
|
|
directories.append(group)
|
|
directories.extend(sorted(p for p in group.iterdir() if p.is_dir()))
|
|
|
|
tasks: list[Task] = []
|
|
for directory in directories:
|
|
plans = sorted(p for p in directory.iterdir() if p.is_file() and PLAN_RE.match(p.name))
|
|
reviews = sorted(p for p in directory.iterdir() if p.is_file() and REVIEW_RE.match(p.name))
|
|
users = sorted(directory.glob("USER_REVIEW.md"))
|
|
complete = directory / "complete.log"
|
|
recovery_log = latest_verdict_log(directory)
|
|
if not plans and not reviews and not users and not complete.exists() and recovery_log is None:
|
|
continue
|
|
name = parse_task_name(task_root, directory)
|
|
errors: list[str] = []
|
|
if len(plans) > 1:
|
|
errors.append(f"active PLAN이 {len(plans)}개다")
|
|
if len(reviews) > 1:
|
|
errors.append(f"active CODE_REVIEW가 {len(reviews)}개다")
|
|
if len(users) > 1:
|
|
errors.append(f"USER_REVIEW가 {len(users)}개다")
|
|
plan = plans[0] if len(plans) == 1 else None
|
|
review = reviews[0] if len(reviews) == 1 else None
|
|
recovery = complete.exists() or recovery_log is not None
|
|
if bool(plan) != bool(review) and not recovery:
|
|
errors.append("active PLAN/CODE_REVIEW pair가 불완전하다")
|
|
relative = directory.relative_to(task_root)
|
|
subtask = relative.parts[1] if len(relative.parts) == 2 else None
|
|
index = 0
|
|
deps: tuple[str, ...] = ()
|
|
if subtask:
|
|
match = SUBTASK_RE.match(subtask)
|
|
if match:
|
|
index = int(match.group("index"))
|
|
deps = tuple((match.group("deps") or "").split(",")) if match.group("deps") else ()
|
|
else:
|
|
errors.append(f"split subtask 이름이 계약과 다르다: {subtask}")
|
|
lane, grade = parse_route(plan)
|
|
write_set, write_set_known = extract_write_set(plan)
|
|
if plan is not None:
|
|
metadata = PLAN_IDENTITY_RE.search(
|
|
plan.read_text(encoding="utf-8", errors="replace")[:1024]
|
|
)
|
|
if metadata is None:
|
|
errors.append("PLAN task/plan/tag metadata를 판별할 수 없다")
|
|
elif metadata.group("task") != name:
|
|
errors.append(
|
|
f"PLAN task metadata가 디렉터리와 다르다: {metadata.group('task')}"
|
|
)
|
|
if not write_set_known:
|
|
errors.append("수정 파일 요약 write-set을 판별할 수 없다")
|
|
task = Task(
|
|
name=name,
|
|
directory=directory,
|
|
plan=plan,
|
|
review=review,
|
|
user_review=users[0] if len(users) == 1 else None,
|
|
recovery=recovery,
|
|
errors=errors,
|
|
index=index,
|
|
deps=deps,
|
|
write_set=write_set,
|
|
write_set_known=write_set_known,
|
|
plan_hash=plan_identity(plan) if plan else sha256_file(recovery_log),
|
|
lane=lane,
|
|
grade=grade,
|
|
)
|
|
tasks.append(task)
|
|
return sorted(tasks, key=lambda t: (t.index, t.name))
|
|
|
|
|
|
def dependency_candidates(workspace: Path, task: Task, predecessor: str) -> list[Path]:
|
|
parts = task.name.split("/")
|
|
if len(parts) != 2:
|
|
return []
|
|
group = parts[0]
|
|
task_root = workspace / "agent-task"
|
|
found: list[Path] = []
|
|
active_group = task_root / group
|
|
for pattern in (f"{predecessor}_*/complete.log", f"{predecessor}+*/complete.log"):
|
|
found.extend(active_group.glob(pattern))
|
|
archive = task_root / "archive"
|
|
if archive.is_dir():
|
|
for year in archive.iterdir():
|
|
if not year.is_dir():
|
|
continue
|
|
for month in year.iterdir():
|
|
archived_group = month / group
|
|
if not archived_group.is_dir():
|
|
continue
|
|
for pattern in (f"{predecessor}_*/complete.log", f"{predecessor}+*/complete.log"):
|
|
found.extend(archived_group.glob(pattern))
|
|
return sorted(set(path.resolve() for path in found))
|
|
|
|
|
|
def dependency_state(workspace: Path, task: Task) -> tuple[bool, str]:
|
|
missing: list[str] = []
|
|
ambiguous: list[str] = []
|
|
for predecessor in task.deps:
|
|
candidates = dependency_candidates(workspace, task, predecessor)
|
|
if not candidates:
|
|
missing.append(predecessor)
|
|
elif len(candidates) > 1:
|
|
ambiguous.append(f"{predecessor}={','.join(str(p) for p in candidates)}")
|
|
if ambiguous:
|
|
return False, "dependency ambiguity: " + "; ".join(ambiguous)
|
|
if missing:
|
|
return False, "predecessor complete.log 대기: " + ",".join(missing)
|
|
return True, "ready"
|
|
|
|
|
|
def route_agent(task: Task) -> AgentSpec:
|
|
if task.lane is None or task.grade is None:
|
|
raise RuntimeError("PLAN route를 판별할 수 없다")
|
|
grade = task.grade
|
|
if task.lane == "local":
|
|
if grade <= 5:
|
|
return AgentSpec("pi", "ornith:35b", "pi/iop/ornith:35b", local_pi=True)
|
|
if grade <= 8:
|
|
return AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True)
|
|
return AgentSpec("claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh")
|
|
if grade <= 2:
|
|
return AgentSpec("agy", "Gemini 3.5 Flash (Low)", "agy/Gemini 3.5 Flash (Low)")
|
|
if grade <= 4:
|
|
return AgentSpec("agy", "Gemini 3.5 Flash (Medium)", "agy/Gemini 3.5 Flash (Medium)")
|
|
if grade <= 6:
|
|
return AgentSpec("agy", "Gemini 3.5 Flash (High)", "agy/Gemini 3.5 Flash (High)")
|
|
if grade <= 8:
|
|
return AgentSpec("claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh")
|
|
return AgentSpec("codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh")
|
|
|
|
|
|
def plan_number(task: Task) -> int:
|
|
if task.plan and task.plan.exists():
|
|
match = PLAN_IDENTITY_RE.search(
|
|
task.plan.read_text(encoding="utf-8", errors="replace")[:1024]
|
|
)
|
|
if match:
|
|
return int(match.group("plan"))
|
|
return 0
|
|
|
|
|
|
def task_requires_selfcheck(task: Task) -> bool:
|
|
return task.lane == "local" and task.grade is not None and task.grade <= 8
|
|
|
|
|
|
def task_stage(task: Task, state: dict[str, Any]) -> str:
|
|
if task.user_review:
|
|
return "user-review"
|
|
if task.errors:
|
|
return "blocked"
|
|
if task.recovery and (task.plan is None or task.review is None):
|
|
return "review"
|
|
if task.review and task.review.exists():
|
|
text = task.review.read_text(encoding="utf-8", errors="replace")
|
|
if VERDICT_RE.search(text):
|
|
return "review"
|
|
if re.search(r"^-\s*상태:(?![ \t]*없음[ \t]*$).+", text, re.MULTILINE):
|
|
return "review"
|
|
if state.get("worker_done"):
|
|
if task_requires_selfcheck(task) and not state.get("selfcheck_done"):
|
|
return "selfcheck"
|
|
return "review"
|
|
if task.review and task.review.exists() and FINAL_CHECK_RE.search(text):
|
|
return "review"
|
|
return "worker"
|
|
|
|
|
|
def classify_failure(output: str) -> str:
|
|
lowered = output.lower()
|
|
for category, patterns in PROMOTABLE_PATTERNS.items():
|
|
if any(re.search(pattern, lowered, re.DOTALL) for pattern in patterns):
|
|
return category
|
|
return "generic-error"
|
|
|
|
|
|
def terminal_diagnostic(cli: str, channel: str, line: str) -> str | None:
|
|
if channel == "stderr":
|
|
return line
|
|
try:
|
|
value = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
if cli == "agy" and re.match(
|
|
r"^\s*(?:error|fatal|provider error|model error)\b", line, re.IGNORECASE
|
|
):
|
|
return line
|
|
return None
|
|
event_type = str(value.get("type", ""))
|
|
if cli == "codex" and event_type in {"turn.failed", "error"}:
|
|
return json.dumps(value.get("error", value), ensure_ascii=False)
|
|
if cli == "claude":
|
|
subtype = str(value.get("subtype", ""))
|
|
if event_type == "result" and (value.get("is_error") or subtype.startswith("error")):
|
|
return str(value.get("result") or value)
|
|
if event_type == "system" and subtype.startswith("error"):
|
|
return json.dumps(value, ensure_ascii=False)
|
|
return None
|
|
|
|
|
|
def agy_log_diagnostics(path: Path) -> list[str]:
|
|
if not path.exists():
|
|
return []
|
|
diagnostics: list[str] = []
|
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines()[-200:]:
|
|
if re.search(r"\b(?:error|fatal)\b", line, re.IGNORECASE):
|
|
diagnostics.append(line)
|
|
elif re.search(r"RESOURCE_EXHAUSTED|rate.?limit|model.{0,40}unavailable", line, re.IGNORECASE):
|
|
diagnostics.append(line)
|
|
return diagnostics
|
|
|
|
|
|
def promoted_spec(spec: AgentSpec, recovery_count: int) -> AgentSpec | None:
|
|
if spec.cli == "agy":
|
|
return AgentSpec("claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh")
|
|
if spec.cli == "claude":
|
|
return AgentSpec("codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh")
|
|
if spec.cli == "codex" and recovery_count < 1:
|
|
return AgentSpec("codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh")
|
|
return None
|
|
|
|
|
|
def render_json_line(cli: str, line: str) -> tuple[list[str], str | None]:
|
|
try:
|
|
value = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
return [line.rstrip()], None
|
|
session_id = value.get("thread_id") or value.get("session_id")
|
|
rendered: list[str] = []
|
|
if cli == "codex":
|
|
if value.get("type") == "thread.started" and session_id:
|
|
rendered.append(f"session={session_id}")
|
|
item = value.get("item") or {}
|
|
item_type = item.get("type")
|
|
if item_type == "agent_message" and item.get("text"):
|
|
rendered.extend(str(item["text"]).splitlines())
|
|
elif item_type == "command_execution":
|
|
rendered.append(f"$ {item.get('command', '')} (exit={item.get('exit_code', '?')})")
|
|
elif item_type in {"mcp_tool_call", "web_search"}:
|
|
rendered.append(f"{item_type}: {item.get('server', '')} {item.get('tool', item.get('query', ''))}")
|
|
elif value.get("type") == "turn.failed":
|
|
rendered.append(str(value.get("error", value)))
|
|
elif cli == "claude":
|
|
message = value.get("message") or {}
|
|
for block in message.get("content") or []:
|
|
if block.get("type") == "text":
|
|
rendered.extend(str(block.get("text", "")).splitlines())
|
|
elif block.get("type") == "tool_use":
|
|
rendered.append(f"tool={block.get('name', '')}")
|
|
if value.get("type") == "result" and value.get("result"):
|
|
rendered.extend(str(value["result"]).splitlines())
|
|
session_id = session_id or value.get("session_id")
|
|
return rendered, str(session_id) if session_id else None
|
|
|
|
|
|
def native_session_path(cli: str, workspace: Path, session_id: str | None, attempt_dir: Path) -> str | None:
|
|
if cli == "claude" and session_id:
|
|
encoded = str(workspace).replace("/", "-")
|
|
return str(Path.home() / ".claude" / "projects" / encoded / f"{session_id}.jsonl")
|
|
if cli == "pi" and session_id:
|
|
matches = list((attempt_dir / "pi-sessions").glob(f"*{session_id}*.jsonl"))
|
|
return str(matches[0]) if matches else str(attempt_dir / "pi-sessions")
|
|
if cli == "codex" and session_id:
|
|
matches = list((Path.home() / ".codex" / "sessions").glob(f"**/*{session_id}*.jsonl"))
|
|
return str(matches[0]) if matches else str(Path.home() / ".codex" / "sessions")
|
|
return None
|
|
|
|
|
|
def agy_conversations() -> dict[Path, int]:
|
|
root = Path.home() / ".gemini" / "antigravity-cli" / "conversations"
|
|
if not root.is_dir():
|
|
return {}
|
|
return {path: path.stat().st_mtime_ns for path in root.glob("*.db")}
|
|
|
|
|
|
def build_command(spec: AgentSpec, prompt: str, workspace: Path, session_id: str, attempt_dir: Path) -> list[str]:
|
|
if spec.cli == "codex":
|
|
return [
|
|
"codex", "exec", "--json", "-C", str(workspace), "-m", spec.model,
|
|
"-c", 'model_reasoning_effort="xhigh"',
|
|
"--dangerously-bypass-approvals-and-sandbox", prompt,
|
|
]
|
|
if spec.cli == "claude":
|
|
return [
|
|
"claude", "-p", "--output-format", "stream-json", "--verbose",
|
|
"--session-id", session_id, "--model", spec.model, "--effort", "xhigh",
|
|
"--dangerously-skip-permissions", prompt,
|
|
]
|
|
if spec.cli == "agy":
|
|
return [
|
|
"agy", "--print", "--print-timeout", "8h", "--model", spec.model,
|
|
"--dangerously-skip-permissions", "--log-file", str(attempt_dir / "agy-cli.log"), prompt,
|
|
]
|
|
if spec.cli == "pi":
|
|
return [
|
|
"pi", "-p", "--approve", "--provider", "iop", "--model", spec.model,
|
|
"--thinking", "high", "--session-id", session_id,
|
|
"--session-dir", str(attempt_dir / "pi-sessions"), prompt,
|
|
]
|
|
raise RuntimeError(f"지원하지 않는 CLI: {spec.cli}")
|
|
|
|
|
|
async def invoke(
|
|
workspace: Path,
|
|
store: StateStore,
|
|
task: Task,
|
|
role: str,
|
|
spec: AgentSpec,
|
|
prompt: str,
|
|
) -> tuple[int, str | None, Path]:
|
|
attempt = store.next_attempt(task, role)
|
|
identity = f"{safe_name(task.name)}__p{plan_number(task)}__{role}__a{attempt:02d}"
|
|
attempt_dir = store.runs / f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}__{identity}"
|
|
attempt_dir.mkdir(parents=True, exist_ok=False)
|
|
locator_path = attempt_dir / "locator.json"
|
|
output_path = attempt_dir / "output.log"
|
|
session_id = str(uuid.uuid4())
|
|
record: dict[str, Any] = {
|
|
"execution_id": identity,
|
|
"task": task.name,
|
|
"plan_number": plan_number(task),
|
|
"role": role,
|
|
"attempt": attempt,
|
|
"workspace": str(workspace),
|
|
"cli": spec.cli,
|
|
"model": spec.model,
|
|
"plan_path": str(task.plan) if task.plan else None,
|
|
"review_path": str(task.review) if task.review else None,
|
|
"session_id": session_id if spec.cli in {"claude", "pi"} else None,
|
|
"native_session_path": native_session_path(spec.cli, workspace, session_id, attempt_dir),
|
|
"output_log": str(output_path),
|
|
"cli_log": str(attempt_dir / "agy-cli.log") if spec.cli == "agy" else None,
|
|
"started_at": now_iso(),
|
|
"status": "running",
|
|
}
|
|
write_json(locator_path, record)
|
|
command = build_command(spec, prompt, workspace, session_id, attempt_dir)
|
|
before_agy = agy_conversations() if spec.cli == "agy" else {}
|
|
prefix = f"[{task.directory.name}][{role}][a{attempt:02d}]"
|
|
diagnostics: list[str] = []
|
|
try:
|
|
process = await asyncio.create_subprocess_exec(
|
|
*command,
|
|
cwd=workspace,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
limit=10 * 1024 * 1024,
|
|
)
|
|
except FileNotFoundError:
|
|
line = f"command not found: {command[0]}"
|
|
output_path.write_text(line + "\n", encoding="utf-8")
|
|
record.update(status="failed", finished_at=now_iso(), exit_code=127, failure_class="generic-error")
|
|
write_json(locator_path, record)
|
|
print(f"{prefix} {line}", flush=True)
|
|
return 127, "generic-error", locator_path
|
|
|
|
readers: list[asyncio.Task[None]] = []
|
|
try:
|
|
assert process.stdout is not None and process.stderr is not None
|
|
queue: asyncio.Queue[tuple[str, bytes | None]] = asyncio.Queue()
|
|
|
|
async def pump(channel: str, stream: asyncio.StreamReader) -> None:
|
|
try:
|
|
while True:
|
|
raw = await stream.readline()
|
|
if not raw:
|
|
break
|
|
await queue.put((channel, raw))
|
|
finally:
|
|
await queue.put((channel, None))
|
|
|
|
readers = [
|
|
asyncio.create_task(pump("stdout", process.stdout)),
|
|
asyncio.create_task(pump("stderr", process.stderr)),
|
|
]
|
|
finished_streams = 0
|
|
with output_path.open("w", encoding="utf-8") as output:
|
|
while finished_streams < len(readers):
|
|
try:
|
|
channel, raw = await asyncio.wait_for(queue.get(), timeout=30)
|
|
except asyncio.TimeoutError:
|
|
print(f"{prefix} 실행 중... locator={locator_path}", flush=True)
|
|
continue
|
|
if raw is None:
|
|
finished_streams += 1
|
|
continue
|
|
line = raw.decode("utf-8", errors="replace").rstrip("\n")
|
|
output.write(f"[{channel}] {line}\n")
|
|
output.flush()
|
|
diagnostic = terminal_diagnostic(spec.cli, channel, line)
|
|
if diagnostic:
|
|
diagnostics.append(diagnostic)
|
|
rendered, discovered = (
|
|
render_json_line(spec.cli, line) if channel == "stdout" else ([line], None)
|
|
)
|
|
if discovered and record.get("session_id") != discovered:
|
|
record["session_id"] = discovered
|
|
record["native_session_path"] = native_session_path(
|
|
spec.cli, workspace, discovered, attempt_dir
|
|
)
|
|
write_json(locator_path, record)
|
|
for display_line in rendered:
|
|
if display_line:
|
|
print(f"{prefix} {display_line}", flush=True)
|
|
await asyncio.gather(*readers)
|
|
return_code = await process.wait()
|
|
except asyncio.CancelledError:
|
|
for reader in readers:
|
|
reader.cancel()
|
|
if readers:
|
|
await asyncio.gather(*readers, return_exceptions=True)
|
|
if process.returncode is None:
|
|
process.terminate()
|
|
await process.wait()
|
|
raise
|
|
|
|
if spec.cli == "agy":
|
|
after_agy = agy_conversations()
|
|
changed = [
|
|
path for path, mtime in after_agy.items()
|
|
if path not in before_agy or before_agy[path] != mtime
|
|
]
|
|
if changed:
|
|
selected = max(changed, key=lambda path: after_agy[path])
|
|
record["session_id"] = selected.stem
|
|
record["native_session_path"] = str(selected)
|
|
diagnostics.extend(agy_log_diagnostics(attempt_dir / "agy-cli.log"))
|
|
if not record.get("native_session_path"):
|
|
record["native_session_path"] = native_session_path(
|
|
spec.cli, workspace, record.get("session_id"), attempt_dir
|
|
)
|
|
failure_class = None if return_code == 0 else classify_failure("\n".join(diagnostics[-50:]))
|
|
record.update(
|
|
status="succeeded" if return_code == 0 else "failed",
|
|
finished_at=now_iso(),
|
|
exit_code=return_code,
|
|
failure_class=failure_class,
|
|
)
|
|
write_json(locator_path, record)
|
|
return return_code, failure_class, locator_path
|
|
|
|
|
|
def base_prompt(task: Task, role: str, spec: AgentSpec) -> str:
|
|
if role == "review":
|
|
target = task.review or task.directory
|
|
if task.review:
|
|
return f"Read {target.resolve()} and start the review. Final in Korean."
|
|
return f"Continue the review for {target.resolve()}. Final in Korean."
|
|
if task.plan is None:
|
|
raise RuntimeError("worker PLAN이 없다")
|
|
target = task.plan.resolve()
|
|
if role == "selfcheck":
|
|
if task.review is None:
|
|
raise RuntimeError("selfcheck CODE_REVIEW 파일이 없다")
|
|
return (
|
|
"Think in English. Final in Korean. This is a self-check of completed work, "
|
|
f"not a review. Read {target} and finish any missing work. Recheck and fix "
|
|
f"your work. Read {task.review.resolve()} and fill any missing implementation "
|
|
"fields."
|
|
)
|
|
if spec.local_pi:
|
|
return f"Think in English. Final in Korean. Read {target} and complete the task."
|
|
return f"Read {target} and complete the task. Final in Korean."
|
|
|
|
|
|
def continuation_prompt(task: Task, role: str, locator: Path) -> str:
|
|
return (
|
|
f"Continue from {locator.resolve()}. Check the saved context and current "
|
|
"workspace. Final in Korean."
|
|
)
|
|
|
|
|
|
async def run_escalating(
|
|
workspace: Path,
|
|
store: StateStore,
|
|
task: Task,
|
|
role: str,
|
|
initial: AgentSpec,
|
|
) -> tuple[bool, Path | None]:
|
|
spec = initial
|
|
previous_locator: Path | None = None
|
|
codex_recovery_count = 0
|
|
while True:
|
|
prompt = base_prompt(task, role, spec) if previous_locator is None else continuation_prompt(
|
|
task, role, previous_locator
|
|
)
|
|
rc, failure, locator = await invoke(workspace, store, task, role, spec, prompt)
|
|
if rc == 0:
|
|
return True, locator
|
|
failure = failure or "generic-error"
|
|
if spec.local_pi or failure == "generic-error":
|
|
banner("작업차단", task.name, [f"reason={failure}", f"locator={locator}"])
|
|
return False, locator
|
|
next_spec = promoted_spec(spec, codex_recovery_count)
|
|
if next_spec is None:
|
|
banner("작업차단", task.name, [f"reason={failure}", f"locator={locator}"])
|
|
return False, locator
|
|
if spec.cli == "codex":
|
|
codex_recovery_count += 1
|
|
banner(
|
|
"모델승격",
|
|
task.name,
|
|
[f"from={spec.display}", f"to={next_spec.display}", f"reason={failure}", f"locator={locator}"],
|
|
)
|
|
spec = next_spec
|
|
previous_locator = locator
|
|
|
|
|
|
def task_signature(workspace: Path, task: Task) -> str:
|
|
digest = hashlib.sha256()
|
|
if not task.directory.exists():
|
|
return "moved"
|
|
for path in sorted(p for p in task.directory.iterdir() if p.is_file()):
|
|
if PLAN_RE.match(path.name) or REVIEW_RE.match(path.name) or path.name.endswith(".log"):
|
|
digest.update(path.name.encode())
|
|
digest.update(sha256_file(path).encode())
|
|
for raw_path in sorted(task.write_set):
|
|
path = Path(raw_path)
|
|
path = path if path.is_absolute() else workspace / path
|
|
digest.update(raw_path.encode())
|
|
if path.is_file():
|
|
digest.update(str(path.stat().st_mode).encode())
|
|
digest.update(sha256_file(path).encode())
|
|
elif path.exists():
|
|
digest.update(b"non-file")
|
|
else:
|
|
digest.update(b"missing")
|
|
return digest.hexdigest()
|
|
|
|
|
|
def read_verdict(path: Path) -> str | None:
|
|
if not path.exists():
|
|
return None
|
|
match = VERDICT_RE.search(path.read_text(encoding="utf-8", errors="replace"))
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def matching_archive_directories(workspace: Path, task: Task) -> list[Path]:
|
|
archive = workspace / "agent-task" / "archive"
|
|
parts = task.name.split("/")
|
|
if not archive.is_dir() or len(parts) not in {1, 2}:
|
|
return []
|
|
group = parts[0]
|
|
final_name = parts[-1]
|
|
suffix_re = re.compile(rf"^{re.escape(final_name)}(?:_\d+)?$")
|
|
matches: list[Path] = []
|
|
for year in archive.iterdir():
|
|
if not year.is_dir():
|
|
continue
|
|
for month in year.iterdir():
|
|
if not month.is_dir():
|
|
continue
|
|
parent = month if len(parts) == 1 else month / group
|
|
if not parent.is_dir():
|
|
continue
|
|
for candidate in parent.iterdir():
|
|
if candidate.is_dir() and suffix_re.match(candidate.name) and (candidate / "complete.log").is_file():
|
|
matches.append(candidate)
|
|
return matches
|
|
|
|
|
|
def review_fingerprints(workspace: Path, task: Task) -> set[tuple[str, str]]:
|
|
directories = [task.directory] if task.directory.is_dir() else []
|
|
directories.extend(matching_archive_directories(workspace, task))
|
|
fingerprints: set[tuple[str, str]] = set()
|
|
for directory in directories:
|
|
for path in directory.iterdir():
|
|
if path.is_file() and (path.name.startswith("code_review_") or REVIEW_RE.match(path.name)):
|
|
fingerprints.add((str(path.resolve()), sha256_file(path)))
|
|
return fingerprints
|
|
|
|
|
|
def review_outcome(
|
|
workspace: Path, task: Task, prior_fingerprints: set[tuple[str, str]]
|
|
) -> dict[str, str]:
|
|
if task.directory.is_dir():
|
|
directories = [task.directory]
|
|
archives: list[Path] = []
|
|
else:
|
|
archives = matching_archive_directories(workspace, task)
|
|
directories = list(archives)
|
|
newest_directory: Path | None = None
|
|
newest_log: Path | None = None
|
|
newest_mtime = -1
|
|
for directory in directories:
|
|
logs = list(directory.glob("code_review_*.log"))
|
|
logs.extend(path for path in directory.iterdir() if path.is_file() and REVIEW_RE.match(path.name))
|
|
for log in logs:
|
|
mtime = log.stat().st_mtime_ns
|
|
fingerprint = (str(log.resolve()), sha256_file(log))
|
|
if fingerprint not in prior_fingerprints and mtime > newest_mtime and read_verdict(log):
|
|
newest_directory = directory
|
|
newest_log = log
|
|
newest_mtime = mtime
|
|
verdict = read_verdict(newest_log) if newest_log else "UNKNOWN"
|
|
if newest_directory in archives:
|
|
state = "archived"
|
|
elif newest_directory and (newest_directory / "USER_REVIEW.md").exists():
|
|
state = "user-review"
|
|
elif newest_directory and (newest_directory / "complete.log").exists():
|
|
state = "complete-finalization"
|
|
elif newest_log and REVIEW_RE.match(newest_log.name):
|
|
state = "finalization-pending"
|
|
elif newest_directory and any(REVIEW_RE.match(path.name) for path in newest_directory.iterdir() if path.is_file()):
|
|
state = "follow-up"
|
|
else:
|
|
state = "changed"
|
|
return {
|
|
"verdict": verdict or "UNKNOWN",
|
|
"state": state,
|
|
"path": str(newest_directory or task.directory),
|
|
"review_log": str(newest_log) if newest_log else "unknown",
|
|
}
|
|
|
|
|
|
async def run_worker(
|
|
workspace: Path,
|
|
store: StateStore,
|
|
task: Task,
|
|
semaphores: dict[str, asyncio.Semaphore],
|
|
) -> None:
|
|
spec = route_agent(task)
|
|
resource = f"pi:{spec.model}" if spec.cli == "pi" else spec.cli
|
|
async with semaphores[resource]:
|
|
banner("작업시작", task.name, [f"model={spec.display}", f"plan={task.plan.resolve()}"])
|
|
success, locator = await run_escalating(workspace, store, task, "worker", spec)
|
|
if not success:
|
|
store.update_task(task, blocked=f"worker failure locator={locator}")
|
|
return
|
|
store.update_task(
|
|
task,
|
|
worker_done=True,
|
|
worker_cli=spec.cli,
|
|
worker_model=spec.model,
|
|
selfcheck_done=not spec.local_pi,
|
|
blocked=None,
|
|
)
|
|
|
|
|
|
async def run_selfcheck(
|
|
workspace: Path,
|
|
store: StateStore,
|
|
task: Task,
|
|
semaphores: dict[str, asyncio.Semaphore],
|
|
) -> None:
|
|
spec = route_agent(task)
|
|
if not spec.local_pi:
|
|
raise RuntimeError("Pi가 아닌 route에 selfcheck stage가 배정됐다")
|
|
resource = f"pi:{spec.model}"
|
|
async with semaphores[resource]:
|
|
banner("자가검증시작", task.name, [f"model={spec.display}", f"plan={task.plan.resolve()}"])
|
|
success, locator = await run_escalating(workspace, store, task, "selfcheck", spec)
|
|
if not success:
|
|
store.update_task(task, blocked=f"selfcheck failure locator={locator}")
|
|
return
|
|
store.update_task(task, selfcheck_done=True, blocked=None)
|
|
|
|
|
|
async def run_review(
|
|
workspace: Path,
|
|
store: StateStore,
|
|
task: Task,
|
|
review_semaphore: asyncio.Semaphore,
|
|
) -> None:
|
|
spec = AgentSpec("codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh")
|
|
before = task_signature(workspace, task)
|
|
prior_review_fingerprints = review_fingerprints(workspace, task)
|
|
async with review_semaphore:
|
|
target = task.review.resolve() if task.review else task.directory.resolve()
|
|
banner("리뷰시작", task.name, [f"model={spec.display}", f"review={target}"])
|
|
success, locator = await run_escalating(workspace, store, task, "review", spec)
|
|
if not success:
|
|
store.update_task(task, blocked=f"review failure locator={locator}")
|
|
return
|
|
after = task_signature(workspace, task)
|
|
if before == after:
|
|
state = store.task_state(task)
|
|
count = int(state.get("review_no_progress", 0)) + 1
|
|
store.update_task(task, review_no_progress=count)
|
|
banner("루프정체경고", task.name, [f"unchanged_review_attempts={count}", f"locator={locator}"])
|
|
await asyncio.sleep(min(30, count * 5))
|
|
else:
|
|
store.update_task(task, review_no_progress=0, blocked=None)
|
|
outcome = review_outcome(workspace, task, prior_review_fingerprints)
|
|
banner(
|
|
"리뷰결과",
|
|
task.name,
|
|
[
|
|
f"verdict={outcome['verdict']}",
|
|
f"state={outcome['state']}",
|
|
f"path={outcome['path']}",
|
|
f"review_log={outcome['review_log']}",
|
|
f"locator={locator}",
|
|
],
|
|
)
|
|
if outcome["verdict"] == "PASS" and outcome["state"] == "archived":
|
|
banner("작업완료", task.name, [f"archive={outcome['path']}", f"locator={locator}"])
|
|
|
|
|
|
def status_lines(task: Task, stage: str, dependency: str) -> list[str]:
|
|
route = f"{task.lane}-G{task.grade:02d}" if task.lane and task.grade else "recovery"
|
|
return [f"stage={stage}", f"route={route}", f"dependency={dependency}"]
|
|
|
|
|
|
def select_dispatch_candidates(
|
|
ready: list[tuple[Task, str]], active_stages: set[str]
|
|
) -> tuple[list[tuple[Task, str]], list[tuple[Task, str]], str]:
|
|
ready_reviews = [(task, stage) for task, stage in ready if stage == "review"]
|
|
ready_workers = [(task, stage) for task, stage in ready if stage in {"worker", "selfcheck"}]
|
|
if "review" in active_stages:
|
|
return [], ready, "공식 review 종료 대기"
|
|
if active_stages & {"worker", "selfcheck"}:
|
|
return ready_workers, ready_reviews, "worker/selfcheck 안정화 대기"
|
|
if ready_reviews:
|
|
return ready_reviews[:1], ready_reviews[1:] + ready_workers, "공식 review 우선 처리 대기"
|
|
return ready_workers, [], ""
|
|
|
|
|
|
async def dispatch(args: argparse.Namespace) -> int:
|
|
workspace = Path(args.workspace).resolve()
|
|
store = StateStore(workspace)
|
|
if args.retry_blocked:
|
|
store.clear_blocked()
|
|
semaphores = {
|
|
"pi:ornith:35b": asyncio.Semaphore(3),
|
|
"pi:laguna-s:2.1": asyncio.Semaphore(2),
|
|
"agy": asyncio.Semaphore(1),
|
|
"claude": asyncio.Semaphore(64),
|
|
"codex": asyncio.Semaphore(64),
|
|
}
|
|
review_semaphore = asyncio.Semaphore(1)
|
|
running: dict[str, asyncio.Task[None]] = {}
|
|
running_stages: dict[str, str] = {}
|
|
running_write_sets: dict[str, set[str]] = {}
|
|
last_wait: dict[str, str] = {}
|
|
|
|
while True:
|
|
tasks = scan_tasks(workspace, args.task_group)
|
|
if not tasks and not running:
|
|
banner("작업완료", args.task_group or "agent-task", ["active task 없음"])
|
|
return 0
|
|
|
|
task_by_name = {task.name: task for task in tasks}
|
|
for name, future in list(running.items()):
|
|
if not future.done():
|
|
continue
|
|
try:
|
|
future.result()
|
|
except Exception as exc: # keep other independent tasks alive
|
|
banner("작업차단", name, [f"dispatcher exception={exc}"])
|
|
task = task_by_name.get(name)
|
|
if task:
|
|
store.update_task(task, blocked=f"dispatcher exception={exc}")
|
|
del running[name]
|
|
running_stages.pop(name, None)
|
|
running_write_sets.pop(name, None)
|
|
|
|
ready: list[tuple[Task, str]] = []
|
|
hard_blocked: list[str] = []
|
|
blocked_details: dict[str, tuple[str, str, str]] = {}
|
|
for task in tasks:
|
|
state = store.task_state(task)
|
|
dependency_ready, dependency = dependency_state(workspace, task)
|
|
stage = task_stage(task, state)
|
|
reason = ""
|
|
if task.errors:
|
|
reason = "; ".join(task.errors)
|
|
elif stage == "user-review":
|
|
reason = f"USER_REVIEW 대기: {task.user_review}"
|
|
elif state.get("blocked"):
|
|
reason = str(state["blocked"])
|
|
elif not dependency_ready:
|
|
reason = dependency
|
|
if reason:
|
|
wait_key = f"{stage}|{reason}"
|
|
event = (
|
|
"작업차단"
|
|
if stage in {"blocked", "user-review"} or state.get("blocked")
|
|
else "작업대기"
|
|
)
|
|
blocked_details[task.name] = (event, stage, reason)
|
|
if not args.dry_run and last_wait.get(task.name) != wait_key:
|
|
banner(event, task.name, status_lines(task, stage, reason))
|
|
last_wait[task.name] = wait_key
|
|
hard_blocked.append(task.name)
|
|
continue
|
|
if task.name not in running:
|
|
ready.append((task, stage))
|
|
|
|
if args.dry_run:
|
|
ready_by_name = {task.name: stage for task, stage in ready}
|
|
for task in tasks:
|
|
if task.name in ready_by_name:
|
|
stage = ready_by_name[task.name]
|
|
spec = (
|
|
AgentSpec("codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh")
|
|
if stage == "review"
|
|
else route_agent(task)
|
|
)
|
|
banner("작업대기", task.name, status_lines(task, stage, "ready") + [f"model={spec.display}"])
|
|
else:
|
|
event, stage, reason = blocked_details[task.name]
|
|
banner(event, task.name, status_lines(task, stage, reason))
|
|
return 2 if hard_blocked and not ready else 0
|
|
|
|
active_stages = {
|
|
running_stages[name] for name, future in running.items() if not future.done()
|
|
}
|
|
candidates, deferred, phase_wait_reason = select_dispatch_candidates(ready, active_stages)
|
|
for task, stage in deferred:
|
|
wait_key = f"{stage}|{phase_wait_reason}"
|
|
if last_wait.get(task.name) != wait_key:
|
|
banner("작업대기", task.name, status_lines(task, stage, phase_wait_reason))
|
|
last_wait[task.name] = wait_key
|
|
|
|
reserved_sets = list(running_write_sets.values())
|
|
scheduled = False
|
|
for task, stage in candidates:
|
|
if task.write_set and any(task.write_set & other for other in reserved_sets):
|
|
reason = "write-set 겹침으로 직렬 대기"
|
|
if last_wait.get(task.name) != reason:
|
|
banner("작업대기", task.name, status_lines(task, stage, reason))
|
|
last_wait[task.name] = reason
|
|
continue
|
|
if stage == "review":
|
|
future = asyncio.create_task(run_review(workspace, store, task, review_semaphore))
|
|
elif stage == "selfcheck":
|
|
future = asyncio.create_task(run_selfcheck(workspace, store, task, semaphores))
|
|
else:
|
|
future = asyncio.create_task(run_worker(workspace, store, task, semaphores))
|
|
running[task.name] = future
|
|
running_stages[task.name] = stage
|
|
running_write_sets[task.name] = task.write_set
|
|
reserved_sets.append(task.write_set)
|
|
last_wait.pop(task.name, None)
|
|
scheduled = True
|
|
|
|
if running:
|
|
await asyncio.wait(running.values(), return_when=asyncio.FIRST_COMPLETED)
|
|
continue
|
|
if not scheduled:
|
|
banner("작업차단", args.task_group or "agent-task", [f"blocked={','.join(sorted(hard_blocked))}"])
|
|
return 2
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--workspace", default=".", help="repository root (default: current directory)")
|
|
parser.add_argument("--task-group", help="run only agent-task/<task_group>")
|
|
parser.add_argument("--dry-run", action="store_true", help="classify and print without launching CLIs")
|
|
parser.add_argument("--retry-blocked", action="store_true", help="clear dispatcher-local blocked state")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
try:
|
|
return asyncio.run(dispatch(args))
|
|
except KeyboardInterrupt:
|
|
print("\n중단됨", file=sys.stderr)
|
|
return 130
|
|
except RuntimeError as exc:
|
|
print(f"dispatcher error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|