agentic-dev-loop 1.0.0a6__tar.gz → 1.0.0a7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/PKG-INFO +1 -1
  2. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/pyproject.toml +1 -1
  3. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/isolation.py +80 -2
  4. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop.egg-info/PKG-INFO +1 -1
  5. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_isolation.py +89 -4
  6. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_isolation_integration.py +35 -0
  7. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/LICENSE +0 -0
  8. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/README.md +0 -0
  9. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/setup.cfg +0 -0
  10. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/__init__.py +0 -0
  11. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/core/__init__.py +0 -0
  12. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/core/config.py +0 -0
  13. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/core/project.py +0 -0
  14. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/core/projection.py +0 -0
  15. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/core/results.py +0 -0
  16. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/core/verdict.py +0 -0
  17. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/core/workflow.js.template +0 -0
  18. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/__init__.py +0 -0
  19. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/actions.py +0 -0
  20. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/agents.py +0 -0
  21. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/cli.py +0 -0
  22. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/commit_shim.py +0 -0
  23. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  24. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  25. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  26. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/graph.py +0 -0
  27. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/llm.py +0 -0
  28. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/loaders.py +0 -0
  29. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/outputs.py +0 -0
  30. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/proc.py +0 -0
  31. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/registry.py +0 -0
  32. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/engine/stages.py +0 -0
  33. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/py.typed +0 -0
  34. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/viz/__init__.py +0 -0
  35. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/viz/__main__.py +0 -0
  36. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/viz/ingest.py +0 -0
  37. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/viz/server.py +0 -0
  38. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/workflow/__init__.py +0 -0
  39. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop/workflow/cli.py +0 -0
  40. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  41. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  42. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  43. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  44. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  45. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_actions.py +0 -0
  46. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_agents.py +0 -0
  47. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_aidlc.py +0 -0
  48. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_cli.py +0 -0
  49. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_config.py +0 -0
  50. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_dry_run.py +0 -0
  51. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_e2e_fixtures.py +0 -0
  52. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_graph.py +0 -0
  53. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_isolation_pbt.py +0 -0
  54. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_llm.py +0 -0
  55. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_loaders.py +0 -0
  56. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_outputs.py +0 -0
  57. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_outputs_pbt.py +0 -0
  58. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_proc.py +0 -0
  59. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_publish.py +0 -0
  60. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_registry.py +0 -0
  61. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_results.py +0 -0
  62. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_sdk_compat.py +0 -0
  63. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_source_resolution.py +0 -0
  64. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_stages.py +0 -0
  65. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_unit4_golden.py +0 -0
  66. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_unit4_pbt.py +0 -0
  67. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_unit4_viz_ingest.py +0 -0
  68. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_unit4_workflow.py +0 -0
  69. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_verdict.py +0 -0
  70. {agentic_dev_loop-1.0.0a6 → agentic_dev_loop-1.0.0a7}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a6
3
+ Version: 1.0.0a7
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a6"
7
+ version = "1.0.0a7"
8
8
  description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -20,6 +20,7 @@ import json
20
20
  import math
21
21
  import os
22
22
  import re
23
+ import shlex
23
24
  import shutil
24
25
  import subprocess
25
26
  import time
@@ -66,6 +67,14 @@ AUDIT_MAX_BYTES = 5 * 1024 * 1024
66
67
  # hung `setup:` hook can never wedge startup (BR-8 fail fast, not a hang).
67
68
  PROVISION_TIMEOUT_SECONDS = 3600
68
69
 
70
+ # The sandbox venv the shipped pipeline relies on (016 sandbox-venv
71
+ # hardening). The engine probes this path INSIDE the exec container
72
+ # before trusting a warm provision stamp — a host-built (darwin) venv
73
+ # symlinks to a host interpreter that does not exist in the Linux
74
+ # container, so it must be detected and re-provisioned there.
75
+ SANDBOX_VENV_DIR = ".venv-sandbox"
76
+ SANDBOX_VENV_PYTHON = ".venv-sandbox/bin/python"
77
+
69
78
 
70
79
  class SandboxError(RuntimeError):
