agentic-dev-loop 1.0.0a39__tar.gz → 1.0.0a41__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.

Potentially problematic release.


This version of agentic-dev-loop might be problematic. Click here for more details.

Files changed (102) hide show
  1. {agentic_dev_loop-1.0.0a39/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a41}/PKG-INFO +11 -1
  2. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/README.md +10 -0
  3. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/core/config.py +4 -1
  5. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/cli.py +106 -14
  6. agentic_dev_loop-1.0.0a41/src/agentic_dev_loop/engine/containers.py +81 -0
  7. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/graph.py +8 -4
  8. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/isolation.py +86 -33
  9. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/stages.py +25 -6
  10. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/viz/server.py +17 -6
  11. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41/src/agentic_dev_loop.egg-info}/PKG-INFO +11 -1
  12. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop.egg-info/SOURCES.txt +2 -0
  13. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_cli.py +83 -0
  14. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_config.py +37 -0
  15. agentic_dev_loop-1.0.0a41/tests/test_containers.py +134 -0
  16. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_isolation.py +157 -1
  17. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_isolation_integration.py +2 -1
  18. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_qa_prompt.py +16 -0
  19. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_stages.py +87 -0
  20. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_verdict.py +24 -0
  21. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_viz.py +20 -0
  22. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/LICENSE +0 -0
  23. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/setup.cfg +0 -0
  24. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/__init__.py +0 -0
  25. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/core/__init__.py +0 -0
  26. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/core/paths.py +0 -0
  27. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/core/results.py +0 -0
  28. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/core/verdict.py +0 -0
  29. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/__init__.py +0 -0
  30. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/actions.py +0 -0
  31. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/agent_error_guide.md +0 -0
  32. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/agent_error_guide.py +0 -0
  33. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/agents.py +0 -0
  34. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/allowlist.py +0 -0
  35. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/brief_coverage.py +0 -0
  36. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/deps.py +0 -0
  37. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  38. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  39. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  40. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  41. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  42. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/forbidden.py +0 -0
  43. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  44. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/llm.py +0 -0
  45. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/loaders.py +0 -0
  46. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/mounts.py +0 -0
  47. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/outputs.py +0 -0
  48. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/presets.py +0 -0
  49. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/proc.py +0 -0
  50. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/registry.py +0 -0
  51. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  52. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  53. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/secrets.py +0 -0
  54. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  55. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  56. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/py.typed +0 -0
  57. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/viz/__init__.py +0 -0
  58. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop/viz/__main__.py +0 -0
  59. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  60. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  61. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  62. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  63. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_028_declared_caches.py +0 -0
  64. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_029_merge_on_conflict.py +0 -0
  65. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_032_stop_when_green.py +0 -0
  66. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_033_brief_step.py +0 -0
  67. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_035_finalize_nudge.py +0 -0
  68. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_036_banned_commands_and_lane_boundary.py +0 -0
  69. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_041_brief_coverage.py +0 -0
  70. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_actions.py +0 -0
  71. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_agent_error_guide.py +0 -0
  72. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_agents.py +0 -0
  73. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_aidlc.py +0 -0
  74. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_deps.py +0 -0
  75. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_dry_run.py +0 -0
  76. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_e2e_fixtures.py +0 -0
  77. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_egress_proxy.py +0 -0
  78. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_graph.py +0 -0
  79. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_hostboundary.py +0 -0
  80. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_hostboundary_integration.py +0 -0
  81. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_isolation_pbt.py +0 -0
  82. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_llm.py +0 -0
  83. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_loaders.py +0 -0
  84. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_module_contract.py +0 -0
  85. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_outputs.py +0 -0
  86. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_outputs_pbt.py +0 -0
  87. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_proc.py +0 -0
  88. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_publish.py +0 -0
  89. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_registry.py +0 -0
  90. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_repo_ascii.py +0 -0
  91. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_results.py +0 -0
  92. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_sdk_compat.py +0 -0
  93. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_secrets.py +0 -0
  94. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_secrets_integration.py +0 -0
  95. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_secrets_pipeline.py +0 -0
  96. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_source_resolution.py +0 -0
  97. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_step_workspace.py +0 -0
  98. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_sync_back.py +0 -0
  99. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_trigger_script.py +0 -0
  100. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_unit5_config.py +0 -0
  101. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_unit5_pbt.py +0 -0
  102. {agentic_dev_loop-1.0.0a39 → agentic_dev_loop-1.0.0a41}/tests/test_unit5_pure.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a39
3
+ Version: 1.0.0a41
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
@@ -160,6 +160,16 @@ agentic-dev-loop pipeline.yaml --resume <thread-id> --max-attempts 5
160
160
 
