From 0dec3756316213b3e50355607600f78685b1bdbb Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:26:36 +0100 Subject: [PATCH] fix(runtime): release and report failed background supervisor setup --- src/containment_worker.py | 11 ++++++++-- tests/test_background_containment.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/containment_worker.py b/src/containment_worker.py index 196efe8aa..84edee8fd 100644 --- a/src/containment_worker.py +++ b/src/containment_worker.py @@ -57,6 +57,9 @@ async def supervise(payload: dict) -> None: output = "\n…[output truncated by containment capture limit]…\n" except BaseException as exc: record = containment._load_records().get(grant.id, {}) + if not record.get("pid") and not record.get("release"): + containment.release(grant, grace_s=0) + record = containment._load_records().get(grant.id, {}) output, code = f"background execution failed: {type(exc).__name__}: {exc}\n", 1 report = {"containment": grant.to_dict(), "teardown": record.get("release") or {"dead": False}, "output_truncated": False} @@ -66,8 +69,12 @@ async def supervise(payload: dict) -> None: if isinstance(exc, containment.ContainmentUnavailable): report.update(containment.unavailable_tool_result(exc, tool="bash")) if output: - with open(payload["log_path"], "a", encoding="utf-8") as log: - log.write(output) + try: + with open(payload["log_path"], "a", encoding="utf-8") as log: + log.write(output) + except OSError: + # A failed log initialization must not hide completion metadata. + sys.stderr.write(output) atomic_write_json(payload["result_path"], report) # Publish completion last: refresh must never see an exit without metadata. atomic_write_text(payload["exit_path"], str(code if code is not None else 1)) diff --git a/tests/test_background_containment.py b/tests/test_background_containment.py index 52d01f483..664788173 100644 --- a/tests/test_background_containment.py +++ b/tests/test_background_containment.py @@ -52,6 +52,37 @@ def test_detached_execution_owns_boundary_and_reports_death(jobs): assert result["teardown"]["dead"] is True +def test_supervisor_setup_failure_closes_unstarted_grant(jobs): + import json + import subprocess + import sys + from pathlib import Path + path, _ = jobs + spec = containment.agent_spec(str(path), dict(os.environ), 5) + grant = containment.acquire(spec, owner="failed-supervisor") + payload = { + "store_path": str(containment._store_path()), + "grant": {**grant.to_dict(), "owner": grant.owner}, + "spec": {"workspace": str(path), "env": dict(spec.env), "wall_clock_s": 5, + "required": sorted(spec.required)}, + "command": "printf effect > must-not-exist", + "log_path": str(path / "missing-directory" / "job.log"), + "result_path": str(path / "result.json"), "exit_path": str(path / "exit"), + } + worker = Path(containment.__file__).with_name("containment_worker.py") + result = subprocess.run([sys.executable, str(worker)], input=json.dumps(payload), + capture_output=True, text=True, timeout=10) + assert result.returncode == 0 # Supervisor publishes the failed job result. + assert "FileNotFoundError" in result.stderr + assert not (path / "must-not-exist").exists() + assert containment.active_grants() == [] + assert (path / "exit").read_text() == "1" + report = json.loads((path / "result.json").read_text()) + assert report["containment"]["executed"] is False + assert report["containment"]["contained"] is False + assert report["teardown"]["dead"] is True + + async def test_bg_marker_refuses_without_spawning_and_authority_still_gates(jobs, monkeypatch): path, _ = jobs monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_ENFORCING)