71
80
  """A loud fail-fast sandbox failure; the message names the problem and
@@ -270,6 +279,58 @@ def setup_stamp_key(setup_cmd: str, lockfile_hashes: dict[str, str]) -> str:
270
279
  json.dumps(canonical, sort_keys=True).encode("utf-8")).hexdigest()
271
280
 
272
281
 
282
+ def venv_probe_command() -> str:
283
+ """In-container probe classifying the sandbox venv (016 self-heal).
284
+
285
+ Returns a shell command that prints exactly one of ``valid`` /
286
+ ``missing`` / ``broken`` and exits 0. ``broken`` is the host-tampered
287
+ class: the python entry exists as a path (including a dangling symlink —
288
+ the host-darwin venv case) but is not executable/importable in the
289
+ container. ``missing`` means no such entry at all (the setup hook may
290
+ legitimately not create a venv), so it is treated as a stamp hit, never a
291
+ self-heal trigger.
292
+ """
293
+ py = shlex.quote(SANDBOX_VENV_PYTHON)
294
+ return (
295
+ f"if [ -L {py} ] && [ ! -x {py} ]; then echo broken; "
296
+ f"elif [ ! -e {py} ]; then echo missing; "
297
+ f"elif {py} -c 'import sys' >/dev/null 2>&1; then echo valid; "
298
+ f"else echo broken; fi"
299
+ )
300
+
301
+
302
+ def venv_probe_argv(container: str, workspace: str) -> list[str]:
303
+ """``docker exec`` argv that runs the venv probe inside the (already
304
+ running) execution container against the bind-mounted workspace."""
305
+ return ["docker", "exec", "-w", workspace, container,
306
+ "/bin/sh", "-c", venv_probe_command()]
307
+
308
+
309
+ def venv_probe_state(stdout: str, exit_code: int | None) -> str:
310
+ """Classify a probe result. A nonzero exit (docker exec failed) is
311
+ ``unknown`` — the caller leaves the stamp alone rather than guessing."""
312
+ if exit_code != 0:
313
+ return "unknown"
314
+ line = stdout.strip().splitlines()[-1] if stdout.strip() else ""
315
+ return line if line in ("valid", "missing", "broken") else "unknown"
316
+
317
+
318
+ def remove_sandbox_venv(workspace: str | Path) -> None:
319
+ """Remove the workspace's sandbox venv before re-provision (016 self-heal).
320
+ Fails loud when the venv exists but cannot be removed — a silent skip
321
+ would re-create the ENOENT failure the next run."""
322
+ venv = Path(workspace) / SANDBOX_VENV_DIR
323
+ try:
324
+ if venv.is_symlink() or venv.is_file():
325
+ venv.unlink()
326
+ elif venv.is_dir():
327
+ shutil.rmtree(venv)
328
+ except OSError as e:
329
+ raise SandboxError(
330
+ f"AE-SBX-014: cannot remove invalid sandbox venv at {venv}: {e} "
331
+ f"— remove it manually and re-run (docs/setup.md §5)") from e
332
+
333
+
273
334
  def host_user_flag() -> str | None:
274
335
  """The ``docker --user`` value binding the sandbox to the host user.
275
336
 
@@ -507,9 +568,20 @@ class DockerBackend(Sandbox):
507
568
  def provision(self, setup_cmd: str, stamp_key: str, stamp_dir: str,
508
569
  force: bool) -> None:
