1"""ll-loop run subcommand."""

2

3 from __future__ import annotations

4

5 import argparse

6 import atexit

7 import hashlib

8 import json

9 import os

10 import re

11 import time

12 import uuid

13 from datetime import UTC, datetime

14 from pathlib import Path

15

16 from little_loops.cli.loop._helpers import (

17 _is_earliest_waiter,

18 _make_instance_id,

19 get_builtin_loops_dir,

20 print_execution_plan,

21 register_loop_signal_handlers,

22 resolve_loop_path,

23 run_background,

24 run_foreground,

25)

26 from little_loops.logger import Logger

27

28

29 def _parse_program_md(path: Path) -> dict[str, str]:

30 """Parse .ll/program.md heading sections into context key-value pairs.

31

32 Sections mapped:

33 ## Directive → directive (prose)

34 ## Targets → targets (space-joined list items)

35 ## Benchmark → each key: value pair injected directly

36 ## Budget → budget (prose)

37 ## Constraints → constraints (prose)

38 """

39 if not path.exists():

40 return {}

41 try:

42 content = path.read_text()

43 except OSError:

44 return {}

45

46 def _extract(heading: str) -> str:

47 m = re.search(rf"^##\s+{re.escape(heading)}\s*$", content, re.MULTILINE | re.IGNORECASE)

48 if not m:

49 return ""

50 start = m.end()

51 nxt = re.search(r"^##\s", content[start:], re.MULTILINE)

52 return content[start : start + nxt.start()].strip() if nxt else content[start:].strip()

53

54 result: dict[str, str] = {}

55

56 directive = _extract("Directive")

57 if directive:

58 result["directive"] = directive

59

60 targets_text = _extract("Targets")

61 if targets_text:

62 items = [

63 line.lstrip("-* \t").strip()

64 for line in targets_text.splitlines()

65 if line.strip().startswith(("-", "*"))

66 ]

67 result["targets"] = " ".join(items) if items else targets_text

68

69 benchmark_text = _extract("Benchmark")

70 if benchmark_text:

71 for line in benchmark_text.splitlines():

72 if ":" in line:

73 k, _, v = line.partition(":")

74 k, v = k.strip(), v.strip()

75 if k and v:

76 result[k] = v

77

78 budget = _extract("Budget")

79 if budget:

80 result["budget"] = budget

81

82 constraints = _extract("Constraints")

83 if constraints:

84 result["constraints"] = constraints

85

86 return result

87

88

89 def cmd_run(

90 loop_name: str,

91 args: argparse.Namespace,

92 loops_dir: Path,

93 logger: Logger,

94) -> int:

95 """Run a loop."""

96 from little_loops.fsm.concurrency import LockManager, resolve_scope

97 from little_loops.fsm.persistence import PersistentExecutor, _reconcile_stale_runs

98 from little_loops.fsm.rate_limit_circuit import RateLimitCircuit

99 from little_loops.fsm.validation import load_and_validate

100

101 try:

102 if getattr(args, "builtin", False):

103 path = get_builtin_loops_dir() / f"{loop_name}.yaml"

104 if not path.exists():

105 logger.error(f"Built-in loop not found: {loop_name!r}")

106 return 1

107 else:

108 path = resolve_loop_path(loop_name, loops_dir)

109 fsm, _ = load_and_validate(path)

110 except FileNotFoundError as e:

111 logger.error(str(e))

112 return 1

113 except ValueError as e:

114 logger.error(f"Validation error: {e}")

115 return 1

116

117 # Apply overrides

118 if getattr(args, "max_steps", None):

119 fsm.max_steps = args.max_steps

120 if getattr(args, "max_iterations", None):

121 fsm.max_iterations = args.max_iterations

122 if args.delay is not None:

123 fsm.backoff = args.delay

124 if args.no_llm:

125 fsm.llm.enabled = False

126 if args.llm_model:

127 fsm.llm.model = args.llm_model

128 # Inject positional input arg before --context so --context can override

129 if getattr(args, "input", None) is not None:

130 raw = args.input

131 try:

132 parsed = json.loads(raw)

133 if isinstance(parsed, dict):

134 matched = {k: v for k, v in parsed.items() if k in fsm.context}

135 if matched:

136 fsm.context.update(matched)

137 else:

138 fsm.context[fsm.input_key] = raw

139 else:

140 fsm.context[fsm.input_key] = raw

141 except (json.JSONDecodeError, ValueError):

142 fsm.context[fsm.input_key] = raw

