agentic-dev-loop 1.0.0a23__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.0a23/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a24}/PKG-INFO +16 -1
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/README.md +15 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/config.py +36 -1
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/isolation.py +69 -7
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/sync_back.py +72 -8
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24/src/agentic_dev_loop.egg-info}/PKG-INFO +16 -1
- {agentic_dev_loop-1.0.0a23 → 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.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_isolation.py +196 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_sync_back.py +124 -1
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/paths.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/mounts.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/presets.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/stages.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_028_declared_caches.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_config.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_e2e_fixtures.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_egress_proxy.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_isolation_integration.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_qa_prompt.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_repo_ascii.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_step_workspace.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_trigger_script.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_unit5_config.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_unit5_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_unit5_pure.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_viz.py +0 -0
{agentic_dev_loop-1.0.0a23/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.0a23 → 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:
|
|
@@ -1373,7 +1434,8 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
1373
1434
|
profile=profile, runner=runner, network=needs_network,
|
|
1374
1435
|
sandbox_venv_dir=sandbox_venv_dir, seccomp_profile=seccomp_profile,
|
|
1375
1436
|
export_root=export_root, cache_mounts=cache_mounts,
|
|
1376
|
-
sync_excludes=list(cfg.sync_excludes)
|
|
1437
|
+
sync_excludes=list(cfg.sync_excludes),
|
|
1438
|
+
merge_on_conflict=list(cfg.merge_on_conflict))
|
|
1377
1439
|
_announce(f"preparing sandbox image {image} (first pull can take a while)")
|
|
1378
1440
|
backend.ensure_image()
|
|
1379
1441
|
_announce("sandbox image ready")
|
{agentic_dev_loop-1.0.0a23 → 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.0a23 → 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.0a23 → 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"]
|
|
@@ -1609,6 +1609,202 @@ class TestUnit5RuntimeEnforcement:
|
|
|
1609
1609
|
backend.close()
|
|
1610
1610
|
|
|
1611
1611
|
|
|
1612
|
+
def test_sync_back_merge_path_converges_and_no_raise(self, tmp_path, capsys):
|
|
1613
|
+
"""029: a conflict on a declared merge path is union-merged: host and
|
|
1614
|
+
export converge, the baseline is re-snapshotted, and no raise occurs."""
|
|
1615
|
+
import hashlib
|
|
1616
|
+
|
|
1617
|
+
ws = tmp_path / "ws"
|
|
1618
|
+
(ws / "src").mkdir(parents=True)
|
|
1619
|
+
(ws / "src" / "audit.md").write_text("base\nhost-line\n")
|
|
1620
|
+
export = tmp_path / "export"
|
|
1621
|
+
(export / "src").mkdir(parents=True)
|
|
1622
|
+
(export / "src" / "audit.md").write_text("base\nexport-line\n")
|
|
1623
|
+
runner = FakeRunner(ok("cid"), ok(""))
|
|
1624
|
+
backend = DockerBackend(
|
|
1625
|
+
image="img@sha256:abc", workspace=str(ws),
|
|
1626
|
+
iso_state_dir=str(tmp_path / "state"),
|
|
1627
|
+
profile=EffectiveProfile(True, True, True, True, True,
|
|
1628
|
+
"img@sha256:abc", "4g", 2.0),
|
|
1629
|
+
runner=runner, network=False, export_root=str(export),
|
|
1630
|
+
merge_on_conflict=["src/audit.md"])
|
|
1631
|
+
backend._baseline = {"src/audit.md": hashlib.sha256(b"base\n").hexdigest()}
|
|
1632
|
+
ms = resolve_mount_set([MountSpec(path="src", mode="rw")])
|
|
1633
|
+
req = SandboxExecutionRequest(argv=["echo", "hi"], cwd=str(export),
|
|
1634
|
+
env={}, network=False, mount_set=ms,
|
|
1635
|
+
egress_policy=EgressPolicy())
|
|
1636
|
+
try:
|
|
1637
|
+
backend.run(req)
|
|
1638
|
+
merged = "base\nhost-line\nexport-line\n"
|
|
1639
|
+
assert (ws / "src" / "audit.md").read_text() == merged
|
|
1640
|
+
assert (export / "src" / "audit.md").read_text() == merged
|
|
1641
|
+
out = capsys.readouterr().out
|
|
1642
|
+
assert ("[isolation] merged concurrent edits: src/audit.md "
|
|
1643
|
+
"(1 sandbox lines appended)") in out
|
|
1644
|
+
audit = (tmp_path / "state" / "sandbox-audit.jsonl").read_text()
|
|
1645
|
+
assert "sync_back_merge" in audit
|
|
1646
|
+
assert "src/audit.md" in audit
|
|
1647
|
+
# the baseline was re-snapshotted: a subsequent sync is clean
|
|
1648
|
+
backend.run(req)
|
|
1649
|
+
finally:
|
|
1650
|
+
backend.close()
|
|
1651
|
+
|
|
1652
|
+
def test_sync_back_conflict_absorbs_baseline_for_next_sync(self, tmp_path,
|
|
1653
|
+
capsys):
|
|
1654
|
+
"""029 fixes 1+2: a hard conflict announces once, discards the
|
|
1655
|
+
conflicted exec's edit (host wins), re-snapshots the baseline, and the
|
|
1656
|
+
next sync-back with no new changes runs clean."""
|
|
1657
|
+
import hashlib
|
|
1658
|
+
|
|
1659
|
+
ws = tmp_path / "ws"
|
|
1660
|
+
(ws / "src").mkdir(parents=True)
|
|
1661
|
+
(ws / "src" / "a.py").write_text("external")
|
|
1662
|
+
export = tmp_path / "export"
|
|
1663
|
+
(export / "src").mkdir(parents=True)
|
|
1664
|
+
(export / "src" / "a.py").write_text("agent")
|
|
1665
|
+
runner = FakeRunner(ok("cid"), ok(""))
|
|
1666
|
+
backend = DockerBackend(
|
|
1667
|
+
image="img@sha256:abc", workspace=str(ws),
|
|
1668
|
+
iso_state_dir=str(tmp_path / "state"),
|
|
1669
|
+
profile=EffectiveProfile(True, True, True, True, True,
|
|
1670
|
+
"img@sha256:abc", "4g", 2.0),
|
|
1671
|
+
runner=runner, network=False, export_root=str(export))
|
|
1672
|
+
backend._baseline = {"src/a.py": hashlib.sha256(b"orig").hexdigest()}
|
|
1673
|
+
ms = resolve_mount_set([MountSpec(path="src", mode="rw")])
|
|
1674
|
+
req = SandboxExecutionRequest(argv=["echo", "hi"], cwd=str(export),
|
|
1675
|
+
env={}, network=False, mount_set=ms,
|
|
1676
|
+
egress_policy=EgressPolicy())
|
|
1677
|
+
try:
|
|
1678
|
+
with pytest.raises(SandboxError, match="AE-SBX-016"):
|
|
1679
|
+
backend.run(req)
|
|
1680
|
+
assert (ws / "src" / "a.py").read_text() == "external"
|
|
1681
|
+
assert (export / "src" / "a.py").read_text() == "external"
|
|
1682
|
+
out = capsys.readouterr().out
|
|
1683
|
+
assert ("[isolation] sync-back conflict: src/a.py - the workspace "
|
|
1684
|
+
"changed underneath; edits to these paths will not sync") in out
|
|
1685
|
+
backend.run(req) # absorbed: no new changes, no raise
|
|
1686
|
+
finally:
|
|
1687
|
+
backend.close()
|
|
1688
|
+
|
|
1689
|
+
def test_conflict_raise_lists_only_hard_paths(self, tmp_path, capsys):
|
|
1690
|
+
"""029: a mixed merge+hard conflict merges the append-style log first
|
|
1691
|
+
and then raises AE-SBX-016 scoped to the hard paths only."""
|
|
1692
|
+
import hashlib
|
|
1693
|
+
|
|
1694
|
+
ws = tmp_path / "ws"
|
|
1695
|
+
(ws / "src").mkdir(parents=True)
|
|
1696
|
+
(ws / "src" / "audit.md").write_text("base\nhost-line\n")
|
|
1697
|
+
(ws / "src" / "b.py").write_text("external-b")
|
|
1698
|
+
export = tmp_path / "export"
|
|
1699
|
+
(export / "src").mkdir(parents=True)
|
|
1700
|
+
(export / "src" / "audit.md").write_text("base\nexport-line\n")
|
|
1701
|
+
(export / "src" / "b.py").write_text("agent-b")
|
|
1702
|
+
runner = FakeRunner(ok("cid"), ok(""))
|
|
1703
|
+
backend = DockerBackend(
|
|
1704
|
+
image="img@sha256:abc", workspace=str(ws),
|
|
1705
|
+
iso_state_dir=str(tmp_path / "state"),
|
|
1706
|
+
profile=EffectiveProfile(True, True, True, True, True,
|
|
1707
|
+
"img@sha256:abc", "4g", 2.0),
|
|
1708
|
+
runner=runner, network=False, export_root=str(export),
|
|
1709
|
+
merge_on_conflict=["src/audit.md"])
|
|
1710
|
+
backend._baseline = {
|
|
1711
|
+
"src/audit.md": hashlib.sha256(b"base\n").hexdigest(),
|
|
1712
|
+
"src/b.py": hashlib.sha256(b"orig-b").hexdigest(),
|
|
1713
|
+
}
|
|
1714
|
+
ms = resolve_mount_set([MountSpec(path="src", mode="rw")])
|
|
1715
|
+
req = SandboxExecutionRequest(argv=["echo", "hi"], cwd=str(export),
|
|
1716
|
+
env={}, network=False, mount_set=ms,
|
|
1717
|
+
egress_policy=EgressPolicy())
|
|
1718
|
+
try:
|
|
1719
|
+
with pytest.raises(SandboxError) as exc:
|
|
1720
|
+
backend.run(req)
|
|
1721
|
+
assert "src/b.py" in str(exc.value)
|
|
1722
|
+
assert "audit.md" not in str(exc.value)
|
|
1723
|
+
merged = "base\nhost-line\nexport-line\n"
|
|
1724
|
+
assert (ws / "src" / "audit.md").read_text() == merged
|
|
1725
|
+
assert (export / "src" / "audit.md").read_text() == merged
|
|
1726
|
+
out = capsys.readouterr().out
|
|
1727
|
+
assert ("[isolation] merged concurrent edits: src/audit.md "
|
|
1728
|
+
"(1 sandbox lines appended)") in out
|
|
1729
|
+
assert ("[isolation] sync-back conflict: src/b.py - the workspace "
|
|
1730
|
+
"changed underneath; edits to these paths will not sync") in out
|
|
1731
|
+
finally:
|
|
1732
|
+
backend.close()
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def test_merge_path_host_deleted_raises_and_absorb_removes_export(
|
|
1736
|
+
self, tmp_path, capsys):
|
|
1737
|
+
"""029: a host-deleted declared merge path is a hard conflict - the
|
|
1738
|
+
host wins, the export copy is unlinked, and AE-SBX-016 raises once."""
|
|
1739
|
+
import hashlib
|
|
1740
|
+
|
|
1741
|
+
ws = tmp_path / "ws"
|
|
1742
|
+
(ws / "src").mkdir(parents=True)
|
|
1743
|
+
export = tmp_path / "export"
|
|
1744
|
+
(export / "src").mkdir(parents=True)
|
|
1745
|
+
(export / "src" / "audit.md").write_text("export\n")
|
|
1746
|
+
runner = FakeRunner(ok("cid"), ok(""))
|
|
1747
|
+
backend = DockerBackend(
|
|
1748
|
+
image="img@sha256:abc", workspace=str(ws),
|
|
1749
|
+
iso_state_dir=str(tmp_path / "state"),
|
|
1750
|
+
profile=EffectiveProfile(True, True, True, True, True,
|
|
1751
|
+
"img@sha256:abc", "4g", 2.0),
|
|
1752
|
+
runner=runner, network=False, export_root=str(export),
|
|
1753
|
+
merge_on_conflict=["src/audit.md"])
|
|
1754
|
+
backend._baseline = {"src/audit.md": hashlib.sha256(b"base\n").hexdigest()}
|
|
1755
|
+
ms = resolve_mount_set([MountSpec(path="src", mode="rw")])
|
|
1756
|
+
req = SandboxExecutionRequest(argv=["echo", "hi"], cwd=str(export),
|
|
1757
|
+
env={}, network=False, mount_set=ms,
|
|
1758
|
+
egress_policy=EgressPolicy())
|
|
1759
|
+
try:
|
|
1760
|
+
with pytest.raises(SandboxError, match="AE-SBX-016"):
|
|
1761
|
+
backend.run(req)
|
|
1762
|
+
assert not (ws / "src" / "audit.md").exists()
|
|
1763
|
+
assert not (export / "src" / "audit.md").exists()
|
|
1764
|
+
out = capsys.readouterr().out
|
|
1765
|
+
assert ("[isolation] sync-back conflict: src/audit.md - the "
|
|
1766
|
+
"workspace changed underneath") in out
|
|
1767
|
+
backend.run(req) # absorbed: no new changes, no raise
|
|
1768
|
+
finally:
|
|
1769
|
+
backend.close()
|
|
1770
|
+
|
|
1771
|
+
def test_merge_path_export_deleted_raises_and_absorb_restores_export(
|
|
1772
|
+
self, tmp_path, capsys):
|
|
1773
|
+
"""029: an export-deleted declared merge path is a hard conflict - the
|
|
1774
|
+
host wins, the export copy is restored, and AE-SBX-016 raises once."""
|
|
1775
|
+
import hashlib
|
|
1776
|
+
|
|
1777
|
+
ws = tmp_path / "ws"
|
|
1778
|
+
(ws / "src").mkdir(parents=True)
|
|
1779
|
+
(ws / "src" / "audit.md").write_text("host\n")
|
|
1780
|
+
export = tmp_path / "export"
|
|
1781
|
+
(export / "src").mkdir(parents=True)
|
|
1782
|
+
runner = FakeRunner(ok("cid"), ok(""))
|
|
1783
|
+
backend = DockerBackend(
|
|
1784
|
+
image="img@sha256:abc", workspace=str(ws),
|
|
1785
|
+
iso_state_dir=str(tmp_path / "state"),
|
|
1786
|
+
profile=EffectiveProfile(True, True, True, True, True,
|
|
1787
|
+
"img@sha256:abc", "4g", 2.0),
|
|
1788
|
+
runner=runner, network=False, export_root=str(export),
|
|
1789
|
+
merge_on_conflict=["src/audit.md"])
|
|
1790
|
+
backend._baseline = {"src/audit.md": hashlib.sha256(b"base\n").hexdigest()}
|
|
1791
|
+
ms = resolve_mount_set([MountSpec(path="src", mode="rw")])
|
|
1792
|
+
req = SandboxExecutionRequest(argv=["echo", "hi"], cwd=str(export),
|
|
1793
|
+
env={}, network=False, mount_set=ms,
|
|
1794
|
+
egress_policy=EgressPolicy())
|
|
1795
|
+
try:
|
|
1796
|
+
with pytest.raises(SandboxError, match="AE-SBX-016"):
|
|
1797
|
+
backend.run(req)
|
|
1798
|
+
assert (ws / "src" / "audit.md").read_text() == "host\n"
|
|
1799
|
+
assert (export / "src" / "audit.md").read_text() == "host\n"
|
|
1800
|
+
out = capsys.readouterr().out
|
|
1801
|
+
assert ("[isolation] sync-back conflict: src/audit.md - the "
|
|
1802
|
+
"workspace changed underneath") in out
|
|
1803
|
+
backend.run(req) # absorbed: no new changes, no raise
|
|
1804
|
+
finally:
|
|
1805
|
+
backend.close()
|
|
1806
|
+
|
|
1807
|
+
|
|
1612
1808
|
class TestDeclaredCachesAndSyncExcludes:
|
|
1613
1809
|
"""028: ``caches:``/``sync_excludes:`` wiring into startup and the
|
|
1614
1810
|
engine's removal of the hardcoded cache whitelist."""
|
|
@@ -6,7 +6,7 @@ import os
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
-
from agentic_dev_loop.engine.sync_back import sync_back
|
|
9
|
+
from agentic_dev_loop.engine.sync_back import merge_append_only, sync_back
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def _digest(p):
|
|
@@ -222,3 +222,126 @@ class TestSyncExcludes:
|
|
|
222
222
|
assert a.to_copy == ("src/a.py",)
|
|
223
223
|
assert a.to_delete == ()
|
|
224
224
|
assert a.conflicts == ()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TestMergeAppendOnly:
|
|
228
|
+
def test_identical_sides_are_noop(self):
|
|
229
|
+
assert merge_append_only("a\nb\n", "a\nb\n") == "a\nb\n"
|
|
230
|
+
|
|
231
|
+
def test_host_only_lines_survive(self):
|
|
232
|
+
assert merge_append_only("a\nb\nhost\n", "a\nb\n") == "a\nb\nhost\n"
|
|
233
|
+
|
|
234
|
+
def test_export_only_lines_appended_in_order(self):
|
|
235
|
+
assert merge_append_only(
|
|
236
|
+
"a\nb\n", "a\nb\nexport1\nexport2\n") == "a\nb\nexport1\nexport2\n"
|
|
237
|
+
|
|
238
|
+
def test_both_appended_tails_union(self):
|
|
239
|
+
assert merge_append_only(
|
|
240
|
+
"a\nb\nhost-new\n", "a\nb\nexport-new\n") == "a\nb\nhost-new\nexport-new\n"
|
|
241
|
+
|
|
242
|
+
def test_deletions_never_applied_and_duplicates_kept(self):
|
|
243
|
+
assert merge_append_only("a\nb\n", "a\nb\na\n") == "a\nb\na\n"
|
|
244
|
+
|
|
245
|
+
def test_empty_host_is_export_text(self):
|
|
246
|
+
assert merge_append_only("", "a\n") == "a\n"
|
|
247
|
+
|
|
248
|
+
def test_no_export_only_lines_returns_host_verbatim(self):
|
|
249
|
+
host = "a\nb"
|
|
250
|
+
assert merge_append_only(host, "a\nb") is host
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TestMergeOnConflictPartition:
|
|
254
|
+
def test_merge_conflict_partitioned_and_non_conflicting_copied(self, tmp_path):
|
|
255
|
+
ws = _workspace(tmp_path)
|
|
256
|
+
exp = _export(tmp_path, ws)
|
|
257
|
+
(ws / "src" / "audit.md").write_text("base\nhost-line\n")
|
|
258
|
+
(exp / "src" / "audit.md").write_text("base\nexport-line\n")
|
|
259
|
+
(exp / "src" / "a.py").write_text("changed-a")
|
|
260
|
+
baseline = {"src/audit.md": hashlib.sha256(b"base\n").hexdigest(),
|
|
261
|
+
"src/a.py": _digest(ws / "src" / "a.py")}
|
|
262
|
+
report = sync_back(exp, ws, ["src"], baseline=baseline,
|
|
263
|
+
merge_on_conflict=["src/audit.md"])
|
|
264
|
+
assert report.conflicts == ()
|
|
265
|
+
assert report.merged_paths == ("src/audit.md",)
|
|
266
|
+
assert "src/a.py" in report.synced_paths
|
|
267
|
+
assert (ws / "src" / "a.py").read_text() == "changed-a"
|
|
268
|
+
# merge paths are merged by the engine, not by the planner
|
|
269
|
+
assert (ws / "src" / "audit.md").read_text() == "base\nhost-line\n"
|
|
270
|
+
assert (exp / "src" / "audit.md").read_text() == "base\nexport-line\n"
|
|
271
|
+
|
|
272
|
+
def test_non_merge_conflict_still_loud_and_atomic(self, tmp_path):
|
|
273
|
+
ws = _workspace(tmp_path)
|
|
274
|
+
exp = _export(tmp_path, ws)
|
|
275
|
+
(exp / "src" / "a.py").write_text("agent-change")
|
|
276
|
+
(ws / "src" / "a.py").write_text("external-change")
|
|
277
|
+
baseline = {"src/a.py": hashlib.sha256(b"orig-a").hexdigest()}
|
|
278
|
+
report = sync_back(exp, ws, ["src"], baseline=baseline,
|
|
279
|
+
merge_on_conflict=["other.md"])
|
|
280
|
+
assert report.conflicts == ("src/a.py",)
|
|
281
|
+
assert report.merged_paths == ()
|
|
282
|
+
assert report.synced_paths == ()
|
|
283
|
+
|
|
284
|
+
def test_mixed_merge_and_hard_keeps_non_merge_atomic(self, tmp_path):
|
|
285
|
+
"""A hard conflict leaves the non-merge copy/delete atomic, but the
|
|
286
|
+
merge path is still reported so the caller can union-merge it first."""
|
|
287
|
+
ws = _workspace(tmp_path)
|
|
288
|
+
exp = _export(tmp_path, ws)
|
|
289
|
+
(ws / "src" / "audit.md").write_text("base\nhost-line\n")
|
|
290
|
+
(exp / "src" / "audit.md").write_text("base\nexport-line\n")
|
|
291
|
+
(exp / "src" / "b.py").write_text("agent-b")
|
|
292
|
+
(ws / "src" / "b.py").write_text("external-b")
|
|
293
|
+
baseline = {"src/audit.md": hashlib.sha256(b"base\n").hexdigest(),
|
|
294
|
+
"src/b.py": hashlib.sha256(b"orig-b").hexdigest()}
|
|
295
|
+
report = sync_back(exp, ws, ["src"], baseline=baseline,
|
|
296
|
+
merge_on_conflict=["src/audit.md"])
|
|
297
|
+
assert report.conflicts == ("src/b.py",)
|
|
298
|
+
assert report.merged_paths == ("src/audit.md",)
|
|
299
|
+
assert report.synced_paths == ()
|
|
300
|
+
|
|
301
|
+
def test_wildcard_merge_match(self, tmp_path):
|
|
302
|
+
ws = _workspace(tmp_path)
|
|
303
|
+
exp = _export(tmp_path, ws)
|
|
304
|
+
(ws / "src" / "build.log").write_text("base\nhost\n")
|
|
305
|
+
(exp / "src" / "build.log").write_text("base\nexport\n")
|
|
306
|
+
baseline = {"src/build.log": hashlib.sha256(b"base\n").hexdigest()}
|
|
307
|
+
report = sync_back(exp, ws, ["src"], baseline=baseline,
|
|
308
|
+
merge_on_conflict=["*.log"])
|
|
309
|
+
assert report.conflicts == ()
|
|
310
|
+
assert report.merged_paths == ("src/build.log",)
|
|
311
|
+
|
|
312
|
+
def test_export_deleted_merge_path_is_hard_conflict(self, tmp_path):
|
|
313
|
+
ws = _workspace(tmp_path)
|
|
314
|
+
exp = _export(tmp_path, ws)
|
|
315
|
+
(ws / "src" / "audit.md").write_text("host\n")
|
|
316
|
+
baseline = {"src/audit.md": hashlib.sha256(b"base\n").hexdigest()}
|
|
317
|
+
report = sync_back(exp, ws, ["src"], baseline=baseline,
|
|
318
|
+
merge_on_conflict=["src/audit.md"])
|
|
319
|
+
assert report.conflicts == ("src/audit.md",)
|
|
320
|
+
assert report.merged_paths == ()
|
|
321
|
+
assert report.synced_paths == ()
|
|
322
|
+
assert (ws / "src" / "audit.md").read_text() == "host\n"
|
|
323
|
+
|
|
324
|
+
def test_host_deleted_merge_path_is_hard_conflict(self, tmp_path):
|
|
325
|
+
ws = _workspace(tmp_path)
|
|
326
|
+
exp = _export(tmp_path, ws)
|
|
327
|
+
(exp / "src" / "audit.md").write_text("export\n")
|
|
328
|
+
baseline = {"src/audit.md": hashlib.sha256(b"base\n").hexdigest()}
|
|
329
|
+
report = sync_back(exp, ws, ["src"], baseline=baseline,
|
|
330
|
+
merge_on_conflict=["src/audit.md"])
|
|
331
|
+
assert report.conflicts == ("src/audit.md",)
|
|
332
|
+
assert report.merged_paths == ()
|
|
333
|
+
assert report.synced_paths == ()
|
|
334
|
+
assert not (ws / "src" / "audit.md").exists()
|
|
335
|
+
|
|
336
|
+
def test_export_only_change_on_merge_path_still_copies(self, tmp_path):
|
|
337
|
+
ws = _workspace(tmp_path)
|
|
338
|
+
exp = _export(tmp_path, ws)
|
|
339
|
+
(ws / "src" / "audit.md").write_text("base\n")
|
|
340
|
+
(exp / "src" / "audit.md").write_text("base\nexport\n")
|
|
341
|
+
baseline = {"src/audit.md": hashlib.sha256(b"base\n").hexdigest()}
|
|
342
|
+
report = sync_back(exp, ws, ["src"], baseline=baseline,
|
|
343
|
+
merge_on_conflict=["src/audit.md"])
|
|
344
|
+
assert report.conflicts == ()
|
|
345
|
+
assert report.merged_paths == ()
|
|
346
|
+
assert "src/audit.md" in report.synced_paths
|
|
347
|
+
assert (ws / "src" / "audit.md").read_text() == "base\nexport\n"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/results.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/verdict.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/__init__.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/actions.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/agents.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/dryrun.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/egress_policy.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/egress_proxy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/graph.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/hostboundary.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/loaders.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/mounts.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/outputs.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/presets.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/secrets.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/stages.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/__init__.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/requires.txt
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_hostboundary_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|