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.

Files changed (89) hide show
  1. {agentic_dev_loop-1.0.0a23/src/agentic_dev_loop.egg-info → agentic_dev_loop-1.0.0a24}/PKG-INFO +16 -1
  2. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/README.md +15 -0
  3. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/pyproject.toml +1 -1
  4. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/config.py +36 -1
  5. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/isolation.py +69 -7
  6. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/sync_back.py +72 -8
  7. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24/src/agentic_dev_loop.egg-info}/PKG-INFO +16 -1
  8. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
  9. agentic_dev_loop-1.0.0a24/tests/test_029_merge_on_conflict.py +97 -0
  10. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_isolation.py +196 -0
  11. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_sync_back.py +124 -1
  12. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/LICENSE +0 -0
  13. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/setup.cfg +0 -0
  14. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/__init__.py +0 -0
  15. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/__init__.py +0 -0
  16. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/paths.py +0 -0
  17. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/results.py +0 -0
  18. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/core/verdict.py +0 -0
  19. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/__init__.py +0 -0
  20. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/actions.py +0 -0
  21. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/agents.py +0 -0
  22. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/cli.py +0 -0
  23. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/deps.py +0 -0
  24. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  25. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/egress_policy.py +0 -0
  26. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/egress_proxy.py +0 -0
  27. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  28. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  29. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/graph.py +0 -0
  30. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  31. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/llm.py +0 -0
  32. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/loaders.py +0 -0
  33. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/mounts.py +0 -0
  34. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/outputs.py +0 -0
  35. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/presets.py +0 -0
  36. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/proc.py +0 -0
  37. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/registry.py +0 -0
  38. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/seccomp/__init__.py +0 -0
  39. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/seccomp/chromium-userns.json +0 -0
  40. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/secrets.py +0 -0
  41. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/stages.py +0 -0
  42. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/engine/step_workspace.py +0 -0
  43. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/py.typed +0 -0
  44. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/__init__.py +0 -0
  45. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/__main__.py +0 -0
  46. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop/viz/server.py +0 -0
  47. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  48. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  49. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  50. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  51. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_028_declared_caches.py +0 -0
  52. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_actions.py +0 -0
  53. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_agents.py +0 -0
  54. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_aidlc.py +0 -0
  55. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_cli.py +0 -0
  56. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_config.py +0 -0
  57. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_deps.py +0 -0
  58. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_dry_run.py +0 -0
  59. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_e2e_fixtures.py +0 -0
  60. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_egress_proxy.py +0 -0
  61. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_graph.py +0 -0
  62. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_hostboundary.py +0 -0
  63. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_hostboundary_integration.py +0 -0
  64. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_isolation_integration.py +0 -0
  65. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_isolation_pbt.py +0 -0
  66. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_llm.py +0 -0
  67. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_loaders.py +0 -0
  68. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_module_contract.py +0 -0
  69. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_outputs.py +0 -0
  70. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_outputs_pbt.py +0 -0
  71. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_proc.py +0 -0
  72. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_publish.py +0 -0
  73. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_qa_prompt.py +0 -0
  74. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_registry.py +0 -0
  75. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_repo_ascii.py +0 -0
  76. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_results.py +0 -0
  77. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_sdk_compat.py +0 -0
  78. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_secrets.py +0 -0
  79. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_secrets_integration.py +0 -0
  80. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_secrets_pipeline.py +0 -0
  81. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_source_resolution.py +0 -0
  82. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_stages.py +0 -0
  83. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_step_workspace.py +0 -0
  84. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_trigger_script.py +0 -0
  85. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_unit5_config.py +0 -0
  86. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_unit5_pbt.py +0 -0
  87. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_unit5_pure.py +0 -0
  88. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_verdict.py +0 -0
  89. {agentic_dev_loop-1.0.0a23 → agentic_dev_loop-1.0.0a24}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a23
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.0a23"
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.
@@ -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. A
1078
- conflict (the real repo changed underneath) is a loud failure."""
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; resolve before retrying (BR-4.4)")
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")
@@ -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, ...] = ()) -> SyncBackReport:
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. Otherwise declared-rw adds/modifies copy
166
- host-side and declared-rw deletions remove host-side; ``.git``, the
167
- bind-mounted caches, and declared sync-excludes are never touched."""
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
- plan = plan_sync_back(changes, declared_rw, cache_paths, excludes)
195
- if plan.conflicts:
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=plan.conflicts, step_id=step_id,
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())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a23
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
@@ -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"