161
161
  Exit code is `0` on pipeline success, `1` otherwise.
162
162
 
163
+ `agentic-dev-loop containers` inspects the engine's sandbox containers and
164
+ networks from the host: the default list is read-only and resolves each
165
+ engine-owned object to its owning run and a `live`, `orphaned`,
166
+ `stale-entry`, or `unregistered` verdict; `containers --reap` removes only
167
+ registry-owned orphans whose owner pid is dead (never unregistered names).
168
+ A SIGTERM to a running pipeline still closes the sandbox (the graph's
169
+ `finally` runs); SIGKILL is unhandleable, so its containers are reclaimed by
170
+ the startup reaper on the next run or by `containers --reap`. Pass
171
+ `--state-dir` to point at a custom `isolation.state_dir`.
172
+
163
173
  **What a run looks like** (the shipped self-loop example):
164
174
 
165
175
  ![The live graph view - pipeline stages with verdict badges, retry cycles, and per-stage consoles](docs/viz-screenshot.png)
@@ -118,6 +118,16 @@ agentic-dev-loop pipeline.yaml --resume <thread-id> --max-attempts 5
118
118
 
119
119
  Exit code is `0` on pipeline success, `1` otherwise.
120
120
 
121
+ `agentic-dev-loop containers` inspects the engine's sandbox containers and
122
+ networks from the host: the default list is read-only and resolves each
123
+ engine-owned object to its owning run and a `live`, `orphaned`,
124
+ `stale-entry`, or `unregistered` verdict; `containers --reap` removes only
125
+ registry-owned orphans whose owner pid is dead (never unregistered names).
126
+ A SIGTERM to a running pipeline still closes the sandbox (the graph's
127
+ `finally` runs); SIGKILL is unhandleable, so its containers are reclaimed by
128
+ the startup reaper on the next run or by `containers --reap`. Pass
129
+ `--state-dir` to point at a custom `isolation.state_dir`.
130
+
121
131
  **What a run looks like** (the shipped self-loop example):
122
132
 
123
133
  ![The live graph view - pipeline stages with verdict badges, retry cycles, and per-stage consoles](docs/viz-screenshot.png)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a39"
7
+ version = "1.0.0a41"
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"
@@ -148,6 +148,7 @@ class VerdictSpec(BaseModel):
148
148
  pass_: str = Field(alias="pass")
149
149
  fail: str = Field(alias="fail")
150
150
  unverifiable: str | None = None
151
+ env: str | None = None
151
152
 
152
153
 
153
154
  class Route(BaseModel):
@@ -404,6 +405,7 @@ class StepConfig(BaseModel):
404
405
  on_pass: RouteTarget | None = None
405
406
  on_fail: RouteTarget | None = None
406
407
  on_unverifiable: RouteTarget | None = None
408
+ on_env: RouteTarget | None = None
407
409
 
408
410
  @property
409
411
  def kind(self) -> str:
@@ -794,7 +796,8 @@ class PipelineConfig(BaseModel):
794
796
  raise ValueError(f"step name {n!r} collides with a reserved terminal")
795
797
  valid = set(names) | TERMINALS
796
798
  for step in self.steps:
797
- for route in (step.on_pass, step.on_fail, step.on_unverifiable):
799
+ for route in (step.on_pass, step.on_fail, step.on_unverifiable,
800
+ step.on_env):
798
801
  target = route_target(route)
799
802
  if target is not None and target not in valid:
