agentic-dev-loop 0.3.0__py3-none-any.whl → 1.0.0a2__py3-none-any.whl

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.
@@ -30,6 +30,15 @@ def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
30
30
  env=child_env())
31
31
 
32
32
 
33
+ def _trusted_hooks_dir() -> str:
34
+ """The agentic-dev-loop-managed hooks dir (Q3): repo hooks in ``.git/hooks``
35
+ never execute during the trusted commit action. Created empty; git runs
36
+ with ``-c core.hooksPath=<this dir>`` so there is no hook to run."""
37
+ d = Path("~/.agentic-dev-loop/state/hooks").expanduser()
38
+ d.mkdir(parents=True, exist_ok=True)
39
+ return str(d)
40
+
41
+
33
42
  def git_worktree_clean(path: Path) -> bool:
34
43
  """True only when `path` is a git repo with no uncommitted changes.
35
44
  Outside a repo there is no clean baseline — False (fail-safe, keeps the
@@ -155,6 +164,28 @@ def kill_listeners_on_port(port: int, match: str | None = None,
155
164
  shell=False, env=child_env())
156
165
 
157
166
 
167
+ def checkpoint_exclude_pathspec(state_dir: str | None, cwd: Path) -> str | None:
168
+ """git pathspec excluding the live checkpoint DB from ``git add -A``.
169
+
170
+ The checkpointer keeps ``state_dir/loop-checkpoints.sqlite`` open for the
171
+ whole run; on Windows an open SQLite file blocks other readers, so
172
+ ``git add`` fails to hash it (CI-red gate failure). When the state dir
173
+ sits inside the repo, exclude its sqlite files (db + wal/shm) from the
174
+ auto-commit — checkpoint state is runtime machinery, not constructed
175
+ work. Returns None when there is nothing in-repo to exclude (no state
176
+ dir known, or it lives outside the repo).
177
+ """
178
+ if not state_dir:
179
+ return None
180
+ rel = os.path.relpath(state_dir, cwd)
181
+ if rel == ".." or rel.startswith(".." + os.sep):
182
+ return None
183
+ rel = rel.replace(os.sep, "/")
184
+ if rel == ".":
185
+ return ":(exclude)*.sqlite*"
186
+ return f":(exclude){rel}/*.sqlite*"
187
+
188
+
158
189
  def commit(state: dict, params: dict) -> dict:
159
190
  """The pipeline's only commit point. Skipped loudly when the worktree was
160
191
  dirty at run start, so the auto-commit can never swallow pre-existing
@@ -172,12 +203,24 @@ def commit(state: dict, params: dict) -> dict:
172
203
  raise ValueError("commit action requires params.message "
173
204
  "(a template rendered with {subject})")
174
205
  cwd = Path.cwd()
175
- _git(["add", "-A"], cwd=cwd)
206
+ hooks = _trusted_hooks_dir()
207
+ add_argv = ["-c", f"core.hooksPath={hooks}", "add", "-A"]
208
+ exclude = checkpoint_exclude_pathspec(state.get("state_dir"), cwd)
209
+ if exclude:
210
+ add_argv += ["--", exclude]
211
+ added = _git(add_argv, cwd=cwd)
212
+ if added.returncode != 0:
213
+ # The add is the step that touches the most files (a locked DB, a
214
+ # permission problem) — its error is the diagnostic that matters.
215
+ print(f"WARNING: [{subject}] auto-commit failed — git add: "
216
+ f"{added.stderr.strip()}", file=sys.stderr, flush=True)
217
+ return {}
176
218
  if not _git(["status", "--porcelain"], cwd=cwd).stdout.strip():
177
219
  print(f"[{subject}] Nothing to commit.", flush=True)
178
220
  return {}
179
221
  message = params["message"].format(subject=subject)
180
- result = _git(["commit", "-m", message], cwd=cwd)
222
+ result = _git(["-c", f"core.hooksPath={hooks}", "commit", "-m", message],
223
+ cwd=cwd)
181
224
  if result.returncode != 0:
