agentic-dev-loop 1.0.0a24__tar.gz → 1.0.0a25__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 (89) hide show
  1. {agentic_dev_loop-1.0.0a24/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a25}/PKG-INFO +122 -3
  2. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/README.md +121 -2
  3. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/agents.py +17 -2
  5. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/isolation.py +214 -5
  6. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/stages.py +6 -0
  7. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25/src/agentic_dev_loop.egg-info}/PKG-INFO +122 -3
  8. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_agents.py +26 -0
  9. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_isolation.py +235 -10
  10. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_isolation_integration.py +36 -3
  11. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_sdk_compat.py +18 -0
  12. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_stages.py +28 -0
  13. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_sync_back.py +4 -1
  14. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/LICENSE +0 -0
  15. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/setup.cfg +0 -0
  16. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/__init__.py +0 -0
  17. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/__init__.py +0 -0
  18. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/config.py +0 -0
  19. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/paths.py +0 -0
  20. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/results.py +0 -0
  21. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/verdict.py +0 -0
  22. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/__init__.py +0 -0
  23. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/actions.py +0 -0
  24. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/cli.py +0 -0
  25. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/deps.py +0 -0
  26. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  27. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  28. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  29. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  30. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  31. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/graph.py +0 -0
  32. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  33. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/llm.py +0 -0
  34. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/loaders.py +0 -0
  35. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/mounts.py +0 -0
  36. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/outputs.py +0 -0
  37. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/presets.py +0 -0
  38. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/proc.py +0 -0
  39. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/registry.py +0 -0
  40. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  41. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  42. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/secrets.py +0 -0
  43. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  44. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/sync_back.py +0 -0
  45. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/py.typed +0 -0
  46. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/viz/__init__.py +0 -0
  47. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/viz/__main__.py +0 -0
  48. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/viz/server.py +0 -0
  49. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
  50. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  51. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  52. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  53. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  54. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_028_declared_caches.py +0 -0
  55. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_029_merge_on_conflict.py +0 -0
  56. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_actions.py +0 -0
  57. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_aidlc.py +0 -0
  58. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_cli.py +0 -0
  59. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_config.py +0 -0
  60. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_deps.py +0 -0
  61. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_dry_run.py +0 -0
  62. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_e2e_fixtures.py +0 -0
  63. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_egress_proxy.py +0 -0
  64. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_graph.py +0 -0
  65. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_hostboundary.py +0 -0
  66. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_hostboundary_integration.py +0 -0
  67. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_isolation_pbt.py +0 -0
  68. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_llm.py +0 -0
  69. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_loaders.py +0 -0
  70. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_module_contract.py +0 -0
  71. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_outputs.py +0 -0
  72. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_outputs_pbt.py +0 -0
  73. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_proc.py +0 -0
  74. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_publish.py +0 -0
  75. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_qa_prompt.py +0 -0
  76. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_registry.py +0 -0
  77. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_repo_ascii.py +0 -0
  78. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_results.py +0 -0
  79. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_secrets.py +0 -0
  80. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_secrets_integration.py +0 -0
  81. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_secrets_pipeline.py +0 -0
  82. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_source_resolution.py +0 -0
  83. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_step_workspace.py +0 -0
  84. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_trigger_script.py +0 -0
  85. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_unit5_config.py +0 -0
  86. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_unit5_pbt.py +0 -0
  87. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_unit5_pure.py +0 -0
  88. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_verdict.py +0 -0
  89. {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/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.0a24
3
+ Version: 1.0.0a25
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
@@ -79,8 +79,10 @@ flowchart LR
79
79
 
80
80
  The fixed policies *are* the product: fail fast on missing config/secrets, no
81
81
  silent fallbacks, verdicts only from artifacts, bounded retries, per-attempt
82
- artifact hygiene, a single clean-worktree-gated commit point, and no default
83
- LLM timeout (agent runs are long).
82
+ artifact hygiene, a single clean-worktree-gated commit point, no default LLM
83
+ timeout (agent runs are long), and fail fast on a broken sandbox - daemon-side
84
+ exec failures are classified and stop the run without an agent retry loop,
85
+ while a startup reaper reclaims containers leaked by a crashed run.
84
86
 
85
87
  ## Installation
86
88
 
@@ -320,6 +322,123 @@ the run continues with `[isolation] merged concurrent edits: ...` on the
320
322
  console.
321
323
 
322
324
 
325
+ ## Authoring a loop for your own repo
326
+
327
+ A consumer repo needs two things written by hand - a pipeline file and the
328
+ agent files it names - both committed before the first run (the host-boundary
329
+ check refuses dirty tooling, and the `commit` step never commits it). Two
330
+ reference layouts ship today: this repo dogfoods the engine on itself
331
+ (python + pytest, no frontend, CLI-only qa), and the consumer repo carries
332
+ the wider shape (.NET + npm frontend + a networked browser-probing qa step).
333
+
334
+ ```
335
+ <repo>/agentic-dev-loop/pipeline.yaml
336
+ <repo>/agentic-dev-loop/sdk_agents/dev.agent.md
337
+ <repo>/agentic-dev-loop/sdk_agents/review.agent.md
338
+ <repo>/agentic-dev-loop/sdk_agents/qa.agent.md
339
+ ```
340
+
341
+ ### The agent files
342
+
343
+ Each `sdk_agents/<name>.agent.md` is a markdown prompt plus YAML frontmatter:
344
+ `name` matches a step's `agent:` value; `tools` lists the tool names the step
345
+ may use (`terminal` always; a repo can add more via extensions, e.g.
346
+ `figma`).
347
+
348
+ ```markdown
349
+ ---
350
+ name: review # matches a step's `agent:` value
351
+ tools: [terminal]
352
+ ---
353
+ # Review Agent
354
+ ## WORKFLOW # numbered steps
355
+ ## RULES # absolute never/always rules
356
+ ## SANDBOX NOTES # the sandbox truth for THIS step
357
+ ## OUTPUT # the verdict contract
358
+ ```
359
+
360
+ Three roles, three contracts:
361
+
362
+ | Role | Verdict contract | Typical sandbox |
363
+ | --- | --- | --- |
364
+ | dev | none - the final response is captured as the `produces:` summary | rw mount, offline |
365
+ | review | `VERDICT: APPROVED` / `VERDICT: NEEDS CHANGES` on the first content line | ro mount, offline |
366
+ | qa | `VERDICT: PASS` / `VERDICT: FAIL`; map `unverifiable: UNVERIFIABLE` if you want a third way | shaped by the repo: rw + network + `class: browser` when it boots servers and drives a browser (the consumer repo); ro + offline when it probes the CLI only (this repo) |
367
+
368
+ The conventions that make a good agent file - each one below exists because
369
+ an agent burned budget learning it mid-run:
370
+
371
+ - **State the sandbox truth in the prompt.** Every agent file carries a
372
+ `SANDBOX NOTES` section naming exactly what its step allows: mounts ro or
373
+ rw, network on or off, and which gates have already run. Never make the
374
+ agent discover it.
375
+ - **Gates are the source of truth - cite the record, never re-run.** A
376
+ command the test gate, the editor-tests gate, or the dev summary reports
377
+ as run is coverage evidence, cited as `covered by <test>` or the gate
378
+ record. Review and qa never re-run builds or suites; qa maps every
379
+ acceptance criterion to existing coverage and probes only the remainder.
380
+ - **dev enumerates its impossible set and stops at it.** The dev prompt
381
+ says: stop the moment your next step needs a command this sandbox cannot
382
+ run, and lists the set (package-manager fetches after a new pin, npm or
383
+ vitest offline, playwright downloads, database-category tests), one
384
+ attempt per command. That keeps the productive edit/build/test loop and
385
+ kills doomed retries. The set is toolchain-specific - write yours; this
386
+ repo's is a single bullet (no package managers), the consumer repo's is
387
+ four.
388
+ - **Failure output returns as *Previous attempt feedback*.** Steps route
389
+ `on_fail: { goto: dev, retry: true }`, so gate failure output lands in
390
+ the dev agent's next context; the dev prompt makes every listed issue a
391
+ required fix.
392
+ - **No git mutations, ever.** The `commit` step is the single commit point.
393
+ - **`max_iterations` per role.** qa is bounded probing (60 in both
394
+ reference repos); dev is open-ended construction and stays uncapped
395
+ (the engine default).
396
+
397
+ ### The pipeline file
398
+
399
+ The full field reference is above; this is the stage lineup and what to
400
+ adapt per repo.
401
+
402
+ | Stage | Kind | Typical shape | Why |
403
+ | --- | --- | --- | --- |
404
+ | dev | agent | rw, offline | does the work; edits are untracked |
405
+ | restore | command | network + `presets: [package-registries]` | fetches what dev pinned (`deps_policy: open`) or the locked install; also the audit escalation point |
406
+ | test | command | offline is fine (warm cache) | exit code is the verdict; the source of truth |
407
+ | editor-tests | command | network + `presets: [package-registries]` | frontend suites that cannot run offline (`npm ci && npm test`). A separate step, never merged into `test`: the step name is the failure diagnosis, its timeout does not contend the backend suite's, and a retry re-runs only its own chain |
408
+ | conformance | command | network + `presets: [package-registries]` | repo-specific fitness checks (the consumer repo: jscpd + DDD rules) |
409
+ | review | agent | ro, offline | static conformance verdict |
410
+ | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 60`; inject secrets only if the probes need them |
411
+ | commit | action | host-side | the single commit point |
412
+
413
+ Adaptation checklist for a new repo:
414
+
415
+ 1. `llm:` - model, `base_url`, `api_key_env`.
416
+ 2. `isolation:` - toolchain image override (the consumer repo pins a
417
+ toolchain image by digest) and `seccomp_profile: bundled:chromium-userns`
418
+ only if a stage runs Chromium.
419
+ 3. `caches:` - toolchain cache dirs as rw bind-mounts shared across runs
420
+ (`.nuget-packages`, `qa/node_modules`, `qa/.browsers`,
421
+ `frontend-editor/node_modules`); caches are never synced back.
422
+ 4. `sync_excludes:` - build output trees (`**/bin/**`, `**/obj/**`).
423
+ 5. `secrets:` - refs only, resolved from host env; each step opts in to the
424
+ refs it needs.
425
+ 6. `egress:` - `presets: [package-registries]` for fetch steps;
426
+ `class: browser` (broad-but-logged) for browser-tier qa only.
427
+ 7. `deps_policy:` and `lockfiles:` - see
428
+ [Dependency policy](#dependency-policy-locked-by-default).
429
+ 8. The dev prompt's impossible set - write it for your toolchain.
430
+ 9. `additional_files:` - the repo's `AGENTS.md`, coding standards, design
431
+ docs: shared context for every agent step.
432
+
433
+ Validate before the first run - dry-run loads and prints the resolved plan
434
+ with no LLM, no Docker, no state writes:
435
+
436
+ ```bash
437
+ agentic-dev-loop agentic-dev-loop/pipeline.yaml --dry-run
438
+ ```
439
+
440
+ Then commit the tooling and run for real.
441
+
323
442
  ## Dependency policy (locked by default)
324
443
 
325
444
  The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
@@ -37,8 +37,10 @@ flowchart LR
37
37
 
38
38
  The fixed policies *are* the product: fail fast on missing config/secrets, no
39
39
  silent fallbacks, verdicts only from artifacts, bounded retries, per-attempt
40
- artifact hygiene, a single clean-worktree-gated commit point, and no default
41
- LLM timeout (agent runs are long).
40
+ artifact hygiene, a single clean-worktree-gated commit point, no default LLM
41
+ timeout (agent runs are long), and fail fast on a broken sandbox - daemon-side
42
+ exec failures are classified and stop the run without an agent retry loop,
43
+ while a startup reaper reclaims containers leaked by a crashed run.
42
44
 
43
45
  ## Installation
44
46
 
@@ -278,6 +280,123 @@ the run continues with `[isolation] merged concurrent edits: ...` on the
278
280
  console.
279
281
 
280
282
 
283
+ ## Authoring a loop for your own repo
284
+
285
+ A consumer repo needs two things written by hand - a pipeline file and the
286
+ agent files it names - both committed before the first run (the host-boundary
287
+ check refuses dirty tooling, and the `commit` step never commits it). Two
288
+ reference layouts ship today: this repo dogfoods the engine on itself
289
+ (python + pytest, no frontend, CLI-only qa), and the consumer repo carries
290
+ the wider shape (.NET + npm frontend + a networked browser-probing qa step).
291
+
292
+ ```
293
+ <repo>/agentic-dev-loop/pipeline.yaml
294
+ <repo>/agentic-dev-loop/sdk_agents/dev.agent.md
295
+ <repo>/agentic-dev-loop/sdk_agents/review.agent.md
296
+ <repo>/agentic-dev-loop/sdk_agents/qa.agent.md
297
+ ```
298
+
299
+ ### The agent files
300
+
301
+ Each `sdk_agents/<name>.agent.md` is a markdown prompt plus YAML frontmatter:
302
+ `name` matches a step's `agent:` value; `tools` lists the tool names the step
303
+ may use (`terminal` always; a repo can add more via extensions, e.g.
304
+ `figma`).
305
+
306
+ ```markdown
307
+ ---
308
+ name: review # matches a step's `agent:` value
309
+ tools: [terminal]
310
+ ---
311
+ # Review Agent
312
+ ## WORKFLOW # numbered steps
313
+ ## RULES # absolute never/always rules
314
+ ## SANDBOX NOTES # the sandbox truth for THIS step
315
+ ## OUTPUT # the verdict contract
316
+ ```
317
+
318
+ Three roles, three contracts:
319
+
320
+ | Role | Verdict contract | Typical sandbox |
321
+ | --- | --- | --- |
322
+ | dev | none - the final response is captured as the `produces:` summary | rw mount, offline |
323
+ | review | `VERDICT: APPROVED` / `VERDICT: NEEDS CHANGES` on the first content line | ro mount, offline |
324
+ | qa | `VERDICT: PASS` / `VERDICT: FAIL`; map `unverifiable: UNVERIFIABLE` if you want a third way | shaped by the repo: rw + network + `class: browser` when it boots servers and drives a browser (the consumer repo); ro + offline when it probes the CLI only (this repo) |
325
+
326
+ The conventions that make a good agent file - each one below exists because
327
+ an agent burned budget learning it mid-run:
328
+
329
+ - **State the sandbox truth in the prompt.** Every agent file carries a
330
+ `SANDBOX NOTES` section naming exactly what its step allows: mounts ro or
331
+ rw, network on or off, and which gates have already run. Never make the
332
+ agent discover it.
333
+ - **Gates are the source of truth - cite the record, never re-run.** A
334
+ command the test gate, the editor-tests gate, or the dev summary reports
335
+ as run is coverage evidence, cited as `covered by <test>` or the gate
336
+ record. Review and qa never re-run builds or suites; qa maps every
337
+ acceptance criterion to existing coverage and probes only the remainder.
338
+ - **dev enumerates its impossible set and stops at it.** The dev prompt
339
+ says: stop the moment your next step needs a command this sandbox cannot
340
+ run, and lists the set (package-manager fetches after a new pin, npm or
341
+ vitest offline, playwright downloads, database-category tests), one
342
+ attempt per command. That keeps the productive edit/build/test loop and
343
+ kills doomed retries. The set is toolchain-specific - write yours; this
344
+ repo's is a single bullet (no package managers), the consumer repo's is
345
+ four.
346
+ - **Failure output returns as *Previous attempt feedback*.** Steps route
347
+ `on_fail: { goto: dev, retry: true }`, so gate failure output lands in
348
+ the dev agent's next context; the dev prompt makes every listed issue a
349
+ required fix.
350
+ - **No git mutations, ever.** The `commit` step is the single commit point.
351
+ - **`max_iterations` per role.** qa is bounded probing (60 in both
352
+ reference repos); dev is open-ended construction and stays uncapped
353
+ (the engine default).
354
+
355
+ ### The pipeline file
356
+
357
+ The full field reference is above; this is the stage lineup and what to
358
+ adapt per repo.
359
+
360
+ | Stage | Kind | Typical shape | Why |
361
+ | --- | --- | --- | --- |
362
+ | dev | agent | rw, offline | does the work; edits are untracked |
363
+ | restore | command | network + `presets: [package-registries]` | fetches what dev pinned (`deps_policy: open`) or the locked install; also the audit escalation point |
364
+ | test | command | offline is fine (warm cache) | exit code is the verdict; the source of truth |
365
+ | editor-tests | command | network + `presets: [package-registries]` | frontend suites that cannot run offline (`npm ci && npm test`). A separate step, never merged into `test`: the step name is the failure diagnosis, its timeout does not contend the backend suite's, and a retry re-runs only its own chain |
366
+ | conformance | command | network + `presets: [package-registries]` | repo-specific fitness checks (the consumer repo: jscpd + DDD rules) |
367
+ | review | agent | ro, offline | static conformance verdict |
368
+ | qa | agent | per repo (see table above) | live-behaviour probes; `max_iterations: 60`; inject secrets only if the probes need them |
369
+ | commit | action | host-side | the single commit point |
370
+
371
+ Adaptation checklist for a new repo:
372
+
373
+ 1. `llm:` - model, `base_url`, `api_key_env`.
374
+ 2. `isolation:` - toolchain image override (the consumer repo pins a
375
+ toolchain image by digest) and `seccomp_profile: bundled:chromium-userns`
376
+ only if a stage runs Chromium.
377
+ 3. `caches:` - toolchain cache dirs as rw bind-mounts shared across runs
378
+ (`.nuget-packages`, `qa/node_modules`, `qa/.browsers`,
379
+ `frontend-editor/node_modules`); caches are never synced back.
380
+ 4. `sync_excludes:` - build output trees (`**/bin/**`, `**/obj/**`).
381
+ 5. `secrets:` - refs only, resolved from host env; each step opts in to the
382
+ refs it needs.
383
+ 6. `egress:` - `presets: [package-registries]` for fetch steps;
384
+ `class: browser` (broad-but-logged) for browser-tier qa only.
385
+ 7. `deps_policy:` and `lockfiles:` - see
386
+ [Dependency policy](#dependency-policy-locked-by-default).
387
+ 8. The dev prompt's impossible set - write it for your toolchain.
388
+ 9. `additional_files:` - the repo's `AGENTS.md`, coding standards, design
389
+ docs: shared context for every agent step.
390
+
391
+ Validate before the first run - dry-run loads and prints the resolved plan
392
+ with no LLM, no Docker, no state writes:
393
+
394
+ ```bash
395
+ agentic-dev-loop agentic-dev-loop/pipeline.yaml --dry-run
396
+ ```
397
+
398
+ Then commit the tooling and run for real.
399
+
281
400
  ## Dependency policy (locked by default)
282
401
 
283
402
  The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentic-dev-loop"
7
- version = "1.0.0a24"
7
+ version = "1.0.0a25"
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"
@@ -15,7 +15,8 @@ from openhands.tools.file_editor import FileEditorTool # noqa: F401 - registers
15
15
  from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401 - registers "task_tracker"
16
16
  from openhands.tools.terminal import TerminalTool # noqa: F401 - registers "terminal"
17
17
 
18
- from .isolation import SandboxExecutionRequest, command_argv, resolve_sandbox_env
18
+ from .isolation import (SandboxError, SandboxExecutionRequest, command_argv,
19
+ resolve_sandbox_env)
19
20
 
20
21
  if TYPE_CHECKING:
21
22
  from openhands.sdk.agent.agent import Agent
@@ -96,7 +97,21 @@ class SandboxTerminalExecutor(ToolExecutor):
96
97
  env=self._env, timeout=action.timeout,
97
98
  pipeline=self._pipeline, step=self._step, network=self._network,
98
99
  mount_set=self._mount_set, egress_policy=self._egress_policy)
99
- result = self._sandbox.run(request)
100
+ try:
101
+ result = self._sandbox.run(request)
102
+ except SandboxError as exc:
103
+ # 030, D3: a sticky infra failure proves the exec path is dead, so
104
+ # tell the SDK run loop to break this step instead of flapping to
105
+ # max_iterations. See COMPAT-SHIMS.md for the reliance + canary.
106
+ if (conversation is not None
107
+ and exc is self._sandbox.infra_error):
108
+ from openhands.sdk.conversation.state import (
109
+ ConversationExecutionStatus,
110
+ )
111
+ conversation.state.execution_status = (
112
+ ConversationExecutionStatus.STUCK
113
+ )
114
+ raise
100
115
  output = result.stdout + result.stderr
101
116
  return TerminalObservation.from_text(
102
117
  output, is_error=not result.succeeded and not result.is_timeout,
@@ -89,6 +89,11 @@ SANDBOX_VENV_PYTHON = ".venv-sandbox/bin/python"
89
89
  # of silently mutating a host-owned throwaway venv.
90
90
  HOST_ONLY_DIRS = (".venv",)
91
91
 
92
+ # 030 infra-failure classifier + orphan-container reaper (D1/D4).
93
+ _EIO_PATTERN = re.compile(r"input/output error|I/O error", re.IGNORECASE)
94
+ CONTAINER_REGISTRY = "container-registry.jsonl"
95
+ _CONTAINER_NAME_RE = re.compile(r"^[0-9a-f]{32}-exec(-[0-9a-f]{16})?(-net)?$")
96
+
92
97
 
93
98
  class SandboxError(RuntimeError):
94
99
  """A loud fail-fast sandbox failure; the message names the problem and
@@ -215,6 +220,25 @@ def container_profile_key(mount_set: MountSet, policy: EgressPolicy) -> str:
215
220
  json.dumps(canonical, sort_keys=True).encode("utf-8")).hexdigest()
216
221
 
217
222
 
223
+ def classify_infra_failure(exit_code: int | None, stderr: str, stdout: str) -> str | None:
224
+ """Classify a daemon-side exec failure (030, D1). Returns a short reason
225
+ string when the failure is Docker/VM infrastructure and None for a routine
226
+ command failure. Pure - no I/O.
227
+
228
+ Only the Docker CLI contract is trusted here: exit 125 means the docker
229
+ client itself failed; exit 126 means the contained command could not be
230
+ invoked, and combined with an EIO read error it proves the guest image was
231
+ unreadable; a daemon error prefix in stderr cannot come from the
232
+ container's command (the CLI prints it before the command runs)."""
233
+ if exit_code == 125:
234
+ return "docker CLI exit 125 (daemon-side error)"
235
+ if exit_code == 126 and _EIO_PATTERN.search(f"{stderr} {stdout}"):
236
+ return "exit 126 with an I/O error: the guest could not load the command"
237
+ if exit_code in (1, 125, 126) and "Error response from daemon:" in stderr:
238
+ return "docker daemon error response"
239
+ return None
240
+
241
+
218
242
  @dataclass(frozen=True)
219
243
  class ContainerProfile:
220
244
  """The profile-keyed pool identity (D-1/BR-10): resolved mount set +
@@ -623,6 +647,8 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
623
647
  payload = [
624
648
  "docker", "run", "--detach", "--rm", "--name",
625
649
  name or exec_container_name(run_id, network),
650
+ "--label", "agentic-dev-loop=true",
651
+ "--label", f"agentic-dev-loop.run={run_id}",
626
652
  "--network", "bridge" if network else "none",
627
653
  ]
628
654
  if network:
@@ -762,6 +788,13 @@ class Sandbox(ABC):
762
788
  @abstractmethod
763
789
  def close(self) -> None: ...
764
790
 
791
+ @property
792
+ def infra_error(self) -> SandboxError | None:
793
+ """The sticky sandbox infra failure (030, D1/D2), or None. Backends
794
+ that classify daemon-side failures override this; the default keeps
795
+ every other Sandbox implementation healthy-sandbox compatible."""
796
+ return None
797
+
765
798
 
766
799
  def _guard_git(request: SandboxExecutionRequest,
767
800
  git_mutations_blocked: bool) -> None:
@@ -833,6 +866,9 @@ class DockerBackend(Sandbox):
833
866
  Path(sandbox_venv_dir).mkdir(parents=True, exist_ok=True)
834
867
  self._started = False
835
868
  self._network_started = False
869
+ # 030: the first classified infra failure, sticky for the rest of the
870
+ # run (D1/D2). None until the exec path proves itself broken.
871
+ self._infra_error: SandboxError | None = None
836
872
 
837
873
  @property
838
874
  def export_root(self) -> str | None:
@@ -840,6 +876,11 @@ class DockerBackend(Sandbox):
840
876
  was materialised (the legacy workspace model)."""
841
877
  return self._export_root
842
878
 
879
+ @property
880
+ def infra_error(self) -> SandboxError | None:
881
+ """The sticky infra failure raised by the last classified exec (030)."""
882
+ return self._infra_error
883
+
843
884
  # -- infrastructure (startup) -------------------------------------------
844
885
 
845
886
  def ensure_image(self) -> None:
@@ -891,6 +932,7 @@ class DockerBackend(Sandbox):
891
932
  raise SandboxError(
892
933
  f"AE-SBX-010: container run failed: "
893
934
  f"{res.stderr.strip() or res.stdout.strip()}")
935
+ self._register_container(self._container)
894
936
  self._started = True
895
937
  if not self._network_enabled:
896
938
  return
@@ -907,6 +949,7 @@ class DockerBackend(Sandbox):
907
949
  raise SandboxError(
908
950
  f"AE-SBX-013: networked container run failed: "
909
951
  f"{net_res.stderr.strip() or net_res.stdout.strip()}")
952
+ self._register_container(self._network_container)
910
953
  self._network_started = True
911
954
 
912
955
  def _create_base_export_container(self) -> None:
@@ -925,6 +968,7 @@ class DockerBackend(Sandbox):
925
968
  raise SandboxError(
926
969
  f"AE-SBX-010: container run failed: "
927
970
  f"{res.stderr.strip() or res.stdout.strip()}")
971
+ self._register_container(self._container)
928
972
  self._started = True
929
973
  if not self._baseline:
930
974
  self._baseline = _snapshot(Path(self._export_root),
@@ -972,6 +1016,8 @@ class DockerBackend(Sandbox):
972
1016
  # -- execution ----------------------------------------------------------
973
1017
 
974
1018
  def run(self, request: SandboxExecutionRequest) -> SandboxExecutionResult:
1019
+ if self._infra_error is not None:
1020
+ raise self._infra_error
975
1021
  _guard_git(request, self._profile.git_mutations_blocked)
976
1022
  if request.mount_set is None:
977
1023
  return self._run_legacy(request)
@@ -1022,6 +1068,8 @@ class DockerBackend(Sandbox):
1022
1068
 
1023
1069
  def _exec(self, request: SandboxExecutionRequest,
1024
1070
  container: str) -> SandboxExecutionResult:
1071
+ if self._infra_error is not None:
1072
+ raise self._infra_error
1025
1073
  start = time.monotonic()
1026
1074
  exec_argv = ["docker", "exec"]
1027
1075
  for key, value in request.env.items():
@@ -1038,11 +1086,29 @@ class DockerBackend(Sandbox):
1038
1086
  result = SandboxExecutionResult(
1039
1087
  exit_code=res.exit_code, stdout=res.stdout, stderr=res.stderr,
1040
1088
  duration_seconds=duration, backend="docker", timed_out=timed_out)
1041
- self._audit(request, result, container)
1042
1089
  if res.exit_code is None:
1043
- raise SandboxError(
1090
+ self._audit(request, result, container)
1091
+ err = SandboxError(
1044
1092
  f"AE-SBX-012: Docker daemon lost mid-run: "
1045
1093
  f"{res.stderr.strip() or res.stdout.strip()}")
1094
+ self._infra_error = err
1095
+ raise err
1096
+ reason = classify_infra_failure(res.exit_code, res.stderr, res.stdout)
1097
+ self._audit(request, result, container, infra_reason=reason)
1098
+ if reason is not None:
1099
+ tail = (res.stderr or res.stdout or "").strip()[-2000:]
1100
+ message = (
1101
+ f"AE-SBX-019: sandbox infrastructure failure: {reason}. The "
1102
+ "exec container cannot run commands; this is a Docker "
1103
+ "engine/VM problem, not a command failure. Check the engine "
1104
+ "health (docker info, docker system df) and restart Docker "
1105
+ "Desktop if the VM reports I/O errors (docs/setup.md)."
1106
+ )
1107
+ if tail:
1108
+ message += f" {tail}"
1109
+ err = SandboxError(message)
1110
+ self._infra_error = err
1111
+ raise err
1046
1112
  return result
1047
1113
 
1048
1114
  def _start_profile(self, key: tuple[str, bool], mount_set: MountSet,
@@ -1072,6 +1138,7 @@ class DockerBackend(Sandbox):
1072
1138
  raise SandboxError(
1073
1139
  f"AE-SBX-010: container run failed: "
1074
1140
  f"{res.stderr.strip() or res.stdout.strip()}")
1141
+ self._register_container(name)
1075
1142
  return _ProfileContainer(name=name, mount_set=mount_set,
1076
1143
  egress_policy=policy, proxy=proxy,
1077
1144
  started=True)
@@ -1181,6 +1248,28 @@ class DockerBackend(Sandbox):
1181
1248
  if prof.proxy is not None:
1182
1249
  prof.proxy.stop()
1183
1250
  self._pool.clear()
1251
+ self._prune_registry()
1252
+
1253
+ def _register_container(self, name: str) -> None:
1254
+ """Record one successfully created exec container in the host registry
1255
+ (030, D4). Best-effort: a broken registry only disables reaping, it
1256
+ never fails the run."""
1257
+ _append_registry_record(self._iso_state_dir, {
1258
+ "run_id": self._run_id,
1259
+ "name": name,
1260
+ "pid": os.getpid(),
1261
+ "process_start": process_start(os.getpid()),
1262
+ "created_at": datetime.now(timezone.utc).isoformat(),
1263
+ })
1264
+
1265
+ def _prune_registry(self) -> None:
1266
+ """Drop this run's registry entries after close() removed the
1267
+ containers (030, D4). Tolerates a missing registry file."""
1268
+ entries = _read_registry(self._iso_state_dir)
1269
+ if not entries:
1270
+ return
1271
+ remaining = [e for e in entries if e.get("run_id") != self._run_id]
1272
+ _write_registry(self._iso_state_dir, remaining)
1184
1273
 
1185
1274
  # -- internal -----------------------------------------------------------
1186
1275
 
@@ -1197,8 +1286,9 @@ class DockerBackend(Sandbox):
1197
1286
  "failed; refusing to continue")
1198
1287
 
1199
1288
  def _audit(self, request: SandboxExecutionRequest,
1200
- result: SandboxExecutionResult, container: str) -> None:
1201
- append_audit_record(self._iso_state_dir, {
1289
+ result: SandboxExecutionResult, container: str,
1290
+ *, infra_reason: str | None = None) -> None:
1291
+ record = {
1202
1292
  "timestamp": datetime.now(timezone.utc).isoformat(),
1203
1293
  "pipeline": request.pipeline or "",
1204
1294
  "step": request.step or "",
@@ -1208,7 +1298,10 @@ class DockerBackend(Sandbox):
1208
1298
  "exit_code": result.exit_code,
1209
1299
  "timed_out": result.timed_out,
1210
1300
  "duration_seconds": result.duration_seconds,
1211
- })
1301
+ }
1302
+ if infra_reason is not None:
1303
+ record["infra_reason"] = infra_reason
1304
+ append_audit_record(self._iso_state_dir, record)
1212
1305
 
1213
1306
 
1214
1307
  # ---------------------------------------------------------------------------
@@ -1300,6 +1393,119 @@ def clean_stamps(state_dir: Path) -> int:
1300
1393
  return len(files)
1301
1394
 
1302
1395
 
1396
+ # ---------------------------------------------------------------------------
1397
+ # Orphan-container registry + reaper (030, D4) - host-side only
1398
+ # ---------------------------------------------------------------------------
1399
+
1400
+
1401
+ def process_start(pid: int) -> str | None:
1402
+ """``ps -o lstart= -p <pid>`` - the owning CLI process's start time. None
1403
+ when ``ps`` is unavailable or the pid is gone. Recorded at create time and
1404
+ re-read at reap time to defeat pid recycling."""
1405
+ try:
1406
+ res = subprocess.run(["ps", "-o", "lstart=", "-p", str(pid)],
1407
+ capture_output=True, text=True, timeout=10)
1408
+ except (OSError, subprocess.SubprocessError):
1409
+ return None
1410
+ if res.returncode != 0:
1411
+ return None
1412
+ return res.stdout.strip()
1413
+
1414
+
1415
+ def _read_registry(state_dir: Path) -> list[dict]:
1416
+ path = Path(state_dir) / CONTAINER_REGISTRY
1417
+ if not path.is_file():
1418
+ return []
1419
+ entries: list[dict] = []
1420
+ try:
1421
+ for line in path.read_text(encoding="utf-8").splitlines():
1422
+ line = line.strip()
1423
+ if not line:
1424
+ continue
1425
+ try:
1426
+ entry = json.loads(line)
1427
+ except json.JSONDecodeError:
1428
+ continue
1429
+ if isinstance(entry, dict):
1430
+ entries.append(entry)
1431
+ except OSError:
1432
+ return []
1433
+ return entries
1434
+
1435
+
1436
+ def _write_registry(state_dir: Path, entries: list[dict]) -> None:
1437
+ path = Path(state_dir) / CONTAINER_REGISTRY
1438
+ try:
1439
+ path.parent.mkdir(parents=True, exist_ok=True)
1440
+ tmp = path.with_name(path.name + ".tmp")
1441
+ with tmp.open("w", encoding="utf-8") as fh:
1442
+ for entry in entries:
1443
+ fh.write(json.dumps(entry, sort_keys=True) + "\n")
1444
+ os.replace(tmp, path)
1445
+ except OSError:
1446
+ pass
1447
+
1448
+
1449
+ def _append_registry_record(state_dir: Path, record: dict) -> None:
1450
+ path = Path(state_dir) / CONTAINER_REGISTRY
1451
+ try:
1452
+ path.parent.mkdir(parents=True, exist_ok=True)
1453
+ with path.open("a", encoding="utf-8") as fh:
1454
+ fh.write(json.dumps(record, sort_keys=True) + "\n")
1455
+ except OSError:
1456
+ pass
1457
+
1458
+
1459
+ def _pid_alive(pid: int) -> bool:
1460
+ try:
1461
+ os.kill(pid, 0)
1462
+ return True
1463
+ except OSError:
1464
+ return False
1465
+
1466
+
1467
+ def _pid_alive_matches(pid: int, recorded_start: str | None) -> bool:
1468
+ """True when the recorded owning CLI process still owns the container: the
1469
+ pid exists and, when a start time was recorded, the process at that pid
1470
+ started at the same time (defeats pid recycling)."""
1471
+ if not _pid_alive(pid):
1472
+ return False
1473
+ if recorded_start is None:
1474
+ return True
1475
+ current = process_start(pid)
1476
+ if current is None:
1477
+ return True # cannot verify the start time - never reap a live pid
1478
+ return current == recorded_start
1479
+
1480
+
1481
+ def reap_orphaned_containers(state_dir: str | Path,
1482
+ runner: CommandRunner | None = None) -> None:
1483
+ """Startup orphan reaper (030, D4): remove exec containers whose owning
1484
+ CLI process is gone (crashed before close()). Exact by construction - only
1485
+ engine registry names are touched, and a live owner (pid alive with the
1486
+ same start time) is left alone. Best-effort: never raises."""
1487
+ runner = runner or _default_runner
1488
+ entries = _read_registry(Path(state_dir))
1489
+ if not entries:
1490
+ return
1491
+ remaining: list[dict] = []
1492
+ for entry in entries:
1493
+ name = entry.get("name")
1494
+ if not isinstance(name, str) or not _CONTAINER_NAME_RE.match(name):
1495
+ # Defense in depth: a corrupted registry entry is never an engine
1496
+ # container, so leave it (and its entry) untouched.
1497
+ remaining.append(entry)
1498
+ continue
1499
+ pid = entry.get("pid")
1500
+ if isinstance(pid, int) and _pid_alive_matches(
1501
+ pid, entry.get("process_start")):
1502
+ remaining.append(entry)
1503
+ continue
1504
+ runner.run(["docker", "rm", "-f", name], timeout=60)
1505
+ _announce(f"reaped orphaned sandbox container {name}")
1506
+ _write_registry(Path(state_dir), remaining)
1507
+
1508
+
1303
1509
  # ---------------------------------------------------------------------------
1304
1510
  # Eager startup (Q2) - probe → image → run -d --rm → provision all setups
1305
1511
  # ---------------------------------------------------------------------------
@@ -1399,6 +1605,9 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
1399
1605
  profile = resolve_effective_profile(cfg.isolation)
1400
1606
  image = resolve_image_ref(profile.image)
1401
1607
  iso_state_dir = isolation_state_dir(cfg)
1608
+ # 030, D4: reap exec containers leaked by a previous run's crash before
1609
+ # creating anything new (exact registry + pid liveness, never heuristics).
1610
+ reap_orphaned_containers(iso_state_dir, runner)
1402
1611
  needs_network = any(step.network for step in cfg.steps)
1403
1612
  sandbox_venv_dir = resolve_sandbox_venv_dir(
1404
1613
  cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
@@ -107,6 +107,12 @@ class Runtime:
107
107
  conv.send_message(message)
108
108
  conv.run()
109
109
 
110
+ # 030, D1/D2: a sticky infra failure must fail the run with no
111
+ # retry - check before any capture/verdict path would schedule an
112
+ # unclear-retry. The existing except turns this into ok=False.
113
+ if self.sandbox is not None and self.sandbox.infra_error is not None:
114
+ raise self.sandbox.infra_error
115
+
110
116
  truncated = conv.state.execution_status != ConversationExecutionStatus.FINISHED
111
117
 
112
118
  if step is not None and (step.artifact is not None or step.produces):