800
803
  raise ValueError(
@@ -6,16 +6,47 @@ extension is listed in that pipeline's YAML."""
6
6
 
7
7
  import argparse
8
8
  import importlib.metadata
9
+ import json
10
+ import os
11
+ import signal
9
12
  import sys
10
13
  from pathlib import Path
11
14
 
12
15
  from agentic_dev_loop.core.config import PipelineConfig, load_config
13
- from .dryrun import dry_run
14
- from .isolation import clean_stamps, isolation_state_dir
16
+ from .containers import containers_report
17
+ from .isolation import (clean_stamps, isolation_state_dir,
18
+ reap_orphaned_containers)
15
19
  from .loaders import mark_plan_completed
16
20
  from .registry import Registry, default_registry, load_extensions
17
21
 
18
22
 
23
+ _sigterm_in_progress = False
24
+
25
+
26
+ def _handle_sigterm(signum, frame) -> None:
27
+ """First SIGTERM raises SystemExit(143) so the graph ``finally`` runs
28
+ close(); a second SIGTERM while already terminating hard-exits (D4)."""
29
+ global _sigterm_in_progress
30
+ if _sigterm_in_progress:
31
+ os._exit(143)
32
+ _sigterm_in_progress = True
33
+ raise SystemExit(143)
34
+
35
+
36
+ def _install_sigterm_handler():
37
+ """Install the graceful-shutdown handler; returns the prior handler."""
38
+ global _sigterm_in_progress
39
+ _sigterm_in_progress = False
40
+ return signal.signal(signal.SIGTERM, _handle_sigterm)
41
+
42
+
43
+ def _restore_sigterm_handler(previous) -> None:
44
+ """Restore the prior SIGTERM handler and reset the in-progress flag."""
45
+ global _sigterm_in_progress
46
+ _sigterm_in_progress = False
47
+ signal.signal(signal.SIGTERM, previous)
48
+
49
+
19
50
  def _version() -> str:
20
51
  """The installed distribution version - the single source of truth."""
21
52
  try:
@@ -77,6 +108,53 @@ def _registry_for(cfg_path: Path) -> tuple[Registry, PipelineConfig]:
77
108
  return registry, cfg
78
109
 
79
110
 
111
+ def _containers_parser() -> argparse.ArgumentParser:
112
+ parser = argparse.ArgumentParser(
113
+ prog="agentic-dev-loop containers",
114
+ description="Inspect engine sandbox containers and networks.")
115
+ parser.add_argument("--reap", action="store_true",
116
+ help="reap orphaned registry-owned containers and "
117
+ "networks and print their names")
118
+ parser.add_argument("--json", action="store_true",
119
+ help="emit the report or reap result as JSON")
120
+ parser.add_argument("--state-dir", default="~/.agentic-dev-loop/state",
121
+ help="isolation state dir "
122
+ "(default: ~/.agentic-dev-loop/state)")
123
+ return parser
124
+
125
+
126
+ def _run_containers(argv: list[str]) -> int:
127
+ args = _containers_parser().parse_args(argv)
128
+ state_dir = Path(args.state_dir).expanduser()
129
+ if args.reap:
130
+ names = reap_orphaned_containers(state_dir, announce=False)
131
+ if args.json:
132
+ print(json.dumps({"reaped": names}, sort_keys=True))
133
+ else:
134
+ for name in names:
135
+ print(name)
136
+ return 0
137
+ report = containers_report(state_dir)
138
+ if args.json:
139
+ print(json.dumps(report, sort_keys=True))
140
+ else:
141
+ _print_containers_report(report)
142
+ return 0
143
+
144
+
145
+ def _print_containers_report(report) -> None:
146
+ rows = report["containers"] + report["unregistered"]
147
+ if not rows:
148
+ print("no engine containers or networks found")
149
+ return
150
+ for item in rows:
151
+ run = item.get("run_id") or "-"
152
+ pid = item.get("pid")
153
+ pid_s = str(pid) if pid is not None else "-"
154
+ print(f"{item['verdict']:12} {item['kind']:9} {item['name']} "
155
+ f"run={run} pid={pid_s}")
156
+
157
+
80
158
  def build_parser(cfg_path) -> argparse.ArgumentParser:
81
159
  """The full parser for one pipeline: core flags + loader-contributed."""
82
160
  registry, _ = _registry_for(cfg_path)
@@ -87,6 +165,12 @@ def build_parser(cfg_path) -> argparse.ArgumentParser:
87
165
 
88
166
 
89
167
  def main(argv: list[str] | None = None) -> int:
168
+ if argv is None:
169
+ argv = sys.argv[1:]
170
+ else:
171
+ argv = list(argv)
172
+ if argv and argv[0] == "containers":
173
+ return _run_containers(argv[1:])
90
174
  probe = argparse.ArgumentParser(add_help=False)
91
175
  probe.add_argument("pipeline", type=Path, nargs="?")
92
176
  _add_version_flag(probe)
@@ -108,16 +192,24 @@ def main(argv: list[str] | None = None) -> int:
108
192
  f"{state / 'stamps'}", flush=True)
109
193
  return 0
110
194
  if args.dry_run:
195
+ from .dryrun import dry_run
111
196
  return dry_run(args.pipeline, args, registry)
