Lokale Matrix liefert Ergebnisse: 119 Anforderungen aus Gemma und Qwen

Skill 13.0.0/13.1.0, Adapter 2.5.2.

Spiegel-Arbeitsverzeichnis: OpenCode laeuft nicht mehr direkt im
Codebasis-Root, sondern in einem Verzeichnis aus Junctions auf die
Top-Level-Eintraege, mit einem echten Ergebnisverzeichnis, dessen Inhalt
nach dem Lauf uebernommen wird. Damit landen relative wie absolute
Ausgabepfade am richtigen Ort, ohne dass der Prompt vom Wortlaut der
Claude-Laeufe abweichen muss. Der Spiegel liegt in _meta des Laufs; die
Codebasis bleibt unberuehrt.

Der Spiegel allein genuegte nicht. Sechs Laeufe schrieben mit korrektem
absolutem Pfad und wurden dennoch abgewiesen, weil OpenCode Ziele im
Repository gegen den worktree-relativen Pfad abgleicht - ein Befund, der
seit Skill 10.0.2 dokumentiert war und erst sichtbar wurde, als der
Spiegel vom Temp-Verzeichnis ins Projekt wanderte.

analyse-anforderungen.py erkennt Kennungen als Markdown-Ueberschrift, auch
ohne Feldnamen, sofern die Pflichtfelder folgen. Regressionsprobe an fuenf
Claude-Laeufen unveraendert.

