diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index dff62be072..236066a588 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -465,6 +465,7 @@ export function createEffectRuntimeHandlers( lazyHandler(() => import("./coordination/legacy_writer_fence.ts"), ({checkLegacyCoordinationWriteAllowed}) => checkLegacyCoordinationWriteAllowed), ], ["task_lease.write_scopes.overlap", lazyHandler(() => import("./work_items/task_lease_acquire_decision.ts"), ({evaluateTaskLeaseWriteScopesOverlap}) => evaluateTaskLeaseWriteScopesOverlap)], + ["quota.task_orchestration.write_scope_overlap", lazyHandler(() => import("./work_items/task_lease_acquire_decision.ts"), ({evaluateQuotaSchedulingWriteScopesOverlap}) => evaluateQuotaSchedulingWriteScopesOverlap)], ["quota.monitor_poll.commit", lazyHandler(() => import("./quota/monitor_poll_commit.ts"), ({evaluateQuotaMonitorPollCommit}) => evaluateQuotaMonitorPollCommit)], ["presentation.decision_notice.project", lazyHandler(() => import("./presentation/decision_notice.ts"), ({projectDecisionNotice}) => projectDecisionNotice)], ["presentation.decision_notice.validate_references", lazyHandler(() => import("./presentation/decision_notice.ts"), ({validateDecisionNoticeReferences}) => validateDecisionNoticeReferences)], diff --git a/loopx/control_plane/quota/task_orchestration_admission.py b/loopx/control_plane/quota/task_orchestration_admission.py index dd2c40cb26..2d1e0e7eeb 100644 --- a/loopx/control_plane/quota/task_orchestration_admission.py +++ b/loopx/control_plane/quota/task_orchestration_admission.py @@ -1,11 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from fnmatch import fnmatchcase from typing import Any from ..agents.capability_gate import missing_required_capabilities from ..agents.runtime_model import peer_work_key, select_peer_for_work +from ..effect_runtime import effect_runtime_result from ..todos.contract import ( normalize_required_capabilities, normalize_required_write_scopes, @@ -472,37 +472,19 @@ def _write_scopes_conflict( left: _NormalizedAdmissionLane, right: _NormalizedAdmissionLane, ) -> bool: + """Consume the scheduling overlap decision owned by the canonical lease module.""" + if not left.required_write_scopes or not right.required_write_scopes: return False if left.effective_repository != right.effective_repository: return False - return any( - _scope_patterns_overlap(left_scope, right_scope) - for left_scope in left.required_write_scopes - for right_scope in right.required_write_scopes - ) - - -def _scope_patterns_overlap(left: str, right: str) -> bool: - if left == right or fnmatchcase(left, right) or fnmatchcase(right, left): - return True - left_prefix = _scope_static_prefix(left) - right_prefix = _scope_static_prefix(right) - return bool( - left_prefix - and right_prefix - and ( - left_prefix == right_prefix - or left_prefix.startswith(f"{right_prefix}/") - or right_prefix.startswith(f"{left_prefix}/") - ) + decision = effect_runtime_result( + "quota.task_orchestration.write_scope_overlap", + { + "left": list(left.required_write_scopes), + "right": list(right.required_write_scopes), + }, ) - - -def _scope_static_prefix(scope: str) -> str: - parts: list[str] = [] - for part in str(scope or "").strip("/").split("/"): - if any(marker in part for marker in ("*", "?", "[")): - break - parts.append(part) - return "/".join(parts) + if not isinstance(decision, dict) or not isinstance(decision.get("overlap"), bool): + raise RuntimeError("quota scheduling write-scope decision shape mismatch") + return bool(decision["overlap"]) diff --git a/loopx/control_plane/work_items/task_lease_acquire_decision.ts b/loopx/control_plane/work_items/task_lease_acquire_decision.ts index 3b3df2ad7f..cf7ca25609 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -451,6 +451,42 @@ export function evaluateTaskLeaseWriteScopesOverlap(value: unknown): JsonObject }; } +/** Literal segments before the first wildcard, folded for comparison. */ +function schedulingScopeRoot(scope: string): string { + const parts: string[] = []; + for (const part of scope.replace(/^\/+|\/+$/gu, "").split("/")) { + if (part.includes("*") || part.includes("?") || part.includes("[")) break; + parts.push(part); + } + return caseFold(parts.join("/")); +} + +function schedulingScopePairOverlaps(left: string, right: string): boolean { + if (scopePairOverlaps(left, right)) return true; + const leftRoot = schedulingScopeRoot(left); + const rightRoot = schedulingScopeRoot(right); + return Boolean(leftRoot && rightRoot && ( + leftRoot === rightRoot || + leftRoot.startsWith(`${rightRoot}/`) || + rightRoot.startsWith(`${leftRoot}/`) + )); +} + +/** + * Quota scheduling keeps its own subtree rule: a literal scope owns the paths + * below it even without a trailing slash. That rule is wider than the canonical + * lease rule, so this entry point composes the two instead of restating the + * case fold or the glob matcher. + */ +export function evaluateQuotaSchedulingWriteScopesOverlap(value: unknown): JsonObject { + const input = requireJsonObject(value, "quota scheduling write-scope overlap"); + const left = comparisonWriteScopes(decisionStringArray(input.left, "left")); + const right = comparisonWriteScopes(decisionStringArray(input.right, "right")); + const overlap = left.length > 0 && right.length > 0 && + left.some((first) => right.some((second) => schedulingScopePairOverlaps(first, second))); + return {overlap}; +} + function decisionBoolean(value: unknown, label: string): boolean { if (typeof value !== "boolean") { throw new EffectRuntimeRequestError(`${label} must be a boolean`); diff --git a/tests/control_plane/test_task_orchestration_admission.py b/tests/control_plane/test_task_orchestration_admission.py index 4ed92c4b44..46609dc8da 100644 --- a/tests/control_plane/test_task_orchestration_admission.py +++ b/tests/control_plane/test_task_orchestration_admission.py @@ -264,6 +264,98 @@ def test_admission_serializes_overlapping_write_scopes() -> None: ] +def _scope_contract( + items: list[dict[str, Any]], + *, + goal_write_scope: list[str], +) -> dict[str, Any] | None: + summary = {"items": items} + contract, _work_lane = apply_task_orchestration_contract( + fallback_work_lane_contract={"lane": "advancement_task"}, + goal_boundary={ + "write_scope": goal_write_scope, + "orchestration": { + "mode": "multi_subagent", + "spawn_allowed": True, + "max_children": 2, + }, + }, + agent_identity={ + "agent_id": AGENT_ID, + "registered_agents": [AGENT_ID], + }, + agent_todo_summary=summary, + raw_agent_todo_summary=summary, + raw_user_todo_summary={"items": []}, + available_capabilities=["subagent_spawn"], + ) + return contract + + +def test_admission_serializes_case_alias_write_scopes() -> None: + contract = _scope_contract( + [ + _todo("todo_primary", action_kind="implement", required_write_scopes=["src/Main.ts"]), + _todo("todo_case_alias", action_kind="implement", required_write_scopes=["src/main.ts"]), + _todo("todo_independent", action_kind="implement", required_write_scopes=["src/other.ts"]), + ], + goal_write_scope=["src/**"], + ) + + assert contract is not None + assert [lane["todo_id"] for lane in contract["eligible_child_lanes"]] == [ + "todo_independent" + ] + assert contract["blocked_lanes"] == [ + { + "todo_id": "todo_case_alias", + "task_domain": "code", + "reason_codes": ["write_scope_conflict"], + "conflicts_with_todo_id": "todo_primary", + } + ] + + +def test_admission_keeps_scheduling_subtree_containment_without_a_trailing_slash() -> None: + contract = _scope_contract( + [ + _todo("todo_primary", action_kind="implement", required_write_scopes=["src"]), + _todo("todo_below", action_kind="implement", required_write_scopes=["src/a.ts"]), + _todo("todo_independent", action_kind="implement", required_write_scopes=["tests/a.py"]), + ], + goal_write_scope=["src", "src/**", "tests/**"], + ) + + assert contract is not None + assert [lane["todo_id"] for lane in contract["eligible_child_lanes"]] == [ + "todo_independent" + ] + assert contract["blocked_lanes"] == [ + { + "todo_id": "todo_below", + "task_domain": "code", + "reason_codes": ["write_scope_conflict"], + "conflicts_with_todo_id": "todo_primary", + } + ] + + +def test_admission_leaves_sibling_files_independent() -> None: + contract = _scope_contract( + [ + _todo("todo_primary", action_kind="implement", required_write_scopes=["src/a.ts"]), + _todo("todo_sibling", action_kind="implement", required_write_scopes=["src/b.ts"]), + ], + goal_write_scope=["src/**"], + ) + + assert contract is not None + assert [lane["todo_id"] for lane in contract["eligible_child_lanes"]] == [ + "todo_sibling" + ] + assert contract["blocked_lanes"] == [] + + def test_admission_serializes_implicit_and_explicit_goal_repository_scopes() -> None: summary = { "items": [ diff --git a/tests/control_plane_ts/effect_runtime_handlers.test.ts b/tests/control_plane_ts/effect_runtime_handlers.test.ts index 3c14d35d68..1de7f3e0cf 100644 --- a/tests/control_plane_ts/effect_runtime_handlers.test.ts +++ b/tests/control_plane_ts/effect_runtime_handlers.test.ts @@ -78,6 +78,41 @@ test("runtime exposes the canonical task-lease write-scope rule", async () => { assert.equal(result.overlap, true); }); +test("scheduling write-scope overlap folds case and keeps subtree containment", async () => { + const rows: [string, string, boolean][] = [ + ["src/Main.ts", "src/main.ts", true], + ["src/MAIN.ts", "src/main.ts", true], + ["src/\u03a3.ts", "src/\u03c2.ts", true], + ["src/\u00df.ts", "src/SS.ts", true], + ["src/", "SRC/", true], + ["src/", "src/a.ts", true], + ["src/*.ts", "src/*.TS", true], + ["src/*.ts", "src/a.ts", true], + ["src/a.ts", "src/b.ts", false], + ["src/main.ts", "src/other.ts", false], + ["src/a.ts", "src/a.txt", false], + ["src", "src/a.ts", true], + ["src/main", "src/main/x", true], + ["src/a", "src/a/b", true], + ["src/a.ts", "src/a.ts/b", true], + ]; + for (const [left, right, overlap] of rows) { + const scheduling = await dispatchEffectRuntimeMethod( + handlers, + "quota.task_orchestration.write_scope_overlap", + { left: [left], right: [right] }, + ) as Record; + assert.equal(scheduling.overlap, overlap, `scheduling ${left} vs ${right}`); + } + // The scheduling entry point stays wider than the canonical lease rule. + const canonical = await dispatchEffectRuntimeMethod( + handlers, + "task_lease.write_scopes.overlap", + { left: ["src"], right: ["src/a.ts"] }, + ) as Record; + assert.equal(canonical.overlap, false); +}); + test("runtime exposes the source-session lifetime decisions", async () => { const goalRef = { goal_id: "release",