From 9eed030ceb7f12640541208d681f22aaf3d1db63 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:16:18 +0800 Subject: [PATCH 1/2] fix(quota): admit scoped local Goal material writes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/quota-allocation.md | 12 +- loopx/control_plane/agents/workspace_guard.py | 86 ++++- .../control_plane/quota/projection_repair.py | 7 + .../quota/settlement_workspace_causality.py | 22 ++ .../quota/settlement_workspace_causality.ts | 7 +- loopx/control_plane/quota/should_run.py | 10 +- .../control_plane/todos/work_requirements.ts | 38 +- .../test_goal_local_write_admission.py | 336 ++++++++++++++++++ 8 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 tests/control_plane/test_goal_local_write_admission.py diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 1487bd62bc..505fd01963 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -1489,12 +1489,22 @@ Post-turn accounting protocol: registered project root. This lets validated non-repository work settle without inventing a repository, including peer research and material work. The existing Todo claim/lease and completion validator still apply; local - delivery is not `same_agent_non_delivery`. A Git peer delivery still requires + identity does not waive causal delivery requirements. A Git peer delivery still requires an `independent_git_worktree`. An explicit Git task repository or an explicit owner isolation requirement rejects a local Goal receipt. An outside-root workspace cannot produce that local receipt. For originless Git, the checkout root must equal the registered project root; nested repositories, linked worktrees, invalid/empty origins and failed Git config reads cannot fall back. +- `quota should-run` also recognizes explicitly declared local material work: + `same_agent_non_delivery`, a non-code task domain, no task repository, and all + relative Todo write scopes contained by absolute Goal grants rooted at the + observed local project. These peer tasks keep their write scopes and use the + existing local Goal delivery snapshot instead of requiring a Git worktree. + Exact paths and recursive directory grants qualify; complex absolute globs + do not. Outside-root work receives a return-to-Goal diagnostic. Code work, + foreign repositories, undeclared contracts and explicit isolation policies + retain their existing guards. This changes local peer material admission, + not authorization, claim/lease rules or causal settlement requirements. - `todo complete --evidence ` can record a validated local artifact. `--result-file` additionally requires approved Goal acceptance criteria bound to that Todo. A standalone Todo validator does not establish Goal acceptance; diff --git a/loopx/control_plane/agents/workspace_guard.py b/loopx/control_plane/agents/workspace_guard.py index f0d07a4545..830a0efdb1 100644 --- a/loopx/control_plane/agents/workspace_guard.py +++ b/loopx/control_plane/agents/workspace_guard.py @@ -323,10 +323,13 @@ def _peer_work_requires_isolated_workspace( agent_todo_summary: dict[str, Any] | None, *, selected_todo: dict[str, Any] | None = None, + local_write_scope_admitted: bool = False, ) -> bool: explicit = workspace_guard_policy.get("peer_independent_worktree_required") if explicit is not None: return explicit is True + if local_write_scope_admitted: + return False candidate = ( selected_todo if isinstance(selected_todo, dict) and selected_todo @@ -344,6 +347,44 @@ def _peer_work_requires_isolated_workspace( ) +def observe_goal_local_write_scopes( + goal: dict[str, Any], + selected_todo: dict[str, Any] | None, + allowed_write_scopes: list[str] | None = None, +) -> dict[str, Any]: + """Read physical Goal identity once; typed work requirements own admission.""" + empty = {"admitted": False, "allowed_write_scopes": []} + if ( + not selected_todo + or selected_todo.get("task_repository") + or selected_todo.get("continuation_policy") != "same_agent_non_delivery" + or not selected_todo.get("required_write_scopes") + ): + return empty + repo = goal.get("repo") or goal.get("project") or goal.get("root") + goal_id = goal.get("goal_id") or goal.get("id") + if not repo or not goal_id: + return empty + root = Path(str(repo)).expanduser() + if not root.is_absolute(): + return empty + snapshot = capture_delivery_workspace( + root, local_goal_id=str(goal_id), local_project_root=root + ) + if not snapshot or snapshot.get("identity_kind") != "local_goal": + return empty + boundary = goal.get("coordination") or {} + raw_scopes = boundary.get("write_scope") if isinstance(boundary, dict) else None + scopes = allowed_write_scopes if allowed_write_scopes is not None else raw_scopes + if not isinstance(scopes, list) or any(not isinstance(scope, str) for scope in scopes): + return empty + from ..quota.settlement_workspace_causality import project_goal_local_write_scopes + + # Physical identity is resolved above. Scope authority stays relative to the + # registered spelling, including an explicit symlink alias of that root. + return project_goal_local_write_scopes(str(root), selected_todo, scopes) + + def build_agent_workspace_guard( goal: dict[str, Any], agent_identity: dict[str, Any] | None, @@ -351,6 +392,7 @@ def build_agent_workspace_guard( agent_todo_summary: dict[str, Any] | None = None, selected_todo: dict[str, Any] | None = None, current_path: Path | None = None, + local_write_scopes: dict[str, Any] | None = None, ) -> dict[str, Any] | None: if not isinstance(agent_identity, dict): return None @@ -361,18 +403,50 @@ def build_agent_workspace_guard( ) if len(agent_identity.get("registered_agents") or []) <= 1: return None - if not _peer_work_requires_isolated_workspace( - workspace_guard_policy, - agent_todo_summary, - selected_todo=selected_todo, - ): - return None current_path = current_path or Path.cwd() candidate = ( selected_todo if isinstance(selected_todo, dict) and selected_todo else next(iter(_peer_candidate_items(agent_todo_summary)), {}) ) + local = ( + local_write_scopes + if local_write_scopes is not None + else observe_goal_local_write_scopes(goal, candidate) + ) + root = goal.get("repo") or goal.get("project") or goal.get("root") + local_admitted = local.get("admitted") is True + if local_admitted and root: + current = capture_delivery_workspace( + current_path, + local_goal_id=str(goal.get("goal_id") or goal.get("id")), + local_project_root=Path(str(root)), + ) + local_admitted = bool(current and current.get("identity_kind") == "local_goal") + if ( + not local_admitted + and workspace_guard_policy.get("peer_independent_worktree_required") + is not False + ): + return { + "schema_version": AGENT_WORKSPACE_GUARD_SCHEMA_VERSION, + "source": "quota.should-run", + "action": local["workspace_repair_action"], + "current_workspace": "foreign_workspace", + "required_workspace": "local_goal_workspace", + "blocks_delivery": True, + "agent_id": agent_identity.get("agent_id"), + "repository_source": "goal.repo", + "reason": "declared local writes must run from the registered Goal workspace", + "required_action": "return to the registered Goal project and rerun quota should-run before local writes", + } + if not _peer_work_requires_isolated_workspace( + workspace_guard_policy, + agent_todo_summary, + selected_todo=selected_todo, + local_write_scope_admitted=local_admitted, + ): + return None task_repository = normalize_todo_task_repository(candidate.get("task_repository")) current_workspace = "" repository_source = "goal.repo" diff --git a/loopx/control_plane/quota/projection_repair.py b/loopx/control_plane/quota/projection_repair.py index 0150dd94a6..d9c99b597c 100644 --- a/loopx/control_plane/quota/projection_repair.py +++ b/loopx/control_plane/quota/projection_repair.py @@ -182,6 +182,7 @@ def build_boundary_projection_repair_hint( candidate_should_run: bool, capability_gate: dict[str, Any] | None = None, selected_todo: dict[str, Any] | None = None, + local_write_scopes: dict[str, Any] | None = None, ) -> dict[str, Any] | None: if not candidate_should_run or not isinstance(agent_todo_summary, dict): return None @@ -219,6 +220,12 @@ def build_boundary_projection_repair_hint( return None boundary = goal_boundary if isinstance(goal_boundary, dict) else {} allowed_scopes = normalize_required_write_scopes(boundary.get("write_scope")) + if local_write_scopes: + allowed_scopes.extend( + normalize_required_write_scopes( + local_write_scopes.get("allowed_write_scopes") + ) + ) missing_scopes = [ scope for scope in required_scopes diff --git a/loopx/control_plane/quota/settlement_workspace_causality.py b/loopx/control_plane/quota/settlement_workspace_causality.py index 0370c1aee4..8ca3a9099a 100644 --- a/loopx/control_plane/quota/settlement_workspace_causality.py +++ b/loopx/control_plane/quota/settlement_workspace_causality.py @@ -32,6 +32,28 @@ DELIVERY_WORKSPACE_REQUIREMENTS = frozenset({"required", "not_required", "unknown"}) +def project_goal_local_write_scopes( + project_root: str, + todo: Mapping[str, Any], + allowed_scopes: list[str], +) -> dict[str, Any]: + """Adapt observed local-root facts to the existing typed work owner.""" + result = _runtime_result( + "goal_local_write_scopes", + project_root=project_root, + todo=dict(todo), + allowed_scopes=allowed_scopes, + ).get("local_write_scopes") + if ( + not isinstance(result, Mapping) + or not isinstance(result.get("admitted"), bool) + or not isinstance(result.get("allowed_write_scopes"), list) + or any(not isinstance(scope, str) for scope in result["allowed_write_scopes"]) + ): + raise RuntimeError("TypeScript local write scope result shape mismatch") + return dict(result) + + def _runtime_result(operation: str, **params: Any) -> Mapping[str, Any]: try: result = effect_runtime_result( diff --git a/loopx/control_plane/quota/settlement_workspace_causality.ts b/loopx/control_plane/quota/settlement_workspace_causality.ts index cfacc178a6..4511ae0355 100644 --- a/loopx/control_plane/quota/settlement_workspace_causality.ts +++ b/loopx/control_plane/quota/settlement_workspace_causality.ts @@ -9,6 +9,7 @@ import { } from "../coordination/coordination_state_contract.generated.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireJsonObject as requiredObject } from "../runtime_decode.ts"; +import { projectGoalLocalWriteScopes } from "../todos/work_requirements.ts"; export const DELIVERY_WORKSPACE_CAUSALITY_SCHEMA_VERSION = DELIVERY_WORKSPACE_CAUSALITY_SCHEMA; @@ -38,6 +39,7 @@ export interface DeliveryWorkspaceCausality extends JsonObject { } type DeliveryWorkspaceCausalityOperation = + | "goal_local_write_scopes" | "classify" | "normalize" | "event_fields" @@ -140,7 +142,7 @@ function operation(value: unknown): DeliveryWorkspaceCausalityOperation { value === "classify" || value === "normalize" || value === "event_fields" || value === "missing_workspace" || value === "settlement_requirement" || - value === "from_event" + value === "from_event" || value === "goal_local_write_scopes" ) return value; throw new EffectRuntimeRequestError("delivery workspace causality operation is unsupported"); } @@ -349,6 +351,9 @@ export function evaluateDeliveryWorkspaceCausality(value: unknown): JsonObject { request.expected_todo_id, "expected_todo_id", ); + if (selectedOperation === "goal_local_write_scopes") { + return result({ local_write_scopes: projectGoalLocalWriteScopes(request) }); + } if (selectedOperation === "classify") { return result({ causality: classifyDeliveryWorkspaceCausality( diff --git a/loopx/control_plane/quota/should_run.py b/loopx/control_plane/quota/should_run.py index 2975899663..133a662e89 100644 --- a/loopx/control_plane/quota/should_run.py +++ b/loopx/control_plane/quota/should_run.py @@ -14,7 +14,10 @@ from ..agents.identity import ( build_quota_agent_identity, ) -from ..agents.workspace_guard import build_agent_workspace_guard +from ..agents.workspace_guard import ( + build_agent_workspace_guard, + observe_goal_local_write_scopes, +) from ..quota.decision_summary import ( quota_plan_items as _quota_plan_items, ) @@ -114,6 +117,9 @@ def _apply_selected_todo_guards( ) route = _resolve_quota_should_run_route(prepared) workspace_guard = None + local_write_scopes = observe_goal_local_write_scopes( + prepared.item, selected_todo, prepared.goal_boundary.get("write_scope", []) + ) if not prepared.inbox_priority_due: workspace_guard = build_agent_workspace_guard( prepared.item, @@ -121,6 +127,7 @@ def _apply_selected_todo_guards( agent_todo_summary=prepared.agent_todo_summary, selected_todo=selected_todo, current_path=workspace_path, + local_write_scopes=local_write_scopes, ) boundary_projection_repair = build_boundary_projection_repair_hint( prepared.goal_boundary, @@ -128,6 +135,7 @@ def _apply_selected_todo_guards( candidate_should_run=bool(route.should_run), capability_gate=prepared.capability_gate, selected_todo=selected_todo, + local_write_scopes=local_write_scopes, ) if not workspace_guard and not boundary_projection_repair: return route diff --git a/loopx/control_plane/todos/work_requirements.ts b/loopx/control_plane/todos/work_requirements.ts index 5be20bf028..5955a9020f 100644 --- a/loopx/control_plane/todos/work_requirements.ts +++ b/loopx/control_plane/todos/work_requirements.ts @@ -2,9 +2,45 @@ * These validate requirements, never grant capabilities or write authority. */ import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; -import { optionalNonEmptyString, requireStringArray } from "../runtime_decode.ts"; +import { optionalNonEmptyString, requireJsonObject, requireStringArray } from "../runtime_decode.ts"; import { compactPythonWhitespace, stripPythonWhitespace } from "../coordination/todo_agents.ts"; import { normalizeWriteScopes } from "../work_items/task_lease_acquire.ts"; +import { isAbsolute, resolve, sep } from "node:path"; + +/** A Goal-local declaration keeps relative Todo scopes. Absolute Goal grants + * are projected only against the observed local Goal root, never by suffix or + * against a different repository. This is a read projection, not a grant. */ +export function projectGoalLocalWriteScopes(value: JsonObject): JsonObject { + const root = optionalNonEmptyString(value.project_root, "project_root"); + const empty = { admitted: false, allowed_write_scopes: [] }; + if (!root || !isAbsolute(root) || value.todo == null) return empty; + const todo = requireJsonObject(value.todo, "local write Todo"); + if (todo.task_repository || !todo.task_domain || todo.task_domain === "code" || + todo.continuation_policy !== "same_agent_non_delivery") return empty; + const required = requireStringArray(todo.required_write_scopes ?? [], "required_write_scopes"); + if (!required.length || required.some(scope => !normalizeWriteScopes([scope]).length || + scope.includes("\\") || scope.split("/").includes("."))) return empty; + const prefix = resolve(root).replaceAll(sep, "/").replace(/\/+$/, "") + "/"; + const allowed: string[] = []; + for (const raw of requireStringArray(value.allowed_scopes ?? [], "allowed_scopes")) { + const scope = raw.replaceAll(sep, "/"); + // Do not resolve a grant's dot segments: that would change its authority. + if (!scope.startsWith(prefix) || scope.includes("\\") || scope.split("/").some(part => part === "." || part === "..")) continue; + const relative = scope.slice(prefix.length); + if (!normalizeWriteScopes([relative]).length) continue; + // Exact paths and recursive directory grants have unambiguous containment. + // Complex glob grants stay on their existing path rather than widening here. + const base = relative.endsWith("/**") ? relative.slice(0, -3) : relative; + if (/[\[*?]/.test(base)) continue; + if (!allowed.includes(relative)) allowed.push(relative); + } + return { + admitted: required.every(scope => allowed.some(grant => + scope === grant || (grant.endsWith("/**") && scope.startsWith(grant.slice(0, -2))))), + allowed_write_scopes: allowed, + workspace_repair_action: "move_to_goal_workspace", + }; +} function optionalText(value: unknown, label: string): string | null { const raw = optionalNonEmptyString(value, label); diff --git a/tests/control_plane/test_goal_local_write_admission.py b/tests/control_plane/test_goal_local_write_admission.py new file mode 100644 index 0000000000..19097d547a --- /dev/null +++ b/tests/control_plane/test_goal_local_write_admission.py @@ -0,0 +1,336 @@ +"""Local material writes retain scopes and a causal workspace without Git.""" + +import json +import subprocess + +import pytest + +from loopx.control_plane.agents.workspace_guard import ( + build_agent_workspace_guard, + observe_goal_local_write_scopes, +) +from loopx.control_plane.quota.projection_repair import ( + build_boundary_projection_repair_hint, +) +from loopx.control_plane.quota.settlement_workspace_causality import ( + project_goal_local_write_scopes, +) + + +def declaration(): + return { + "todo_id": "todo_local_material", + "role": "agent", + "status": "open", + "task_class": "advancement_task", + "task_domain": "validation", + "action_kind": "validate_material", + "continuation_policy": "same_agent_non_delivery", + "required_write_scopes": ["materials/run/**", "reports/result.md"], + } + + +def test_absolute_scope_projection_is_root_bound_and_does_not_widen(tmp_path): + root = str(tmp_path) + todo = declaration() + grants = [f"{root}/materials/**", f"{root}/reports/result.md"] + assert project_goal_local_write_scopes(root, todo, grants)["admitted"] is True + for bad in [ + "reports/other.md", + "materials-elsewhere/run/**", + "../materials/**", + "/materials/**", + "materials/./run/**", + "materials/run/..\\other", + ]: + assert ( + project_goal_local_write_scopes( + root, {**todo, "required_write_scopes": [bad]}, grants + )["admitted"] + is False + ) + for bad_grant in [ + f"{root}-other/materials/**", + f"{root}/../materials/**", + "materials/**", + f"{root}/material*/**", + ]: + assert ( + project_goal_local_write_scopes(root, todo, [bad_grant])["admitted"] + is False + ) + + +@pytest.mark.parametrize( + "metadata", + [ + {"task_repository": "git:github.com/example/project"}, + {"task_domain": "code"}, + {"task_domain": None}, + {"continuation_policy": "independent_handoff"}, + {"continuation_policy": None}, + {"required_write_scopes": []}, + ], +) +def test_repository_and_unknown_contracts_never_become_local(tmp_path, metadata): + todo = {**declaration(), **metadata} + assert ( + project_goal_local_write_scopes( + str(tmp_path), todo, [f"{tmp_path}/materials/**", f"{tmp_path}/reports/**"] + )["admitted"] + is False + ) + + +def test_peer_local_materials_admit_both_guards_and_reject_foreign_workspace(tmp_path): + root = tmp_path / "local" + root.mkdir() + goal = { + "id": "local-materials", + "repo": str(root), + "coordination": { + "write_scope": [f"{root}/materials/**", f"{root}/reports/result.md"], + }, + } + todo = declaration() + identity = {"agent_id": "worker", "registered_agents": ["worker", "peer"]} + local = observe_goal_local_write_scopes(goal, todo) + assert local["admitted"] is True + assert ( + build_agent_workspace_guard( + goal, + identity, + selected_todo=todo, + current_path=root, + local_write_scopes=local, + ) + is None + ) + + assert ( + build_boundary_projection_repair_hint( + goal["coordination"], + {"first_executable_items": [todo]}, + candidate_should_run=True, + selected_todo=todo, + local_write_scopes=local, + ) + is None + ) + guard = build_agent_workspace_guard( + goal, + identity, + selected_todo=todo, + current_path=tmp_path, + local_write_scopes=local, + ) + assert guard["blocks_delivery"] is True + assert guard["required_workspace"] == "local_goal_workspace" + assert guard["action"] == "move_to_goal_workspace" + assert ( + build_agent_workspace_guard( + { + **goal, + "workspace_guard_policy": {"peer_independent_worktree_required": True}, + }, + identity, + selected_todo=todo, + current_path=root, + local_write_scopes=local, + )["blocks_delivery"] + is True + ) + # Preserve the explicit off-state policy without creating a new switch. + assert ( + build_agent_workspace_guard( + { + **goal, + "workspace_guard_policy": {"peer_independent_worktree_required": False}, + }, + identity, + selected_todo=todo, + current_path=tmp_path, + local_write_scopes=local, + ) + is None + ) + nested = root / "nested" + nested.mkdir() + subprocess.run(["git", "init", "-q", str(nested)], check=True) + assert ( + build_agent_workspace_guard( + goal, + identity, + selected_todo=todo, + current_path=nested, + local_write_scopes=local, + )["blocks_delivery"] + is True + ) + + +def test_registered_root_alias_keeps_its_literal_authorization(tmp_path): + physical = tmp_path / "physical" + physical.mkdir() + registered = tmp_path / "registered" + registered.symlink_to(physical, target_is_directory=True) + goal = { + "id": "local-alias", + "repo": str(registered), + "coordination": { + "write_scope": [ + f"{registered}/materials/**", + f"{registered}/reports/result.md", + ], + }, + } + assert observe_goal_local_write_scopes(goal, declaration())["admitted"] is True + # A different spelling is not silently added as a second grant. + goal["coordination"]["write_scope"] = [ + f"{physical}/materials/**", + f"{physical}/reports/result.md", + ] + assert observe_goal_local_write_scopes(goal, declaration())["admitted"] is False + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_real_cli_local_write_guard_replay_and_causal_settlement( + tmp_path, monkeypatch, provider +): + from canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, + ) + from test_quota_settlement_cli import ( + _write_fixture, + _run_cli, + _spend_run_count, + GOAL_ID, + AGENT_ID, + TODO_ID, + ) + from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, + ) + from loopx.control_plane.todos.active_state_todo_parser import ( + parse_active_state_todos, + ) + + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime, registry = _write_fixture(tmp_path) + config = json.loads(registry.read_text()) + goal = config["goals"][0] + goal["coordination"].update( + registered_agents=[AGENT_ID, "other-worker"], + write_scope=[f"{project}/materials/**", f"{project}/reports/result.md"], + ) + registry.write_text(json.dumps(config)) + state = project / goal["state_file"] + todos = parse_active_state_todos(state.read_text(), item_limit=None)["agent_todos"][ + "items" + ] + todos[0].update( + { + **declaration(), + "todo_id": TODO_ID, + "claimed_by": AGENT_ID, + "required_capabilities": ["filesystem_read", "filesystem_write", "shell"], + } + ) + initialize_canonical_authority( + runtime, + GOAL_ID, + build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, todos=todos, handoff_mode="soft_claim" + ), + state_path=state, + provider=provider, + ) + caps = [ + "--available-capability", + "filesystem_read", + "--available-capability", + "filesystem_write", + "--available-capability", + "shell", + ] + turn = f"local-writes-{provider}" + guard_args = [ + "quota", + "should-run", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--codex-app", + "--turn-instance-id", + turn, + *caps, + ] + rc, outside = _run_cli(registry, runtime, *guard_args, cwd=tmp_path) + assert rc == 0 and outside["normal_delivery_allowed"] is False, outside + assert outside["workspace_guard"]["required_workspace"] == "local_goal_workspace" + assert _spend_run_count(runtime) == 0 + rc, guard = _run_cli(registry, runtime, *guard_args, cwd=project) + assert rc == 0 and guard["normal_delivery_allowed"] is True, guard + assert ( + guard["selected_todo"]["required_write_scopes"] + == todos[0]["required_write_scopes"] + ) + rc, replay = _run_cli(registry, runtime, *guard_args, cwd=project) + assert ( + rc == 0 + and replay["interaction_contract"]["cli_channel"]["settlement_plan"]["identity"] + == guard["interaction_contract"]["cli_channel"]["settlement_plan"]["identity"] + ) + binding = [ + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--todo-id", + TODO_ID, + "--turn-instance-id", + turn, + *caps, + ] + rc, writeback = _run_cli( + registry, + runtime, + "refresh-state", + *binding, + "--classification", + "local_material_validated", + "--delivery-batch-scale", + "single_surface", + "--delivery-outcome", + "outcome_progress", + "--delivery-boundary", + "in_flight_continuation", + "--progress-result-class", + "advanced", + "--progress-surface-id", + "material:source-review", + cwd=project, + ) + assert rc == 0 and writeback["appended"] is True, writeback + assert writeback["delivery_workspace"]["identity_kind"] == "local_goal" + assert str(project) not in json.dumps(writeback["delivery_workspace"]) + spend = [ + "quota", + "spend-slot", + *binding, + "--slots", + "1", + "--source", + "heartbeat", + "--execute", + ] + rc, settled = _run_cli(registry, runtime, *spend, cwd=project) + assert rc == 0 and settled["ok"] is True, settled + rc, repeated = _run_cli(registry, runtime, *spend, cwd=project) + assert rc == 0 and repeated["ok"] is True, repeated + assert repeated["idempotent_replay"] is True + assert repeated["appended"] is False + assert _spend_run_count(runtime) == 1 From ababc82a487e4d6c7f339f8c4c61cf6865cdf3f2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:07:37 +0800 Subject: [PATCH 2/2] fix(workspace): reuse local Goal defaults and scope matching Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/quota-allocation.md | 22 +- loopx/control_plane/agents/workspace_guard.py | 60 ++--- .../control_plane/quota/projection_repair.py | 7 - .../quota/settlement_workspace_causality.py | 11 +- .../quota/settlement_workspace_causality.ts | 10 +- loopx/control_plane/quota/should_run.py | 12 +- .../control_plane/todos/work_requirements.ts | 37 +-- .../work_items/interaction_contract.py | 2 +- .../test_goal_local_write_admission.py | 234 ++++++------------ ...nteraction_contract_workspace_causality.py | 6 +- 10 files changed, 129 insertions(+), 272 deletions(-) diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 505fd01963..571edbfd86 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -1495,16 +1495,18 @@ Post-turn accounting protocol: workspace cannot produce that local receipt. For originless Git, the checkout root must equal the registered project root; nested repositories, linked worktrees, invalid/empty origins and failed Git config reads cannot fall back. -- `quota should-run` also recognizes explicitly declared local material work: - `same_agent_non_delivery`, a non-code task domain, no task repository, and all - relative Todo write scopes contained by absolute Goal grants rooted at the - observed local project. These peer tasks keep their write scopes and use the - existing local Goal delivery snapshot instead of requiring a Git worktree. - Exact paths and recursive directory grants qualify; complex absolute globs - do not. Outside-root work receives a return-to-Goal diagnostic. Code work, - foreign repositories, undeclared contracts and explicit isolation policies - retain their existing guards. This changes local peer material admission, - not authorization, claim/lease rules or causal settlement requirements. +- `quota should-run` reuses that registered `local_goal` identity for local + tasks by default. Declaring write scopes does not turn local work into a Git + edit or require a particular task domain or continuation marker. An explicit + task repository and owner isolation requirements keep their existing guards. + Relative Goal scopes keep their existing matching semantics; absolute grants + under the registered root are projected into the same relative view, including + existing glob patterns. The existing boundary guard alone checks coverage. + Caller cwd does not rebase the declared targets or itself block local work. + If actual delivery is produced in the Goal project from another cwd, use + `refresh-state --delivery-workspace-path `; settlement consumes + that recorded local workspace without requiring a cwd move. This changes local + task admission, not grants, claim/lease or causal settlement requirements. - `todo complete --evidence ` can record a validated local artifact. `--result-file` additionally requires approved Goal acceptance criteria bound to that Todo. A standalone Todo validator does not establish Goal acceptance; diff --git a/loopx/control_plane/agents/workspace_guard.py b/loopx/control_plane/agents/workspace_guard.py index 830a0efdb1..acdd3c4097 100644 --- a/loopx/control_plane/agents/workspace_guard.py +++ b/loopx/control_plane/agents/workspace_guard.py @@ -323,12 +323,12 @@ def _peer_work_requires_isolated_workspace( agent_todo_summary: dict[str, Any] | None, *, selected_todo: dict[str, Any] | None = None, - local_write_scope_admitted: bool = False, + local_goal_workspace: bool = False, ) -> bool: explicit = workspace_guard_policy.get("peer_independent_worktree_required") if explicit is not None: return explicit is True - if local_write_scope_admitted: + if local_goal_workspace: return False candidate = ( selected_todo @@ -347,19 +347,14 @@ def _peer_work_requires_isolated_workspace( ) -def observe_goal_local_write_scopes( +def observe_goal_local_workspace( goal: dict[str, Any], selected_todo: dict[str, Any] | None, allowed_write_scopes: list[str] | None = None, ) -> dict[str, Any]: - """Read physical Goal identity once; typed work requirements own admission.""" - empty = {"admitted": False, "allowed_write_scopes": []} - if ( - not selected_todo - or selected_todo.get("task_repository") - or selected_todo.get("continuation_policy") != "same_agent_non_delivery" - or not selected_todo.get("required_write_scopes") - ): + """Reuse the registered workspace identity and project its existing grants.""" + empty: dict[str, Any] = {} + if not selected_todo or selected_todo.get("task_repository"): return empty repo = goal.get("repo") or goal.get("project") or goal.get("root") goal_id = goal.get("goal_id") or goal.get("id") @@ -378,11 +373,11 @@ def observe_goal_local_write_scopes( scopes = allowed_write_scopes if allowed_write_scopes is not None else raw_scopes if not isinstance(scopes, list) or any(not isinstance(scope, str) for scope in scopes): return empty - from ..quota.settlement_workspace_causality import project_goal_local_write_scopes + from ..quota.settlement_workspace_causality import project_goal_write_scopes # Physical identity is resolved above. Scope authority stays relative to the # registered spelling, including an explicit symlink alias of that root. - return project_goal_local_write_scopes(str(root), selected_todo, scopes) + return {"workspace": snapshot, **project_goal_write_scopes(str(root), scopes)} def build_agent_workspace_guard( @@ -392,7 +387,7 @@ def build_agent_workspace_guard( agent_todo_summary: dict[str, Any] | None = None, selected_todo: dict[str, Any] | None = None, current_path: Path | None = None, - local_write_scopes: dict[str, Any] | None = None, + local_workspace: dict[str, Any] | None = None, ) -> dict[str, Any] | None: if not isinstance(agent_identity, dict): return None @@ -410,41 +405,18 @@ def build_agent_workspace_guard( else next(iter(_peer_candidate_items(agent_todo_summary)), {}) ) local = ( - local_write_scopes - if local_write_scopes is not None - else observe_goal_local_write_scopes(goal, candidate) + local_workspace + if local_workspace is not None + else observe_goal_local_workspace(goal, candidate) ) - root = goal.get("repo") or goal.get("project") or goal.get("root") - local_admitted = local.get("admitted") is True - if local_admitted and root: - current = capture_delivery_workspace( - current_path, - local_goal_id=str(goal.get("goal_id") or goal.get("id")), - local_project_root=Path(str(root)), - ) - local_admitted = bool(current and current.get("identity_kind") == "local_goal") - if ( - not local_admitted - and workspace_guard_policy.get("peer_independent_worktree_required") - is not False - ): - return { - "schema_version": AGENT_WORKSPACE_GUARD_SCHEMA_VERSION, - "source": "quota.should-run", - "action": local["workspace_repair_action"], - "current_workspace": "foreign_workspace", - "required_workspace": "local_goal_workspace", - "blocks_delivery": True, - "agent_id": agent_identity.get("agent_id"), - "repository_source": "goal.repo", - "reason": "declared local writes must run from the registered Goal workspace", - "required_action": "return to the registered Goal project and rerun quota should-run before local writes", - } + # Local declarations are relative to the registered Goal target, not the + # caller cwd. Causal accounting still names the actual delivery workspace. + local_admitted = (local.get("workspace") or {}).get("identity_kind") == "local_goal" if not _peer_work_requires_isolated_workspace( workspace_guard_policy, agent_todo_summary, selected_todo=selected_todo, - local_write_scope_admitted=local_admitted, + local_goal_workspace=local_admitted, ): return None task_repository = normalize_todo_task_repository(candidate.get("task_repository")) diff --git a/loopx/control_plane/quota/projection_repair.py b/loopx/control_plane/quota/projection_repair.py index d9c99b597c..0150dd94a6 100644 --- a/loopx/control_plane/quota/projection_repair.py +++ b/loopx/control_plane/quota/projection_repair.py @@ -182,7 +182,6 @@ def build_boundary_projection_repair_hint( candidate_should_run: bool, capability_gate: dict[str, Any] | None = None, selected_todo: dict[str, Any] | None = None, - local_write_scopes: dict[str, Any] | None = None, ) -> dict[str, Any] | None: if not candidate_should_run or not isinstance(agent_todo_summary, dict): return None @@ -220,12 +219,6 @@ def build_boundary_projection_repair_hint( return None boundary = goal_boundary if isinstance(goal_boundary, dict) else {} allowed_scopes = normalize_required_write_scopes(boundary.get("write_scope")) - if local_write_scopes: - allowed_scopes.extend( - normalize_required_write_scopes( - local_write_scopes.get("allowed_write_scopes") - ) - ) missing_scopes = [ scope for scope in required_scopes diff --git a/loopx/control_plane/quota/settlement_workspace_causality.py b/loopx/control_plane/quota/settlement_workspace_causality.py index 8ca3a9099a..027ade80be 100644 --- a/loopx/control_plane/quota/settlement_workspace_causality.py +++ b/loopx/control_plane/quota/settlement_workspace_causality.py @@ -32,25 +32,22 @@ DELIVERY_WORKSPACE_REQUIREMENTS = frozenset({"required", "not_required", "unknown"}) -def project_goal_local_write_scopes( +def project_goal_write_scopes( project_root: str, - todo: Mapping[str, Any], allowed_scopes: list[str], ) -> dict[str, Any]: """Adapt observed local-root facts to the existing typed work owner.""" result = _runtime_result( - "goal_local_write_scopes", + "goal_write_scopes", project_root=project_root, - todo=dict(todo), allowed_scopes=allowed_scopes, - ).get("local_write_scopes") + ).get("write_scope_projection") if ( not isinstance(result, Mapping) - or not isinstance(result.get("admitted"), bool) or not isinstance(result.get("allowed_write_scopes"), list) or any(not isinstance(scope, str) for scope in result["allowed_write_scopes"]) ): - raise RuntimeError("TypeScript local write scope result shape mismatch") + raise RuntimeError("TypeScript Goal write scope projection shape mismatch") return dict(result) diff --git a/loopx/control_plane/quota/settlement_workspace_causality.ts b/loopx/control_plane/quota/settlement_workspace_causality.ts index 4511ae0355..5f98a629ae 100644 --- a/loopx/control_plane/quota/settlement_workspace_causality.ts +++ b/loopx/control_plane/quota/settlement_workspace_causality.ts @@ -9,7 +9,7 @@ import { } from "../coordination/coordination_state_contract.generated.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireJsonObject as requiredObject } from "../runtime_decode.ts"; -import { projectGoalLocalWriteScopes } from "../todos/work_requirements.ts"; +import { projectGoalWriteScopes } from "../todos/work_requirements.ts"; export const DELIVERY_WORKSPACE_CAUSALITY_SCHEMA_VERSION = DELIVERY_WORKSPACE_CAUSALITY_SCHEMA; @@ -39,7 +39,7 @@ export interface DeliveryWorkspaceCausality extends JsonObject { } type DeliveryWorkspaceCausalityOperation = - | "goal_local_write_scopes" + | "goal_write_scopes" | "classify" | "normalize" | "event_fields" @@ -142,7 +142,7 @@ function operation(value: unknown): DeliveryWorkspaceCausalityOperation { value === "classify" || value === "normalize" || value === "event_fields" || value === "missing_workspace" || value === "settlement_requirement" || - value === "from_event" || value === "goal_local_write_scopes" + value === "from_event" || value === "goal_write_scopes" ) return value; throw new EffectRuntimeRequestError("delivery workspace causality operation is unsupported"); } @@ -351,8 +351,8 @@ export function evaluateDeliveryWorkspaceCausality(value: unknown): JsonObject { request.expected_todo_id, "expected_todo_id", ); - if (selectedOperation === "goal_local_write_scopes") { - return result({ local_write_scopes: projectGoalLocalWriteScopes(request) }); + if (selectedOperation === "goal_write_scopes") { + return result({ write_scope_projection: projectGoalWriteScopes(request) }); } if (selectedOperation === "classify") { return result({ diff --git a/loopx/control_plane/quota/should_run.py b/loopx/control_plane/quota/should_run.py index 133a662e89..b645f2f16b 100644 --- a/loopx/control_plane/quota/should_run.py +++ b/loopx/control_plane/quota/should_run.py @@ -16,7 +16,7 @@ ) from ..agents.workspace_guard import ( build_agent_workspace_guard, - observe_goal_local_write_scopes, + observe_goal_local_workspace, ) from ..quota.decision_summary import ( quota_plan_items as _quota_plan_items, @@ -117,7 +117,7 @@ def _apply_selected_todo_guards( ) route = _resolve_quota_should_run_route(prepared) workspace_guard = None - local_write_scopes = observe_goal_local_write_scopes( + local_workspace = observe_goal_local_workspace( prepared.item, selected_todo, prepared.goal_boundary.get("write_scope", []) ) if not prepared.inbox_priority_due: @@ -127,15 +127,17 @@ def _apply_selected_todo_guards( agent_todo_summary=prepared.agent_todo_summary, selected_todo=selected_todo, current_path=workspace_path, - local_write_scopes=local_write_scopes, + local_workspace=local_workspace, ) boundary_projection_repair = build_boundary_projection_repair_hint( - prepared.goal_boundary, + {**prepared.goal_boundary, "write_scope": [ + *prepared.goal_boundary.get("write_scope", []), + *local_workspace.get("allowed_write_scopes", []), + ]}, prepared.agent_todo_summary, candidate_should_run=bool(route.should_run), capability_gate=prepared.capability_gate, selected_todo=selected_todo, - local_write_scopes=local_write_scopes, ) if not workspace_guard and not boundary_projection_repair: return route diff --git a/loopx/control_plane/todos/work_requirements.ts b/loopx/control_plane/todos/work_requirements.ts index 5955a9020f..b5e04726b3 100644 --- a/loopx/control_plane/todos/work_requirements.ts +++ b/loopx/control_plane/todos/work_requirements.ts @@ -2,44 +2,29 @@ * These validate requirements, never grant capabilities or write authority. */ import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; -import { optionalNonEmptyString, requireJsonObject, requireStringArray } from "../runtime_decode.ts"; +import { optionalNonEmptyString, requireStringArray } from "../runtime_decode.ts"; import { compactPythonWhitespace, stripPythonWhitespace } from "../coordination/todo_agents.ts"; import { normalizeWriteScopes } from "../work_items/task_lease_acquire.ts"; import { isAbsolute, resolve, sep } from "node:path"; -/** A Goal-local declaration keeps relative Todo scopes. Absolute Goal grants +/** Goal scope projection preserves existing relative scopes. Absolute grants * are projected only against the observed local Goal root, never by suffix or * against a different repository. This is a read projection, not a grant. */ -export function projectGoalLocalWriteScopes(value: JsonObject): JsonObject { +export function projectGoalWriteScopes(value: JsonObject): JsonObject { const root = optionalNonEmptyString(value.project_root, "project_root"); - const empty = { admitted: false, allowed_write_scopes: [] }; - if (!root || !isAbsolute(root) || value.todo == null) return empty; - const todo = requireJsonObject(value.todo, "local write Todo"); - if (todo.task_repository || !todo.task_domain || todo.task_domain === "code" || - todo.continuation_policy !== "same_agent_non_delivery") return empty; - const required = requireStringArray(todo.required_write_scopes ?? [], "required_write_scopes"); - if (!required.length || required.some(scope => !normalizeWriteScopes([scope]).length || - scope.includes("\\") || scope.split("/").includes("."))) return empty; + if (!root || !isAbsolute(root)) return { allowed_write_scopes: [] }; const prefix = resolve(root).replaceAll(sep, "/").replace(/\/+$/, "") + "/"; const allowed: string[] = []; for (const raw of requireStringArray(value.allowed_scopes ?? [], "allowed_scopes")) { const scope = raw.replaceAll(sep, "/"); - // Do not resolve a grant's dot segments: that would change its authority. - if (!scope.startsWith(prefix) || scope.includes("\\") || scope.split("/").some(part => part === "." || part === "..")) continue; - const relative = scope.slice(prefix.length); - if (!normalizeWriteScopes([relative]).length) continue; - // Exact paths and recursive directory grants have unambiguous containment. - // Complex glob grants stay on their existing path rather than widening here. - const base = relative.endsWith("/**") ? relative.slice(0, -3) : relative; - if (/[\[*?]/.test(base)) continue; - if (!allowed.includes(relative)) allowed.push(relative); + // Only strip the registered root. Preserve the existing relative/glob + // semantics; the boundary guard, not this projection, checks coverage. + const relative = isAbsolute(scope) + ? (scope.startsWith(prefix) ? scope.slice(prefix.length) : "") + : scope; + if (normalizeWriteScopes([relative]).length && !allowed.includes(relative)) allowed.push(relative); } - return { - admitted: required.every(scope => allowed.some(grant => - scope === grant || (grant.endsWith("/**") && scope.startsWith(grant.slice(0, -2))))), - allowed_write_scopes: allowed, - workspace_repair_action: "move_to_goal_workspace", - }; + return { allowed_write_scopes: allowed }; } function optionalText(value: unknown, label: string): string | null { diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index 2a2ec9206e..89b501fe2c 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -1493,7 +1493,7 @@ def _build_interaction_cli_channel( "refresh": ( "delivery_workspace; otherwise --delivery-workspace-path" if selected_todo.get("task_repository") - else "registered local Goal workspace; follow the current workspace guard and repository rules" + else "registered local Goal target; when caller cwd differs, refresh with --delivery-workspace-path for the actual target; spend uses the recorded delivery workspace" ), "spend": "recorded_delivery_workspace", "mismatch": "fail_closed", diff --git a/tests/control_plane/test_goal_local_write_admission.py b/tests/control_plane/test_goal_local_write_admission.py index 19097d547a..1e9830118e 100644 --- a/tests/control_plane/test_goal_local_write_admission.py +++ b/tests/control_plane/test_goal_local_write_admission.py @@ -1,4 +1,4 @@ -"""Local material writes retain scopes and a causal workspace without Git.""" +"""Registered local writes reuse Goal identity and baseline scope matching.""" import json import subprocess @@ -6,196 +6,86 @@ import pytest from loopx.control_plane.agents.workspace_guard import ( - build_agent_workspace_guard, - observe_goal_local_write_scopes, + build_agent_workspace_guard, observe_goal_local_workspace, ) from loopx.control_plane.quota.projection_repair import ( build_boundary_projection_repair_hint, ) from loopx.control_plane.quota.settlement_workspace_causality import ( - project_goal_local_write_scopes, + project_goal_write_scopes, ) def declaration(): return { - "todo_id": "todo_local_material", - "role": "agent", - "status": "open", - "task_class": "advancement_task", - "task_domain": "validation", - "action_kind": "validate_material", - "continuation_policy": "same_agent_non_delivery", + "todo_id": "todo_local_material", "role": "agent", "status": "open", + "task_class": "advancement_task", "action_kind": "validate_material", "required_write_scopes": ["materials/run/**", "reports/result.md"], } -def test_absolute_scope_projection_is_root_bound_and_does_not_widen(tmp_path): +def scope_admitted(root, todo, grants): + projected = project_goal_write_scopes(root, grants)["allowed_write_scopes"] + return build_boundary_projection_repair_hint( + {"write_scope": [*grants, *projected]}, + {"first_executable_items": [todo]}, candidate_should_run=True, + selected_todo=todo, + ) is None + + +def test_scope_projection_keeps_baseline_relative_and_glob_matching(tmp_path): root = str(tmp_path) todo = declaration() - grants = [f"{root}/materials/**", f"{root}/reports/result.md"] - assert project_goal_local_write_scopes(root, todo, grants)["admitted"] is True - for bad in [ - "reports/other.md", - "materials-elsewhere/run/**", - "../materials/**", - "/materials/**", - "materials/./run/**", - "materials/run/..\\other", - ]: - assert ( - project_goal_local_write_scopes( - root, {**todo, "required_write_scopes": [bad]}, grants - )["admitted"] - is False - ) - for bad_grant in [ - f"{root}-other/materials/**", - f"{root}/../materials/**", - "materials/**", - f"{root}/material*/**", + for grants in [ + ["materials/**", "reports/result.md"], + [f"{root}/materials/**", f"{root}/reports/result.md"], + ["material*/**", "reports/*.md"], + [f"{root}/material*/**", f"{root}/reports/*.md"], ]: - assert ( - project_goal_local_write_scopes(root, todo, [bad_grant])["admitted"] - is False - ) + assert scope_admitted(root, todo, grants) is True + for grants in [[], [f"{root}-other/**"], [f"{root}/../elsewhere/**"], ["other/**"]]: + assert scope_admitted(root, todo, grants) is False + assert scope_admitted(root, {**todo, "required_write_scopes": ["reports/other.txt"]}, ["reports/*.md"]) is False -@pytest.mark.parametrize( - "metadata", - [ - {"task_repository": "git:github.com/example/project"}, - {"task_domain": "code"}, - {"task_domain": None}, - {"continuation_policy": "independent_handoff"}, - {"continuation_policy": None}, - {"required_write_scopes": []}, - ], -) -def test_repository_and_unknown_contracts_never_become_local(tmp_path, metadata): - todo = {**declaration(), **metadata} - assert ( - project_goal_local_write_scopes( - str(tmp_path), todo, [f"{tmp_path}/materials/**", f"{tmp_path}/reports/**"] - )["admitted"] - is False - ) - - -def test_peer_local_materials_admit_both_guards_and_reject_foreign_workspace(tmp_path): +@pytest.mark.parametrize("metadata", [{}, {"task_domain": "code"}, {"continuation_policy": "independent_handoff"}]) +def test_local_identity_avoids_git_requirement_without_special_task_flags(tmp_path, metadata): root = tmp_path / "local" root.mkdir() - goal = { - "id": "local-materials", - "repo": str(root), - "coordination": { - "write_scope": [f"{root}/materials/**", f"{root}/reports/result.md"], - }, - } - todo = declaration() + goal = {"id": "local-work", "repo": str(root), "coordination": {"write_scope": ["materials/**", "reports/*.md"]}} + todo = {**declaration(), **metadata} identity = {"agent_id": "worker", "registered_agents": ["worker", "peer"]} - local = observe_goal_local_write_scopes(goal, todo) - assert local["admitted"] is True - assert ( - build_agent_workspace_guard( - goal, - identity, - selected_todo=todo, - current_path=root, - local_write_scopes=local, - ) - is None - ) - - assert ( - build_boundary_projection_repair_hint( - goal["coordination"], - {"first_executable_items": [todo]}, - candidate_should_run=True, - selected_todo=todo, - local_write_scopes=local, - ) - is None - ) - guard = build_agent_workspace_guard( - goal, - identity, - selected_todo=todo, - current_path=tmp_path, - local_write_scopes=local, - ) - assert guard["blocks_delivery"] is True - assert guard["required_workspace"] == "local_goal_workspace" - assert guard["action"] == "move_to_goal_workspace" - assert ( - build_agent_workspace_guard( - { - **goal, - "workspace_guard_policy": {"peer_independent_worktree_required": True}, - }, - identity, - selected_todo=todo, - current_path=root, - local_write_scopes=local, - )["blocks_delivery"] - is True - ) - # Preserve the explicit off-state policy without creating a new switch. - assert ( - build_agent_workspace_guard( - { - **goal, - "workspace_guard_policy": {"peer_independent_worktree_required": False}, - }, - identity, - selected_todo=todo, - current_path=tmp_path, - local_write_scopes=local, - ) - is None - ) - nested = root / "nested" - nested.mkdir() - subprocess.run(["git", "init", "-q", str(nested)], check=True) - assert ( - build_agent_workspace_guard( - goal, - identity, - selected_todo=todo, - current_path=nested, - local_write_scopes=local, - )["blocks_delivery"] - is True - ) + local = observe_goal_local_workspace(goal, todo) + assert local["workspace"]["identity_kind"] == "local_goal" + # Caller cwd does not rebase the declared output target or demand a repo. + assert build_agent_workspace_guard(goal, identity, selected_todo=todo, current_path=tmp_path, local_workspace=local) is None + assert build_agent_workspace_guard({**goal, "workspace_guard_policy": {"peer_independent_worktree_required": False}}, identity, selected_todo=todo, current_path=tmp_path) is None + assert build_agent_workspace_guard({**goal, "workspace_guard_policy": {"peer_independent_worktree_required": True}}, identity, selected_todo=todo, current_path=root)["blocks_delivery"] is True + assert observe_goal_local_workspace(goal, {**todo, "task_repository": "git:github.com/example/project"}) == {} -def test_registered_root_alias_keeps_its_literal_authorization(tmp_path): +def test_registered_local_identity_reuses_originless_owner_and_literal_grants(tmp_path): physical = tmp_path / "physical" physical.mkdir() + subprocess.run(["git", "init", "-q", str(physical)], check=True) registered = tmp_path / "registered" registered.symlink_to(physical, target_is_directory=True) - goal = { - "id": "local-alias", - "repo": str(registered), - "coordination": { - "write_scope": [ - f"{registered}/materials/**", - f"{registered}/reports/result.md", - ], - }, - } - assert observe_goal_local_write_scopes(goal, declaration())["admitted"] is True - # A different spelling is not silently added as a second grant. - goal["coordination"]["write_scope"] = [ - f"{physical}/materials/**", - f"{physical}/reports/result.md", - ] - assert observe_goal_local_write_scopes(goal, declaration())["admitted"] is False + goal = {"id": "local-alias", "repo": str(registered), "coordination": {"write_scope": [f"{registered}/materials/**"]}} + local = observe_goal_local_workspace(goal, declaration()) + assert local["workspace"]["identity_kind"] == "local_goal" + assert local["allowed_write_scopes"] == ["materials/**"] + goal["coordination"]["write_scope"] = [f"{physical}/materials/**"] + assert observe_goal_local_workspace(goal, declaration())["allowed_write_scopes"] == [] + subprocess.run(["git", "-C", str(physical), "remote", "add", "origin", "https://github.com/example/project.git"], check=True) + assert observe_goal_local_workspace(goal, declaration()) == {} @pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("agent_count", [1, 2]) +@pytest.mark.parametrize("isolation_policy", [None, False]) +@pytest.mark.parametrize("scope_style", ["absolute", "relative_glob"]) def test_real_cli_local_write_guard_replay_and_causal_settlement( - tmp_path, monkeypatch, provider + tmp_path, monkeypatch, provider, agent_count, isolation_policy, scope_style ): from canonical_authority_fixture import ( initialize_canonical_authority, @@ -222,9 +112,15 @@ def test_real_cli_local_write_guard_replay_and_causal_settlement( config = json.loads(registry.read_text()) goal = config["goals"][0] goal["coordination"].update( - registered_agents=[AGENT_ID, "other-worker"], + registered_agents=[AGENT_ID, "other-worker"][:agent_count], write_scope=[f"{project}/materials/**", f"{project}/reports/result.md"], ) + if isolation_policy is not None: + goal["workspace_guard_policy"] = { + "peer_independent_worktree_required": isolation_policy, + } + if scope_style == "relative_glob": + goal["coordination"]["write_scope"] = ["material*/**", "reports/*.md"] registry.write_text(json.dumps(config)) state = project / goal["state_file"] todos = parse_active_state_todos(state.read_text(), item_limit=None)["agent_todos"][ @@ -269,8 +165,11 @@ def test_real_cli_local_write_guard_replay_and_causal_settlement( *caps, ] rc, outside = _run_cli(registry, runtime, *guard_args, cwd=tmp_path) - assert rc == 0 and outside["normal_delivery_allowed"] is False, outside - assert outside["workspace_guard"]["required_workspace"] == "local_goal_workspace" + assert rc == 0 and outside["normal_delivery_allowed"] is True, outside + assert not outside.get("workspace_guard"), outside + assert outside["selected_todo"]["required_write_scopes"] == todos[0]["required_write_scopes"] + hint = outside["interaction_contract"]["cli_channel"]["delivery_workspace_causality"]["refresh"] + assert "--delivery-workspace-path" in hint assert _spend_run_count(runtime) == 0 rc, guard = _run_cli(registry, runtime, *guard_args, cwd=project) assert rc == 0 and guard["normal_delivery_allowed"] is True, guard @@ -295,6 +194,9 @@ def test_real_cli_local_write_guard_replay_and_causal_settlement( turn, *caps, ] + artifact = project / "reports" / "result.md" + artifact.parent.mkdir() + artifact.write_text("Validated local output.\n") rc, writeback = _run_cli( registry, runtime, @@ -312,7 +214,9 @@ def test_real_cli_local_write_guard_replay_and_causal_settlement( "advanced", "--progress-surface-id", "material:source-review", - cwd=project, + "--delivery-workspace-path", + str(project), + cwd=tmp_path, ) assert rc == 0 and writeback["appended"] is True, writeback assert writeback["delivery_workspace"]["identity_kind"] == "local_goal" @@ -327,9 +231,9 @@ def test_real_cli_local_write_guard_replay_and_causal_settlement( "heartbeat", "--execute", ] - rc, settled = _run_cli(registry, runtime, *spend, cwd=project) + rc, settled = _run_cli(registry, runtime, *spend, cwd=tmp_path) assert rc == 0 and settled["ok"] is True, settled - rc, repeated = _run_cli(registry, runtime, *spend, cwd=project) + rc, repeated = _run_cli(registry, runtime, *spend, cwd=tmp_path) assert rc == 0 and repeated["ok"] is True, repeated assert repeated["idempotent_replay"] is True assert repeated["appended"] is False diff --git a/tests/control_plane/test_interaction_contract_workspace_causality.py b/tests/control_plane/test_interaction_contract_workspace_causality.py index 132b849472..0d3948be88 100644 --- a/tests/control_plane/test_interaction_contract_workspace_causality.py +++ b/tests/control_plane/test_interaction_contract_workspace_causality.py @@ -38,14 +38,16 @@ def test_non_delivery_contract_omits_workspace_causality() -> None: assert "delivery_workspace_causality" not in contract["cli_channel"] -def test_local_delivery_packet_defers_isolation_to_workspace_guard() -> None: +def test_local_delivery_packet_names_target_without_requiring_caller_relocation() -> None: payload = _payload(should_run=True) payload["selected_todo"].pop("task_repository") contract = build_interaction_contract(payload) causality = contract["cli_channel"]["delivery_workspace_causality"] - assert causality["refresh"] == "registered local Goal workspace; follow the current workspace guard and repository rules" + assert "--delivery-workspace-path" in causality["refresh"] + assert "caller cwd differs" in causality["refresh"] + assert "recorded delivery workspace" in causality["refresh"] assert causality["spend"] == "recorded_delivery_workspace" assert causality["mismatch"] == "fail_closed"