Ergebnisse der gueltigen Laeufe: Qwen 3.5-9B liefert 107 der 119
Anforderungen, davon 75 im Modus custom mit nur drei Subagenten. Gemma
kommt auf 12. Der Modus builtin fiel bei beiden Modellen aus - drei von
drei Laeufen endeten nach einem Turn ohne einen Werkzeugaufruf. Die 13
Laeufe mit Adapter 2.3.0 bis 2.5.1 sind Artefakte der Fehlersuche und
als adapterbedingte Fehlmessungen gekennzeichnet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christoph Schwörer
2026-09-01 22:49:53 +02:00
co-authored by Claude Opus 5
parent 28e927013b
commit 6c5c26a2e4
416 changed files with 85993 additions and 13 deletions
@@ -27,6 +27,7 @@ import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
@@ -36,7 +37,7 @@ from datetime import datetime, timezone
from pathlib import Path
ADAPTER_VERSION = "2.2.0"
ADAPTER_VERSION = "2.5.2"
DEFAULT_PROVIDER = "tensorx"
PROVIDER_ID = DEFAULT_PROVIDER
PROVIDERS: dict[str, dict] = {
@@ -229,6 +230,114 @@ def readonly_shell_permissions() -> dict[str, str]:
return permissions
def _mklink(argument: str, verknuepfung: Path, ziel: Path) -> bool:
"""Windows-Verknuepfung anlegen; /J = Verzeichnis-Junction, /H = Hardlink."""
fertig = subprocess.run(
["cmd", "/c", "mklink", argument, str(verknuepfung), str(ziel)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
return fertig.returncode == 0
def erstelle_spiegel(root: Path, spiegel: Path, output_dir: Path, log) -> Path:
"""Arbeitsverzeichnis, das wie die Codebasis aussieht, aber beschreibbar ist.
Der Agent loest ``Ergebnisse/StRS.md`` relativ zu seinem Arbeitsverzeichnis
auf. Steht dort die eingefrorene Codebasis, geht jeder solche Schreibversuch
ins Leere - gemessen an sechs Laeufen, die kein einziges Artefakt ablegten,
obwohl sie die richtigen Dateinamen erzeugten.
Statt die Codebasis zu kopieren (ueber zehn Minuten fuer 24.663 Dateien)
entsteht ein Spiegel aus Verknuepfungen: je eine Junction auf jeden
Top-Level-Ordner, je ein Hardlink auf jede Top-Level-Datei, und ``Ergebnisse``
als Junction auf das echte Laufverzeichnis. Der Spiegel ist damit in
Sekunden angelegt, sieht fuer den Agenten aus wie die Codebasis, und
``Ergebnisse/...`` landet ohne Zutun am richtigen Ort.
Die Codebasis selbst bleibt unberuehrt - die Verknuepfungen liegen im
Spiegel, nicht im Snapshot. Schreibzugriffe *durch* eine Junction bleiben
ueber die Editierrechte gesperrt; die belastbare Kontrolle ist wie bisher
der Vorher/Nachher-Vergleich per ``git status``.
"""
if spiegel.exists():
entferne_spiegel(spiegel, log)
spiegel.mkdir(parents=True)
ordner = dateien = 0
for eintrag in sorted(root.iterdir()):
ziel = spiegel / eintrag.name
if eintrag.is_dir():
if _mklink("/J", ziel, eintrag):
ordner += 1
elif _mklink("/H", ziel, eintrag):
dateien += 1
else: # Hardlinks scheitern ueber Laufwerksgrenzen hinweg.
shutil.copy2(eintrag, ziel)
dateien += 1
# Bewusst ein **echtes** Verzeichnis, keine Junction: Zeigt `Ergebnisse`
# per Junction nach aussen, weist OpenCode jeden Schreibvorgang ab - auch
# dann, wenn die passenden Muster (relativ, kanonisch, ueber den Spiegel)
# allesamt freigegeben sind. Nachgewiesen an Lauf v13.0.0-9b01, dessen
# Fehlermeldung genau diese Regeln auflistet und den Aufruf dennoch
# verweigert. Als echtes Verzeichnis innerhalb des Arbeitsverzeichnisses
# entfaellt die Sonderbehandlung; der Inhalt wird nach dem Lauf umgezogen.
ergebnisse = spiegel / output_dir.name
ergebnisse.mkdir()
log(
f"Spiegel angelegt: {spiegel} ({ordner} Junctions, {dateien} Dateien, "
f"{output_dir.name} -> {output_dir})"
)
return spiegel
def uebernimm_ergebnisse(spiegel: Path, output_dir: Path, log) -> int:
"""Erzeugte Dateien aus dem Spiegel ins Laufverzeichnis uebernehmen."""
quelle = spiegel / output_dir.name
if not quelle.is_dir():
return 0
anzahl = 0
for pfad in sorted(quelle.rglob("*")):
if not pfad.is_file():
continue
ziel = output_dir / pfad.relative_to(quelle)
ziel.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(pfad), str(ziel))
anzahl += 1
log(f"{anzahl} Ergebnisdatei(en) aus dem Spiegel uebernommen")
return anzahl
def entferne_spiegel(spiegel: Path, log) -> None:
"""Spiegel abbauen, ohne durch die Verknuepfungen hindurch zu loeschen.
``shutil.rmtree`` wuerde Junctions unter Umstaenden verfolgen und damit die
Codebasis loeschen. Deshalb wird jeder Eintrag einzeln behandelt: Junctions
per ``os.rmdir`` (entfernt nur die Verknuepfung), echte Dateien per
``unlink``.
"""
if not spiegel.exists():
return
for eintrag in spiegel.iterdir():
try:
if eintrag.is_dir():
# Junctions loest os.rmdir nur auf; ein echtes (leeres)
# Ergebnisverzeichnis verschwindet damit ebenfalls.
os.rmdir(eintrag)
else:
eintrag.unlink()
except OSError as exc:
log(f"Spiegeleintrag {eintrag} nicht entfernbar: {exc}")
try:
spiegel.rmdir()
except OSError as exc:
log(f"Spiegelverzeichnis {spiegel} nicht entfernbar: {exc}")
def task_permissions(mode: str, custom_names: list[str]) -> str | dict[str, str]:
if mode == "solo":
return "deny"
@@ -265,6 +374,7 @@ def build_run_config(
agents_file: Path | None,
provider: str = DEFAULT_PROVIDER,
context_limit: int | None = None,
zusatz_ausgaben: list[Path] | None = None,
) -> dict:
config = copy.deepcopy(base_config)
provider_config = config.setdefault("provider", {}).setdefault(provider, {})
@@ -283,6 +393,36 @@ def build_run_config(
output_dir,
git_worktree_root(root),
)
# Beim Spiegel-Arbeitsverzeichnis zeigt `Ergebnisse` per Junction auf das
# Laufverzeichnis. Das Dateisystem loest das korrekt auf - OpenCode prueft
# die Berechtigung aber gegen den *Pfadstring* des Werkzeugaufrufs, also
# gegen "Ergebnisse/StRS.md" relativ zum Arbeitsverzeichnis. Ohne dieses
# Muster wird der Schreibvorgang abgewiesen, obwohl das Ziel erlaubt ist.
#
# Bewusst ohne `resolve()`: Unter Windows folgt das der Junction und liefert
# wieder den kanonischen Laufpfad - genau die relative Form, auf die es hier
# ankommt, ginge dabei verloren.
worktree = git_worktree_root(root)
for zusatz in zusatz_ausgaben or []:
formen = [zusatz.as_posix()]
try:
formen.append(zusatz.relative_to(root).as_posix())
except ValueError:
pass
# Liegt der Spiegel innerhalb des Arbeitsrepositories, prueft OpenCode
# gegen den Pfad relativ zur Git-Worktree-Wurzel - derselbe Befund, der
# schon Skill 10.0.2 ausgeloest hat. Ohne diese Form wird ein Ziel
# abgewiesen, obwohl kanonische und relative Form freigegeben sind.
if worktree is not None:
try:
formen.append(zusatz.relative_to(worktree).as_posix())
except ValueError:
pass
for form in formen:
normalisiert = form.rstrip("/")
for muster in (normalisiert, normalisiert + "/**"):
if muster not in output_patterns:
output_patterns.append(muster)
edit_permissions = {"*": "deny"}
edit_permissions.update({pattern: "allow" for pattern in output_patterns})
custom_agents: dict[str, dict] = {}
@@ -967,6 +1107,28 @@ def main() -> int:
"1 und verliert bei mehr."
),
)
parser.add_argument(
"--spiegel-basis",
default=None,
help=(
"Basisverzeichnis fuer das Spiegel-Arbeitsverzeichnis; Standard ist "
"'_meta' im Laufverzeichnis. Damit bleibt der Lauf selbstenthalten "
"und alle Artefakte liegen unterhalb der Versuchsreihe. Ein Spiegel "
"im Benutzerprofil (%TEMP%) waere zudem dem Zugriffsbereich von "
"Virenschutz und Controlled Folder Access ausgesetzt."
),
)
parser.add_argument(
"--arbeitsverzeichnis",
default="root",
choices=("root", "spiegel"),
help=(
"root: OpenCode arbeitet direkt im Codebasis-Root (bisheriges "
"Verhalten). spiegel: ein Verzeichnis aus Verknuepfungen auf die "
"Codebasis, in dem 'Ergebnisse/' auf das Laufverzeichnis zeigt - "
"loest relative Ausgabepfade auf, ohne den Snapshot zu beruehren."
),
)
parser.add_argument(
"--lmstudio-context",
default="max",
@@ -1081,6 +1243,21 @@ def main() -> int:
sys.stderr.write(line + "\n")
sys.stderr.flush()
spiegel: Path | None = None
arbeitswurzel = root
if args.arbeitsverzeichnis == "spiegel":
# resolve() ist hier wesentlich: Unter Windows liefern sowohl
# tempfile.gettempdir() als auch verkuerzte Nutzerpfade die
# 8.3-Kurzform ("C:/Users/CHRIST~1/..."), OpenCode prueft seine Regeln
# aber gegen den aufgeloesten Langpfad. Ohne die Aufloesung passt kein
# absolutes Freigabemuster.
basis = (
Path(args.spiegel_basis).resolve() if args.spiegel_basis else meta_dir
)
basis.mkdir(parents=True, exist_ok=True)
spiegel = basis / "spiegel"
arbeitswurzel = erstelle_spiegel(root, spiegel, output_dir, log)
model_ref, upstream_model = normalize_model(args.model, provider)
base_config = json.loads(template_path.read_text(encoding="utf-8-sig"))
@@ -1114,11 +1291,12 @@ def main() -> int:
model_ref,
upstream_model,
args.mode,
root,
arbeitswurzel,
output_dir,
agents_file,
provider=provider,
context_limit=context_limit,
zusatz_ausgaben=[spiegel / output_dir.name] if spiegel else None,
)
config_path.write_text(
json.dumps(run_config, indent=2, ensure_ascii=False), encoding="utf-8"
@@ -1140,7 +1318,7 @@ def main() -> int:
"--title",
args.title,
"--dir",
str(root),
str(arbeitswurzel),
]
effort_applied = args.effort in variants
if effort_applied:
@@ -1171,7 +1349,7 @@ def main() -> int:
)
process = subprocess.Popen(
command,
cwd=root,
cwd=arbeitswurzel,
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
@@ -1262,11 +1440,13 @@ def main() -> int:
exit_code = process.wait(timeout=5)
duration_s = time.monotonic() - start_time
if spiegel is not None:
uebernimm_ergebnisse(spiegel, output_dir, log)
session = None
if session_id:
try:
session = export_session(
opencode, session_id, env, root, session_path, log
opencode, session_id, env, arbeitswurzel, session_path, log
)
except Exception as exc: # Sessionexport darf RawResult nicht verhindern.
errors.append(f"Sessionexport fehlgeschlagen: {exc}")
@@ -1295,6 +1475,8 @@ def main() -> int:
result["is_error"] = True
if result["subtype"] == "success":
result["subtype"] = "error"
result["arbeitsverzeichnis"] = args.arbeitsverzeichnis
result["arbeitswurzel"] = str(arbeitswurzel)
result["start_time"] = start_iso
result["end_time"] = utc_now()
result["opencode_path"] = str(opencode)
@@ -1302,6 +1484,9 @@ def main() -> int:
raw_result_path.write_text(
json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8"
)
if spiegel is not None:
entferne_spiegel(spiegel, log)
log(
f"Ende: Exitcode={exit_code}; Status={result['subtype']}; "
f"Turns={result['num_turns']}; Tokens={result['usage']['total_tokens']}; "