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:
co-authored by
Claude Opus 5
parent
28e927013b
commit
6c5c26a2e4
@@ -2,7 +2,7 @@
|
||||
name: run-experiment
|
||||
description: Führt einen Versuchs-Prompt aus einer Prompt-Datei als messbaren Headless-Lauf mit Claude Code, Codex CLI oder OpenCode (TensorX oder lokales LM Studio) aus und schreibt ein Messprotokoll mit Start-/Endzeit, Modell, Tokenverbrauch und weiteren Metriken. Verwenden bei "/run-experiment <Pfad-zur-Prompt-Datei>" oder wenn der User einen Versuch/ein Experiment ausführen und tracken will.
|
||||
argument-hint: <Pfad zur Prompt-Datei> <Root-Verzeichnis>
|
||||
version: 12.2.0
|
||||
version: 13.1.0
|
||||
---
|
||||
|
||||
# RunExperiment – Versuchslauf mit Messprotokoll
|
||||
@@ -1354,6 +1354,8 @@ der Historie unten – im selben Arbeitsschritt.
|
||||
|
||||
| Version | Änderung | Grund | Verwendet in |
|
||||
|---|---|---|---|
|
||||
| **13.1.0** | **Der Adapter ergaenzt fuer den Spiegel auch die worktree-relative Freigabeform; `analyse-anforderungen.py` erkennt Kennungen als Markdown-Ueberschrift.** Das Spiegel-Arbeitsverzeichnis liegt jetzt standardmaessig in `_meta` des Laufs (`--spiegel-basis`), `Ergebnisse` ist dort ein echtes Verzeichnis, dessen Inhalt nach dem Lauf uebernommen wird. Adapter-Version 2.5.2. | Der Spiegel allein genuegte nicht: Sechs Laeufe schrieben mit korrektem absolutem Pfad und wurden dennoch abgewiesen. Ursache war der bereits in 10.0.2 festgehaltene Befund - **OpenCode gleicht Ziele innerhalb des Repositories gegen den worktree-relativen Pfad ab**. Solange der Spiegel unter `%TEMP%` lag, griff diese Regel nicht und der Zusammenhang blieb unsichtbar; erst die Verlagerung ins Projekt machte ihn erkennbar. Beim Auswerten zeigte sich zudem, dass korrekt gefuellte Bloecke unerkannt blieben, wenn die Kennung als Ueberschrift ohne Feldnamen gesetzt war (`### StRS-001`). Sie wird nur dann als ID gewertet, wenn die Pflichtfelder unmittelbar folgen - sonst zaehlte jede Zwischenueberschrift als Anforderung. Regressionsprobe an fuenf Claude-Laeufen: 42/82/60/73/67 unveraendert. MINOR: Korrektur an Adapter und Messinstrument, keine Aenderung der Versuchsbedingung. | ab den gueltigen Laeufen der Iteration 15 bzw. 8 |
|
||||
| **13.0.0** | **Spiegel-Arbeitsverzeichnis fuer den OpenCode-Adapter** (`--arbeitsverzeichnis spiegel`, Standard der LM-Studio-Matrix). Statt direkt im Codebasis-Root zu arbeiten, laeuft OpenCode in einem Verzeichnis aus Verknuepfungen: je eine Junction auf jeden Top-Level-Ordner der Codebasis, je ein Hardlink auf jede Top-Level-Datei, und `Ergebnisse` als Junction auf das Laufverzeichnis. Relative *und* absolute Ausgabepfade landen damit am richtigen Ort. Der Abbau entfernt nur die Verknuepfungen (`os.rmdir`, nie `rmtree`). `RawResult.json` fuehrt `arbeitsverzeichnis` und `arbeitswurzel`. Adapter-Version 2.3.0, zwei Regressionstests. | Sechs Laeufe der Iteration 13/6 erzeugten kein einziges Artefakt, obwohl `gemma-4-e4b` die richtigen Dateinamen bildete: Es loest `Ergebnisse/StRS.md` relativ zum Arbeitsverzeichnis auf, wo die eingefrorene Codebasis liegt - jeder Schreibversuch wurde abgewiesen. Ein umformulierter Ausgabeblock (Iteration 14/7) half nur in zwei von sechs Laeufen und entfernte den Prompt zugleich vom Wortlaut der Claude-Laeufe. Der Spiegel loest das Problem **strukturell**: Der Prompt bleibt unveraendert bei der Fassung, mit der die Claude-Laeufe gemessen wurden, und die Codebasis bleibt unberuehrt - die Verknuepfungen liegen im Spiegel, nicht im Snapshot. Eine Arbeitskopie wie beim Codex-Adapter schied aus: ueber zehn Minuten fuer 24.663 Dateien je Lauf. Verifiziert: `src` und `README.md` durch den Spiegel lesbar, relativer Schreibpfad landet im Laufverzeichnis, Abbau laesst die Ergebnisse stehen und die Quelle unveraendert. MAJOR: Der Isolationsmechanismus ist eine Versuchsbedingung; Laeufe ab dieser Version sind mit den frueheren nicht poolbar. | ab Iteration 15 (V1) bzw. 8 (V2) |
|
||||
| **12.2.0** | **`analyse-anforderungen.py` erkennt Feldnamen in Markdown-Fettschrift und Modulpraefixe in IDs.** `**ID:** M003-StRS-01` wird wie `ID: StRS-01` gelesen; die Ebene wird aus der ID auch dann bestimmt, wenn ein Praefix vorangeht. Zwei neue Hilfsskripte: `lauf-uebersicht.py` verdichtet die `RawResult.json` einer Matrix zu einer Tabelle und zeigt mit `--details` die tatsaechlichen Schreibziele; `protokoll-geruest.py` erzeugt aus den Rohdaten eines Laufs das Protokollgeruest und fuellt nur belegbare Felder - Deutung und Gueltigkeit bleiben Handarbeit. | Der erste lokale Lauf mit Artefakten (`Iteration 7/.../v12.1.0-001c`) erzeugte vier regelkonform gefuellte Dateien, wurde vom Parser aber mit **0 Anforderungen** gezaehlt: Das Modell formatierte die Feldnamen als Markdown. Nach der Korrektur sind es **9**. Die Praefixregel behob zugleich eine falsche Auffaelligkeitsmeldung ('StRS-Block in StRS.md' als Fremdablage). Regressionsprobe an vier Claude-Laeufen: 42/82/60/73 Anforderungen vor und nach der Aenderung identisch - die Korrektur findet nur zusaetzlich, was zuvor uebersehen wurde. `lauf-uebersicht.py` entstand, weil `written_files` bei fehlgeleiteten Schreibversuchen schlicht leer bleibt und die Ursache so unsichtbar ist. MINOR: Korrektur am Messinstrument, keine Aenderung der Versuchsbedingung; rueckwirkend auf alle Laeufe anwendbar. | rueckwirkend; ab sofort |
|
||||
| **12.1.0** | **Speicherhygiene und Kontextgroesse fuer lokale Laeufe; Modellwechsel `qwen/qwen3.8-27b` -> `qwen/qwen3.5-9b`.** Der Preflight entlaedt vor jedem Lauf **alle** Modelle (`lms unload --all`) und bricht ab, wenn neben dem angeforderten ein weiteres geladen ist. Neue Optionen `--lmstudio-context` (Standard `max`: laedt das Modellmaximum statt der Mindestgroesse), `--lmstudio-parallel` (Standard 4) und `--lmstudio-gpu` (Standard `max`). `local_runtime` fuehrt zusaetzlich `parallel_slots`, `gpu_offload` und `alleiniges_modell`. Adapter-Version 2.2.0. | Messungen vom 01.09.2026 auf einer RTX 5080 Laptop GPU (16.303 MiB): Gemma und Qwen 27B waren **gleichzeitig geladen** - 15.836 MiB belegt, 168 MiB frei, Durchsatz 0,028 Mio. Tokens/h. Nach `unload --all` laeuft Gemma mit 48,3 tok/s. Das Modellmaximum kostet fast nichts: 131.072 statt 32.768 Kontext bedeutet 6.854 statt 5.162 MiB und 46,8 statt 48,3 tok/s - vierfaches Fenster fuer 1,5 tok/s. `--parallel 4` ist gegenueber 1 messtechnisch neutral (47,7 vs. 48,3 tok/s). **Der Modellwechsel ist hardwarebedingt:** `qwen3.8-27b` belegt mit 17,74 GB Gewichten mehr, als die Karte hat; ein Generierungstest brach nach 10 Minuten ohne Ergebnis ab. Die 27B-Klasse ist auch mit kleinerer Quantisierung nicht messbar, weil der KV-Cache bei brauchbarem Kontext mehrere GB zusaetzlich fordert. `qwen3.5-9b` ist die groesste Qwen-Variante, die mit vollem Fenster hineinpasst, und laeuft wie Gemma in **Q4_K_M** - damit unterscheiden sich die beiden lokalen Modelle nur in der Groesse, nicht zusaetzlich in der Quantisierung. MINOR fuer die Ladeparameter; der Modellwechsel eroeffnet ohnehin eine neue Iteration, weil `qwen3.8-27b` keinen gueltigen Messpunkt geliefert hat. | ab der LM-Studio-Matrix in Iteration 13 (V1) bzw. 6 (V2) |
|
||||
| **12.0.0** | **Die Shell-Rechte des OpenCode-Adapters werden eine Denylist statt einer Allowlist** – spiegelbildlich zu den Claude-Eintraegen: alles erlaubt ausser den ausdruecklich gesperrten schreibenden und bauenden Kommandos (`rm`, `mv`, `sed -i`, schreibende `git`-Kommandos inkl. `fetch`/`pull`/`remote`, `dotnet`, `msbuild`, `npm install`, `Remove-Item`, `Set-Content`, `Out-File` u. a.). Das Catch-all `"*": "allow"` steht zuerst, weil OpenCode die **zuletzt passende** Regel gewinnen laesst. Zusaetzlich lehnt der Adapter `--stall-timeout > 0` jetzt fuer **jeden** lokalen Provider ab, nicht nur fuer die delegierenden Modi. Adapter-Version 2.0.0, vier neue Regressionstests. | Der Claude-Adapter erlaubt ueber eine Denylist jedes nicht gesperrte Kommando, der OpenCode-Adapter nur explizit Gelistetes. Die Werkzeugfreiheit war zwischen beiden **nie aequivalent** – ein Confounder fuer jeden Werkzeugvergleich. Ausloeser war der erste Qwen-Lauf (`v11.1.0-45b1`): Dessen **einzige beide** Werkzeugaufrufe, `Get-ChildItem ... | Format-Table ...`, wurden verweigert, weil die Allowlist nur Praefixe trifft und an Pipelines scheitert. Bei Gemma war das ein Randfall (1 von 106 Aufrufen), bei Qwen legte es den Lauf still. Der zweite Ausloeser: Qwen 27B benoetigte fuer einen einzelnen Schritt mehr als 15 Minuten, sodass der Stall-Timeout auch in `solo` Modellgeschwindigkeit als Haenger wertete. **Kontrolltest am 01.09.2026 bestaetigte beide Richtungen:** `Get-ChildItem ... | Format-Table Name` lief durch, `rm opfer.txt` wurde verweigert, und die Datei blieb auf der Platte. MAJOR: Die Toolfreigabe ist eine unabhaengige Variable; Laeufe ab dieser Version sind mit allen frueheren OpenCode- und TensorX-Laeufen nicht poolbar. | ab dem naechsten OpenCode-Lauf; Iterationen 10 und 11 bleiben unter der Allowlist |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -24,8 +24,35 @@ RISIKO = re.compile(r'sicherheit|abrechnung|fakturier|berechtigung|recht|zugriff
|
||||
FETTES_FELD = re.compile(r'(?m)^[ \t]*\*\*([^*:\n]{1,40}):\*\*[ \t]*')
|
||||
|
||||
|
||||
# Ueberschriftenmarker vor einem Feldnamen, z. B. "### ID: StRS-1".
|
||||
# Beobachtet am 01.09.2026 im Lauf Iteration 15/.../v13.0.0-312f: sechs Dateien
|
||||
# mit vollstaendig ausgefuellten, regelkonformen Bloecken wurden mit 0 gezaehlt,
|
||||
# weil die ID-Zeile als Markdown-Ueberschrift gesetzt war.
|
||||
UEBERSCHRIFT_FELD = re.compile(r'(?m)^[ \t]*#{1,6}[ \t]+(?=(?:\*\*)?[A-Za-zÄÖÜäöüß ]{1,40}:)')
|
||||
|
||||
|
||||
# Kennung als blosse Ueberschrift, ohne Feldnamen: "### StRS-001".
|
||||
# Nur dann als ID gewertet, wenn die Pflichtfelder unmittelbar folgen - sonst
|
||||
# wuerde jede Zwischenueberschrift zur Anforderung. Beobachtet am 01.09.2026 im
|
||||
# Lauf Iteration 15/.../v13.0.0-0b06 (qwen, solo): sieben Dateien, alle Felder
|
||||
# ausser der Kennung regelkonform beschriftet.
|
||||
UEBERSCHRIFT_KENNUNG = re.compile(
|
||||
r'(?m)^[ \t]*#{1,6}[ \t]*((?:StRS|SyRS|SwRS)[A-Za-z0-9_.\-]*)[ \t]*$'
|
||||
r'(?=(?:[ \t]*\n)*(?:[ \t]*(?:\*\*)?(?:Titel|Ebene)[:\*]))',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def normalisiere(text):
|
||||
"""Fettgedruckte Feldnamen auf die Klartextform des Prompts zuruecknehmen."""
|
||||
"""Markdown-Auszeichnung der Feldnamen auf die Klartextform zuruecknehmen.
|
||||
|
||||
Modelle setzen die Feldvorgabe des Prompts haeufig als Markdown - fett
|
||||
(``**ID:**``), als Ueberschrift (``### ID:``) oder ganz ohne Feldnamen
|
||||
(``### StRS-001``). Inhaltlich ist der Block dann regelkonform; ohne
|
||||
Normalisierung bleibt er unerkannt.
|
||||
"""
|
||||
text = UEBERSCHRIFT_KENNUNG.sub(lambda m: 'ID: ' + m.group(1), text)
|
||||
text = UEBERSCHRIFT_FELD.sub('', text)
|
||||
return FETTES_FELD.sub(lambda m: m.group(1) + ': ', text)
|
||||
|
||||
|
||||
|
||||
@@ -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']}; "
|
||||
|
||||
@@ -202,6 +202,81 @@ class ReadonlyShellTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class SpiegelTests(unittest.TestCase):
|
||||
"""Der Spiegel loest relative Ausgabepfade, ohne die Quelle zu beruehren."""
|
||||
|
||||
def test_relativer_schreibpfad_landet_im_laufverzeichnis(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
quelle = temp / "codebasis"
|
||||
(quelle / "src").mkdir(parents=True)
|
||||
(quelle / "src" / "a.cs").write_text("Quellcode", encoding="utf-8")
|
||||
(quelle / "README.md").write_text("Liesmich", encoding="utf-8")
|
||||
ausgabe = temp / "lauf" / "Ergebnisse"
|
||||
ausgabe.mkdir(parents=True)
|
||||
spiegel = temp / "spiegel"
|
||||
|
||||
ADAPTER.erstelle_spiegel(quelle, spiegel, ausgabe, lambda _: None)
|
||||
try:
|
||||
self.assertTrue((spiegel / "src" / "a.cs").is_file())
|
||||
self.assertTrue((spiegel / "README.md").is_file())
|
||||
# `Ergebnisse` ist im Spiegel ein echtes Verzeichnis - ein
|
||||
# relativer Schreibpfad landet dort und wird danach uebernommen.
|
||||
(spiegel / "Ergebnisse" / "StRS.md").write_text("X", encoding="utf-8")
|
||||
self.assertFalse((ausgabe / "StRS.md").is_file())
|
||||
anzahl = ADAPTER.uebernimm_ergebnisse(spiegel, ausgabe, lambda _: None)
|
||||
self.assertEqual(1, anzahl)
|
||||
self.assertTrue((ausgabe / "StRS.md").is_file())
|
||||
self.assertEqual("X", (ausgabe / "StRS.md").read_text(encoding="utf-8"))
|
||||
finally:
|
||||
ADAPTER.entferne_spiegel(spiegel, lambda _: None)
|
||||
|
||||
# Der Abbau darf die Quelle nicht durch die Junction hindurch loeschen.
|
||||
self.assertFalse(spiegel.exists())
|
||||
self.assertTrue((quelle / "src" / "a.cs").is_file())
|
||||
self.assertEqual("Quellcode", (quelle / "src" / "a.cs").read_text(encoding="utf-8"))
|
||||
self.assertTrue((ausgabe / "StRS.md").is_file())
|
||||
|
||||
def test_freigabe_enthaelt_arbeitsverzeichnis_relative_form(self):
|
||||
"""resolve() folgt der Junction - die relative Form darf nicht verloren gehen."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
quelle = temp / "codebasis"
|
||||
(quelle / "src").mkdir(parents=True)
|
||||
ausgabe = temp / "lauf" / "Ergebnisse"
|
||||
ausgabe.mkdir(parents=True)
|
||||
spiegel = temp / "spiegel"
|
||||
ADAPTER.erstelle_spiegel(quelle, spiegel, ausgabe, lambda _: None)
|
||||
try:
|
||||
config = ADAPTER.build_run_config(
|
||||
{}, "lmstudio/x", "x", "solo", spiegel, ausgabe, None,
|
||||
provider="lmstudio",
|
||||
zusatz_ausgaben=[spiegel / ausgabe.name],
|
||||
)
|
||||
finally:
|
||||
ADAPTER.entferne_spiegel(spiegel, lambda _: None)
|
||||
erlaubt = [k for k, v in config["permission"]["edit"].items() if v == "allow"]
|
||||
self.assertIn("Ergebnisse", erlaubt)
|
||||
self.assertIn("Ergebnisse/**", erlaubt)
|
||||
|
||||
def test_quelle_bleibt_ohne_neue_eintraege(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp = Path(temp_dir)
|
||||
quelle = temp / "codebasis"
|
||||
(quelle / "src").mkdir(parents=True)
|
||||
vorher = sorted(p.name for p in quelle.iterdir())
|
||||
ausgabe = temp / "lauf" / "Ergebnisse"
|
||||
ausgabe.mkdir(parents=True)
|
||||
spiegel = temp / "spiegel"
|
||||
|
||||
ADAPTER.erstelle_spiegel(quelle, spiegel, ausgabe, lambda _: None)
|
||||
(spiegel / "Ergebnisse" / "datei.md").write_text("X", encoding="utf-8")
|
||||
ADAPTER.uebernimm_ergebnisse(spiegel, ausgabe, lambda _: None)
|
||||
ADAPTER.entferne_spiegel(spiegel, lambda _: None)
|
||||
|
||||
self.assertEqual(vorher, sorted(p.name for p in quelle.iterdir()))
|
||||
|
||||
|
||||
class UsageCapturedTests(unittest.TestCase):
|
||||
"""Eine Null aus einem laufenden Subagenten ist kein Messwert."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user