112
- # The run path alone needs the heavy graph/SDK imports - defer so a
113
- # dry run, --clean-stamps, and --help stay side-effect free.
114
- from .graph import run_pipeline
115
- success, _attempts = run_pipeline(args.pipeline, args, registry=registry)
116
- if success and args.plan is not None:
117
- # Only a completed run earns the stamp - failed runs leave the plan
118
- # as-is. AI-DLC runs never pass --plan, so aidlc docs stay untouched.
119
- mark_plan_completed(args.plan)
120
- if not success:
121
- print("agentic-dev-loop: pipeline failed - exiting with code 1",
122
- file=sys.stderr)
123
- return 0 if success else 1
197
+ # The run path alone needs the heavy graph/SDK imports, and --dry-run
198
+ # needs the dryrun import chain in-branch; defer both so `containers`,
199
+ # --clean-stamps, --help, and --version stay side-effect free. 042 D4: a
200
+ # SIGTERM here raises SystemExit(143) so graph.run_pipeline's finally
201
+ # still closes the sandbox; a second SIGTERM hard-exits.
202
+ previous = _install_sigterm_handler()
203
+ try:
204
+ from .graph import run_pipeline
205
+ success, _attempts = run_pipeline(args.pipeline, args, registry=registry)
206
+ if success and args.plan is not None:
207
+ # Only a completed run earns the stamp - failed runs leave the plan
208
+ # as-is. AI-DLC runs never pass --plan, so aidlc docs stay untouched.
209
+ mark_plan_completed(args.plan)
210
+ if not success:
211
+ print("agentic-dev-loop: pipeline failed - exiting with code 1",
212
+ file=sys.stderr)
213
+ return 0 if success else 1
214
+ finally:
215
+ _restore_sigterm_handler(previous)
@@ -0,0 +1,81 @@
1
+ """Host-side container inspection and reaping (042 D5/D6).
2
+
3
+ Reads the engine's isolation-state container registry plus the labeled
4
+ engine-owned daemon objects, and resolves each entry to a verdict without
5
+ ever removing anything whose ownership is unproven. The only mutating path is
6
+ ``reap_orphaned_containers`` (registry-only, pid-gated), which the CLI's
7
+ ``containers --reap`` flag invokes explicitly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .isolation import (CommandRunner, _default_runner, _pid_alive_matches,
16
+ _read_registry)
17
+
18
+
19
+ def _labeled_names(runner: CommandRunner, kind: str) -> set[str]:
20
+ """The engine-owned object names currently visible to the daemon."""
21
+ if kind == "network":
22
+ argv = ["docker", "network", "ls", "--filter",
23
+ "label=agentic-dev-loop=true", "--format", "{{.Name}}"]
24
+ else:
25
+ argv = ["docker", "ps", "-a", "--filter",
26
+ "label=agentic-dev-loop=true", "--format", "{{.Names}}"]
27
+ res = runner.run(argv, timeout=60)
28
+ if res.exit_code != 0:
29
+ return set()
30
+ return {line.strip() for line in res.stdout.splitlines() if line.strip()}
31
+
32
+
33
+ def containers_report(state_dir: str | Path,
34
+ runner: CommandRunner | None = None) -> dict[str, Any]:
35
+ """Resolve registry entries and labeled-but-unregistered engine objects
36
+ to verdicts (042 D5). Read-only: never removes anything.
37
+
38
+ Registry entries become ``live`` (object present + owner pid alive with
39
+ start-time match), ``orphaned`` (object present + owner pid dead), or
40
+ ``stale-entry`` (entry present, object gone). Labeled objects with no
41
+ registry entry become ``unregistered`` - ownership is unproven, so they
42
+ are surfaced, never auto-reaped (D6)."""
43
+ runner = runner or _default_runner
44
+ entries = _read_registry(Path(state_dir))
45
+ container_names = _labeled_names(runner, "container")
46
+ network_names = _labeled_names(runner, "network")
47
+ registry_names = {e.get("name") for e in entries
48
+ if isinstance(e.get("name"), str)}
49
+ containers: list[dict[str, Any]] = []
50
+ for entry in entries:
51
+ name = entry.get("name")
52
+ if not isinstance(name, str):
53
+ continue
54
+ kind = entry.get("kind", "container")
55
+ present = name in (network_names if kind == "network" else container_names)
56
+ if not present:
57
+ verdict = "stale-entry"
58
+ else:
59
+ pid = entry.get("pid")
60
+ if isinstance(pid, int) and _pid_alive_matches(
61
+ pid, entry.get("process_start")):
62
+ verdict = "live"
63
+ else:
64
+ verdict = "orphaned"
65
+ containers.append({
66
+ "name": name,
67
+ "run_id": entry.get("run_id"),
68
+ "kind": kind,
69
+ "pid": entry.get("pid") if isinstance(entry.get("pid"), int) else None,
70
+ "process_start": entry.get("process_start"),
71
+ "created_at": entry.get("created_at"),
72
+ "verdict": verdict,
73
+ })
74
+ unregistered: list[dict[str, Any]] = []
75
+ for name in sorted(container_names - registry_names):
76
+ unregistered.append({"name": name, "kind": "container",
77
+ "verdict": "unregistered"})
78
+ for name in sorted(network_names - registry_names):
79
+ unregistered.append({"name": name, "kind": "network",
80
+ "verdict": "unregistered"})
81
+ return {"containers": containers, "unregistered": unregistered}
@@ -163,15 +163,19 @@ def run_pipeline(cfg_path, args, *, viz_bus=None,
163
163
  # Eager startup (Q2): probe -> image -> create+start exec container ->
164
164
  # pre-provision all declared setups, BEFORE the graph builds. Zero infra
165
165
  # operations mid-run; the only sandbox call during steps is run(request).
166
- sandbox = startup_sandbox(cfg, workspace=Path.cwd(),
167
- refresh=getattr(args, "refresh", False),
168
- state_dir=state_dir)
166
+ # 042 D3: startup is close-protected - a post-create provision failure
167
+ # must still reach close() and reap the container it created.
168
+ sandbox = None
169
169
  try:
170
+ sandbox = startup_sandbox(cfg, workspace=Path.cwd(),
171
+ refresh=getattr(args, "refresh", False),
172
+ state_dir=state_dir)
170
173
  return _run_pipeline(cfg, base, registry, state_dir, max_attempts,
171
174
  args, viz_bus, sandbox, resolved_secrets,
172
175
  tooling_paths)
173
176
  finally:
174
- sandbox.close()
177
+ if sandbox is not None:
178
+ sandbox.close()
175
179
 
176
180
 
177
181
  def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
@@ -1053,12 +1053,12 @@ class DockerBackend(Sandbox):
1053
1053
  workspace=self._workspace, network=False,
1054
1054
  sandbox_venv_dir=self._sandbox_venv_dir,
1055
1055
  seccomp_profile=self._seccomp_profile)
1056
+ self._register_container(self._container)
1056
1057
  res = self._runner.run(payload, timeout=60)
1057
1058
  if res.exit_code != 0:
1058
1059
  raise SandboxError(
1059
1060
  f"AE-SBX-010: container run failed: "
1060
1061
  f"{res.stderr.strip() or res.stdout.strip()}")
1061
- self._register_container(self._container)
1062
1062
  self._started = True
1063
1063
  if not self._network_enabled:
1064
1064
  return
@@ -1068,6 +1068,7 @@ class DockerBackend(Sandbox):
1068
1068
  network_name=self._run_net,
1069
1069
  sandbox_venv_dir=self._sandbox_venv_dir,
1070
1070
  seccomp_profile=self._seccomp_profile)
1071
+ self._register_container(self._network_container)
1071
1072
  net_res = self._runner.run(net_payload, timeout=60)
1072
1073
  if net_res.exit_code != 0:
1073
1074
  # reclaim the already-started network-none container - never leak it.
@@ -1076,13 +1077,13 @@ class DockerBackend(Sandbox):
1076
1077
  raise SandboxError(
1077
1078
  f"AE-SBX-013: networked container run failed: "
1078
1079
  f"{net_res.stderr.strip() or net_res.stdout.strip()}")
1079
- self._register_container(self._network_container)
1080
1080
  self._network_started = True
1081
1081
 
1082
1082
  def _ensure_run_network(self) -> None:
1083
1083
  """Create the per-run internal network once (038, D1/D6)."""
1084
1084
  if self._network_created:
1085
1085
  return
1086
+ self._register_network(self._run_net)
1086
1087
  res = self._runner.run(
1087
1088
  ["docker", "network", "create", "--internal",
1088
1089
  "--label", "agentic-dev-loop=true",
@@ -1093,10 +1094,12 @@ class DockerBackend(Sandbox):
1093
1094
  f"AE-SBX-021: internal network create failed: "
1094
1095
  f"{res.stderr.strip() or res.stdout.strip()}")
1095
1096
  self._network_created = True
1096
- self._register_network(self._run_net)
1097
1097
 
1098
1098
  def _register_network(self, name: str) -> None:
1099
- """Record the per-run network for the orphan reaper (038, D6)."""
1099
+ """Record the per-run network for the orphan reaper (038 D6, 042 D1).
1100
+ Appended BEFORE the create command so any object that exists is
1101
+ registered; the reaper tolerates a stale entry (a dead owner whose
1102
+ create never happened gets a no-op rm and a pruned entry)."""
1100
1103
  _append_registry_record(self._iso_state_dir, {
1101
1104
  "run_id": self._run_id,
1102
1105
  "name": name,
@@ -1117,12 +1120,12 @@ class DockerBackend(Sandbox):
1117
1120
  seccomp_profile=self._seccomp_profile,
1118
1121
  mount_set=base, export_root=self._export_root,
1119
1122
  cache_mounts=self._cache_mounts)
1123
+ self._register_container(self._container)
1120
1124
  res = self._runner.run(payload, timeout=60)
1121
1125
  if res.exit_code != 0:
1122
1126
  raise SandboxError(
1123
1127
  f"AE-SBX-010: container run failed: "
1124
1128
  f"{res.stderr.strip() or res.stdout.strip()}")
1125
- self._register_container(self._container)
1126
1129
  self._started = True
1127
1130
  if not self._baseline:
1128
1131
  self._baseline = _snapshot(Path(self._export_root),
@@ -1297,6 +1300,7 @@ class DockerBackend(Sandbox):
1297
1300
  mount_set=mount_set, export_root=self._export_root,
1298
1301
  cache_mounts=self._cache_mounts, egress_proxy=proxy_url_value,
1299
1302
  name=name)
1303
+ self._register_container(name)
1300
1304
  res = self._runner.run(payload, timeout=60)
1301
1305
  if res.exit_code != 0:
1302
1306
  if proxy_name is not None:
@@ -1304,7 +1308,6 @@ class DockerBackend(Sandbox):
1304
1308
  raise SandboxError(
1305
1309
  f"AE-SBX-010: container run failed: "
1306
1310
  f"{res.stderr.strip() or res.stdout.strip()}")
1307
- self._register_container(name)
1308
1311
  return _ProfileContainer(name=name, mount_set=mount_set,
1309
1312
  egress_policy=policy, proxy_name=proxy_name,
1310
1313
  started=True)
@@ -1334,6 +1337,7 @@ class DockerBackend(Sandbox):
1334
1337
  egress_proxy_host_path=str(Path(egress_proxy_module.__file__)),
1335
1338
  egress_policy_host_path=str(Path(egress_policy_module.__file__)),
1336
1339
  bind_host=bind_host, port=EGRESS_PROXY_PORT)
1340
+ self._register_container(name)
1337
1341
  res = self._runner.run(payload, timeout=60)
1338
1342
  if res.exit_code != 0:
1339
1343
  raise SandboxError(
@@ -1353,7 +1357,6 @@ class DockerBackend(Sandbox):
1353
1357
  f"AE-SBX-021: egress proxy failed to bind "
1354
1358
  f"{bind_host}:{EGRESS_PROXY_PORT} after "
1355
1359
  f"{PROXY_READY_ATTEMPTS} readiness probes")
1356
- self._register_container(name)
1357
1360
  self._proxy_containers.append(name)
1358
1361
 
1359
1362
  def _proxy_internal_ip(self) -> str:
@@ -1521,12 +1524,14 @@ class DockerBackend(Sandbox):
1521
1524
  self._runner.run(["docker", "network", "rm", self._run_net], timeout=60)
1522
1525
  self._network_created = False
1523
1526
  merge_egress_events(self._iso_state_dir, self._egress_events_path)
1524
- self._prune_registry()
1527
+ self._verify_and_prune_registry()
1525
1528
 
1526
1529
  def _register_container(self, name: str) -> None:
1527
- """Record one successfully created exec container in the host registry
1528
- (030, D4). Best-effort: a broken registry only disables reaping, it
1529
- never fails the run."""
1530
+ """Record one exec/proxy container in the host registry (030 D4, 042
1531
+ D1). Appended BEFORE the create command so any object that exists is
1532
+ registered. Best-effort: a broken registry only disables reaping, it
1533
+ never fails the run; the reaper tolerates a stale entry (a dead owner
1534
+ whose create never happened gets a no-op rm and a pruned entry)."""
1530
1535
  _append_registry_record(self._iso_state_dir, {
1531
1536
  "run_id": self._run_id,
1532
1537
  "name": name,
@@ -1535,15 +1540,50 @@ class DockerBackend(Sandbox):
1535
1540
  "created_at": datetime.now(timezone.utc).isoformat(),
1536
1541
  })
1537
1542
 
1538
- def _prune_registry(self) -> None:
1539
- """Drop this run's registry entries after close() removed the
1540
- containers (030, D4). Tolerates a missing registry file."""
1543
+ def _verify_and_prune_registry(self) -> None:
1544
+ """Prune this run's registry entries only after the daemon confirms
1545
+ the object is gone (042 D2). A surviving object (daemon down, rm
1546
+ failure) keeps its entry so the next startup reaper retries. Never
1547
+ raises."""
1541
1548
  entries = _read_registry(self._iso_state_dir)
1542
1549
  if not entries:
1543
1550
  return
1544
- remaining = [e for e in entries if e.get("run_id") != self._run_id]
1551
+ remaining: list[dict] = []
1552
+ for entry in entries:
1553
+ if entry.get("run_id") != self._run_id:
1554
+ remaining.append(entry)
1555
+ continue
1556
+ name = entry.get("name")
1557
+ if not isinstance(name, str):
1558
+ remaining.append(entry)
1559
+ continue
1560
+ if entry.get("kind") == "network":
1561
+ gone = self._network_gone(name)
1562
+ else:
1563
+ gone = self._container_gone(name)
1564
+ if not gone:
1565
+ remaining.append(entry)
1545
1566
  _write_registry(self._iso_state_dir, remaining)
1546
1567
 
1568
+ def _container_gone(self, name: str) -> bool:
1569
+ """True only when the daemon confirms the container no longer exists.
1570
+ A nonzero daemon exit keeps the entry."""
1571
+ res = self._runner.run(
1572
+ ["docker", "ps", "-a", "--filter", f"name={name}",
1573
+ "--format", "{{.Names}}"], timeout=30)
1574
+ if res.exit_code != 0:
1575
+ return False
1576
+ return name not in res.stdout.splitlines()
1577
+
1578
+ def _network_gone(self, name: str) -> bool:
1579
+ """True only when the daemon reports the network is missing. Any
1580
+ other inspect failure (daemon unreachable) keeps the entry."""
1581
+ res = self._runner.run(
1582
+ ["docker", "network", "inspect", name], timeout=30)
1583
+ if res.exit_code == 0:
1584
+ return False
1585
+ return "No such network" in f"{res.stderr} {res.stdout}"
1586
+
1547
1587
  # -- internal -----------------------------------------------------------
1548
1588
 
1549
1589
  def _verify_digest(self) -> None:
@@ -1777,7 +1817,8 @@ def _pid_alive_matches(pid: int, recorded_start: str | None) -> bool:
1777
1817
 
1778
1818
 
1779
1819
  def reap_orphaned_containers(state_dir: str | Path,
1780
- runner: CommandRunner | None = None) -> None:
1820
+ runner: CommandRunner | None = None,
1821
+ *, announce: bool = True) -> list[str]:
1781
1822
  """Startup orphan reaper (030/038, D4/D6): remove exec/proxy containers
