This commit is contained in:
team3
2026-06-30 00:14:18 +02:00
parent 3e3559aa8f
commit c794fcaccf
152 changed files with 9485 additions and 9583 deletions

View File

@@ -1,7 +1,7 @@
"""Provider-Schicht: führt Agent-Aufrufe über die Claude-CLI oder OpenCode (MiniMax) aus.
"""Provider layer: runs agent calls via the Claude CLI or OpenCode (MiniMax).
Beide Runner sind unabhängig. Fehlt ein Binary/Key, schlägt nur der
jeweilige Provider fehl — der andere läuft unverändert weiter.
Both runners are independent. If a binary/key is missing, only the
respective provider fails — the other keeps running unchanged.
"""
import asyncio
@@ -21,9 +21,9 @@ log = logging.getLogger("creator.agents")
_active_processes: dict[str, asyncio.subprocess.Process] = {}
# Abgebrochene Scopes (Schlüssel-Präfixe, symmetrisch zu kill_process). Ein Agent, dessen
# Key mit einem dieser Präfixe beginnt, bricht VOR dem Spawn ab — so werden auch in der
# Semaphore-Schlange WARTENDE Agenten beim Abbruch sofort gestoppt, statt noch zu starten.
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
# in the semaphore queue are also stopped immediately on abort instead of still starting.
_cancelled_prefixes: set[str] = set()
@@ -38,15 +38,15 @@ def clear_scope(prefix: str) -> None:
def _scope_cancelled(agent_key: str) -> bool:
return any(agent_key.startswith(p) for p in _cancelled_prefixes)
# Deckelt die realen CLI-Prozesse — unabhängig von der Pipeline-Semaphore in
# generator.py. Acquire passiert VOR dem Spawn, damit Wartezeit in der Queue
# nicht gegen den Agent-Timeout zählt.
# Caps the real CLI processesindependent of the pipeline semaphore in
# generator.py. The acquire happens BEFORE the spawn so that queue wait time
# does not count against the agent timeout.
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
# OpenCode-Starts serialisieren: gleichzeitig startende Prozesse kollidieren an
# der internen Session-DB ("database is locked", Exit nach <1s). Der kurze
# Versatz entzerrt die Starts; danach laufen die Prozesse normal parallel.
# Serialize OpenCode starts: processes starting simultaneously collide on the
# internal session DB ("database is locked", exit after <1s). The short
# stagger spreads out the starts; afterwards the processes run in parallel normally.
_opencode_start_lock = asyncio.Lock()
_OPENCODE_START_DELAY = 1.0
@@ -58,7 +58,7 @@ _CLAUDE_TOOLS = {
"none": None,
}
# Capability → OpenCode-Agent (Tool-Rechte in dev-ops/opencode.json definiert)
# Capability → OpenCode agent (tool permissions defined in dev-ops/opencode.json)
_OPENCODE_AGENTS = {
"full": "full",
"files": "files",
@@ -86,8 +86,8 @@ def provider_available(provider: str) -> bool:
def _kill(process) -> None:
"""Killt den Agenten samt Kindprozessen über die Prozess-Gruppe (sonst überleben die
von der CLI gestarteten Kinder, halten die Pipes offen und blockieren communicate())."""
"""Kill the agent and its child processes via the process group (otherwise the
children spawned by the CLI survive, keep the pipes open and block communicate())."""
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
@@ -98,9 +98,9 @@ def _kill(process) -> None:
def kill_process(agent_key_prefix: str) -> None:
"""Killt alle aktiven Prozesse, deren Key mit dem Prefix beginnt (deckt -plan/-w1… ab)."""
"""Kill all active processes whose key starts with the prefix (covers -plan/-w1…)."""
for key, process in list(_active_processes.items()):
if process.returncode is not None: # tote Einträge beim Iterieren aufräumen
if process.returncode is not None: # clean up dead entries while iterating
_active_processes.pop(key, None)
continue
if key.startswith(agent_key_prefix):
@@ -117,16 +117,16 @@ async def run_agent(
capabilities: str = "none",
lane: str = "batch",
) -> tuple[int, str, str]:
if _scope_cancelled(agent_key): # vor dem Anstehen: gar nicht erst in die Schlange
return 1, "", "abgebrochen"
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
return 1, "", "cancelled"
if provider not in PROVIDERS:
return 1, "", f"Unbekannter Provider: {provider}"
return 1, "", f"Unknown provider: {provider}"
if shutil.which(PROVIDERS[provider]["cli"]) is None:
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
sem = _interactive_sem if lane == "interactive" else _batch_sem
async with sem:
if _scope_cancelled(agent_key): # nach dem Acquire: in der Schlange abgebrochen → kein Spawn
return 1, "", "abgebrochen"
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
return 1, "", "cancelled"
if PROVIDERS[provider]["cli"] == "opencode":
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities)
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
@@ -141,7 +141,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True, # eigene Prozess-Gruppe → killpg killt auch Kindprozesse
start_new_session=True, # own process group → killpg also kills child processes
)
if stagger:
@@ -163,16 +163,16 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
await asyncio.wait_for(process.wait(), timeout=5)
except asyncio.TimeoutError:
pass
log.info("agent %s: Timeout nach %ds", agent_key, timeout)
log.info("agent %s: timeout after %ds", agent_key, timeout)
raise
log.info(
"agent %s: exit %s nach %.1fs (%d Bytes stdout)",
"agent %s: exit %s after %.1fs (%d bytes stdout)",
agent_key, process.returncode, time.monotonic() - start, len(stdout),
)
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
finally:
# Pop nur bei Identität: ein Slot-Restart unter demselben Key darf den
# NEUEN Prozess nicht aus dem Tracking werfen.
# Pop only on identity: a slot restart under the same key must not evict
# the NEW process from tracking.
if _active_processes.get(agent_key) is process:
del _active_processes[agent_key]
@@ -189,12 +189,12 @@ async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str,
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, role: str, capabilities: str) -> tuple[int, str, str]:
cfg = PROVIDERS[provider]
# Prompt über Tempdatei statt argv (ARG_MAX-Schutz bei großen Projekt-Prompts)
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
f.write(prompt)
prompt_path = Path(f.name)
# Positional-Message MUSS vor -f stehen: -f ist ein Array-Flag und
# frisst sonst den Text als zweiten Dateinamen ("File not found").
# The positional message MUST come before -f: -f is an array flag and
# would otherwise eat the text as a second file name ("File not found").
cmd = [
cfg["cli"], "run",
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
@@ -214,7 +214,7 @@ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def _clean_opencode_output(text: str) -> str:
"""Entfernt ANSI-Codes und den führenden Banner ("> agent · modell")."""
"""Strip ANSI codes and the leading banner ("> agent · model")."""
text = _ANSI_RE.sub("", text)
lines = text.splitlines()
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):