182
225
  # The work is built and verified — a git error is a loud warning,
183
226
  # not a pipeline failure.
@@ -1,19 +1,27 @@
1
1
  """Agent file loading + SDK factory mechanics.
2
2
 
3
3
  The SDK's built-in tools are registered at import (its registry is global);
4
- consumer tools are registered by extensions via `Registry.add_tool`."""
4
+ consumer tools are registered by extensions via `Registry.add_tool`. Unit-3
5
+ adds the sandbox terminal seam: `SandboxTerminalExecutor` delegates each
6
+ terminal action to a `Sandbox.run`, and `register_sandbox_terminal` re-registers
7
+ the `terminal` tool name per run (KD-4 / §6)."""
5
8
 
6
9
  from pathlib import Path
7
10
  from typing import TYPE_CHECKING
8
11
 
9
12
  from openhands.sdk import LLM
13
+ from openhands.sdk.tool import ToolExecutor
10
14
  from openhands.tools.file_editor import FileEditorTool # noqa: F401 — registers "file_editor"
11
15
  from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 — registers "task_tracker"
12
16
  from openhands.tools.terminal import TerminalTool # noqa: F401 — registers "terminal"
13
17
 
18
+ from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
19
+
14
20
  if TYPE_CHECKING:
15
21
  from openhands.sdk.agent.agent import Agent
16
22
 
23
+ from .isolation import Sandbox
24
+
17
25
 
18
26
  def load_agent(agents_dir: Path | str, name: str, llm: LLM) -> "Agent":
19
27
  """Load `<agents_dir>/<name>.agent.md` and build the Agent via the SDK
@@ -47,3 +55,71 @@ def load_agent(agents_dir: Path | str, name: str, llm: LLM) -> "Agent":
47
55
  f"Agent file {path} name {agent_def.name!r} does not match "
48
56
  f"filename {stem!r}")
49
57
  return agent_definition_to_factory(agent_def)(llm)
58
+
59
+
60
+ class SandboxTerminalExecutor(ToolExecutor):
61
+ """A stateless terminal executor: each action is a fresh ``Sandbox.run``
62
+ (no persistent shell session). ``is_input`` degrades to an unsupported
63
+ observation; ``reset`` is a no-op (there is no session to reset)."""
64
+
65
+ def __init__(self, sandbox: "Sandbox", *, workspace: str = "",
66
+ env: dict[str, str] | None = None,
67
+ pipeline: str | None = None, step: str | None = None):
68
+ self._sandbox = sandbox
69
+ self._workspace = workspace
70
+ self._env = env or {}
71
+ self._pipeline = pipeline
72
+ self._step = step
73
+
74
+ def __call__(self, action, conversation=None):
75
+ from openhands.tools.terminal import TerminalObservation
76
+ from openhands.tools.terminal.metadata import CmdOutputMetadata
77
+
78
+ if action.reset:
79
+ return TerminalObservation.from_text(
80
+ "[reset unsupported — stateless sandbox]",
81
+ command=action.command, exit_code=None, timeout=False,
82
+ metadata=CmdOutputMetadata(exit_code=-1))
83
+
84
+ if action.is_input:
85
+ return TerminalObservation.from_text(
86
+ "[input unsupported — stateless sandbox]",
87
+ command=action.command, exit_code=None, timeout=False,
88
+ metadata=CmdOutputMetadata(exit_code=-1))
89
+
90
+ request = SandboxExecutionRequest(
91
+ argv=command_argv(action.command), cwd=self._workspace,
92
+ env=self._env, timeout=action.timeout,
93
+ pipeline=self._pipeline, step=self._step)
94
+ result = self._sandbox.run(request)
95
+ output = result.stdout + result.stderr
96
+ return TerminalObservation.from_text(
97
+ output, is_error=not result.succeeded and not result.is_timeout,
98
+ command=action.command, exit_code=result.exit_code,
99
+ timeout=result.timed_out,
100
+ metadata=CmdOutputMetadata(
101
+ exit_code=result.exit_code if result.exit_code is not None else -1,
102
+ working_dir=self._workspace))
103
+
104
+ def close(self) -> None:
105
+ pass # the sandbox is closed by Runtime; idempotent
106
+
107
+
108
+ def register_sandbox_terminal(sandbox: "Sandbox", *, env: dict[str, str | None] | None = None,
109
+ workspace: str = "", pipeline: str | None = None,
110
+ step: str | None = None) -> None:
111
+ """Per-run re-registration of the `terminal` name (KD-4): a callable
112
+ factory that builds the SDK terminal with a `SandboxTerminalExecutor`.
113
+ Pipelines are sequential, so re-registering per agent step is safe."""
114
+ from openhands.sdk.tool import register_tool
115
+
116
+ resolved_env = resolve_sandbox_env(env)
117
+
118
+ def factory(conv_state, **params):
119
+ return TerminalTool.create(
120
+ conv_state,
121
+ executor=SandboxTerminalExecutor(
122
+ sandbox, workspace=workspace, env=resolved_env,
123
+ pipeline=pipeline, step=step))
124
+
125
+ register_tool("terminal", factory)
agentic_dev_loop/cli.py CHANGED
@@ -10,7 +10,8 @@ import sys
10
10
  from pathlib import Path