1782
1823
  and the per-run internal network whose owning CLI process is gone (crashed
1783
1824
  before close()). Exact by construction - only engine registry names are
@@ -1786,8 +1827,9 @@ def reap_orphaned_containers(state_dir: str | Path,
1786
1827
  runner = runner or _default_runner
1787
1828
  entries = _read_registry(Path(state_dir))
1788
1829
  if not entries:
1789
- return
1830
+ return []
1790
1831
  remaining: list[dict] = []
1832
+ reaped: list[str] = []
1791
1833
  for entry in entries:
1792
1834
  name = entry.get("name")
1793
1835
  kind = entry.get("kind", "container")
@@ -1801,7 +1843,9 @@ def reap_orphaned_containers(state_dir: str | Path,
1801
1843
  remaining.append(entry)
1802
1844
  continue
1803
1845
  runner.run(["docker", "network", "rm", name], timeout=60)
1804
- _announce(f"reaped orphaned sandbox network {name}")
1846
+ if announce:
1847
+ _announce(f"reaped orphaned sandbox network {name}")
1848
+ reaped.append(name)
1805
1849
  continue
1806
1850
  if not isinstance(name, str) or not _CONTAINER_NAME_RE.match(name):
1807
1851
  # Defense in depth: a corrupted registry entry is never an engine
@@ -1814,8 +1858,11 @@ def reap_orphaned_containers(state_dir: str | Path,
1814
1858
  remaining.append(entry)
1815
1859
  continue
1816
1860
  runner.run(["docker", "rm", "-f", name], timeout=60)
1817
- _announce(f"reaped orphaned sandbox container {name}")
1861
+ if announce:
1862
+ _announce(f"reaped orphaned sandbox container {name}")
1863
+ reaped.append(name)
1818
1864
  _write_registry(Path(state_dir), remaining)
1865
+ return reaped
1819
1866
 
1820
1867
 
1821
1868
  # ---------------------------------------------------------------------------
@@ -1961,17 +2008,23 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
1961
2008
  sync_excludes=list(cfg.sync_excludes),
1962
2009
  merge_on_conflict=list(cfg.merge_on_conflict),
1963
2010
  dial_rewrites=dial_rewrites)
1964
- _announce(f"preparing sandbox image {image} (first pull can take a while)")
1965
- backend.ensure_image()
1966
- _announce("sandbox image ready")
1967
- _announce("starting execution container...")
1968
- backend.create_execution_container()
1969
- _announce("execution container started")
1970
- if refresh is None:
1971
- refresh = bool(cfg.isolation and cfg.isolation.refresh)
1972
- for setup_cmd, stamp_key in _declared_setups(cfg, workspace):
1973
- _announce(f"provisioning setup (stamp {stamp_key[:8]})")
1974
- backend.provision(setup_cmd, stamp_key, str(iso_state_dir / "stamps"),
1975
- force=refresh)
1976
- _announce("sandbox ready")
1977
- return backend
2011
+ try:
2012
+ _announce(f"preparing sandbox image {image} (first pull can take a while)")
2013
+ backend.ensure_image()
2014
+ _announce("sandbox image ready")
2015
+ _announce("starting execution container...")
2016
+ backend.create_execution_container()
2017
+ _announce("execution container started")
2018
+ if refresh is None:
2019
+ refresh = bool(cfg.isolation and cfg.isolation.refresh)
2020
+ for setup_cmd, stamp_key in _declared_setups(cfg, workspace):
2021
+ _announce(f"provisioning setup (stamp {stamp_key[:8]})")
2022
+ backend.provision(setup_cmd, stamp_key,
2023
+ str(iso_state_dir / "stamps"), force=refresh)
2024
+ _announce("sandbox ready")
2025
+ return backend
2026
+ except BaseException:
2027
+ # 042 D3: a post-create startup failure (e.g. a setup hook) must still
2028
+ # reap the exec container it created, never leak it to the reaper.
2029
+ backend.close()
2030
+ raise
@@ -683,13 +683,13 @@ def _unclear_note(runtime: Runtime, step: StepConfig, attempt: int,
683
683
  captured response when it exists, so real findings are never swallowed."""
684
684
  expected = ""
685
685
  if step.verdicts is not None:
686
+ keywords = [f"`VERDICT: {step.verdicts.pass_}`",
687
+ f"`VERDICT: {step.verdicts.fail}`"]
686
688
  if step.verdicts.unverifiable is not None:
687
- expected = (f" (expected `VERDICT: {step.verdicts.pass_}`, "
688
- f"`VERDICT: {step.verdicts.fail}`, or "
689
- f"`VERDICT: {step.verdicts.unverifiable}` on its own line)")
690
- else:
691
- expected = (f" (expected `VERDICT: {step.verdicts.pass_}` or "
692
- f"`VERDICT: {step.verdicts.fail}` on its own line)")
689
+ keywords.append(f"`VERDICT: {step.verdicts.unverifiable}`")
690
+ if step.verdicts.env is not None:
691
+ keywords.append(f"`VERDICT: {step.verdicts.env}`")
692
+ expected = f" (expected {' or '.join(keywords)} on its own line)"
693
693
  if truncated:
694
694
  lead = (f"{step.name} agent was cut short on attempt {attempt} "
695
695
  f"(hit the {max_iterations}-turn limit or was stopped "
@@ -718,6 +718,8 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
718
718
  verdicts_map = {step.verdicts.pass_: "pass", step.verdicts.fail: "fail"}
719
719
  if step.verdicts.unverifiable is not None:
720
720
  verdicts_map[step.verdicts.unverifiable] = "unverifiable"
721
+ if step.verdicts.env is not None:
722
+ verdicts_map[step.verdicts.env] = "env"
721
723
  captures = step.artifact is not None or bool(step.produces)
722
724
 
723
725
  def node(state: dict) -> dict:
@@ -787,6 +789,21 @@ def make_agent_node(runtime: Runtime, step: StepConfig):
787
789
  if captures and result.response is not None:
788
790
  update["artifacts"] = {step.name: result.response}
789
791
  return update
792
+ if verdict == "env":
793
+ print(f"[{subject}] {step.name} verdict: ENV "
794
+ f"(environmental, halting).", flush=True)
795
+ if step.on_env is None:
796
+ runtime.state_dir.mkdir(parents=True, exist_ok=True)
797
+ (runtime.state_dir / LAST_ERROR).write_text(
798
+ f"# Environmental failure (verdict: ENV)\n\n"
799
+ f"{result.response or 'no captured response'}\n",
800
+ encoding="utf-8")
801
+ return {"step_verdicts": merged,
802
+ "failed": True, "failed_step": step.name}
803
+ update = {"step_verdicts": merged, "failed": False}
804
+ if captures and result.response is not None:
805
+ update["artifacts"] = {step.name: result.response}
806
+ return update
790
807
  print(f"[{subject}] {step.name} verdict: fail on attempt {attempt}.",
791
808
  flush=True)
792
809
  update = {"step_verdicts": merged,
@@ -1115,6 +1132,8 @@ def make_router(runtime: Runtime, step: StepConfig):
1115
1132
  if step.on_unverifiable is not None:
1116
1133
  return resolve(step.on_unverifiable)
1117
1134
  return resolve(step.on_pass)
1135
+ if verdict == "env":
1136
+ return resolve(step.on_env) if step.on_env is not None else END
1118
1137
  if verdict == "fail":
1119
1138
  if step.on_fail is None:
1120
1139
  return END