143 # Inject program.md fields before --context so --context can override

144 program_md_arg = getattr(args, "program_md", None)

145 program_md_path = (

146 program_md_arg if program_md_arg is not None else Path.cwd() / ".ll" / "program.md"

147 )

148 for key, value in _parse_program_md(program_md_path).items():

149 fsm.context[key] = value

150 for kv in getattr(args, "context", None) or []:

151 if "=" not in kv:

152 raise SystemExit(f"Invalid --context format: {kv!r} (expected KEY=VALUE)")

153 key, _, value = kv.partition("=")

154 fsm.context[key.strip()] = value.strip()

155

156 # Generate instance_id early so run_dir can be derived before the validation scan

157 if getattr(args, "foreground_internal", False):

158 _pre_instance_id: str | None = getattr(args, "instance_id", None)

159 else:

160 _pre_instance_id = _make_instance_id(loop_name)

161

162 # Inject run_dir into context before validation so ${context.run_dir} resolves.

163 # --context run_dir=VALUE (already applied above) takes precedence.

164 if "run_dir" not in fsm.context:

165 fsm.context["run_dir"] = str(loops_dir / "runs" / (_pre_instance_id or loop_name)) + "/"

166

167 # Inject input hash for checkpoint fingerprinting.

168 # --context input_hash=VALUE (already applied above) takes precedence.

169 if "input_hash" not in fsm.context and isinstance(fsm.context.get("input"), str):

170 fsm.context["input_hash"] = hashlib.sha256(fsm.context["input"].encode()).hexdigest()[:12]

171

172 # Inject loop metadata into context so templates can reference

173 # ${context.max_steps} in state actions and evaluator prompts.

174 # --context max_steps=VALUE (already applied above) takes precedence.

175 # Must run after the --max-steps CLI override so the context value reflects

176 # any CLI-supplied override.

177 if "max_steps" not in fsm.context:

178 fsm.context["max_steps"] = fsm.max_steps

179 if fsm.max_iterations is not None and "max_iterations" not in fsm.context:

180 fsm.context["max_iterations"] = fsm.max_iterations

181

182 # Apply YAML loop config env-var overrides (CLI flags below overwrite these)

183 if fsm.config is not None and isinstance(fsm.config.handoff_threshold, int):

184 os.environ["LL_HANDOFF_THRESHOLD"] = str(fsm.config.handoff_threshold)

185

186 if getattr(args, "handoff_threshold", None) is not None:

187 if not (1 <= args.handoff_threshold <= 100):

188 raise SystemExit("--handoff-threshold must be between 1 and 100")

189 os.environ["LL_HANDOFF_THRESHOLD"] = str(args.handoff_threshold)

190

191 if getattr(args, "context_limit", None) is not None:

192 os.environ["LL_CONTEXT_LIMIT"] = str(args.context_limit)

193

194 from little_loops.config import BRConfig

195 from little_loops.design_tokens import load_design_tokens, render_as_prompt_context

196

197 _config = BRConfig(Path.cwd())

198

199 # Inject include allowlist from config default.

200 # --context include=VALUE (already applied above) takes precedence.

201 if "include" not in fsm.context and _config.loops.run_defaults.include:

202 fsm.context["include"] = _config.loops.run_defaults.include

203

204 if not fsm.context.get("design_tokens_context"):

205 _tokens = load_design_tokens(_config)

206 fsm.context["design_tokens_context"] = render_as_prompt_context(_tokens) if _tokens else ""

207

208 _edge_label_colors = _config.cli.colors.fsm_edge_labels.to_dict()

209 _highlight_color = _config.cli.colors.fsm_active_state

210 _badges = _config.loops.glyphs.to_dict()

211

212 # Dry run

213 if args.dry_run:

214 from little_loops.cli.loop.diagram_modes import resolve_facets

215 from little_loops.cli.loop.layout import _render_fsm_diagram

216 from little_loops.cli.output import terminal_width

217

218 facets = resolve_facets(args)

219 if facets is not None:

220 tw = terminal_width()

221 header_text = f"== loop: {fsm.name} "

222 print(header_text + "=" * max(0, tw - len(header_text)))

223 diagram = _render_fsm_diagram(

224 fsm,

225 highlight_color=_highlight_color,

226 edge_label_colors=_edge_label_colors,

227 badges=_badges,

228 mode=facets.scope,

229 suppress_labels=not facets.edge_labels,

230 title_only=facets.state_detail == "title",

231 )

232 print(diagram)

233 print()