509
570
  """Run one ``setup:`` hook in the provision container (network ON),
510
- stamp-cached. Nonzero exit → AE-SBX-009, no stamp written."""
571
+ stamp-cached. Nonzero exit → AE-SBX-009, no stamp written.
572
+
573
+ 016 self-heal: a warm stamp no longer blind-skips. Before trusting it,
574
+ probe the sandbox venv inside the exec container — a host-built venv
575
+ (darwin symlinks to a host interpreter) looks valid on the host but is
576
+ broken in the Linux container. A ``broken`` probe removes the venv
577
+ (fail loud if removal is impossible) and falls through to re-provision;
578
+ ``valid``/``missing``/``unknown`` keep the stamp skip.
579
+ """
511
580
  if not force and read_stamp(Path(stamp_dir), stamp_key):
512
- return
581
+ if self._probe_venv() == "broken":
582
+ remove_sandbox_venv(self._workspace)
583
+ else:
584
+ return
513
585
  payload = docker_provision_payload(
514
586
  run_id=self._run_id, image=self._image, workspace=self._workspace,
515
587
  setup_cmd=setup_cmd)
@@ -521,6 +593,12 @@ class DockerBackend(Sandbox):
521
593
  f"— stamp not written (docs/setup.md §5)")
522
594
  write_stamp(Path(stamp_dir), stamp_key)
523
595
 
596
+ def _probe_venv(self) -> str:
597
+ """Run the in-container venv probe and classify it (016 self-heal)."""
598
+ res = self._runner.run(
599
+ venv_probe_argv(self._container, self._workspace), timeout=30)
600
+ return venv_probe_state(res.stdout, res.exit_code)
601
+
524
602
  # -- execution ----------------------------------------------------------
525
603
 
526
604
  def run(self, request: SandboxExecutionRequest) -> SandboxExecutionResult:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a6
3
+ Version: 1.0.0a7
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -20,8 +20,9 @@ from agentic_dev_loop.engine.isolation import (
20
20
  SandboxExecutionRequest, SandboxExecutionResult, append_audit_record,
21
21
  clean_stamps, docker_create_payload, docker_provision_payload,
22
22
  exec_container_name, is_git_mutation, probe_docker, read_stamp,
23
- resolve_effective_profile, resolve_image_ref, resolve_sandbox_env,
24
- select_backend, setup_stamp_key, translate_mount_path, write_stamp,
23
+ remove_sandbox_venv, resolve_effective_profile, resolve_image_ref,
24
+ resolve_sandbox_env, select_backend, setup_stamp_key, translate_mount_path,
25
+ venv_probe_argv, venv_probe_command, venv_probe_state, write_stamp,
25
26
  )
26
27
 
27
28
 
@@ -243,6 +244,66 @@ class TestSetupStampKey:
243
244
  assert a == b
244
245
 
245
246
 
247
+ class TestVenvProbe:
248
+ def test_command_classifies_broken_symlink_first(self):
249
+ cmd = venv_probe_command()
250
+ # the dangling-symlink (host-darwin) case is caught before -e, which
251
+ # would otherwise report "missing"
252
+ assert "echo broken" in cmd and "echo missing" in cmd and "echo valid" in cmd
253
+ assert "-L .venv-sandbox/bin/python" in cmd
254
+ assert "import sys" in cmd
255
+
256
+ def test_argv_assembly(self):
257
+ argv = venv_probe_argv("c1", "/ws")
258
+ assert argv[:5] == ["docker", "exec", "-w", "/ws", "c1"]
259
+ assert argv[5:] == ["/bin/sh", "-c", venv_probe_command()]
260
+
261
+ @pytest.mark.parametrize("stdout,exit_code,expected", [
262
+ ("valid\n", 0, "valid"),
263
+ ("missing\n", 0, "missing"),
264
+ ("broken\n", 0, "broken"),
265
+ ("", 0, "unknown"),
266
+ ("garbage", 0, "unknown"),
267
+ ("valid", 1, "unknown"),
268
+ ])
269
+ def test_state_classification(self, stdout, exit_code, expected):
270
+ assert venv_probe_state(stdout, exit_code) == expected
271
+
272
+
273
+ class TestRemoveSandboxVenv:
274
+ def test_removes_broken_symlink_dir(self, tmp_path):
275
+ ws = tmp_path / "ws"
276
+ venv = ws / ".venv-sandbox"
277
+ (venv / "bin").mkdir(parents=True)
278
+ (venv / "bin" / "python").symlink_to("/opt/homebrew/opt/python/bin/python3.14")
279
+ remove_sandbox_venv(ws)
280
+ assert not venv.exists()
281
+
282
+ def test_removes_regular_dir(self, tmp_path):
283
+ ws = tmp_path / "ws"
284
+ venv = ws / ".venv-sandbox"
285
+ (venv / "lib").mkdir(parents=True)
286
+ (venv / "lib" / "x.txt").write_text("x")
287
+ remove_sandbox_venv(ws)
288
+ assert not venv.exists()
289
+
290
+ def test_missing_is_noop(self, tmp_path):
291
+ ws = tmp_path / "ws"
292
+ ws.mkdir()
293
+ remove_sandbox_venv(ws) # must not raise
294
+
295
+ def test_fails_loud_when_removal_impossible(self, tmp_path, monkeypatch):
296
+ ws = tmp_path / "ws"
297
+ (ws / ".venv-sandbox").mkdir(parents=True)
298
+
299
+ def boom(*args, **kwargs):
300
+ raise OSError("permission denied")
301
+
302
+ monkeypatch.setattr("agentic_dev_loop.engine.isolation.shutil.rmtree", boom)
303
+ with pytest.raises(SandboxError, match="AE-SBX-014"):
304
+ remove_sandbox_venv(ws)
305
+
306
+
246
307
  class TestPayloads:
247
308
  def _profile(self):
248
309
  return EffectiveProfile(True, True, True, True, True, "img", "4g", 2.0)
@@ -650,12 +711,36 @@ class TestProvisionStampsAudit:
650
711
  assert runner.calls
651
712
 
652
713
  def test_provision_skips_on_stamp(self, tmp_path):
653
- runner = FakeRunner(ok(""))
714
+ """A warm stamp still skips, but only after the in-container venv
715
+ probe reports ``valid`` (016 self-heal) — no blind skip."""
716
+ runner = FakeRunner(ok("valid"))
654
717
  backend = _backend(tmp_path, runner)
655
718
  stamp_dir = tmp_path / "stamps"
656
719
  write_stamp(stamp_dir, "key1")
657
720
  backend.provision("make build", "key1", str(stamp_dir), force=False)
658
- assert runner.calls == []
721
+ # the probe ran (docker exec); no provision container ran
722
+ assert [c for c in runner.calls if c[:2] == ["docker", "exec"]]
723
+ assert not [c for c in runner.calls if c[:2] == ["docker", "run"]]
724
+
725
+ def test_provision_self_heals_broken_venv_on_stamp(self, tmp_path):
726
+ """016 repro (unit): stamp hit + broken venv → probe reports broken →
727
+ the venv is removed and the setup re-runs instead of blind-skipping."""
728
+ runner = FakeRunner(ok("broken"), ok(""))
729
+ backend = _backend(tmp_path, runner)
730
+ stamp_dir = tmp_path / "stamps"
731
+ write_stamp(stamp_dir, "key1")
732
+ ws = tmp_path / "ws"
733
+ venv_bin = ws / ".venv-sandbox" / "bin"
734
+ venv_bin.mkdir(parents=True)
735
+ # host-darwin venv: a python symlink whose target doesn't exist in the
736
+ # Linux container.
737
+ (venv_bin / "python").symlink_to("/opt/homebrew/opt/python/bin/python3.14")
738
+ backend.provision("python -m venv .venv-sandbox", "key1",
739
+ str(stamp_dir), force=False)
740
+ # broken venv removed, then provision re-ran
741
+ assert not (ws / ".venv-sandbox").exists()
742
+ assert [c for c in runner.calls if c[:2] == ["docker", "run"]]
743
+ assert read_stamp(stamp_dir, "key1")
659
744
 
660
745
  def test_provision_refresh_forces(self, tmp_path):
661
746
  runner = FakeRunner(ok(""))
@@ -180,3 +180,38 @@ class TestProvisionE2E:
180
180
  b.provision("printf refreshed > stamp-probe", "k1", str(stamp_dir),
181
181
  force=True)
182
182
  assert (ws / "stamp-probe").read_text() == "refreshed"
183
+
184
+
185
+ class TestVenvSelfHealE2E:
186
+ """016 exact repro: a warm stamp plus a host-built (darwin) venv that is
187
+ broken inside the Linux container must self-heal — remove + re-provision —
188
+ instead of blind-skipping into a later ENOENT."""
189
+
190
+ def test_stamp_hit_with_host_venv_reprovisions(self, backend, tmp_path):
191
+ b, ws = backend
192
+ # Simulate a host-rebuilt venv: `bin/python` is a symlink to a host
193
+ # interpreter path that does not exist inside the Linux container.
194
+ venv_bin = ws / ".venv-sandbox" / "bin"
195
+ venv_bin.mkdir(parents=True)
196
+ (venv_bin / "python").symlink_to(
197
+ "/opt/homebrew/opt/python@3.14/bin/python3.14")
198
+ (venv_bin / "pip").symlink_to(
199
+ "/opt/homebrew/opt/python@3.14/bin/pip3.14")
200
+
201
+ stamp_dir = tmp_path / "stamps"
202
+ key = "warm-stamp"
203
+ write_stamp(stamp_dir, key)
204
+
205
+ b.provision("python -m venv .venv-sandbox", key, str(stamp_dir),
206
+ force=False)
207
+
208
+ # The broken venv was removed and re-provisioned: the sandbox python
209
+ # is now executable and importable, and the warm stamp still stands.
210
+ req = SandboxExecutionRequest(
211
+ argv=[".venv-sandbox/bin/python", "-c", "import sys; print('ok')"],
212
+ cwd=ws,
213
+ env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"})
214
+ result = b.run(req)
215
+ assert result.exit_code == 0, result.stderr
216
+ assert "ok" in result.stdout
217
+ assert read_stamp(stamp_dir, key)