Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/quota-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -1489,12 +1489,24 @@ 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` 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 <Goal-project>`; 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 <pointer>` 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;
Expand Down
58 changes: 52 additions & 6 deletions loopx/control_plane/agents/workspace_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_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_goal_workspace:
return False
candidate = (
selected_todo
if isinstance(selected_todo, dict) and selected_todo
Expand All @@ -344,13 +347,47 @@ def _peer_work_requires_isolated_workspace(
)


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]:
"""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")
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_write_scopes

# Physical identity is resolved above. Scope authority stays relative to the
# registered spelling, including an explicit symlink alias of that root.
return {"workspace": snapshot, **project_goal_write_scopes(str(root), scopes)}


def build_agent_workspace_guard(
goal: dict[str, Any],
agent_identity: dict[str, Any] | None,
*,
agent_todo_summary: dict[str, Any] | None = None,
selected_todo: dict[str, Any] | None = None,
current_path: Path | None = None,
local_workspace: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
if not isinstance(agent_identity, dict):
return None
Expand All @@ -361,18 +398,27 @@ 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_workspace
if local_workspace is not None
else observe_goal_local_workspace(goal, candidate)
)
# 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_goal_workspace=local_admitted,
):
return None
task_repository = normalize_todo_task_repository(candidate.get("task_repository"))
current_workspace = ""
repository_source = "goal.repo"
Expand Down
19 changes: 19 additions & 0 deletions loopx/control_plane/quota/settlement_workspace_causality.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@
DELIVERY_WORKSPACE_REQUIREMENTS = frozenset({"required", "not_required", "unknown"})


def project_goal_write_scopes(
project_root: str,
allowed_scopes: list[str],
) -> dict[str, Any]:
"""Adapt observed local-root facts to the existing typed work owner."""
result = _runtime_result(
"goal_write_scopes",
project_root=project_root,
allowed_scopes=allowed_scopes,
).get("write_scope_projection")
if (
not isinstance(result, Mapping)
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 Goal write scope projection shape mismatch")
return dict(result)


def _runtime_result(operation: str, **params: Any) -> Mapping[str, Any]:
try:
result = effect_runtime_result(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { projectGoalWriteScopes } from "../todos/work_requirements.ts";

export const DELIVERY_WORKSPACE_CAUSALITY_SCHEMA_VERSION =
DELIVERY_WORKSPACE_CAUSALITY_SCHEMA;
Expand Down Expand Up @@ -38,6 +39,7 @@ export interface DeliveryWorkspaceCausality extends JsonObject {
}

type DeliveryWorkspaceCausalityOperation =
| "goal_write_scopes"
| "classify"
| "normalize"
| "event_fields"
Expand Down Expand Up @@ -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_write_scopes"
) return value;
throw new EffectRuntimeRequestError("delivery workspace causality operation is unsupported");
}
Expand Down Expand Up @@ -349,6 +351,9 @@ export function evaluateDeliveryWorkspaceCausality(value: unknown): JsonObject {
request.expected_todo_id,
"expected_todo_id",
);
if (selectedOperation === "goal_write_scopes") {
return result({ write_scope_projection: projectGoalWriteScopes(request) });
}
if (selectedOperation === "classify") {
return result({
causality: classifyDeliveryWorkspaceCausality(
Expand Down
14 changes: 12 additions & 2 deletions loopx/control_plane/quota/should_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_workspace,
)
from ..quota.decision_summary import (
quota_plan_items as _quota_plan_items,
)
Expand Down Expand Up @@ -114,16 +117,23 @@ def _apply_selected_todo_guards(
)
route = _resolve_quota_should_run_route(prepared)
workspace_guard = None
local_workspace = observe_goal_local_workspace(
prepared.item, selected_todo, prepared.goal_boundary.get("write_scope", [])
)
if not prepared.inbox_priority_due:
workspace_guard = build_agent_workspace_guard(
prepared.item,
prepared.agent_identity,
agent_todo_summary=prepared.agent_todo_summary,
selected_todo=selected_todo,
current_path=workspace_path,
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,
Expand Down
21 changes: 21 additions & 0 deletions loopx/control_plane/todos/work_requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.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";

/** 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 projectGoalWriteScopes(value: JsonObject): JsonObject {
const root = optionalNonEmptyString(value.project_root, "project_root");
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, "/");
// 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 { allowed_write_scopes: allowed };
}

function optionalText(value: unknown, label: string): string | null {
const raw = optionalNonEmptyString(value, label);
Expand Down
2 changes: 1 addition & 1 deletion loopx/control_plane/work_items/interaction_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading