Add OpenCode TensorX experiment adapter
This commit is contained in:
@@ -0,0 +1,727 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless-Adapter fuer TensorX-Versuchslaeufe ueber OpenCode.
|
||||
|
||||
OpenCode verwaltet Provider-Credentials und Agentensitzungen. Dieser Wrapper
|
||||
erzeugt pro Lauf eine isolierte OpenCode-Konfiguration, streamt JSON-Ereignisse
|
||||
direkt in den Laufordner und normalisiert die Session nach RawResult.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ADAPTER_VERSION = "1.0.2"
|
||||
PROVIDER_ID = "tensorx"
|
||||
EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
||||
MODES = ("solo", "builtin", "custom")
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def resolve_opencode(explicit: str | None = None) -> Path:
|
||||
candidates: list[Path] = []
|
||||
if explicit:
|
||||
candidates.append(Path(explicit))
|
||||
|
||||
which_exe = shutil.which("opencode.exe")
|
||||
if which_exe:
|
||||
candidates.append(Path(which_exe))
|
||||
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if appdata:
|
||||
candidates.append(
|
||||
Path(appdata)
|
||||
/ "npm"
|
||||
/ "node_modules"
|
||||
/ "opencode-ai"
|
||||
/ "bin"
|
||||
/ "opencode.exe"
|
||||
)
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate.resolve()
|
||||
raise FileNotFoundError(
|
||||
"OpenCode nicht gefunden. Erwartet wird 'opencode.exe' im PATH oder "
|
||||
"die npm-Installation 'npm install -g opencode-ai'."
|
||||
)
|
||||
|
||||
|
||||
def normalize_model(model: str) -> tuple[str, str]:
|
||||
if model.startswith(f"{PROVIDER_ID}/"):
|
||||
upstream = model[len(PROVIDER_ID) + 1 :]
|
||||
return model, upstream
|
||||
return f"{PROVIDER_ID}/{model}", model
|
||||
|
||||
|
||||
def normalized_path(path: Path) -> str:
|
||||
return path.resolve().as_posix()
|
||||
|
||||
|
||||
def git_worktree_root(root: Path) -> Path | None:
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(root), "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0 or not completed.stdout.strip():
|
||||
return None
|
||||
candidate = Path(completed.stdout.strip()).resolve()
|
||||
return candidate if candidate.is_dir() else None
|
||||
|
||||
|
||||
def output_permission_patterns(
|
||||
root: Path,
|
||||
output_dir: Path,
|
||||
worktree_root: Path | None = None,
|
||||
) -> list[str]:
|
||||
"""Return both canonical and worktree-relative patterns used by OpenCode.
|
||||
|
||||
OpenCode matches paths inside the active worktree as location-relative resources,
|
||||
even when a tool call supplied an absolute Windows path. That relative resource may
|
||||
contain ``..`` when the active location is a worktree subdirectory. OpenCode may instead
|
||||
use a path relative to the Git worktree root, so that form is included when available.
|
||||
Truly external outputs are matched canonically. Both the directory itself and descendants
|
||||
are allowed.
|
||||
"""
|
||||
root = root.resolve()
|
||||
output_dir = output_dir.resolve()
|
||||
bases = [output_dir.as_posix()]
|
||||
try:
|
||||
bases.insert(0, output_dir.relative_to(root).as_posix())
|
||||
except ValueError:
|
||||
try:
|
||||
bases.insert(0, Path(os.path.relpath(output_dir, root)).as_posix())
|
||||
except ValueError: # Verschiedene Windows-Laufwerke.
|
||||
pass
|
||||
if worktree_root is not None:
|
||||
try:
|
||||
bases.insert(0, output_dir.relative_to(worktree_root.resolve()).as_posix())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
patterns: list[str] = []
|
||||
for base in bases:
|
||||
normalized = base.rstrip("/")
|
||||
for pattern in (normalized, normalized + "/**"):
|
||||
if pattern not in patterns:
|
||||
patterns.append(pattern)
|
||||
return patterns
|
||||
|
||||
|
||||
def readonly_shell_permissions() -> dict[str, str]:
|
||||
return {
|
||||
"*": "deny",
|
||||
"rg *": "allow",
|
||||
"git status*": "allow",
|
||||
"git ls-files*": "allow",
|
||||
"git rev-parse*": "allow",
|
||||
"Get-ChildItem *": "allow",
|
||||
"Get-Content *": "allow",
|
||||
"Select-String *": "allow",
|
||||
"Test-Path *": "allow",
|
||||
"Resolve-Path *": "allow",
|
||||
"where.exe *": "allow",
|
||||
}
|
||||
|
||||
|
||||
def task_permissions(mode: str, custom_names: list[str]) -> str | dict[str, str]:
|
||||
if mode == "solo":
|
||||
return "deny"
|
||||
if mode == "builtin":
|
||||
return {
|
||||
"*": "deny",
|
||||
"general": "allow",
|
||||
"explore": "allow",
|
||||
}
|
||||
permissions = {"*": "deny"}
|
||||
permissions.update({name: "allow" for name in custom_names})
|
||||
return permissions
|
||||
|
||||
|
||||
def load_custom_agents(path: Path) -> dict[str, dict]:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(data, dict) or not data:
|
||||
raise ValueError("Agentendatei muss ein nicht-leeres JSON-Objekt sein")
|
||||
for name, definition in data.items():
|
||||
if not isinstance(definition, dict):
|
||||
raise ValueError(f"Agent '{name}' ist kein JSON-Objekt")
|
||||
if not definition.get("description") or not definition.get("prompt"):
|
||||
raise ValueError(f"Agent '{name}' benoetigt description und prompt")
|
||||
return data
|
||||
|
||||
|
||||
def build_run_config(
|
||||
base_config: dict,
|
||||
model_ref: str,
|
||||
upstream_model: str,
|
||||
mode: str,
|
||||
root: Path,
|
||||
output_dir: Path,
|
||||
agents_file: Path | None,
|
||||
) -> dict:
|
||||
config = copy.deepcopy(base_config)
|
||||
provider = config.setdefault("provider", {}).setdefault(PROVIDER_ID, {})
|
||||
models = provider.setdefault("models", {})
|
||||
if upstream_model not in models:
|
||||
models[upstream_model] = {"name": upstream_model}
|
||||
|
||||
config["model"] = model_ref
|
||||
output_patterns = output_permission_patterns(
|
||||
root,
|
||||
output_dir,
|
||||
git_worktree_root(root),
|
||||
)
|
||||
edit_permissions = {"*": "deny"}
|
||||
edit_permissions.update({pattern: "allow" for pattern in output_patterns})
|
||||
custom_agents: dict[str, dict] = {}
|
||||
if mode == "custom":
|
||||
if agents_file is None:
|
||||
raise ValueError("Modus custom erfordert --agents")
|
||||
custom_agents = load_custom_agents(agents_file)
|
||||
|
||||
config["permission"] = {
|
||||
"*": "deny",
|
||||
"read": "allow",
|
||||
"glob": "allow",
|
||||
"grep": "allow",
|
||||
"list": "allow",
|
||||
"edit": edit_permissions,
|
||||
"external_directory": copy.deepcopy(edit_permissions),
|
||||
"bash": readonly_shell_permissions(),
|
||||
"task": task_permissions(mode, list(custom_agents)),
|
||||
"webfetch": "deny",
|
||||
"websearch": "deny",
|
||||
"skill": "deny",
|
||||
"question": "deny",
|
||||
}
|
||||
|
||||
agents = config.setdefault("agent", {})
|
||||
agents["build"] = {"model": model_ref, "mode": "primary"}
|
||||
agents["general"] = {"model": model_ref, "mode": "subagent"}
|
||||
agents["explore"] = {"model": model_ref, "mode": "subagent"}
|
||||
|
||||
child_permission = {
|
||||
"edit": "deny",
|
||||
"task": "deny",
|
||||
"webfetch": "deny",
|
||||
"websearch": "deny",
|
||||
"skill": "deny",
|
||||
"bash": readonly_shell_permissions(),
|
||||
}
|
||||
for name, definition in custom_agents.items():
|
||||
agents[name] = {
|
||||
"description": definition["description"],
|
||||
"mode": "subagent",
|
||||
"model": model_ref,
|
||||
"prompt": definition["prompt"],
|
||||
"permission": child_permission,
|
||||
}
|
||||
|
||||
config["default_agent"] = "build"
|
||||
return config
|
||||
|
||||
|
||||
def terminate_process_tree(process: subprocess.Popen) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
if os.name == "nt":
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
else:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
|
||||
def stream_reader(stream, source: str, sink: Path, updates: queue.Queue) -> None:
|
||||
with sink.open("a", encoding="utf-8", newline="") as handle:
|
||||
for line in iter(stream.readline, ""):
|
||||
handle.write(line)
|
||||
handle.flush()
|
||||
updates.put((source, line, time.monotonic()))
|
||||
stream.close()
|
||||
updates.put((source, None, time.monotonic()))
|
||||
|
||||
|
||||
def parse_event(line: str) -> dict | None:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return event if isinstance(event, dict) else None
|
||||
|
||||
|
||||
def json_from_mixed_output(text: str) -> dict | None:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
if start < 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text[start:])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def export_session(
|
||||
opencode: Path,
|
||||
session_id: str,
|
||||
env: dict[str, str],
|
||||
root: Path,
|
||||
destination: Path,
|
||||
log,
|
||||
) -> dict | None:
|
||||
completed = subprocess.run(
|
||||
[str(opencode), "export", session_id, "--pure"],
|
||||
cwd=root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
if completed.stderr.strip():
|
||||
log("OpenCode export: " + completed.stderr.strip())
|
||||
data = json_from_mixed_output(completed.stdout)
|
||||
if data is not None:
|
||||
destination.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def collect_written_files(output_dir: Path) -> list[dict]:
|
||||
if not output_dir.is_dir():
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"path": str(path.relative_to(output_dir)),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
for path in sorted(output_dir.rglob("*"))
|
||||
if path.is_file()
|
||||
]
|
||||
|
||||
|
||||
def normalize_result(
|
||||
session: dict | None,
|
||||
events: list[dict],
|
||||
model_ref: str,
|
||||
mode: str,
|
||||
effort: str,
|
||||
exit_code: int,
|
||||
timed_out: bool,
|
||||
interrupted: bool,
|
||||
duration_s: float,
|
||||
output_dir: Path,
|
||||
errors: list[str],
|
||||
) -> dict:
|
||||
info = (session or {}).get("info", {})
|
||||
messages = (session or {}).get("messages", [])
|
||||
assistants = [m for m in messages if m.get("info", {}).get("role") == "assistant"]
|
||||
tools: list[dict] = []
|
||||
result_text = ""
|
||||
finish_reason = ""
|
||||
for message in assistants:
|
||||
finish_reason = message.get("info", {}).get("finish", finish_reason)
|
||||
for part in message.get("parts", []):
|
||||
if part.get("type") == "text" and part.get("text"):
|
||||
result_text = part["text"]
|
||||
if part.get("type") == "tool":
|
||||
state = part.get("state", {})
|
||||
tools.append(
|
||||
{
|
||||
"name": part.get("tool", ""),
|
||||
"status": state.get("status", ""),
|
||||
"input": state.get("input", {}),
|
||||
"title": state.get("title", ""),
|
||||
}
|
||||
)
|
||||
|
||||
tokens = info.get("tokens", {})
|
||||
cache = tokens.get("cache", {})
|
||||
input_tokens = int(tokens.get("input", 0) or 0)
|
||||
output_tokens = int(tokens.get("output", 0) or 0)
|
||||
reasoning_tokens = int(tokens.get("reasoning", 0) or 0)
|
||||
cache_read = int(cache.get("read", 0) or 0)
|
||||
cache_write = int(cache.get("write", 0) or 0)
|
||||
total_tokens = int(tokens.get("total", 0) or 0)
|
||||
if total_tokens == 0:
|
||||
total_tokens = (
|
||||
input_tokens
|
||||
+ output_tokens
|
||||
+ reasoning_tokens
|
||||
+ cache_read
|
||||
+ cache_write
|
||||
)
|
||||
|
||||
model_info = info.get("model", {})
|
||||
reported_model = model_info.get("id") or model_ref.split("/", 1)[-1]
|
||||
task_calls = [tool for tool in tools if tool["name"] in ("task", "subagent")]
|
||||
subagent_details = [
|
||||
{
|
||||
"id": index,
|
||||
"type": call.get("input", {}).get("subagent_type")
|
||||
or call.get("input", {}).get("agent")
|
||||
or call.get("input", {}).get("type", ""),
|
||||
"description": call.get("input", {}).get("description")
|
||||
or call.get("input", {}).get("prompt", ""),
|
||||
"status": call.get("status", ""),
|
||||
}
|
||||
for index, call in enumerate(task_calls, start=1)
|
||||
]
|
||||
by_type = Counter(detail["type"] for detail in subagent_details if detail["type"])
|
||||
completed_subagents = sum(
|
||||
1 for detail in subagent_details if detail["status"] == "completed"
|
||||
)
|
||||
failed_subagents = len(subagent_details) - completed_subagents
|
||||
|
||||
aborted = timed_out or interrupted
|
||||
is_error = exit_code != 0 or aborted or bool(errors)
|
||||
subtype = "aborted" if aborted else ("error" if is_error else "success")
|
||||
event_counts = Counter(event.get("type", "unknown") for event in events)
|
||||
usage = {
|
||||
"prompt_tokens": input_tokens,
|
||||
"completion_tokens": output_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"cached_tokens": cache_read,
|
||||
"cache_read_tokens": cache_read,
|
||||
"cache_creation_tokens": cache_write,
|
||||
"reasoning_tokens": reasoning_tokens,
|
||||
"output_tokens_details": {"thinking_tokens": reasoning_tokens},
|
||||
}
|
||||
return {
|
||||
"is_error": is_error,
|
||||
"subtype": subtype,
|
||||
"duration_ms": int(duration_s * 1000),
|
||||
"duration_api_ms": 0,
|
||||
"num_turns": len(assistants)
|
||||
or sum(1 for event in events if event.get("type") == "step_finish"),
|
||||
"model": reported_model,
|
||||
"model_requested": model_ref.split("/", 1)[-1],
|
||||
"provider": PROVIDER_ID,
|
||||
"effort": effort,
|
||||
"usage": usage,
|
||||
"modelUsage": {
|
||||
reported_model: {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"cache_read_input_tokens": cache_read,
|
||||
"cache_creation_input_tokens": cache_write,
|
||||
"reasoning_tokens": reasoning_tokens,
|
||||
}
|
||||
},
|
||||
"cost": info.get("cost", 0),
|
||||
"tool_calls": tools,
|
||||
"tool_call_count": len(tools),
|
||||
"tool_call_types": dict(Counter(tool["name"] for tool in tools)),
|
||||
"event_counts": dict(event_counts),
|
||||
"written_files": collect_written_files(output_dir),
|
||||
"result": result_text,
|
||||
"finish_reason": finish_reason,
|
||||
"errors": errors,
|
||||
"session_id": info.get("id", ""),
|
||||
"adapter": "opencode-tensorx",
|
||||
"adapter_version": ADAPTER_VERSION,
|
||||
"opencode_version": info.get("version", ""),
|
||||
"mode": mode,
|
||||
"subagent_stats": {
|
||||
"spawned": len(subagent_details),
|
||||
"completed": completed_subagents,
|
||||
"failed": failed_subagents,
|
||||
"by_type": dict(by_type),
|
||||
},
|
||||
"subagent_details": subagent_details,
|
||||
"timed_out": timed_out,
|
||||
"interrupted": interrupted,
|
||||
"exit_code": exit_code,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="TensorX-Versuchslauf ueber OpenCode"
|
||||
)
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--root", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--model", required=True)
|
||||
parser.add_argument("--effort", default="low", choices=EFFORTS)
|
||||
parser.add_argument("--mode", default="solo", choices=MODES)
|
||||
parser.add_argument("--agents")
|
||||
parser.add_argument("--result-dir")
|
||||
parser.add_argument("--opencode")
|
||||
parser.add_argument("--config-template")
|
||||
parser.add_argument(
|
||||
"--stall-timeout",
|
||||
type=int,
|
||||
default=600,
|
||||
help="Sekunden ohne stdout/stderr bis zum Abbruch; 0 deaktiviert",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-runtime",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Maximale Gesamtlaufzeit in Sekunden; 0 deaktiviert",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-empty-output",
|
||||
action="store_true",
|
||||
help="Leeres Ergebnisse-Verzeichnis nicht als Fehler werten (nur Smoke-Tests)",
|
||||
)
|
||||
parser.add_argument("--title", default="run-experiment TensorX")
|
||||
args = parser.parse_args()
|
||||
|
||||
prompt_path = Path(args.prompt).resolve()
|
||||
root = Path(args.root).resolve()
|
||||
output_dir = Path(args.output).resolve()
|
||||
result_dir = Path(args.result_dir).resolve() if args.result_dir else output_dir.parent
|
||||
agents_file = Path(args.agents).resolve() if args.agents else None
|
||||
template_path = (
|
||||
Path(args.config_template).resolve()
|
||||
if args.config_template
|
||||
else Path(__file__).with_name("opencode-tensorx.json")
|
||||
)
|
||||
if not prompt_path.is_file():
|
||||
parser.error(f"Prompt-Datei fehlt: {prompt_path}")
|
||||
if not root.is_dir():
|
||||
parser.error(f"Root-Verzeichnis fehlt: {root}")
|
||||
if not template_path.is_file():
|
||||
parser.error(f"OpenCode-Konfiguration fehlt: {template_path}")
|
||||
|
||||
opencode = resolve_opencode(args.opencode)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
result_dir.mkdir(parents=True, exist_ok=True)
|
||||
meta_dir = result_dir / "_meta"
|
||||
meta_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
events_path = result_dir / "OpenCodeEvents.jsonl"
|
||||
stderr_path = result_dir / "OpenCode.log"
|
||||
adapter_log_path = result_dir / "Adapter.log"
|
||||
config_path = meta_dir / "opencode-config.json"
|
||||
session_path = meta_dir / "opencode-session.json"
|
||||
raw_result_path = result_dir / "RawResult.json"
|
||||
|
||||
for path in (events_path, stderr_path, adapter_log_path):
|
||||
path.write_text("", encoding="utf-8")
|
||||
|
||||
def log(message: str) -> None:
|
||||
line = f"[{utc_now()}] {message}"
|
||||
with adapter_log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
handle.flush()
|
||||
sys.stderr.write(line + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
model_ref, upstream_model = normalize_model(args.model)
|
||||
base_config = json.loads(template_path.read_text(encoding="utf-8-sig"))
|
||||
run_config = build_run_config(
|
||||
base_config,
|
||||
model_ref,
|
||||
upstream_model,
|
||||
args.mode,
|
||||
root,
|
||||
output_dir,
|
||||
agents_file,
|
||||
)
|
||||
config_path.write_text(
|
||||
json.dumps(run_config, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
model_config = run_config["provider"][PROVIDER_ID]["models"][upstream_model]
|
||||
variants = model_config.get("variants", {})
|
||||
command = [
|
||||
str(opencode),
|
||||
"run",
|
||||
"--pure",
|
||||
"--auto",
|
||||
"--format",
|
||||
"json",
|
||||
"--model",
|
||||
model_ref,
|
||||
"--agent",
|
||||
"build",
|
||||
"--title",
|
||||
args.title,
|
||||
"--dir",
|
||||
str(root),
|
||||
]
|
||||
if args.effort in variants:
|
||||
command.extend(["--variant", args.effort])
|
||||
|
||||
env = os.environ.copy()
|
||||
env["OPENCODE_CONFIG"] = str(config_path)
|
||||
prompt_text = prompt_path.read_text(encoding="utf-8-sig")
|
||||
start_time = time.monotonic()
|
||||
start_iso = utc_now()
|
||||
timed_out = False
|
||||
interrupted = False
|
||||
events: list[dict] = []
|
||||
errors: list[str] = []
|
||||
session_id = ""
|
||||
exit_code = -1
|
||||
|
||||
log(
|
||||
f"Start OpenCode {opencode}; Modell={model_ref}; Modus={args.mode}; "
|
||||
f"Effort={args.effort}; Stall-Timeout={args.stall_timeout}s"
|
||||
)
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=root,
|
||||
env=env,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
)
|
||||
assert process.stdin is not None
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
process.stdin.write(prompt_text)
|
||||
process.stdin.close()
|
||||
|
||||
updates: queue.Queue = queue.Queue()
|
||||
threads = [
|
||||
threading.Thread(
|
||||
target=stream_reader,
|
||||
args=(process.stdout, "stdout", events_path, updates),
|
||||
daemon=True,
|
||||
),
|
||||
threading.Thread(
|
||||
target=stream_reader,
|
||||
args=(process.stderr, "stderr", stderr_path, updates),
|
||||
daemon=True,
|
||||
),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
last_activity = time.monotonic()
|
||||
closed_streams = 0
|
||||
try:
|
||||
while process.poll() is None or closed_streams < 2:
|
||||
try:
|
||||
source, line, activity_time = updates.get(timeout=1)
|
||||
last_activity = activity_time
|
||||
if line is None:
|
||||
closed_streams += 1
|
||||
continue
|
||||
if source == "stdout":
|
||||
event = parse_event(line)
|
||||
if event:
|
||||
events.append(event)
|
||||
session_id = event.get("sessionID", session_id)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
now = time.monotonic()
|
||||
if args.stall_timeout > 0 and now - last_activity > args.stall_timeout:
|
||||
timed_out = True
|
||||
errors.append(
|
||||
f"Keine OpenCode-Ausgabe seit {args.stall_timeout} Sekunden"
|
||||
)
|
||||
log(errors[-1] + "; Prozessbaum wird beendet")
|
||||
terminate_process_tree(process)
|
||||
if args.max_runtime > 0 and now - start_time > args.max_runtime:
|
||||
timed_out = True
|
||||
errors.append(
|
||||
f"Maximale Laufzeit von {args.max_runtime} Sekunden ueberschritten"
|
||||
)
|
||||
log(errors[-1] + "; Prozessbaum wird beendet")
|
||||
terminate_process_tree(process)
|
||||
except KeyboardInterrupt:
|
||||
interrupted = True
|
||||
errors.append("Lauf durch Benutzer unterbrochen")
|
||||
log(errors[-1] + "; Prozessbaum wird beendet")
|
||||
terminate_process_tree(process)
|
||||
finally:
|
||||
for thread in threads:
|
||||
thread.join(timeout=5)
|
||||
try:
|
||||
exit_code = process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
terminate_process_tree(process)
|
||||
exit_code = process.wait(timeout=5)
|
||||
|
||||
duration_s = time.monotonic() - start_time
|
||||
session = None
|
||||
if session_id:
|
||||
try:
|
||||
session = export_session(
|
||||
opencode, session_id, env, root, session_path, log
|
||||
)
|
||||
except Exception as exc: # Sessionexport darf RawResult nicht verhindern.
|
||||
errors.append(f"Sessionexport fehlgeschlagen: {exc}")
|
||||
log(errors[-1])
|
||||
|
||||
if exit_code != 0 and not timed_out and not interrupted:
|
||||
errors.append(f"OpenCode beendete sich mit Exitcode {exit_code}")
|
||||
result = normalize_result(
|
||||
session,
|
||||
events,
|
||||
model_ref,
|
||||
args.mode,
|
||||
args.effort,
|
||||
exit_code,
|
||||
timed_out,
|
||||
interrupted,
|
||||
duration_s,
|
||||
output_dir,
|
||||
errors,
|
||||
)
|
||||
if not args.allow_empty_output and not result["written_files"]:
|
||||
result["errors"].append("Ergebnisse-Verzeichnis ist leer")
|
||||
result["is_error"] = True
|
||||
if result["subtype"] == "success":
|
||||
result["subtype"] = "error"
|
||||
result["start_time"] = start_iso
|
||||
result["end_time"] = utc_now()
|
||||
result["opencode_path"] = str(opencode)
|
||||
result["config_path"] = str(config_path)
|
||||
raw_result_path.write_text(
|
||||
json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
log(
|
||||
f"Ende: Exitcode={exit_code}; Status={result['subtype']}; "
|
||||
f"Turns={result['num_turns']}; Tokens={result['usage']['total_tokens']}; "
|
||||
f"Dateien={len(result['written_files'])}; RawResult={raw_result_path}"
|
||||
)
|
||||
return 1 if result["is_error"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user