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.
- {agentic_dev_loop-1.0.0a24/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a25}/PKG-INFO +122 -3
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/README.md +121 -2
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/agents.py +17 -2
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/isolation.py +214 -5
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/stages.py +6 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25/src/agentic_dev_loop.egg-info}/PKG-INFO +122 -3
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_agents.py +26 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_isolation.py +235 -10
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_isolation_integration.py +36 -3
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_sdk_compat.py +18 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_stages.py +28 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_sync_back.py +4 -1
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/config.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/paths.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/presets.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/sync_back.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/SOURCES.txt +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_028_declared_caches.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_029_merge_on_conflict.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_e2e_fixtures.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_unit5_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/tests/test_viz.py +0 -0
{agentic_dev_loop-1.0.0a24/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a25}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentic-dev-loop
|
|
3
|
-
Version: 1.0.
|
|
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,
|
|
83
|
-
|
|
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,
|
|
41
|
-
|
|
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.
|
|
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"
|
{agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/agents.py
RENAMED
|
@@ -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,
|
|
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
|
-
|
|
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,
|
{agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/isolation.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
1201
|
-
|
|
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)
|
{agentic_dev_loop-1.0.0a24 → agentic_dev_loop-1.0.0a25}/src/agentic_dev_loop/engine/stages.py
RENAMED
|
@@ -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):
|