agentic-dev-loop 1.0.0a22__tar.gz → 1.0.0a24__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.0a22/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a24}/PKG-INFO +16 -1
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/README.md +15 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/config.py +36 -1
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/isolation.py +89 -7
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/sync_back.py +72 -8
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24/src/agentic_dev_loop.egg-info}/PKG-INFO +16 -1
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
- agentic_dev_loop-1.0.0a24/tests/test_029_merge_on_conflict.py +97 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_graph.py +0 -16
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_isolation.py +233 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_sync_back.py +124 -1
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/paths.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/presets.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/stages.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_028_declared_caches.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_e2e_fixtures.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_isolation_integration.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_unit5_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/tests/test_viz.py +0 -0
{agentic_dev_loop-1.0.0a22/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a24}/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.0a24
|
|
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
|
|
@@ -221,6 +221,7 @@ steps:
|
|
|
221
221
|
| `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
|
|
222
222
|
| `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` - a `locked` pipeline with no lockfiles fails fast at load. |
|
|
223
223
|
| `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
|
|
224
|
+
| `merge_on_conflict:` | Top-level run-wide list of append-style logs (same path vocabulary as `sync_excludes:`, default `[]`). A concurrent host edit to a listed path is union-merged instead of raising: the host file wins and the sandbox's export-only lines are appended in order; both sides converge and the run continues. See [docs/setup.md](docs/setup.md). |
|
|
224
225
|
|
|
225
226
|
### Secrets (`secrets:`) - refs only, never values
|
|
226
227
|
|
|
@@ -305,6 +306,20 @@ steps:
|
|
|
305
306
|
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
306
307
|
residual risks are documented in [docs/setup.md](docs/setup.md).
|
|
307
308
|
|
|
309
|
+
### Sync-back conflicts (host edits during a run)
|
|
310
|
+
|
|
311
|
+
Only declared-`rw` paths sync back to the real repo after a step. If the host
|
|
312
|
+
also edits a declared-`rw` path while the step is running, sync-back prints
|
|
313
|
+
`[isolation] sync-back conflict: ...` and raises `AE-SBX-016`; the host's
|
|
314
|
+
version wins, the baseline is re-snapshotted, and the next step (even
|
|
315
|
+
`echo hello`) runs clean instead of re-raising forever. For append-style logs
|
|
316
|
+
edited by both sides (an audit log, say), declare them in the top-level
|
|
317
|
+
`merge_on_conflict:` list: those paths are union-merged - the host keeps its
|
|
318
|
+
lines, the sandbox's new lines are appended in order, both sides converge, and
|
|
319
|
+
the run continues with `[isolation] merged concurrent edits: ...` on the
|
|
320
|
+
console.
|
|
321
|
+
|
|
322
|
+
|
|
308
323
|
## Dependency policy (locked by default)
|
|
309
324
|
|
|
310
325
|
The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
|
|
@@ -179,6 +179,7 @@ steps:
|
|
|
179
179
|
| `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
|
|
180
180
|
| `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` - a `locked` pipeline with no lockfiles fails fast at load. |
|
|
181
181
|
| `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
|
|
182
|
+
| `merge_on_conflict:` | Top-level run-wide list of append-style logs (same path vocabulary as `sync_excludes:`, default `[]`). A concurrent host edit to a listed path is union-merged instead of raising: the host file wins and the sandbox's export-only lines are appended in order; both sides converge and the run continues. See [docs/setup.md](docs/setup.md). |
|
|
182
183
|
|
|
183
184
|
### Secrets (`secrets:`) - refs only, never values
|
|
184
185
|
|
|
@@ -263,6 +264,20 @@ steps:
|
|
|
263
264
|
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
264
265
|
residual risks are documented in [docs/setup.md](docs/setup.md).
|
|
265
266
|
|
|
267
|
+
### Sync-back conflicts (host edits during a run)
|
|
268
|
+
|
|
269
|
+
Only declared-`rw` paths sync back to the real repo after a step. If the host
|
|
270
|
+
also edits a declared-`rw` path while the step is running, sync-back prints
|
|
271
|
+
`[isolation] sync-back conflict: ...` and raises `AE-SBX-016`; the host's
|
|
272
|
+
version wins, the baseline is re-snapshotted, and the next step (even
|
|
273
|
+
`echo hello`) runs clean instead of re-raising forever. For append-style logs
|
|
274
|
+
edited by both sides (an audit log, say), declare them in the top-level
|
|
275
|
+
`merge_on_conflict:` list: those paths are union-merged - the host keeps its
|
|
276
|
+
lines, the sandbox's new lines are appended in order, both sides converge, and
|
|
277
|
+
the run continues with `[isolation] merged concurrent edits: ...` on the
|
|
278
|
+
console.
|
|
279
|
+
|
|
280
|
+
|
|
266
281
|
## Dependency policy (locked by default)
|
|
267
282
|
|
|
268
283
|
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.0a24"
|
|
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"
|
|
@@ -446,6 +446,10 @@ class PipelineConfig(BaseModel):
|
|
|
446
446
|
# "no sync-back excludes".
|
|
447
447
|
caches: list[str] = []
|
|
448
448
|
sync_excludes: list[str] = []
|
|
449
|
+
# 029: append-style logs that sync-back union-merges on a concurrent
|
|
450
|
+
# edit instead of raising. Same declared-path vocabulary as
|
|
451
|
+
# ``sync_excludes:``.
|
|
452
|
+
merge_on_conflict: list[str] = []
|
|
449
453
|
|
|
450
454
|
@field_validator("deps_policy")
|
|
451
455
|
@classmethod
|
|
@@ -460,7 +464,7 @@ class PipelineConfig(BaseModel):
|
|
|
460
464
|
def _coerce_lockfiles(cls, v: Any) -> Any:
|
|
461
465
|
return [v] if isinstance(v, str) else v
|
|
462
466
|
|
|
463
|
-
@field_validator("caches", "sync_excludes", mode="before")
|
|
467
|
+
@field_validator("caches", "sync_excludes", "merge_on_conflict", mode="before")
|
|
464
468
|
@classmethod
|
|
465
469
|
def _coerce_declared_paths(cls, v: Any) -> Any:
|
|
466
470
|
return [v] if isinstance(v, str) else v
|
|
@@ -475,6 +479,11 @@ class PipelineConfig(BaseModel):
|
|
|
475
479
|
def _valid_sync_excludes(cls, v: list[str]) -> list[str]:
|
|
476
480
|
return [_validate_declared_path(c, allow_wildcards=True) for c in v]
|
|
477
481
|
|
|
482
|
+
@field_validator("merge_on_conflict")
|
|
483
|
+
@classmethod
|
|
484
|
+
def _valid_merge_on_conflict(cls, v: list[str]) -> list[str]:
|
|
485
|
+
return [_validate_declared_path(m, allow_wildcards=True) for m in v]
|
|
486
|
+
|
|
478
487
|
@field_validator("secrets", mode="before")
|
|
479
488
|
@classmethod
|
|
480
489
|
def _reject_value_shaped_refs(cls, v: Any) -> Any:
|
|
@@ -579,6 +588,32 @@ class PipelineConfig(BaseModel):
|
|
|
579
588
|
f"{('/'.join(cp))!r}")
|
|
580
589
|
return self
|
|
581
590
|
|
|
591
|
+
@model_validator(mode="after")
|
|
592
|
+
def _merge_avoid_excludes_and_caches(self) -> "PipelineConfig":
|
|
593
|
+
"""029: a literal merge_on_conflict entry equal to or under a literal
|
|
594
|
+
sync_excludes entry or a literal caches entry is redundant - excluded
|
|
595
|
+
and cached paths never sync, so they can never conflict. Wildcard
|
|
596
|
+
merge entries are skipped (their overlap is fuzzy, 028 parity)."""
|
|
597
|
+
cache_parts = [relative_parts(c) for c in self.caches if relative_parts(c)]
|
|
598
|
+
exclude_parts = [relative_parts(e) for e in self.sync_excludes
|
|
599
|
+
if not _has_wildcards(e) and relative_parts(e)]
|
|
600
|
+
for merge in self.merge_on_conflict:
|
|
601
|
+
if _has_wildcards(merge):
|
|
602
|
+
continue
|
|
603
|
+
mp = relative_parts(merge)
|
|
604
|
+
for cp in cache_parts:
|
|
605
|
+
if mp[:len(cp)] == cp:
|
|
606
|
+
raise ValueError(
|
|
607
|
+
f"merge_on_conflict entry {merge!r} is redundant: it is "
|
|
608
|
+
f"already excluded as declared cache {('/'.join(cp))!r}")
|
|
609
|
+
for ep in exclude_parts:
|
|
610
|
+
if mp[:len(ep)] == ep:
|
|
611
|
+
raise ValueError(
|
|
612
|
+
f"merge_on_conflict entry {merge!r} is redundant: it is "
|
|
613
|
+
f"already excluded by sync_excludes entry "
|
|
614
|
+
f"{('/'.join(ep))!r}")
|
|
615
|
+
return self
|
|
616
|
+
|
|
582
617
|
@model_validator(mode="after")
|
|
583
618
|
def _sandbox_venv_not_declared_cache(self) -> "PipelineConfig":
|
|
584
619
|
"""028: the sandbox venv is engine infrastructure, never author data.
|
{agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/isolation.py
RENAMED
|
@@ -43,7 +43,7 @@ from .mounts import Mount, MountSet
|
|
|
43
43
|
from .proc import needs_shell, split_command
|
|
44
44
|
from .seccomp import resolve_seccomp_profile
|
|
45
45
|
from .step_workspace import StepWorkspace
|
|
46
|
-
from .sync_back import SyncBackReport, _snapshot, sync_back
|
|
46
|
+
from .sync_back import SyncBackReport, _snapshot, merge_append_only, sync_back
|
|
47
47
|
|
|
48
48
|
# ---------------------------------------------------------------------------
|
|
49
49
|
# Constants (KD-2 / ID-4)
|
|
@@ -791,7 +791,8 @@ class DockerBackend(Sandbox):
|
|
|
791
791
|
seccomp_profile: str | None = None,
|
|
792
792
|
export_root: str | None = None,
|
|
793
793
|
cache_mounts: list[tuple[str, str, str]] | None = None,
|
|
794
|
-
sync_excludes: list[str] | None = None
|
|
794
|
+
sync_excludes: list[str] | None = None,
|
|
795
|
+
merge_on_conflict: list[str] | None = None):
|
|
795
796
|
self._image = image
|
|
796
797
|
self._workspace = workspace
|
|
797
798
|
self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
|
|
@@ -818,6 +819,9 @@ class DockerBackend(Sandbox):
|
|
|
818
819
|
# 028: run-wide sync-back excludes (scratch paths - skipped from both
|
|
819
820
|
# the export copy and every snapshot).
|
|
820
821
|
self._sync_excludes = list(sync_excludes or ())
|
|
822
|
+
# 029: append-style logs union-merged on a concurrent-edit conflict
|
|
823
|
+
# instead of raising (host wins, export-only lines appended).
|
|
824
|
+
self._merge_on_conflict = list(merge_on_conflict or ())
|
|
821
825
|
self._baseline: dict[str, str] = {}
|
|
822
826
|
# The profile-keyed pool (D-1/BR-10): profile key -> running container.
|
|
823
827
|
self._pool: dict[tuple[str, bool], _ProfileContainer] = {}
|
|
@@ -1072,19 +1076,75 @@ class DockerBackend(Sandbox):
|
|
|
1072
1076
|
egress_policy=policy, proxy=proxy,
|
|
1073
1077
|
started=True)
|
|
1074
1078
|
|
|
1079
|
+
def _reset_export_to_host(self, rel: str) -> None:
|
|
1080
|
+
"""Absorb one hard-conflict path: the host wins, so the export is
|
|
1081
|
+
reset to match it. A host-side deletion removes the export file; any
|
|
1082
|
+
other host state is copied back (preserving symlinks)."""
|
|
1083
|
+
host_path = Path(self._workspace) / rel
|
|
1084
|
+
export_path = Path(self._export_root) / rel
|
|
1085
|
+
if not host_path.exists() and not host_path.is_symlink():
|
|
1086
|
+
export_path.unlink(missing_ok=True)
|
|
1087
|
+
return
|
|
1088
|
+
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1089
|
+
shutil.copy2(host_path, export_path, follow_symlinks=False)
|
|
1090
|
+
|
|
1075
1091
|
def _sync_back(self, mount_set: MountSet) -> None:
|
|
1076
1092
|
"""Sync declared-rw export changes back to the real repo (BR-4/F1),
|
|
1077
|
-
git-aware, host-side, with the sandbox step already complete.
|
|
1078
|
-
|
|
1093
|
+
git-aware, host-side, with the sandbox step already complete.
|
|
1094
|
+
|
|
1095
|
+
029: a conflict on a declared ``merge_on_conflict:`` path is
|
|
1096
|
+
union-merged (host wins, export-only lines appended) and does not
|
|
1097
|
+
raise. A conflict on any other path - including a declared merge path
|
|
1098
|
+
one side deleted - is announced, absorbed (the host wins and the
|
|
1099
|
+
export is reset to it), and then still raises AE-SBX-016 scoped to
|
|
1100
|
+
those hard paths, so the next sync-back runs clean. Merge paths are
|
|
1101
|
+
union-merged first, before any hard-conflict raise."""
|
|
1079
1102
|
report: SyncBackReport = sync_back(
|
|
1080
1103
|
self._export_root, self._workspace, list(mount_set.rw_paths()),
|
|
1081
1104
|
baseline=self._baseline, step_id=None,
|
|
1082
|
-
cache_paths=self._cache_paths, excludes=self._sync_excludes
|
|
1105
|
+
cache_paths=self._cache_paths, excludes=self._sync_excludes,
|
|
1106
|
+
merge_on_conflict=self._merge_on_conflict)
|
|
1107
|
+
|
|
1108
|
+
# 029 step 2: union-merge declared merge paths first, so a mixed
|
|
1109
|
+
# merge+hard conflict still converges the append-style logs before
|
|
1110
|
+
# the hard paths raise.
|
|
1111
|
+
for rel in report.merged_paths:
|
|
1112
|
+
host_path = Path(self._workspace) / rel
|
|
1113
|
+
export_path = Path(self._export_root) / rel
|
|
1114
|
+
host_text = host_path.read_text(encoding="utf-8")
|
|
1115
|
+
export_text = export_path.read_text(encoding="utf-8")
|
|
1116
|
+
merged = merge_append_only(host_text, export_text)
|
|
1117
|
+
appended = len(merged.splitlines()) - len(host_text.splitlines())
|
|
1118
|
+
host_path.write_text(merged, encoding="utf-8")
|
|
1119
|
+
export_path.write_text(merged, encoding="utf-8")
|
|
1120
|
+
_announce(
|
|
1121
|
+
f"merged concurrent edits: {rel} "
|
|
1122
|
+
f"({appended} sandbox lines appended)")
|
|
1123
|
+
append_audit_record(self._iso_state_dir, {
|
|
1124
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
1125
|
+
"event": "sync_back_merge",
|
|
1126
|
+
"path": rel,
|
|
1127
|
+
"appended_lines": appended,
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1083
1130
|
if report.conflicts:
|
|
1131
|
+
# fix 1 + fix 2: absorb the divergence once (host wins on every
|
|
1132
|
+
# hard path, reset the export to match) and announce before the
|
|
1133
|
+
# raise, so the next exec - even `echo hello` - runs clean.
|
|
1134
|
+
for rel in report.conflicts:
|
|
1135
|
+
self._reset_export_to_host(rel)
|
|
1136
|
+
_announce(
|
|
1137
|
+
f"sync-back conflict: {', '.join(report.conflicts)} - the "
|
|
1138
|
+
"workspace changed underneath; edits to these paths will "
|
|
1139
|
+
"not sync")
|
|
1140
|
+
self._baseline = _snapshot(Path(self._export_root),
|
|
1141
|
+
self._cache_paths,
|
|
1142
|
+
self._sync_excludes)
|
|
1084
1143
|
raise SandboxError(
|
|
1085
1144
|
f"AE-SBX-016: sync-back conflict in "
|
|
1086
1145
|
f"{', '.join(report.conflicts)} - the real repo changed "
|
|
1087
|
-
f"underneath;
|
|
1146
|
+
f"underneath; edits to these paths will not sync (BR-4.4)")
|
|
1147
|
+
|
|
1088
1148
|
self._baseline = _snapshot(Path(self._export_root),
|
|
1089
1149
|
self._cache_paths,
|
|
1090
1150
|
self._sync_excludes)
|
|
@@ -1092,6 +1152,7 @@ class DockerBackend(Sandbox):
|
|
|
1092
1152
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
1093
1153
|
"event": "sync_back",
|
|
1094
1154
|
"synced_paths": list(report.synced_paths),
|
|
1155
|
+
"merged_paths": list(report.merged_paths),
|
|
1095
1156
|
})
|
|
1096
1157
|
|
|
1097
1158
|
def close(self) -> None:
|
|
@@ -1307,6 +1368,16 @@ def resolve_image_ref(image: str, environ: Mapping[str, str] | None = None) -> s
|
|
|
1307
1368
|
return image
|
|
1308
1369
|
|
|
1309
1370
|
|
|
1371
|
+
_MODULE_TAG = __name__.rsplit(".", 1)[-1]
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _announce(msg: str) -> None:
|
|
1375
|
+
"""One-line console progress note for the eager startup phases. The tag is
|
|
1376
|
+
derived from the emitting module's own name (what a logging library's
|
|
1377
|
+
name field would record), never hardcoded."""
|
|
1378
|
+
print(f"[{_MODULE_TAG}] {msg}", flush=True)
|
|
1379
|
+
|
|
1380
|
+
|
|
1310
1381
|
def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
1311
1382
|
state_dir: str | Path,
|
|
1312
1383
|
runner: CommandRunner | None = None,
|
|
@@ -1323,6 +1394,7 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
1323
1394
|
direct Sandbox API (DockerBackend without an export + requests carrying
|
|
1324
1395
|
``mount_set=None``), which is integration-tests-only."""
|
|
1325
1396
|
seccomp_profile = resolved_seccomp_profile(cfg)
|
|
1397
|
+
_announce("probing docker daemon...")
|
|
1326
1398
|
probe_docker(runner)
|
|
1327
1399
|
profile = resolve_effective_profile(cfg.isolation)
|
|
1328
1400
|
image = resolve_image_ref(profile.image)
|
|
@@ -1343,15 +1415,18 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
1343
1415
|
cache_paths.append(SANDBOX_VENV_DIR)
|
|
1344
1416
|
for cache in cfg.caches:
|
|
1345
1417
|
(workspace / cache).mkdir(parents=True, exist_ok=True)
|
|
1418
|
+
_announce("materialising workspace export...")
|
|
1346
1419
|
ws = StepWorkspace.materialise(
|
|
1347
1420
|
workspace, cache_paths, sys.platform, export_root,
|
|
1348
1421
|
state_dir=str(state_dir), excludes=list(cfg.sync_excludes))
|
|
1349
1422
|
export_root = str(ws.export_root)
|
|
1350
1423
|
cache_mounts = ws.mount_points()[1:] # skip the export root itself
|
|
1424
|
+
_announce(f"export ready at {export_root}")
|
|
1351
1425
|
# Repair console-script shebangs that bake a previous run's dead export
|
|
1352
1426
|
# path (028): a venv built by export A must execute its ``bin/*`` scripts
|
|
1353
1427
|
# when mounted into export B.
|
|
1354
1428
|
venv_dir = Path(sandbox_venv_dir) if sandbox_venv_dir else workspace / SANDBOX_VENV_DIR
|
|
1429
|
+
_announce("checking venv shebangs")
|
|
1355
1430
|
repair_venv_shebangs(venv_dir, state_dir=state_dir, export_root=export_root)
|
|
1356
1431
|
|
|
1357
1432
|
backend = DockerBackend(
|
|
@@ -1359,12 +1434,19 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
1359
1434
|
profile=profile, runner=runner, network=needs_network,
|
|
1360
1435
|
sandbox_venv_dir=sandbox_venv_dir, seccomp_profile=seccomp_profile,
|
|
1361
1436
|
export_root=export_root, cache_mounts=cache_mounts,
|
|
1362
|
-
sync_excludes=list(cfg.sync_excludes)
|
|
1437
|
+
sync_excludes=list(cfg.sync_excludes),
|
|
1438
|
+
merge_on_conflict=list(cfg.merge_on_conflict))
|
|
1439
|
+
_announce(f"preparing sandbox image {image} (first pull can take a while)")
|
|
1363
1440
|
backend.ensure_image()
|
|
1441
|
+
_announce("sandbox image ready")
|
|
1442
|
+
_announce("starting execution container...")
|
|
1364
1443
|
backend.create_execution_container()
|
|
1444
|
+
_announce("execution container started")
|
|
1365
1445
|
if refresh is None:
|
|
1366
1446
|
refresh = bool(cfg.isolation and cfg.isolation.refresh)
|
|
1367
1447
|
for setup_cmd, stamp_key in _declared_setups(cfg, workspace):
|
|
1448
|
+
_announce(f"provisioning setup (stamp {stamp_key[:8]})")
|
|
1368
1449
|
backend.provision(setup_cmd, stamp_key, str(iso_state_dir / "stamps"),
|
|
1369
1450
|
force=refresh)
|
|
1451
|
+
_announce("sandbox ready")
|
|
1370
1452
|
return backend
|
{agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/sync_back.py
RENAMED
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
import hashlib
|
|
17
17
|
import os
|
|
18
18
|
import shutil
|
|
19
|
+
from collections import Counter
|
|
19
20
|
from dataclasses import dataclass
|
|
20
21
|
from datetime import datetime, timezone
|
|
21
22
|
from pathlib import Path
|
|
@@ -43,10 +44,38 @@ class SyncBackReport:
|
|
|
43
44
|
|
|
44
45
|
synced_paths: tuple[str, ...]
|
|
45
46
|
conflicts: tuple[str, ...]
|
|
47
|
+
merged_paths: tuple[str, ...] = ()
|
|
46
48
|
step_id: str | None = None
|
|
47
49
|
timestamp: str = ""
|
|
48
50
|
|
|
49
51
|
|
|
52
|
+
def merge_append_only(host_text: str, export_text: str) -> str:
|
|
53
|
+
"""Union-merge two append-style logs (029): the host file wins for every
|
|
54
|
+
line it already contains (host order is preserved) and each export-only
|
|
55
|
+
line is appended in export order. Nothing is ever deleted, so both sides
|
|
56
|
+
converge on the line-level union; identical sides return the host text
|
|
57
|
+
unchanged.
|
|
58
|
+
|
|
59
|
+
Export-only is a multiset difference against the host, not a naive
|
|
60
|
+
``difflib`` opcode filter: the common both-appended-tail case is a
|
|
61
|
+
``replace`` opcode there, and a union must keep both tails."""
|
|
62
|
+
host_lines = host_text.splitlines()
|
|
63
|
+
export_lines = export_text.splitlines()
|
|
64
|
+
remaining: Counter[str] = Counter(host_lines)
|
|
65
|
+
extra: list[str] = []
|
|
66
|
+
for line in export_lines:
|
|
67
|
+
if remaining[line]:
|
|
68
|
+
remaining[line] -= 1
|
|
69
|
+
else:
|
|
70
|
+
extra.append(line)
|
|
71
|
+
if not extra:
|
|
72
|
+
return host_text
|
|
73
|
+
merged = "\n".join(host_lines + extra)
|
|
74
|
+
if host_text.endswith("\n") or export_text.endswith("\n"):
|
|
75
|
+
merged += "\n"
|
|
76
|
+
return merged
|
|
77
|
+
|
|
78
|
+
|
|
50
79
|
def _parts(rel: str) -> tuple[str, ...]:
|
|
51
80
|
"""Normalise a repo-relative path into its non-empty, non-``.`` segments."""
|
|
52
81
|
return tuple(seg for seg in rel.replace("\\", "/").split("/")
|
|
@@ -157,14 +186,20 @@ def sync_back(export_root: str | Path, real_repo: str | Path,
|
|
|
157
186
|
baseline: Mapping[str, str] | None = None,
|
|
158
187
|
step_id: str | None = None,
|
|
159
188
|
cache_paths: list[str] | tuple[str, ...] = (),
|
|
160
|
-
excludes: list[str] | tuple[str, ...] = ()
|
|
189
|
+
excludes: list[str] | tuple[str, ...] = (),
|
|
190
|
+
merge_on_conflict: list[str] | tuple[str, ...] = ()) -> SyncBackReport:
|
|
161
191
|
"""Diff-copy declared-rw export changes into the real repo (ID-5).
|
|
162
192
|
|
|
163
193
|
A conflict - a declared-rw path changed in the export AND in the real repo
|
|
164
194
|
since materialisation - is a loud failure (BR-4.4): the report carries the
|
|
165
|
-
conflicts and nothing is copied.
|
|
166
|
-
|
|
167
|
-
|
|
195
|
+
conflicts and nothing is copied. A ``merge_on_conflict:`` path (029) is
|
|
196
|
+
partitioned across every change kind: a both-sides-present conflict is
|
|
197
|
+
reported in ``merged_paths`` for the caller to union-merge, while a
|
|
198
|
+
one-side deletion (``added``/``deleted``) stays a hard conflict so the
|
|
199
|
+
append-only log is never silently resurrected or erased. Non-conflicting
|
|
200
|
+
adds/modifies still copy and deletions still remove host-side when no
|
|
201
|
+
hard conflict remains. ``.git``, the bind-mounted caches, and declared
|
|
202
|
+
sync-excludes are never touched."""
|
|
168
203
|
export_root = Path(export_root)
|
|
169
204
|
real_repo = Path(real_repo)
|
|
170
205
|
baseline = dict(baseline or {})
|
|
@@ -191,10 +226,39 @@ def sync_back(export_root: str | Path, real_repo: str | Path,
|
|
|
191
226
|
else:
|
|
192
227
|
changes[rel] = "modified"
|
|
193
228
|
|
|
194
|
-
|
|
195
|
-
|
|
229
|
+
merge_specs = list(merge_on_conflict or ())
|
|
230
|
+
merged_paths: list[str] = []
|
|
231
|
+
hard_conflicts: list[str] = []
|
|
232
|
+
plan_changes: dict[str, str] = {}
|
|
233
|
+
for rel, kind in changes.items():
|
|
234
|
+
if path_excluded(rel, merge_specs):
|
|
235
|
+
# Declared merge path (029). Union-merge only handles both sides
|
|
236
|
+
# present; a one-side deletion stays a hard conflict (raise +
|
|
237
|
+
# absorb, host wins) so a concurrent host/export delete can never
|
|
238
|
+
# silently resurrect or erase the append-only log.
|
|
239
|
+
if kind == "conflict":
|
|
240
|
+
merged_paths.append(rel)
|
|
241
|
+
elif kind in ("added", "deleted"):
|
|
242
|
+
hard_conflicts.append(rel)
|
|
243
|
+
else:
|
|
244
|
+
plan_changes[rel] = kind
|
|
245
|
+
elif kind == "conflict":
|
|
246
|
+
hard_conflicts.append(rel)
|
|
247
|
+
else:
|
|
248
|
+
plan_changes[rel] = kind
|
|
249
|
+
|
|
250
|
+
plan = plan_sync_back(plan_changes, declared_rw, cache_paths, excludes)
|
|
251
|
+
merged_paths = tuple(sorted(merged_paths))
|
|
252
|
+
hard_conflicts = tuple(sorted(hard_conflicts))
|
|
253
|
+
|
|
254
|
+
# A hard (non-merge) conflict keeps the BR-4.4 loud failure and the
|
|
255
|
+
# copy/delete stays atomic: nothing non-merge copies or deletes when one
|
|
256
|
+
# remains. Merge paths are still reported so the caller union-merges them
|
|
257
|
+
# first (029 step order: merge before the hard-conflict raise).
|
|
258
|
+
if hard_conflicts:
|
|
196
259
|
return SyncBackReport(
|
|
197
|
-
synced_paths=(), conflicts=
|
|
260
|
+
synced_paths=(), conflicts=hard_conflicts,
|
|
261
|
+
merged_paths=merged_paths, step_id=step_id,
|
|
198
262
|
timestamp=datetime.now(timezone.utc).isoformat())
|
|
199
263
|
|
|
200
264
|
for rel in plan.to_copy:
|
|
@@ -207,5 +271,5 @@ def sync_back(export_root: str | Path, real_repo: str | Path,
|
|
|
207
271
|
|
|
208
272
|
return SyncBackReport(
|
|
209
273
|
synced_paths=plan.to_copy + plan.to_delete,
|
|
210
|
-
conflicts=(), step_id=step_id,
|
|
274
|
+
conflicts=(), merged_paths=merged_paths, step_id=step_id,
|
|
211
275
|
timestamp=datetime.now(timezone.utc).isoformat())
|
{agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24/src/agentic_dev_loop.egg-info}/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.0a24
|
|
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
|
|
@@ -221,6 +221,7 @@ steps:
|
|
|
221
221
|
| `deps_policy` | `locked` (default) or `open`. `locked` = the human-owned lockfile is the source of truth and the agent may not add dependencies; `open` = the bootstrap protocol (the agent may add exact-pinned manifest entries). See [Dependency policy](#dependency-policy-locked-by-default). |
|
|
222
222
|
| `lockfiles` | The pipeline's declared lockfile names (workspace-relative), injected into agent context and named by the missing-dependency fail-fast. Required (non-empty) when `deps_policy: locked` - a `locked` pipeline with no lockfiles fails fast at load. |
|
|
223
223
|
| `caches:` / `sync_excludes:` | Top-level run-wide lists. `caches:` names repo-relative toolchain cache dirs bind-mounted rw from the real workspace (auto-created if missing); `sync_excludes:` names scratch paths skipped from both the export copy and sync-back (wildcard `*` spans separators, so `**/bin/**` matches a `/bin/` segment at any depth). Both default to `[]`. See [docs/setup.md](docs/setup.md). |
|
|
224
|
+
| `merge_on_conflict:` | Top-level run-wide list of append-style logs (same path vocabulary as `sync_excludes:`, default `[]`). A concurrent host edit to a listed path is union-merged instead of raising: the host file wins and the sandbox's export-only lines are appended in order; both sides converge and the run continues. See [docs/setup.md](docs/setup.md). |
|
|
224
225
|
|
|
225
226
|
### Secrets (`secrets:`) - refs only, never values
|
|
226
227
|
|
|
@@ -305,6 +306,20 @@ steps:
|
|
|
305
306
|
everywhere. Reserve it for stages that need it (QA, integration). The
|
|
306
307
|
residual risks are documented in [docs/setup.md](docs/setup.md).
|
|
307
308
|
|
|
309
|
+
### Sync-back conflicts (host edits during a run)
|
|
310
|
+
|
|
311
|
+
Only declared-`rw` paths sync back to the real repo after a step. If the host
|
|
312
|
+
also edits a declared-`rw` path while the step is running, sync-back prints
|
|
313
|
+
`[isolation] sync-back conflict: ...` and raises `AE-SBX-016`; the host's
|
|
314
|
+
version wins, the baseline is re-snapshotted, and the next step (even
|
|
315
|
+
`echo hello`) runs clean instead of re-raising forever. For append-style logs
|
|
316
|
+
edited by both sides (an audit log, say), declare them in the top-level
|
|
317
|
+
`merge_on_conflict:` list: those paths are union-merged - the host keeps its
|
|
318
|
+
lines, the sandbox's new lines are appended in order, both sides converge, and
|
|
319
|
+
the run continues with `[isolation] merged concurrent edits: ...` on the
|
|
320
|
+
console.
|
|
321
|
+
|
|
322
|
+
|
|
308
323
|
## Dependency policy (locked by default)
|
|
309
324
|
|
|
310
325
|
The supply-chain dial is a pipeline-level `deps_policy:` field, **locked by
|
{agentic_dev_loop-1.0.0a22 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/SOURCES.txt
RENAMED
|
@@ -44,6 +44,7 @@ src/agentic_dev_loop/viz/__init__.py
|
|
|
44
44
|
src/agentic_dev_loop/viz/__main__.py
|
|
45
45
|
src/agentic_dev_loop/viz/server.py
|
|
46
46
|
tests/test_028_declared_caches.py
|
|
47
|
+
tests/test_029_merge_on_conflict.py
|
|
47
48
|
tests/test_actions.py
|
|
48
49
|
tests/test_agents.py
|
|
49
50
|
tests/test_aidlc.py
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""029: ``merge_on_conflict:`` config surface and validation.
|
|
2
|
+
|
|
3
|
+
Top-level run-wide list of append-style logs that sync-back union-merges on a
|
|
4
|
+
concurrent-edit conflict instead of raising. Same declared-path vocabulary as
|
|
5
|
+
``sync_excludes:``; literals under a literal ``sync_excludes:``/``caches:``
|
|
6
|
+
entry are redundant and rejected.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
import pytest
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from agentic_dev_loop.core.config import load_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _base() -> dict:
|
|
17
|
+
return {
|
|
18
|
+
"name": "t",
|
|
19
|
+
"llm": {"model": "m", "api_key_env": "K"},
|
|
20
|
+
"agents_dir": "agents",
|
|
21
|
+
"lockfiles": ["uv.lock"],
|
|
22
|
+
"steps": [{"name": "dev", "agent": "dev", "on_pass": "success"}],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load(tmp_path, data):
|
|
27
|
+
p = tmp_path / "pipeline.yaml"
|
|
28
|
+
p.write_text(yaml.safe_dump(data))
|
|
29
|
+
return load_config(p)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestCoercionAndAcceptance:
|
|
33
|
+
def test_single_string_coerced_to_list(self, tmp_path):
|
|
34
|
+
data = _base()
|
|
35
|
+
data["merge_on_conflict"] = "aidlc-docs/audit.md"
|
|
36
|
+
assert _load(tmp_path, data).merge_on_conflict == ["aidlc-docs/audit.md"]
|
|
37
|
+
|
|
38
|
+
def test_defaults_empty(self, tmp_path):
|
|
39
|
+
assert _load(tmp_path, _base()).merge_on_conflict == []
|
|
40
|
+
|
|
41
|
+
def test_wildcards_accepted(self, tmp_path):
|
|
42
|
+
data = _base()
|
|
43
|
+
data["merge_on_conflict"] = ["**/audit.md", "*.log"]
|
|
44
|
+
assert _load(tmp_path, data).merge_on_conflict == ["**/audit.md", "*.log"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestRejected:
|
|
48
|
+
def test_traversal(self, tmp_path):
|
|
49
|
+
data = _base()
|
|
50
|
+
data["merge_on_conflict"] = ["../escape"]
|
|
51
|
+
with pytest.raises(ValidationError, match="traversal"):
|
|
52
|
+
_load(tmp_path, data)
|
|
53
|
+
|
|
54
|
+
def test_absolute(self, tmp_path):
|
|
55
|
+
data = _base()
|
|
56
|
+
data["merge_on_conflict"] = ["/abs"]
|
|
57
|
+
with pytest.raises(ValidationError, match="repo-relative"):
|
|
58
|
+
_load(tmp_path, data)
|
|
59
|
+
|
|
60
|
+
def test_git(self, tmp_path):
|
|
61
|
+
data = _base()
|
|
62
|
+
data["merge_on_conflict"] = [".git/config"]
|
|
63
|
+
with pytest.raises(ValidationError, match=r"\.git"):
|
|
64
|
+
_load(tmp_path, data)
|
|
65
|
+
|
|
66
|
+
def test_host_only(self, tmp_path):
|
|
67
|
+
data = _base()
|
|
68
|
+
data["merge_on_conflict"] = [".venv"]
|
|
69
|
+
with pytest.raises(ValidationError, match="host-only"):
|
|
70
|
+
_load(tmp_path, data)
|
|
71
|
+
|
|
72
|
+
def test_under_sync_excludes_redundant(self, tmp_path):
|
|
73
|
+
data = _base()
|
|
74
|
+
data["sync_excludes"] = ["scratch"]
|
|
75
|
+
data["merge_on_conflict"] = ["scratch/audit.md"]
|
|
76
|
+
with pytest.raises(ValidationError, match="redundant"):
|
|
77
|
+
_load(tmp_path, data)
|
|
78
|
+
|
|
79
|
+
def test_under_cache_redundant(self, tmp_path):
|
|
80
|
+
data = _base()
|
|
81
|
+
data["caches"] = ["node_modules"]
|
|
82
|
+
data["merge_on_conflict"] = ["node_modules/audit.md"]
|
|
83
|
+
with pytest.raises(ValidationError, match="redundant"):
|
|
84
|
+
_load(tmp_path, data)
|
|
85
|
+
|
|
86
|
+
def test_wildcard_overlap_not_rejected(self, tmp_path):
|
|
87
|
+
"""Wildcard overlap is fuzzy, never a hard contradiction (028 parity)."""
|
|
88
|
+
data = _base()
|
|
89
|
+
data["sync_excludes"] = ["**/bin/**"]
|
|
90
|
+
data["caches"] = ["qa"]
|
|
91
|
+
data["merge_on_conflict"] = ["**/audit.md"]
|
|
92
|
+
assert _load(tmp_path, data).merge_on_conflict == ["**/audit.md"]
|
|
93
|
+
|
|
94
|
+
def test_clear_entry_accepted(self, tmp_path):
|
|
95
|
+
data = _base()
|
|
96
|
+
data["merge_on_conflict"] = ["aidlc-docs/audit.md"]
|
|
97
|
+
assert _load(tmp_path, data).merge_on_conflict == ["aidlc-docs/audit.md"]
|
|
@@ -9,7 +9,6 @@ import argparse
|
|
|
9
9
|
import queue
|
|
10
10
|
import sqlite3
|
|
11
11
|
import subprocess
|
|
12
|
-
import time
|
|
13
12
|
from pathlib import Path
|
|
14
13
|
|
|
15
14
|
import pytest
|
|
@@ -353,21 +352,6 @@ class TestCommandStep:
|
|
|
353
352
|
second_dev = fake_agents.messages_for("dev")[1]
|
|
354
353
|
assert "test output" in second_dev and "attempt 1" in second_dev
|
|
355
354
|
|
|
356
|
-
def test_timeout_exhausts_run_and_kills_process_group(self, repo,
|
|
357
|
-
fake_agents):
|
|
358
|
-
yaml = COMMAND_STAGE_YAML.replace(
|
|
359
|
-
" command: python -c \"import sys; import pathlib; print('test output'); sys.exit(0 if pathlib.Path('.pr/fix.marker').exists() else 1)\"",
|
|
360
|
-
' command: python -c "import time; time.sleep(3)"\n timeout: 0.2')
|
|
361
|
-
self._write(repo, yaml)
|
|
362
|
-
fake_agents.set("dev", [("write", "fix.marker", "")])
|
|
363
|
-
start = time.monotonic()
|
|
364
|
-
ok, attempts = run_pipeline("commandstage.yaml", args())
|
|
365
|
-
elapsed = time.monotonic() - start
|
|
366
|
-
assert (ok, attempts) == (False, 3)
|
|
367
|
-
assert fake_agents.agents_called() == ["dev"] * 3
|
|
368
|
-
assert all("timed out" in m for m in fake_agents.messages_for("dev")[1:])
|
|
369
|
-
assert elapsed < 5.0 # killpg cut the 3s sleeps short
|
|
370
|
-
|
|
371
355
|
def test_fail_forever_with_cli_max_attempts(self, repo, fake_agents):
|
|
372
356
|
yaml = COMMAND_STAGE_YAML.replace(
|
|
373
357
|
"sys.exit(0 if pathlib.Path('.pr/fix.marker').exists() else 1)",
|