234 print_execution_plan(fsm, edge_label_colors=_edge_label_colors)

235 return 0

236

237 # Pre-run validation: check required context variables are present

238 _ctx_var_re = re.compile(r"\$\{context\.([^}.]+)")

239 missing_keys: set[str] = set()

240 for state in fsm.states.values():

241 templates = [state.action] if state.action else []

242 if state.evaluate and state.evaluate.prompt:

243 templates.append(state.evaluate.prompt)

244 for template in templates:

245 for m in _ctx_var_re.finditer(template):

246 key = m.group(1)

247 if key not in fsm.context:

248 missing_keys.add(key)

249 if missing_keys:

250 for key in sorted(missing_keys):

251 logger.error(

252 f"Missing required context variable: '{key}'. "

253 f"Run with: ll-loop run {loop_name} --context {key}=VALUE"

254 )

255 return 1

256

257 # Pre-run validation: check required_inputs are present and non-empty

258 for key in fsm.required_inputs:

259 if not fsm.context.get(key, ""):

260 logger.error(

261 f"loop '{loop_name}' requires input '{key}' but none was provided. "

262 f"Pass it via: ll-loop run {loop_name} <value>"

263 )

264 return 1

265

266 # Baseline mode: apply to context before background gate so flags forward correctly

267 baseline_enabled = getattr(args, "baseline", False)

268 if baseline_enabled:

269 if getattr(args, "worktree", False):

270 raise SystemExit("--baseline and --worktree cannot be combined")

271 fsm.context["_baseline"] = {

272 "enabled": True,

273 "skill": getattr(args, "baseline_skill", None),

274 "items": getattr(args, "items", None),

275 "cross_host": getattr(args, "cross_host", False),

276 }

277

278 # Background mode: spawn detached process and return

279 if getattr(args, "background", False):

280 if getattr(args, "worktree", False):

281 raise SystemExit("--worktree and --background cannot be combined")

282 if getattr(args, "follow", False):

283 raise SystemExit(

284 "--follow and --background cannot be combined; use 'll-logs tail' to watch a background loop"

285 )

286 return run_background(loop_name, args, loops_dir)

287

288 # Register PID file for all foreground runs so cmd_stop can send SIGTERM (BUG-639).

289 # Background-spawned processes (foreground_internal=True) have their PID written by the

290 # parent in run_background(); plain foreground runs must write their own PID here.

291 running_dir = loops_dir / ".running"

292 running_dir.mkdir(parents=True, exist_ok=True)

293 _reconcile_stale_runs(loops_dir)

294 instance_id = _pre_instance_id

295 pid_file = running_dir / f"{instance_id or loop_name}.pid"

296 foreground_pid_file: Path | None = pid_file

297

298 if not getattr(args, "foreground_internal", False):

299 pid_file.parent.mkdir(parents=True, exist_ok=True)

300 pid_file.write_text(str(os.getpid()))

301

302 def _cleanup_pid() -> None:

303 pid_file.unlink(missing_ok=True)

304

305 atexit.register(_cleanup_pid)

306

307 # Scope-based locking

308 lock_manager = LockManager(loops_dir)

309 scope = resolve_scope(fsm.scope or ["."], fsm.context)

310 _queue_entry_file: Path | None = None

311

312 def _cleanup_queue_entry() -> None:

313 if _queue_entry_file is not None:

314 _queue_entry_file.unlink(missing_ok=True)

315

316 if not getattr(args, "no_lock", False):

317 if not lock_manager.acquire(fsm.name, scope, instance_id=instance_id):

318 conflict = lock_manager.find_conflict(scope)

319 if conflict and getattr(args, "queue", False):

320 # Write queue entry so dashboard shows the waiting loop

321 queue_dir = loops_dir / ".queue"

322 queue_dir.mkdir(parents=True, exist_ok=True)

323 entry_id = str(uuid.uuid4())

324 entry = {

325 "id": entry_id,

326 "loopName": loop_name,

327 "enqueuedAt": datetime.now(UTC).isoformat(),

328 "context": {

329 "waitingFor": conflict.loop_name,

330 "scope": conflict.scope,

331 "pid": os.getpid(),

332 },

333 }

334 _queue_entry_file = queue_dir / f"{entry_id}.json"

335 _queue_entry_file.write_text(json.dumps(entry, indent=2))

336 atexit.register(_cleanup_queue_entry)

337

338 logger.info(f"Waiting for conflicting loop '{conflict.loop_name}' to finish...")

339 # Retry loop: when N waiters are released simultaneously, only one wins

