update
This commit is contained in:
@@ -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 processes — independent 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(">")):
|
||||
|
||||
Reference in New Issue
Block a user