11
11
 
12
12
  from .config import load_config
13
- from .graph import run_pipeline
13
+ from .dryrun import dry_run
14
+ from .isolation import clean_stamps, isolation_state_dir
14
15
  from .registry import default_registry, load_extensions
15
16
 
16
17
 
@@ -49,6 +50,15 @@ def _core_parser() -> argparse.ArgumentParser:
49
50
  help="preferred port for the live graph view "
50
51
  "(default: 8321; falls back to a free port when "
51
52
  "busy)")
53
+ parser.add_argument("--refresh", action="store_true",
54
+ help="force re-provision of every setup: hook "
55
+ "(bypasses the stamp cache)")
56
+ parser.add_argument("--dry-run", action="store_true",
57
+ help="validate the pipeline and print a summary plan "
58
+ "without executing anything (no LLM, no "
59
+ "sandbox, no state writes)")
60
+ parser.add_argument("--clean-stamps", action="store_true",
61
+ help="delete cached provision stamps and exit")
52
62
  _add_version_flag(parser)
53
63
  return parser
54
64
 
@@ -75,6 +85,18 @@ def main(argv: list[str] | None = None) -> int:
75
85
  _core_parser().parse_args(argv)
76
86
  return 2 # unreachable — parse_args exits first
77
87
  args = build_parser(known.pipeline).parse_args(argv)
88
+ if args.clean_stamps:
89
+ cfg = load_config(args.pipeline)
90
+ state = isolation_state_dir(cfg)
91
+ removed = clean_stamps(state)
92
+ print(f"Removed {removed} cached provision stamp(s) from "
93
+ f"{state / 'stamps'}", flush=True)
94
+ return 0
95
+ if args.dry_run:
96
+ return dry_run(args.pipeline, args)
97
+ # The run path alone needs the heavy graph/SDK imports — defer so a
98
+ # dry run, --clean-stamps, and --help stay side-effect free.
99
+ from .graph import run_pipeline
78
100
  success, _attempts = run_pipeline(args.pipeline, args)
79
101
  if not success:
