diff --git a/src/agent_comms/storage_rules.py b/src/agent_comms/storage_rules.py index 932353015a..d720a9db5a 100644 --- a/src/agent_comms/storage_rules.py +++ b/src/agent_comms/storage_rules.py @@ -689,14 +689,17 @@ def require_write(self, path: Path) -> None: owner, = owners resolved = path.resolve() sources = Checkout.git_roots(resolved, *resolved.parents) + scratch = self.rule.roots_of(owner).scratch.resolve() + # The registry's scratch allocation is more specific than an enclosing + # source tree. A repository rooted in that allocation is still source. + if resolved.is_relative_to(scratch) and not any( + root.is_relative_to(scratch) for root in sources): + return store = self.comms.goals.todos allowed = frozenset(boundary for root in sources for allocation in CheckoutAllocation.for_path(self.rule, owner, root) for boundary in allocation.edit_boundary(self.rule, owner, root, store, self.reads)) - # A thread registered at a directory holding its scratch (unit-fieldplan at $HOME) still - # owns that scratch: the rule gate's audit lives there, and refusing it deadlocked the unit. - in_project = (resolved.is_relative_to(owner.project_path.resolve()) - and not resolved.is_relative_to(self.rule.roots_of(owner).scratch.resolve())) + in_project = resolved.is_relative_to(owner.project_path.resolve()) if (sources or path.is_relative_to(self.rule.checkout_base) or in_project) and not any( resolved.is_relative_to(root) for root in allowed): self.rule.deny(f'{path} is a read/launch source, not allocated editable work of {owner.name}.') diff --git a/tests/test_storage_rules.py b/tests/test_storage_rules.py index 38b3e59b4e..5b50cdb83f 100644 --- a/tests/test_storage_rules.py +++ b/tests/test_storage_rules.py @@ -282,19 +282,44 @@ def test_equal_repository_spellings_allocate_one_owned_checkout(checkout_comms: assert path in rule.checkouts_of(thread, RepositoryReads()) -def test_a_thread_registered_above_its_scratch_may_write_its_scratch_and_only_that(checkout_comms: Comms): - """unit-fieldplan is registered at $HOME: its audit path, which the rule gate requires, sat - inside its project and was refused as source it does not hold, so nothing could run.""" +@pytest.mark.parametrize('enclosing_source', (False, True)) +def test_a_thread_registered_above_its_scratch_may_write_its_scratch_and_only_that( + checkout_comms: Comms, enclosing_source: bool): + """An enclosing project or Git tree cannot take the registry's scratch allocation.""" comms = checkout_comms rule = comms.registry.storage_rule above = rule.scratch_base.parent + if enclosing_source: + Repo.git(above, 'init', '-q') thread = comms.registry.declare(Thread('homed', frozenset(), Checkout(above))) projection = StorageProjection(rule, thread, comms) - projection.require_write(rule.roots_of(thread).scratch / 'compaction-deal.audit.json') + scratch = rule.roots_of(thread).scratch + projection.require_write(scratch / 'compaction-deal.audit.json') with pytest.raises(StorageDenied, match='not allocated editable work'): projection.require_write(above / 'notes.txt') with pytest.raises(StorageDenied, match='foreign scratch'): projection.require_write(rule.scratch_base / 'worker' / 'notes.txt') + with pytest.raises(StorageDenied, match='artifacts'): + projection.require_write(scratch / '.artifacts' / 'output') + scratch.mkdir(parents=True) + (scratch / 'project-link').symlink_to(above, target_is_directory=True) + with pytest.raises(StorageDenied, match='not allocated editable work'): + projection.require_write(scratch / 'project-link' / 'notes.txt') + + +@pytest.mark.parametrize('source_path', (Path(), Path('nested-source'))) +def test_git_source_inside_scratch_still_requires_an_edit_allocation( + checkout_comms: Comms, source_path: Path): + comms = checkout_comms + rule = comms.registry.storage_rule + above = rule.scratch_base.parent + thread = comms.registry.declare(Thread('homed', frozenset(), Checkout(above))) + scratch = rule.roots_of(thread).scratch + source = scratch / source_path + source.mkdir(parents=True) + Repo.git(source, 'init', '-q') + with pytest.raises(StorageDenied, match='not allocated editable work'): + StorageProjection(rule, thread, comms).require_write(source / 'implementation.py') def test_three_small_checkouts_are_admitted(checkout_comms: Comms):