340 # acquire(); losers loop back and wait again rather than exiting (BUG-1281).

341 acquired = False

342 _wait_start = time.time()

343 _budget = _config.loops.queue_wait_timeout_seconds

344 while time.time() - _wait_start < _budget:

345 _remaining = _budget - (time.time() - _wait_start)

346 if not lock_manager.wait_for_scope(scope, timeout=int(_remaining)):

347 _cleanup_queue_entry()

348 logger.error("Timeout waiting for scope to become available")

349 return 1

350 if not _is_earliest_waiter(entry_id, queue_dir):

351 time.sleep(1)

352 continue

353 if lock_manager.acquire(fsm.name, scope, instance_id=instance_id):

354 acquired = True

355 break

356 if not acquired:

357 _cleanup_queue_entry()

358 logger.error("Failed to acquire lock after waiting")

359 return 1

360 # Lock acquired - no longer queued

361 _cleanup_queue_entry()

362 elif conflict:

363 logger.error(f"Scope conflict with running loop: {conflict.loop_name}")

364 logger.info(f" Conflicting scope: {conflict.scope}")

365 logger.info(" Use --queue to wait for it to finish")

366 return 1

367 else:

368 # Unexpected: find_conflict returned None but acquire failed

369 logger.error("Failed to acquire scope lock (unknown reason)")

370 return 1

371

372 executor: PersistentExecutor | None = None

373 try:

374 # Worktree isolation: create branch + directory before anything reads Path.cwd()

375 if getattr(args, "worktree", False):

376 from little_loops.config import BRConfig as _MainBRConfig

377 from little_loops.parallel.git_lock import GitLock

378 from little_loops.worktree_utils import cleanup_worktree, setup_worktree

379

380 _main_config = _MainBRConfig(Path.cwd())

381 _worktree_base = _main_config.get_worktree_base()

382 _copy_files = _main_config.parallel.worktree_copy_files

383 _repo_path = Path.cwd()

384

385 _timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")

386 _safe_name = re.sub(r"[^a-zA-Z0-9-]", "-", loop_name)

387 _branch_name = f"{_timestamp}-{_safe_name}"

388 _worktree_path = _worktree_base / _branch_name

389

390 _worktree_base.mkdir(parents=True, exist_ok=True)

391 _git_lock = GitLock(logger)

392

393 setup_worktree(

394 repo_path=_repo_path,

395 worktree_path=_worktree_path,

396 branch_name=_branch_name,

397 copy_files=_copy_files,

398 logger=logger,

399 git_lock=_git_lock,

400 )

401

402 logger.info(f"Worktree: {_worktree_path}")

403 logger.info(f"Branch: {_branch_name}")

404

405 def _cleanup_worktree_on_exit() -> None:

406 cleanup_worktree(

407 worktree_path=_worktree_path,

408 repo_path=_repo_path,

409 logger=logger,

410 git_lock=_git_lock,

411 delete_branch=True,

412 )

413

414 atexit.register(_cleanup_worktree_on_exit)

415

416 os.environ["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1"

417 os.chdir(_worktree_path)

418

419 circuit = (

420 RateLimitCircuit(Path(_config.commands.rate_limits.circuit_breaker_path))

421 if _config.commands.rate_limits.circuit_breaker_enabled

422 else None

423 )

424 Path(fsm.context["run_dir"]).mkdir(parents=True, exist_ok=True)

425 executor = PersistentExecutor(

426 fsm,

427 loops_dir=loops_dir,

428 circuit=circuit,

429 instance_id=instance_id,

430 pid=os.getpid(),

431 run_model=getattr(args, "run_model", None) or None,

432 )

433

434 # Register signal handlers for graceful shutdown

435 register_loop_signal_handlers(executor, pid_file=foreground_pid_file)

436

437 from little_loops.extension import wire_extensions

438 from little_loops.transport import wire_transports

439

440 wire_extensions(executor.event_bus, _config.extensions, executor=executor)

441 wire_transports(executor.event_bus, _config.events)

442 return run_foreground(

443 executor,

444 fsm,

445 args,

446 highlight_color=_highlight_color,

447 edge_label_colors=_edge_label_colors,

448 badges=_badges,

449 instance_id=instance_id,

450 loop_path=path,

451 running_dir=running_dir,

452 model=fsm.llm.model,

453 )

454 finally:

455 if executor is not None:

456 executor.close_transports()

457 if not getattr(args, "no_lock", False):

458 lock_manager.release(fsm.name, instance_id=instance_id)