80
102
  print("agentic-dev-loop: pipeline failed — exiting with code 1",
@@ -1,5 +1,9 @@
1
1
  """Pipeline YAML schema and loader. Strict: unknown keys fail at every level."""
2
2
 
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
3
7
  from pathlib import Path
4
8
  from typing import Any
5
9
 
@@ -32,6 +36,36 @@ def route_target(route: RouteTarget | None) -> str | None:
32
36
  return route.goto if isinstance(route, Route) else route
33
37
 
34
38
 
39
+ class IsolationConfig(BaseModel):
40
+ """Optional `isolation:` section — configures the always-on sandbox (BR-2).
41
+ Absence means opinionated defaults, never unsandboxed behaviour. Unknown
42
+ keys fail at load (BR-5). `state_dir` is HOST-ONLY (stamps + audit) and is
43
+ never mounted into the sandbox (Q1)."""
44
+
45
+ model_config = ConfigDict(extra="forbid")
46
+
47
+ image: str | None = None # per-pipeline override (KD-2 ref rules)
48
+ refresh: bool = False # force re-provision (NFR-5)
49
+ memory: str | None = None # override exec container memory
50
+ cpus: float | None = None # override exec container cpus
51
+ allow_git_mutations: bool = False # BR-6 knob
52
+ state_dir: str = "~/.agentic-dev-loop/state" # stamps + audit (host-only)
53
+
54
+ @field_validator("memory")
55
+ @classmethod
56
+ def _check_memory(cls, v: str | None) -> str | None:
57
+ if v is not None and re.fullmatch(r"\d+[kmg]", v) is None:
58
+ raise ValueError(f"memory {v!r} must match ^\\d+[kmg]$")
59
+ return v
60
+
61
+ @field_validator("cpus")
62
+ @classmethod
63
+ def _check_cpus(cls, v: float | None) -> float | None:
64
+ if v is not None and v <= 0:
65
+ raise ValueError("cpus must be > 0")
66
+ return v
67
+
68
+
35
69
  class StepConfig(BaseModel):
36
70
  """One pipeline step. Its type IS the key it carries: exactly one of
37
71
  `agent`, `action`, or `command` — empty values do not count, anything
@@ -49,6 +83,8 @@ class StepConfig(BaseModel):
49
83
  verdicts: VerdictSpec | None = None
50
84
  max_iterations: int | None = None
51
85
  timeout: float | None = Field(default=None, gt=0) # seconds, command steps only
86
+ env: dict[str, str | None] | None = None # NFR-1: null = inherit host value
87
+ setup: str | None = None # BR-13: command steps only
52
88
  on_pass: RouteTarget | None = None
53
89
  on_fail: RouteTarget | None = None
54
90
 
@@ -102,6 +138,10 @@ class StepConfig(BaseModel):
102
138
  raise ValueError(
103
139
  f"step {self.name!r}: 'timeout' requires 'command'"
104
140
  )
141
+ if self.setup is not None and self.command is None:
142
+ raise ValueError(
143
+ f"step {self.name!r}: 'setup' is only allowed on 'command' steps"
144
+ )
105
145
  return self
106
146
 
107
147
 
@@ -122,7 +162,8 @@ class PipelineConfig(BaseModel):
122
162
  additional_files: list[str] = []
123
163
  env_allowlist: list[str] = []
124
164
  llm: LlmConfig
125
- state_dir: str = ".pr"
165
+ isolation: IsolationConfig | None = None # None = always-on defaults
166
+ state_dir: str = ".pr" # default relocated to ~/.agentic-dev-loop/runs/<hash>/ at load
126
167
  max_attempts: int = 3
127
168
  agents_dir: str
128
169
  steps: list[StepConfig] = Field(min_length=1)
@@ -147,10 +188,23 @@ class PipelineConfig(BaseModel):
147
188
  return self
148
189
 
149
190
 
191
+ def default_state_dir(pipeline_path: Path) -> str:
192
+ """The new run-state default (Q1): checkpoint DB + last-error.md live OUTSIDE
193
+ the workspace mount, under ~/.agentic-dev-loop/runs/<pipeline-hash>/ where
194
+ pipeline-hash is the first 12 chars of the sha256 of the absolute
195
+ pipeline-file path. Explicit `state_dir:` configs keep working unchanged."""
196
+ digest = hashlib.sha256(
197
+ str(Path(pipeline_path).resolve()).encode("utf-8")).hexdigest()[:12]
198
+ return str(Path("~/.agentic-dev-loop/runs").expanduser() / digest)
199
+
200
+
150
201
  def load_config(path: Path) -> PipelineConfig:
151
- data = yaml.safe_load(Path(path).read_text())
202
+ path = Path(path)
203
+ data = yaml.safe_load(path.read_text())
152
204
  if not isinstance(data, dict):
153
205
  raise ValueError(
154
206
  f"pipeline config must be a mapping, got {type(data).__name__}: {path}"
155
207
  )
208
+ if "state_dir" not in data:
209
+ data["state_dir"] = default_state_dir(path)
156
210
  return PipelineConfig.model_validate(data)
@@ -0,0 +1,122 @@
1
+ """`--dry-run`: the full load-and-validate pass with zero execution.
2
+
3
+ Everything short of running a step happens here — YAML parse, step schema
4
+ validation, extension loading (document loaders included), agents_dir /
5
+ verdict / routing-target resolution, isolation config validation, and pure
6
+ image-ref resolution (no Docker calls). No LLM is built, no sandbox is
7
+ started, and no state/checkpoint/viz writes happen, so a dry run needs no
8
+ API key.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from .config import PipelineConfig, StepConfig, load_config
16
+ from .loaders import resolve_docs
17
+ from .isolation import (isolation_state_dir, resolve_effective_profile,
18
+ resolve_image_ref)
19
+ from .registry import Registry, default_registry, load_extensions
20
+
21
+
22
+ def _validate_agents(cfg: PipelineConfig, agents_dir: Path) -> None:
23
+ """Every `agent:` step must resolve to an existing file under agents_dir
24
+ (the same fail-fast `load_agent` enforces at run time)."""
25
+ for step in cfg.steps:
26
+ if step.agent is not None:
27
+ path = agents_dir / f"{step.agent}.agent.md"
28
+ if not path.is_file():
29
+ raise FileNotFoundError(f"Agent file not found: {path}")
30
+
31
+
32
+ def _validate_actions(cfg: PipelineConfig, registry: Registry) -> None:
33
+ """Unknown action names fail at graph build time; catch them here so a
34
+ dry run is a true load pass."""
35
+ for step in cfg.steps:
36
+ if step.action is not None and step.action not in registry.actions:
37
+ raise ValueError(
38
+ f"step {step.name!r}: unknown action {step.action!r} "
39
+ f"(registered: {sorted(registry.actions)})")
40
+
41
+
42
+ def _resolved_image(cfg: PipelineConfig) -> tuple[str, str, float]:
43
+ """The exec image ref + resource caps. An explicit `image: ""` is a hard
44
+ fail (AE-SBX-005) rather than a silent fallback to the digest-pinned
45
+ default; the env override still wins, exactly as at startup."""
46
+ profile = resolve_effective_profile(cfg.isolation)
47
+ if cfg.isolation is not None and cfg.isolation.image == "":
48
+ image = resolve_image_ref("")
49
+ else:
50
+ image = resolve_image_ref(profile.image)
51
+ return image, profile.memory, profile.cpus
52
+
53
+
54
+ def _step_summary(step: StepConfig) -> str:
55
+ """The kind + key params for one step, on a single line."""
56
+ if step.kind == "agent":
57
+ parts = [f"agent={step.agent}"]
58
+ if step.artifact is not None:
59
+ parts.append(f"artifact={step.artifact}")
60
+ if step.verdicts is not None:
61
+ parts.append(f"verdicts={step.verdicts.pass_}/{step.verdicts.fail}")
62
+ if step.produces:
63
+ parts.append(f"produces={','.join(step.produces)}")
64
+ if step.max_iterations is not None:
65
+ parts.append(f"max_iterations={step.max_iterations}")
66
+ elif step.kind == "command":
67
+ parts = [f"command={step.command}"]
68
+ if step.timeout is not None:
69
+ parts.append(f"timeout={step.timeout:g}")
70
+ if step.setup is not None:
71
+ parts.append(f"setup={step.setup}")
72
+ else:
73
+ parts = [f"action={step.action}"]
74
+ if step.params:
75
+ parts.append(f"params={step.params}")
76
+ return ", ".join(parts)
77
+
78
+
79
+ def _print_summary(cfg_path: Path, cfg: PipelineConfig, subject: str,
80
+ loader, docs: dict[str, str], image: str, memory: str,
81
+ cpus: float, state_dir: Path) -> None:
82
+ print(f"Pipeline: {cfg_path.resolve()}")
83
+ print(f"Subject: {subject}")
84
+ if loader is not None:
85
+ print(f"Document loader: {loader.name} ({len(docs)} docs)")
86
+ for key in sorted(docs):
87
+ print(f" - {key}")
88
+ else:
89
+ print("Document loader: none")
90
+ print("Steps:")
91
+ for step in cfg.steps:
92
+ print(f" - {step.name} [{step.kind}]: {_step_summary(step)}")
93
+ print("Isolation:")
94
+ print(" backend: docker")
95
+ print(f" image: {image}")
96
+ print(f" memory: {memory}")
97
+ print(f" cpus: {cpus}")
98
+ print(f" state_dir: {state_dir}")
99
+ print("dry run complete — nothing executed")
100
+
101
+
102
+ def dry_run(cfg_path, args) -> int:
103
+ """Validate a pipeline end-to-end without executing it, and print a
104
+ summary plan. Returns 0 on a clean load; any load failure propagates as
105
+ the existing loud error (the CLI exits 1)."""
106
+ cfg_path = Path(cfg_path)
107
+ cfg = load_config(cfg_path)
108
+ base = cfg_path.resolve().parent
109
+
110
+ registry = default_registry()
111
+ load_extensions(cfg.extensions, registry)
112
+
113
+ _validate_agents(cfg, base / cfg.agents_dir)
114
+ _validate_actions(cfg, registry)
115
+
116
+ loader, docs, subject = resolve_docs(cfg, base, registry, args)
117
+ image, memory, cpus = _resolved_image(cfg)
118
+ state_dir = isolation_state_dir(cfg)
119
+
120
+ _print_summary(cfg_path, cfg, subject, loader, docs, image, memory, cpus,
121
+ state_dir)
122
+ return 0
@@ -45,7 +45,7 @@ def resolve_unit_docs(docs_dir: Path | str, unit_slug: str) -> dict[str, str]:
45
45
 
46
46
  for file in sorted(unit_dir.rglob("*")):
47
47
  if file.is_file() and file.suffix in _DOC_SUFFIXES:
48
- key = str(file.relative_to(unit_dir))
48
+ key = file.relative_to(unit_dir).as_posix()
49
49
  docs[key] = file.read_text()
50
50
 
51
51
  # At this point docs holds only unit-dir files — an empty load means the
agentic_dev_loop/graph.py CHANGED
@@ -19,8 +19,9 @@ from langgraph.graph import END, START, StateGraph
19
19
  from . import viz
20
20
  from .actions import git_worktree_clean, kill_listeners_on_port
21
21
  from .config import load_config
22
+ from .isolation import startup_sandbox
22
23
  from .llm import build_llm
23
- from .loaders import select_loader
24
+ from .loaders import resolve_docs
24
25
  from .registry import Registry, default_registry, load_extensions
25
26
  from .stages import (Runtime, build_context, clear_attempt_artifacts,
26
27
  make_action_node, make_agent_node, make_bump_node,
@@ -43,6 +44,7 @@ class LoopState(TypedDict):
43
44
  resume_target: str | None # where a resumed run re-enters (START edge)
44
45
  outcome: str | None # "success" once routed to the success terminal
45
46
  commit_allowed: bool # worktree was clean at run start
47
+ state_dir: str # resolved pipeline state dir (for the commit action)
46
48
  step_verdicts: dict[str, str | None] # per-step pass/fail/None(unclear)
47
49
  retry_target: str | None # where the bump node loops back to
48
50
  thread_id: str
@@ -77,29 +79,6 @@ def build_graph(runtime: Runtime, checkpointer=None):
77
79
  return graph.compile(checkpointer=checkpointer)
78
80
 
79
81
 
80
- def _resolve_docs(cfg, base: Path, registry: Registry,
81
- args) -> tuple[dict[str, str], str]:
82
- """Select and invoke the document loader, then append the pipeline's
83
- additional_files (resolved relative to the pipeline file's dir;
84
- listed-but-missing fails fast)."""
85
- loader = select_loader(registry, args)
86
- if loader is not None:
87
- docs, subject = loader.resolve(args)
88
- if not docs:
89
- # U3 policy: an empty load silently degraded to a context the
90
- # agents couldn't act on. Fail fast at the call site instead.
91
- raise RuntimeError(
92
- f"Document loader {loader.name!r} returned no docs — "
93
- "refusing to build context from nothing")
94
- else:
95
- docs, subject = {}, cfg.name
96
- for rel in cfg.additional_files:
97
- path = base / rel
98
- if not path.is_file():
99
- raise FileNotFoundError(f"additional_files entry not found: {path}")
100
- docs[path.name] = path.read_text()
101
- return docs, subject
102
-
103
82
 
104
83
  def run_pipeline(cfg_path, args, *, viz_bus=None) -> tuple[bool, int]:
105
84
  """Load a pipeline YAML, resolve docs via the selected document loader,
@@ -125,10 +104,27 @@ def run_pipeline(cfg_path, args, *, viz_bus=None) -> tuple[bool, int]:
125
104
  registry = default_registry()
126
105
  load_extensions(cfg.extensions, registry)
127
106
 
128
- state_dir = base / cfg.state_dir
107
+ state_dir = Path(cfg.state_dir).expanduser()
108
+ if not state_dir.is_absolute():
109
+ state_dir = base / state_dir
110
+
111
+ # Eager startup (Q2): probe -> image -> create+start exec container ->
112
+ # pre-provision all declared setups, BEFORE the graph builds. Zero infra
113
+ # operations mid-run; the only sandbox call during steps is run(request).
114
+ sandbox = startup_sandbox(cfg, workspace=Path.cwd(),
115
+ refresh=getattr(args, "refresh", False))
116
+ try:
117
+ return _run_pipeline(cfg, base, registry, state_dir, max_attempts,
118
+ args, viz_bus, sandbox)
119
+ finally:
120
+ sandbox.close()
121
+
122
+
123
+ def _run_pipeline(cfg, base, registry, state_dir, max_attempts, args, viz_bus,
124
+ sandbox) -> tuple[bool, int]:
129
125
  runtime = Runtime(cfg=cfg, registry=registry, llm=build_llm(cfg.llm),
130
126
  agents_dir=base / cfg.agents_dir, state_dir=state_dir,
131
- workspace=Path.cwd())
127
+ workspace=Path.cwd(), sandbox=sandbox)
132
128
  state_dir.mkdir(parents=True, exist_ok=True)
133
129
 
134
130
  resume = getattr(args, "resume", None)
@@ -138,7 +134,7 @@ def run_pipeline(cfg_path, args, *, viz_bus=None) -> tuple[bool, int]:
138
134
  if resume:
139
135
  initial = None # state (docs/subject/context) comes from the checkpoint
140
136
  else:
141
- docs, subject = _resolve_docs(cfg, base, registry, args)
137
+ _loader, docs, subject = resolve_docs(cfg, base, registry, args)
142
138
  # A fresh run owns state_dir: drop cross-run leftovers (a previous
143
139
  # failure's last-error.md, stale artifacts) so nothing mistakes
144
140
  # them for this run's output. --resume keeps them.
@@ -159,6 +155,7 @@ def run_pipeline(cfg_path, args, *, viz_bus=None) -> tuple[bool, int]:
159
155
  # the checkpointer creates its db under state_dir, which would
160
156
  # itself dirty the worktree.
161
157
  "commit_allowed": git_worktree_clean(Path.cwd()),
158
+ "state_dir": str(state_dir),
162
159
  "step_verdicts": {},
163
160
  "retry_target": None,
164
161
  "thread_id": thread_id,