diff --git a/docker/OpenShell-RSI-Training.Dockerfile b/docker/OpenShell-RSI-Training.Dockerfile index c8ed6cc..b19a037 100644 --- a/docker/OpenShell-RSI-Training.Dockerfile +++ b/docker/OpenShell-RSI-Training.Dockerfile @@ -1,16 +1,17 @@ # Training-only extension. It is selected only by OpenShell training/model-evaluation jobs. +# It provides the pinned torch stack only; the trainer scripts ship inside each +# job's snapshot bundle from the frozen base commit (guest /opt is unreachable). FROM headlesscode-openshell-rsi:local USER root +# gcc + libc/Python headers: torch's triton JIT compiles a CUDA driver shim +# at forward time inside the guest. RUN apt-get update \ - && apt-get install -y --no-install-recommends python3 python3-pip \ + && apt-get install -y --no-install-recommends python3 python3-pip python3-dev gcc libc6-dev \ && rm -rf /var/lib/apt/lists/* COPY scripts/rsi-training/requirements.lock /opt/headlesscode-rsi-training/requirements.lock RUN python3 -m pip install --break-system-packages --no-cache-dir --require-hashes -r /opt/headlesscode-rsi-training/requirements.lock RUN python3 -c "import torch; assert torch.version.cuda is not None, 'RSI training image requires a CUDA-enabled torch wheel'" -COPY scripts/rsi-training/train_qlora.py /opt/headlesscode-rsi-training/train_qlora.py -COPY scripts/rsi-training/evaluate_adapter.py /opt/headlesscode-rsi-training/evaluate_adapter.py -RUN chmod 0555 /opt/headlesscode-rsi-training/train_qlora.py /opt/headlesscode-rsi-training/evaluate_adapter.py \ - && chmod -R a-w /opt/headlesscode-rsi-training +RUN chmod -R a-w /opt/headlesscode-rsi-training USER sandbox diff --git a/scripts/rsi-training/train_qlora.py b/scripts/rsi-training/train_qlora.py index 6595bb9..df12275 100644 --- a/scripts/rsi-training/train_qlora.py +++ b/scripts/rsi-training/train_qlora.py @@ -132,8 +132,12 @@ def main() -> int: raise RuntimeError("training loss became non-finite") adapter_dir = output / "adapter" model.save_pretrained(adapter_dir, safe_serialization=True) - if not (adapter_dir / "adapter_config.json").is_file() or not (adapter_dir / "adapter_model.safetensors").is_file(): - raise RuntimeError("training ended without a complete PEFT adapter artifact") + readme = adapter_dir / "README.md" + if readme.is_file(): + readme.unlink() + names = sorted(path.name for path in adapter_dir.iterdir() if path.is_file()) + if names != ["adapter_config.json", "adapter_model.safetensors"]: + raise RuntimeError("training ended without exactly the fixed PEFT adapter file set") result.update({ "status": "completed", "stepsCompleted": steps, diff --git a/src/rsi/openshell.ts b/src/rsi/openshell.ts index 0575585..cf361b3 100644 --- a/src/rsi/openshell.ts +++ b/src/rsi/openshell.ts @@ -697,9 +697,30 @@ export async function runOpenShellModelJob(candidate: CandidateRecord, config: R if (training && (task.maxSteps !== 8 || task.adapterPath || task.modelKind)) throw new Error("training job differs from the bounded QLoRA policy") if (!training && (!task.modelKind || !task.cellIds.length || (task.modelKind === "adapter" && !task.adapterPath) || (task.modelKind === "base" && task.adapterPath))) throw new Error("model evaluation job is missing or mismatches its fixed cell/model identity") const output = "/workspace/.headlesscode-rsi-model-output" + const scripts = "/workspace/scripts/rsi-training" + const modelDir = "/workspace/.headlesscode-rsi-model" + const base = "/workspace/.rsi-base-model" + const shared = [ + `--base-model ${base}`, + `--base-model-id '${task.baseModelId}'`, + ] const command = training - ? `python3 /opt/headlesscode-rsi-training/train_qlora.py --base-model /workspace/.rsi-base-model --base-model-id '${task.baseModelId}' --dataset /workspace/.headlesscode-rsi-model/dataset.jsonl --manifest /workspace/.headlesscode-rsi-model/manifest.json --output /workspace/.headlesscode-rsi-model-output` - : `python3 /opt/headlesscode-rsi-training/evaluate_adapter.py --base-model /workspace/.rsi-base-model --base-model-id '${task.baseModelId}' --cells /workspace/.headlesscode-rsi-model/evaluation-cells.jsonl --output ${output}/evaluation-result.json${task.modelKind === "adapter" ? " --adapter-archive /workspace/.headlesscode-rsi-model/adapter.tar" : ""}` + ? [ + `python3 ${scripts}/train_qlora.py`, + ...shared, + `--dataset ${modelDir}/dataset.jsonl`, + `--manifest ${modelDir}/manifest.json`, + `--output ${output}`, + ].join(" ") + : [ + `python3 ${scripts}/evaluate_adapter.py`, + ...shared, + `--cells ${modelDir}/evaluation-cells.jsonl`, + `--output ${output}/evaluation-result.json`, + ...(task.modelKind === "adapter" + ? [`--adapter-archive ${modelDir}/adapter.tar`] + : []), + ].join(" ") const isolated = await runSnapshot({ config, sourceRoot: candidate.worktree, @@ -716,7 +737,14 @@ export async function runOpenShellModelJob(candidate: CandidateRecord, config: R try { const resultName = training ? "training-result.json" : "evaluation-result.json" const resultPath = path.join(isolated.snapshot, ".headlesscode-rsi-model-output", resultName) - if (!fs.existsSync(resultPath)) throw new Error(`OpenShell model job omitted ${resultName}; exit=${isolated.result.exitCode}`) + if (!fs.existsSync(resultPath)) { + const guest = isolated.result.stdout.trim() || "(empty guest output)" + const detail = mutationFailureDiagnostic(guest) + const summary = detail.replaceAll("\n", " | ") + const omitted = `OpenShell model job omitted ${resultName}` + const code = `exit=${isolated.result.exitCode}` + throw new Error(`${omitted}; ${code}; guest output: ${summary}`) + } const result = JSON.parse(fs.readFileSync(resultPath, "utf8")) as Record if (result.schemaVersion !== 1 || !["completed", "failed", "partial"].includes(String(result.status))) throw new Error("OpenShell model job returned an invalid result record") if (training && result.status === "completed") {