diff --git a/docs/state-interaction-model.md b/docs/state-interaction-model.md index a014c7cde9..0ffbe26ac3 100644 --- a/docs/state-interaction-model.md +++ b/docs/state-interaction-model.md @@ -450,6 +450,17 @@ grant write authority by itself. If a selected todo declares `quota should-run` must route to `boundary_projection_repair` or a concrete user/controller gate instead of letting the agent perform the protected write. +Write-scope patterns denote sets of possible paths, including future files. +Every path denoted by a Todo's required scope must be covered by the union of +active grants; matching the pattern's spelling as if it were a filename does +not establish coverage. Goal authorization retains case-sensitive `fnmatch` +semantics, including wildcards that can match `/`, independently of lease +conflict comparison. Coverage search is bounded and fails closed when it cannot +prove inclusion; narrow complex declarations to concrete scopes in that case. +Inactive grants may explain missing-scope repair hints but do not authorize the +write. Evaluate them only after active coverage fails; optional hint failures +must not suppress that repair or interrupt an already authorized task. + ```mermaid flowchart TB subgraph Harness["LoopX control plane"] diff --git a/examples/control_plane/quota-projection-repair-state-machine-smoke.py b/examples/control_plane/quota-projection-repair-state-machine-smoke.py index f42ea8cf00..30f45d01cf 100644 --- a/examples/control_plane/quota-projection-repair-state-machine-smoke.py +++ b/examples/control_plane/quota-projection-repair-state-machine-smoke.py @@ -16,7 +16,6 @@ build_boundary_projection_repair_hint, build_state_projection_gap, build_state_projection_gap_repair_hint, - write_scope_allowed, ) @@ -166,14 +165,20 @@ def assert_boundary_repair_reports_missing_scope_and_authority_lineage() -> None def assert_boundary_repair_respects_scope_patterns_and_capability_gate() -> None: todo = agent_todo(required_write_scopes=["src/runtime/**"]) - assert write_scope_allowed("src/runtime/**", ["src/**"]) - assert not write_scope_allowed("src/runtime/**", ["docs/**"]) assert build_boundary_projection_repair_hint( {"write_scope": ["src/**"]}, agent_summary([todo]), candidate_should_run=True, capability_gate={"action": "run"}, ) is None + repair = build_boundary_projection_repair_hint( + {"write_scope": ["docs/**"]}, + agent_summary([todo]), + candidate_should_run=True, + capability_gate={"action": "run"}, + ) + assert repair is not None, todo + assert repair["missing_write_scopes"] == ["src/runtime/**"], repair assert build_boundary_projection_repair_hint( {"write_scope": ["docs/**"]}, agent_summary([todo]), @@ -181,6 +186,17 @@ def assert_boundary_repair_respects_scope_patterns_and_capability_gate() -> None capability_gate={"action": "ask_owner"}, ) is None + for required, covered in [("src/b.ts", False), ("src/c.ts", True), + ("src/[b].ts", False), ("src/[ac].ts", True)]: + todo = agent_todo(required_write_scopes=[required]) + repair = build_boundary_projection_repair_hint( + {"write_scope": ["src/[z-a!b].ts"]}, agent_summary([todo]), + candidate_should_run=True, capability_gate={"action": "run"}, + ) + assert (repair is None) is covered, (required, repair) + if repair is not None: + assert repair["missing_write_scopes"] == [required], repair + def assert_boundary_repair_prefers_capability_runnable_candidate() -> None: blocked_first = agent_todo( diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 3ac138a461..04e52f113b 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -434,6 +434,7 @@ export function createEffectRuntimeHandlers( ], ["task_lease.write_scopes.overlap", lazyHandler(() => import("./work_items/task_lease_acquire_decision.ts"), ({evaluateTaskLeaseWriteScopesOverlap}) => evaluateTaskLeaseWriteScopesOverlap)], ["quota.task_orchestration.admit", lazyHandler(() => import("./quota/adaptive_children.ts"), ({admitAdaptiveChildren}) => admitAdaptiveChildren)], + ["quota.write_scope_coverage", lazyHandler(() => import("./work_items/task_lease_acquire_decision.ts"), ({evaluateWriteScopeCoverage}) => evaluateWriteScopeCoverage)], ["quota.monitor_poll.commit", lazyHandler(() => import("./quota/monitor_poll_commit.ts"), ({evaluateQuotaMonitorPollCommit}) => evaluateQuotaMonitorPollCommit)], ["presentation.global_risks.project", lazyHandler(() => import("./presentation/global_risk_read.ts"), ({projectGlobalRisks}) => projectGlobalRisks)], ["presentation.decision_notice.project", lazyHandler(() => import("./presentation/decision_notice.ts"), ({projectDecisionNotice}) => projectDecisionNotice)], diff --git a/loopx/control_plane/quota/projection_repair.py b/loopx/control_plane/quota/projection_repair.py index 0150dd94a6..a811061db7 100644 --- a/loopx/control_plane/quota/projection_repair.py +++ b/loopx/control_plane/quota/projection_repair.py @@ -1,10 +1,12 @@ from __future__ import annotations from .effective_action import EffectiveAction -import fnmatch from typing import Any from ...state_projection import actions_are_projection_aligned, is_user_wait_text +from ..effect_runtime import ( + EffectRuntimeRemoteError, EffectRuntimeStartupError, effect_runtime_result, +) from ..todos.contract import ( TODO_TASK_CLASS_ADVANCEMENT, normalize_required_write_scopes, @@ -160,19 +162,21 @@ def build_state_projection_gap_repair_hint( } -def write_scope_allowed(required_scope: str, allowed_scopes: list[str]) -> bool: - required = str(required_scope or "").strip() - if not required: - return True - for allowed in allowed_scopes: - pattern = str(allowed or "").strip() - if not pattern: - continue - if required == pattern: - return True - if fnmatch.fnmatchcase(required, pattern): - return True - return False +def _write_scope_coverage( + required_scopes: list[str], scope_groups: list[list[str]], +) -> list[list[bool]]: + """Transport one batch to the same scope owner used by child admission.""" + checks = [ + {"required": required, "allowed": allowed} + for allowed in scope_groups for required in required_scopes + ] + response = effect_runtime_result("quota.write_scope_coverage", {"checks": checks}) + covered = response.get("covered") if isinstance(response, dict) else None + if (not isinstance(covered, list) or len(covered) != len(checks) + or any(not isinstance(value, bool) for value in covered)): + raise TypeError("invalid typed write-scope coverage result") + width = len(required_scopes) + return [covered[index:index + width] for index in range(0, len(covered), width)] def build_boundary_projection_repair_hint( @@ -219,19 +223,20 @@ 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")) - missing_scopes = [ - scope - for scope in required_scopes - if not write_scope_allowed(scope, allowed_scopes) - ] + active_coverage = _write_scope_coverage(required_scopes, [allowed_scopes])[0] + missing_scopes = [scope for scope, covered in zip(required_scopes, active_coverage) if not covered] if not missing_scopes: return None - selected_text = protocol_action_text(selected.get("text"), limit=220) boundary_authority = ( boundary.get("checkpointed_boundary_authority") if isinstance(boundary.get("checkpointed_boundary_authority"), dict) else None ) + inactive_entries = [ + entry for entry in (boundary_authority or {}).get("entries", []) or [] + if isinstance(entry, dict) and entry.get("active") is not True + ] + selected_text = protocol_action_text(selected.get("text"), limit=220) repair: dict[str, Any] = { "source": "quota.should-run", "trigger": "required_write_scope_missing_from_goal_boundary", @@ -271,11 +276,17 @@ def build_boundary_projection_repair_hint( "active_write_scope": boundary_authority.get("active_write_scope"), } inactive_candidates = [] - for entry in boundary_authority.get("entries") or []: - if not isinstance(entry, dict) or entry.get("active") is True: - continue - scopes = normalize_required_write_scopes(entry.get("write_scope")) - if any(write_scope_allowed(scope, scopes) for scope in missing_scopes): + try: + inactive_coverage = _write_scope_coverage(missing_scopes, [ + normalize_required_write_scopes(entry.get("write_scope")) + for entry in inactive_entries + ]) if inactive_entries else [] + except (EffectRuntimeRemoteError, EffectRuntimeStartupError, TypeError): + # Optional provenance hints cannot overturn the active decision or + # hide the repair for uncovered writes. Active failures still raise. + inactive_coverage = [] + for entry, entry_coverage in zip(inactive_entries, inactive_coverage): + if any(entry_coverage): inactive_candidates.append( { key: entry.get(key) 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 cf0c0795f9..f2e5c7154f 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -180,7 +180,7 @@ type GlobToken = | {kind: "literal"; folded: string} | {kind: "star"} | {kind: "question"} - | {kind: "class"; body: string; negated: boolean}; + | {kind: "class"; body: string; negated: boolean; members?: CharacterClass}; function globTokens(pattern: string, fold = true): GlobToken[] { const tokens: GlobToken[] = []; @@ -207,9 +207,10 @@ function globTokens(pattern: string, fold = true): GlobToken[] { if (end >= 0) { flushLiteral(); let body = pattern.slice(index + 1, end); + const normalized = fold ? {} : fnmatchCharacterClass(body); const negated = body.startsWith("!"); if (negated) body = body.slice(1); - tokens.push({kind: "class", body, negated}); + tokens.push({kind: "class", body, negated, ...normalized}); index = end; continue; } @@ -225,6 +226,44 @@ type CharacterClass = { ranges: readonly (readonly [number, number])[]; }; +/** Python fnmatch discards reversed ranges before interpreting a leading !. + * Keep chunk-internal hyphens literal; only chunk boundaries denote ranges. + * Folded lease overlap retains its separate, existing class interpretation. + */ +function fnmatchCharacterClass(body: string): {members: CharacterClass; negated: boolean} { + const symbols = [...body], chunks: string[][] = []; + let start = 0, search = symbols[0] === "!" ? 2 : 1; + for (let dash = symbols.indexOf("-", search); dash >= 0; dash = symbols.indexOf("-", search)) { + chunks.push(symbols.slice(start, dash)); + start = dash + 1; + search = dash + 3; + } + if (start < symbols.length) chunks.push(symbols.slice(start)); + else if (chunks.length) chunks[chunks.length - 1].push("-"); + for (let index = chunks.length - 1; index > 0; index -= 1) { + const left = chunks[index - 1], right = chunks[index]; + if (left.at(-1)!.codePointAt(0)! > right[0].codePointAt(0)!) { + chunks[index - 1] = [...left.slice(0, -1), ...right.slice(1)]; + chunks.splice(index, 1); + } + } + const negated = chunks[0]?.[0] === "!"; + if (negated) chunks[0].shift(); + if (chunks.length > 1 && chunks[0].length === 0) { + // Removing ! can leave a range separator at the start of the class. + // Python treats that leading hyphen as a literal, not a missing endpoint. + chunks.shift(); + chunks[0].unshift("-"); + } + const literals: string[] = [], ranges: Array = []; + for (const [index, chunk] of chunks.entries()) { + literals.push(...chunk.slice(index === 0 ? 0 : 1, + index === chunks.length - 1 ? chunk.length : -1)); + if (index > 0) ranges.push([chunks[index - 1].at(-1)!.codePointAt(0)!, chunk[0].codePointAt(0)!]); + } + return {members: {literals, ranges}, negated}; +} + /** Class members are code points, and a range keeps its original interval bounds. */ function characterClass(body: string): CharacterClass { const members = [...body]; @@ -406,7 +445,7 @@ function fnmatchcase(value: string, pattern: string, descendants = false, fold = .some((length) => match(valueIndex + length, tokenIndex + 1)); } else if (token.kind === "class") { const lengths = fold ? classMatchLengths(token.body, symbols, valueIndex) - : classContains(characterClass(token.body), symbols[valueIndex]) ? [1] : []; + : classContains(token.members ?? characterClass(token.body), symbols[valueIndex]) ? [1] : []; result = token.negated ? (fold ? singleCharacterMatchLengths(symbols, valueIndex) : [1]).some((length) => !lengths.includes(length) && match(valueIndex + length, tokenIndex + 1)) @@ -789,5 +828,104 @@ export function materializeTaskLeaseAcquire(identity: {goal_id: string; todo_id: /** Goal allow scopes use case-sensitive fnmatch, unlike folded lease overlap. */ export function schedulingWriteScopeAllowed(required: string, allowed: readonly string[]): boolean { - return !required || allowed.some(pattern => pattern === required || fnmatchcase(required, pattern, false, false)); + if (!required || allowed.includes(required)) return true; + if (allowed.length === 0) return false; + // A literal requirement still uses the existing path matcher. A glob denotes + // all possible writes, including files that have not been created yet. + if (!/[?*[]/u.test(required)) return allowed.some(pattern => fnmatchcase(required, pattern, false, false)); + return scopeLanguageCovered(required, allowed); +} + +type ScopeInterval = readonly [number, number]; +type ScopeTransition = readonly ScopeInterval[] | null; + +/** Compile case-sensitive fnmatch tokens to an epsilon-NFA. null is a star. */ +function scopeTransitions(pattern: string): ScopeTransition[] { + return globTokens(pattern, false).flatMap((token): ScopeTransition[] => { + if (token.kind === "star") return [null]; + if (token.kind === "question") return [[[0, 0x10ffff]]]; + if (token.kind === "literal") return [...token.folded].map(character => { + const code = character.codePointAt(0)!; + return [[code, code]]; + }); + const members = token.members ?? characterClass(token.body); + const ranges: ScopeInterval[] = [...members.ranges, + ...members.literals.map(character => [character.codePointAt(0)!, character.codePointAt(0)!] as const)] + .filter(([low, high]) => low <= high).sort(([a], [b]) => a - b); + if (!token.negated) return [ranges]; + const complement: ScopeInterval[] = []; + let start = 0; + for (const [low, high] of ranges) { + if (start < low) complement.push([start, low - 1]); + start = Math.max(start, high + 1); + } + if (start <= 0x10ffff) complement.push([start, 0x10ffff]); + return [complement]; + }); +} + +/** Search required × complement(union(grants)) for a counterexample path. + * Alphabet intervals make wide/negated Unicode classes exact without scanning + * characters or the filesystem. Each grant has its own accepting state. + */ +function scopeLanguageCovered(required: string, allowed: readonly string[]): boolean { + const tokens = scopeTransitions(required); + const requiredEnd = tokens.length; + tokens.push([]); // accepting states have no outgoing transition + const starts: number[] = [], grantEnds = new Set(); + for (const pattern of new Set(allowed)) { + starts.push(tokens.length); + tokens.push(...scopeTransitions(pattern)); + grantEnds.add(tokens.length); + tokens.push([]); + } + const boundaries = new Set([0, 0x110000]); + for (const ranges of tokens) for (const [low, high] of ranges ?? []) { + boundaries.add(low); + boundaries.add(high + 1); + } + const alphabet = [...boundaries].filter(code => code < 0x110000).sort((a, b) => a - b); + const closure = (positions: readonly number[]): number[] => { + const result = new Set(positions); + for (const position of result) if (tokens[position] === null) result.add(position + 1); + return [...result].sort((a, b) => a - b); + }; + const step = (positions: readonly number[], code: number): number[] => closure(positions.flatMap(position => { + const ranges = tokens[position]; + if (ranges === null) return [position]; + return ranges.some(([low, high]) => low <= code && code <= high) ? [position + 1] : []; + })); + const initial: [number[], number[]] = [closure([0]), closure(starts)]; + const key = (left: number[], right: number[]) => `${left.join(",")}|${right.join(",")}`; + const queue = [initial], seen = new Set([key(...initial)]); + // Subset construction can be exponential. Unproven coverage must never grant + // authority; callers can narrow pathological declarations to concrete scopes. + let remaining = 100_000; + for (let index = 0; index < queue.length; index += 1) { + const [left, right] = queue[index]; + if (left.includes(requiredEnd) && !right.some(position => grantEnds.has(position))) return false; + for (const code of alphabet) { + if (--remaining < 0) return false; + const nextLeft = step(left, code); + if (nextLeft.length === 0) continue; + const nextRight = step(right, code), nextKey = key(nextLeft, nextRight); + if (!seen.has(nextKey)) { + if (seen.size >= 4096) return false; + seen.add(nextKey); + queue.push([nextLeft, nextRight]); + } + } + } + return true; +} + +/** Batch coverage checks through the scope owner used by admission and hints. */ +export function evaluateWriteScopeCoverage(value: unknown): JsonObject { + const input = requireJsonObject(value, "write-scope coverage"); + if (!Array.isArray(input.checks)) throw new EffectRuntimeRequestError("checks must be an array"); + return {covered: input.checks.map((raw, index) => { + const check = requireJsonObject(raw, `checks[${index}]`); + return schedulingWriteScopeAllowed(stringValue(check.required, "required"), + decisionStringArray(check.allowed, "allowed")); + })}; } diff --git a/tests/control_plane/test_write_scope_coverage.py b/tests/control_plane/test_write_scope_coverage.py new file mode 100644 index 0000000000..174663af2a --- /dev/null +++ b/tests/control_plane/test_write_scope_coverage.py @@ -0,0 +1,225 @@ +"""Boundary decisions use the real typed handler over an isolated test transport. + +The subprocess adapter tests the Python/TypeScript boundary without a daemon or +socket. Real CLI cases also qualify managed File and SQLite admission. +""" + +import copy +import json +import os +from pathlib import Path +import runpy +import subprocess + +import pytest + +from loopx.control_plane.quota import projection_repair +from loopx.control_plane.effect_runtime import ( + EffectRuntimeInternalError, EffectRuntimeStartupError, +) + + +@pytest.fixture +def scope_transport(monkeypatch): + calls = [] + + def request(method, params): + calls.append((method, params)) + result = subprocess.run( + ["node", "--input-type=module", "-e", """ +import {readFileSync} from 'node:fs'; +import {createEffectRuntimeHandlers, dispatchEffectRuntimeMethod} from './loopx/control_plane/effect_runtime_handlers.ts'; +const {method, params} = JSON.parse(readFileSync(0, 'utf8')); +const handlers = createEffectRuntimeHandlers({fingerprint: 'synthetic', requestShutdown() {}}); +console.log(JSON.stringify(await dispatchEffectRuntimeMethod(handlers, method, params))); +"""], + input=json.dumps({"method": method, "params": params}), text=True, + capture_output=True, check=True, timeout=10, + cwd=Path(__file__).resolve().parents[2], + env={**os.environ, "LOOPX_USAGE_PING": "0"}, + ) + return json.loads(result.stdout) + + monkeypatch.setattr(projection_repair, "effect_runtime_result", request, raising=False) + return calls + + +def selected(scopes): + return {"todo_id": "todo_scope_fixture", "task_class": "advancement_task", + "status": "open", "done": False, "action_kind": "implement", + "text": "Implement synthetic change", "required_write_scopes": scopes} + + +@pytest.mark.parametrize(("required", "allowed", "needs_repair"), [ + (["src/*.ts"], ["src/?.ts"], True), + (["src/[ab].ts"], ["src/?.ts"], False), + (["src/[ab].ts"], ["src/a.ts", "src/b.ts"], False), + (["src/a.ts", "src/long.ts"], ["src/?.ts"], True), + (["materials/run/**", "reports/result.md"], ["material*/**", "reports/*.md"], False), + (["src/[A-Z].ts"], ["src/[a-z].ts"], True), + (["src/a.ts"], [], True), + (["src/b.ts"], ["src/[z-a!b].ts"], True), + (["src/c.ts"], ["src/[z-a!b].ts"], False), + (["src/[b].ts"], ["src/[z-a!b].ts"], True), + (["src/[ac].ts"], ["src/[z-a!b].ts"], False), + (["src/[!b].ts"], ["src/[z-a!b].ts"], False), + (["src/a.ts"], ["src/[z-a!-b].ts"], False), + (["src/b.ts"], ["src/[z-a!-b].ts"], True), + (["src/-.ts"], ["src/[z-a!-b].ts"], True), + (["src/[ac].ts"], ["src/[z-a!-b].ts"], False), + (["src/[ab].ts"], ["src/[z-a!-b].ts"], True), +]) +def test_boundary_checks_entire_declared_language(scope_transport, required, allowed, needs_repair): + todo = selected(required) + boundary = {"write_scope": allowed} + before = copy.deepcopy((todo, boundary)) + repair = projection_repair.build_boundary_projection_repair_hint( + boundary, {"first_executable_items": [todo]}, + candidate_should_run=True, selected_todo=todo, + ) + assert (repair is not None) is needs_repair + if repair: + assert repair["effective_action"] == "boundary_projection_repair" + assert repair["missing_write_scopes"] == (required[-1:] if len(required) > 1 else required) + assert (todo, boundary) == before + + +def test_inactive_grants_use_the_same_coverage_without_granting_authority(scope_transport): + todo = selected(["src/[ab].ts", "docs/*.md"]) + boundary = {"write_scope": ["docs/**"], "checkpointed_boundary_authority": { + "entries": [ + {"decision_id": "active", "active": True, "write_scope": ["src/**"]}, + {"decision_id": "too_narrow", "active": False, "write_scope": ["src/a.ts"]}, + {"decision_id": "covers_missing", "active": False, "write_scope": ["src/?.ts"]}, + {"decision_id": "covers_other", "active": False, "write_scope": ["docs/**"]}, + ], + }} + repair = projection_repair.build_boundary_projection_repair_hint( + boundary, {}, candidate_should_run=True, selected_todo=todo, + ) + assert repair["missing_write_scopes"] == ["src/[ab].ts"] + assert [entry["decision_id"] for entry in repair["inactive_authority_candidates"]] == ["covers_missing"] + assert len(scope_transport) == 2 + assert all(check["required"] == "src/[ab].ts" for check in scope_transport[1][1]["checks"]) + + +@pytest.mark.parametrize("active_covers", [True, False]) +@pytest.mark.parametrize("failure", ["internal", "startup", "malformed"]) +def test_optional_inactive_diagnostics_cannot_override_active_decision(monkeypatch, active_covers, failure): + calls = [] + + def request(method, params): + calls.append(params) + if all(check["allowed"] == ["docs/**"] for check in params["checks"]): + return {"covered": [active_covers]} + if failure == "malformed": + return {"covered": [1]} + error = EffectRuntimeInternalError if failure == "internal" else EffectRuntimeStartupError + raise error("synthetic diagnostic failure", diagnostic_code="synthetic") + + monkeypatch.setattr(projection_repair, "effect_runtime_result", request) + repair = projection_repair.build_boundary_projection_repair_hint( + {"write_scope": ["docs/**"], "checkpointed_boundary_authority": { + "entries": [{"active": False, "write_scope": ["src/**"]}], + }}, {}, candidate_should_run=True, + selected_todo=selected(["docs/file.md" if active_covers else "src/file.ts"]), + ) + assert len(calls) == (1 if active_covers else 2) + if active_covers: + assert repair is None + else: + assert repair["effective_action"] == "boundary_projection_repair" + assert repair["missing_write_scopes"] == ["src/file.ts"] + assert "inactive_authority_candidates" not in repair + + +def test_existing_projection_smoke_uses_typed_boundary_entrypoint(scope_transport): + smoke = Path(__file__).resolve().parents[2] / "examples/control_plane/quota-projection-repair-state-machine-smoke.py" + # Reuse every retained smoke assertion over the real typed handler. The + # standalone smoke separately qualifies the managed runtime transport. + assert runpy.run_path(str(smoke))["main"]() == 0 + assert scope_transport + assert all(method == "quota.write_scope_coverage" for method, _ in scope_transport) + + +@pytest.mark.parametrize(("run", "scopes"), [(False, ["src/**"]), (True, [])]) +def test_inactive_or_read_only_route_needs_no_scope_request(scope_transport, run, scopes): + assert projection_repair.build_boundary_projection_repair_hint( + {}, {}, candidate_should_run=run, selected_todo=selected(scopes), + ) is None + assert scope_transport == [] + + +@pytest.mark.parametrize("response", [None, {}, {"covered": []}, {"covered": [1]}, {"covered": [True, True]}]) +def test_malformed_coverage_does_not_silently_authorize(monkeypatch, response): + monkeypatch.setattr(projection_repair, "effect_runtime_result", lambda *args: response) + with pytest.raises(TypeError, match="invalid typed write-scope coverage result"): + projection_repair.build_boundary_projection_repair_hint( + {"write_scope": ["src/**"]}, {}, candidate_should_run=True, + selected_todo=selected(["src/*.ts"]), + ) + + +@pytest.mark.parametrize("error_type", [EffectRuntimeInternalError, EffectRuntimeStartupError]) +def test_active_coverage_errors_are_not_optional(monkeypatch, error_type): + def request(*args): + raise error_type("synthetic active failure", diagnostic_code="synthetic") + + monkeypatch.setattr(projection_repair, "effect_runtime_result", request) + with pytest.raises(error_type): + projection_repair.build_boundary_projection_repair_hint( + {"write_scope": ["src/**"]}, {}, candidate_should_run=True, + selected_todo=selected(["src/a.ts"]), + ) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("mode", ["active", "expired_covered", "expired_missing"]) +@pytest.mark.parametrize("required", ["src/a.ts", "src/b.ts"]) +def test_real_cli_preserves_class_authority_and_expired_hints(tmp_path, monkeypatch, provider, mode, required): + from tests.control_plane.canonical_authority_fixture import ( + initialize_canonical_authority, isolate_sqlite_runtime, read_fixture_todos, + ) + from tests.control_plane.test_quota_settlement_cli import ( + AGENT_ID, GOAL_ID, TODO_ID, _run_cli, _spend_run_count, _write_fixture, + ) + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + + 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] + pattern = "src/[z-a!-b].ts" + goal["coordination"]["write_scope"] = ( + [pattern] if mode == "active" else [required] if mode == "expired_covered" else [] + ) + if mode != "active": + goal["coordination"]["checkpointed_boundary_authority"] = [{ + "decision_id": "expired-scope-fixture", "write_scope": [pattern], + "source": "synthetic approval", "recorded_at": "2000-01-01T00:00:00Z", + "expires_at": "2000-01-02T00:00:00Z", + }] + registry.write_text(json.dumps(config)) + state = project / goal["state_file"] + todos = read_fixture_todos(state) + todos[0]["required_write_scopes"] = [required] + 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) + rc, packet = _run_cli(registry, runtime, "quota", "should-run", + "--goal-id", GOAL_ID, "--agent-id", AGENT_ID, "--codex-app", + "--turn-instance-id", "scope-compatibility", cwd=project) + covered = mode == "expired_covered" or (mode == "active" and required == "src/a.ts") + assert rc == 0, json.dumps(packet, ensure_ascii=False) + assert packet["normal_delivery_allowed"] is covered, packet + assert packet["selected_todo"]["todo_id"] == TODO_ID + if not covered: + assert packet["effective_action"] == "boundary_projection_repair", packet + repair = packet["boundary_projection_gap"] + assert repair["missing_write_scopes"] == [required] + candidates = repair.get("inactive_authority_candidates", []) + assert [entry["decision_id"] for entry in candidates] == ( + ["expired-scope-fixture"] if mode == "expired_missing" and required == "src/a.ts" else [] + ) + assert _spend_run_count(runtime) == 0 diff --git a/tests/control_plane_ts/write_scope_coverage.test.ts b/tests/control_plane_ts/write_scope_coverage.test.ts new file mode 100644 index 0000000000..1734886035 --- /dev/null +++ b/tests/control_plane_ts/write_scope_coverage.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {spawnSync} from "node:child_process"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {createEffectRuntimeHandlers, dispatchEffectRuntimeMethod} from "../../loopx/control_plane/effect_runtime_handlers.ts"; +import {schedulingWriteScopeAllowed} from "../../loopx/control_plane/work_items/task_lease_acquire_decision.ts"; + +const cases: [string, string[], boolean][] = [ + ["src/*.ts", ["src/?.ts"], false], + ["src/[ab].ts", ["src/?.ts"], true], + ["src/a.ts", ["src/?.ts"], true], + ["src/b.ts", ["src/[z-a!b].ts"], false], + ["src/c.ts", ["src/[z-a!b].ts"], true], + ["src/!.ts", ["src/[z-a!b].ts"], true], + ["src/[b].ts", ["src/[z-a!b].ts"], false], + ["src/[ac].ts", ["src/[z-a!b].ts"], true], + ["src/[z-a!b].ts", ["src/[!b].ts"], true], + ["src/[!b].ts", ["src/[z-a!b].ts"], true], + ["src/c.ts", ["src/[!z-a].ts"], true], + ["src/a.ts", ["src/[z-a!-b].ts"], true], + ["src/b.ts", ["src/[z-a!-b].ts"], false], + ["src/-.ts", ["src/[z-a!-b].ts"], false], + ["src/[ac].ts", ["src/[z-a!-b].ts"], true], + ["src/[ab].ts", ["src/[z-a!-b].ts"], false], + ["src/[!-b].ts", ["src/[z-a!-b].ts"], true], + ["src/[z-a!-b].ts", ["src/[!-b].ts"], true], + ["src/long.ts", ["src/?.ts"], false], + ["src/?.ts", ["src/*.ts"], true], + ["src/*.ts", ["src/[!x]*.ts"], false], + ["src/[ab].ts", ["src/a.ts", "src/b.ts"], true], + ["src/[a-c].ts", ["src/[ab].ts", "src/c.ts"], true], + ["src/[a-c].ts", ["src/a.ts", "src/c.ts"], false], + ["src/[!a].ts", ["src/?.ts"], true], + ["src/[!a].ts", ["src/[!b].ts"], false], + ["src/?.ts", ["src/a.ts", "src/[!a].ts"], true], + ["src/??.ts", ["src/?.ts"], false], + ["src/😀?.ts", ["src/??.ts"], true], + ["src/[😀-🙏].ts", ["src/?.ts"], true], + ["src/[A-Z].ts", ["src/[a-z].ts"], false], + ["src/SS.ts", ["src/[ß].ts"], false], + ["src/**/*.ts", ["src/*.ts"], true], + ["src/*.ts", ["src/**/*.ts"], false], + ["src/[[]*.ts", ["src/*.ts"], true], + ["src/[*].ts", ["src/?.ts"], true], + ["src/*.ts", ["src/[*].ts"], false], + ["materials/run/**", ["material*/**"], true], + ["reports/result.md", ["reports/*.md"], true], + ["src/a.ts", ["src/"], false], + ["src/[.ts", ["src/[.ts"], true], + ["src/[]].ts", ["src/?.ts"], true], + ["src/[a-z].ts", [], false], + ["", [], true], +]; + +for (const [required, allowed, expected] of cases) { + test(`scope coverage ${required} within ${JSON.stringify(allowed)}`, () => { + assert.equal(schedulingWriteScopeAllowed(required, allowed), expected); + }); +} + +test("ambiguous coverage remains bounded and never grants unproved authority", {timeout: 5_000}, () => { + const required = `src/${"?".repeat(24)}`; + const allowed = Array.from({length: 24}, (_, index) => `src/${"?".repeat(index)}a${"?".repeat(23 - index)}`); + // An all-b filename is outside every grant. Subset construction must not + // become an unbounded admission task while looking for that counterexample. + assert.equal(schedulingWriteScopeAllowed(required, allowed), false); + assert.equal(schedulingWriteScopeAllowed(required, [required, ...allowed]), true); +}); + +test("registered child admission checks the paths denoted by a required glob", async () => { + const handlers = createEffectRuntimeHandlers({fingerprint: "synthetic", requestShutdown() {}}); + const lane = (id: string, scopes: string[]): JsonObject => ({ + todo_id: id, dependency_todo_id: id, task_class: "advancement_task", done: false, + status: "open", resume_when: null, claimed_by: null, excluded_agents: [], task_domain: "code", + task_repository: null, required_write_scopes: scopes, required_capabilities: [], target_capabilities: [], + objective: "Implement synthetic change", action_kind: scopes.length ? "implement" : "inspect", + continuation_policy: null, target_key: null, validation_label: null, + }); + for (const [required, expected] of [["src/*.ts", false], ["src/[ab].ts", true]] as const) { + const result = await dispatchEffectRuntimeMethod(handlers, "quota.task_orchestration.admit", { + agent_id: "agent-a", registered_agents: ["agent-a"], goal_repository: null, + goal_write_scope: ["src/?.ts"], allowed_domains: null, max_children: 2, user_items: [], + available_capabilities: ["subagent_spawn"], items: [lane("todo_primary", []), lane("todo_child", [required])], + }) as JsonObject | null; + assert.equal(result !== null, expected, required); + if (result) assert.equal((result.eligible_child_lanes as JsonObject[])[0].todo_id, "todo_child"); + } +}); + +test("finite required languages agree with Python fnmatch's independent oracle", () => { + const probe = spawnSync(resolveTestPython(), ["-c", ` +import fnmatch, itertools, json +required = ['[ab]', '[a-c]', '[a-b-c]', '[a--c]', '[a-b-c-d]', '[--a]', + '[]a]', '[[]', '[*]', '[?]', '[z-a]', '[😀-😃]', '[ab][bc]'] +grants = ['?', '*', '[!a]', '[!b]', '[a-c]', '[a-b-c]', '[a--c]', '[--a]', + '[z-a]', 'a*', '*b', '[ab][bc]', '[😀-🙏]', '[*?]', '[]a]'] +alphabet = [chr(i) for i in range(128)] + [chr(i) for i in range(0x1f600, 0x1f604)] +names = [''] + alphabet + [''.join(p) for p in itertools.product('abc', repeat=2)] +rows = [] +for r in required: + language = [s for s in names if fnmatch.fnmatchcase(s, r)] + for allowed in [[g] for g in grants] + [['a', '[!a]'], ['[ab]', 'c']]: + rows.append([r, allowed, all(any(fnmatch.fnmatchcase(s, g) for g in allowed) for s in language)]) +print(json.dumps(rows)) +`], {encoding: "utf8", timeout: 10_000}); + assert.equal(probe.status, 0, probe.stderr); + for (const [required, allowed, expected] of JSON.parse(probe.stdout) as [string, string[], boolean][]) { + assert.equal(schedulingWriteScopeAllowed(required, allowed), expected, `${required} in ${allowed.join(",")}`); + } +}); + +test("coverage transport batches decisions and rejects malformed facts", async () => { + const handlers = createEffectRuntimeHandlers({fingerprint: "synthetic", requestShutdown() {}}); + const result = await dispatchEffectRuntimeMethod(handlers, "quota.write_scope_coverage", { + checks: cases.map(([required, allowed]) => ({required, allowed})), + }); + assert.deepEqual(result, {covered: cases.map(([, , expected]) => expected)}); + for (const params of [{checks: null}, {checks: [{required: 1, allowed: []}]}, + {checks: [{required: "src/*.ts", allowed: [null]}]}]) { + await assert.rejects(dispatchEffectRuntimeMethod(handlers, "quota.write_scope_coverage", params)); + } +}); + +test("literal and singleton-glob authorization preserve Python character-class normalization", () => { + const probe = spawnSync(resolveTestPython(), ["-c", ` +import fnmatch, itertools, json +classes = [''.join(p) for size in range(1, 7) for p in itertools.product('!az-', repeat=size)] +classes += ['z-a!b', '!z-a!b', 'z-a^b', 'z-a[b', 'z-a]b', 'z-a\\\\b', + 'a-b-c-d', '😀-a!b', '!😀-a', '&~|', '[]', '[]!', + 'z-a!-b', 'z-a!-b-d', 'z-a!-bc-d', '😀-a!-b'] +names = list('abcz!-^]&|~') + [chr(92), '😀'] +print(json.dumps([[body, [fnmatch.fnmatchcase('src/' + name + '.ts', 'src/[' + body + '].ts') + for name in names]] for body in classes])) +`], {encoding: "utf8", timeout: 10_000}); + assert.equal(probe.status, 0, probe.stderr); + const names = [..."abcz!-^]&|~", "\\", "😀"]; + for (const [body, expected] of JSON.parse(probe.stdout) as [string, boolean[]][]) { + const allowed = [`src/[${body}].ts`]; + for (const [index, name] of names.entries()) { + assert.equal(schedulingWriteScopeAllowed(`src/${name}.ts`, allowed), expected[index], + `${name} within ${allowed[0]}`); + // A leading ! denotes negation rather than a singleton class. + if (name === "!") continue; + const singleton = `[${name}]`; + assert.equal(schedulingWriteScopeAllowed(`src/${singleton}.ts`, allowed), expected[index], + `${singleton} within ${allowed[0]}`); + } + } +}); + +test("star coverage never admits a counterexample from an independent bounded path set", () => { + const probe = spawnSync(resolveTestPython(), ["-c", ` +import fnmatch, itertools, json +patterns = ['*', '?', 'a*', '*a', '*b*', '?*', '*?*', '[ab]*', '[!a]*', + 'a*b', 'a?b', '*/*', '**/a', '[ab][ab]*', '*a*b*', '*b*a*'] +names = [''.join(p) for size in range(5) for p in itertools.product('ab/😀', repeat=size)] +print(json.dumps([[r, [a], next((s for s in names if fnmatch.fnmatchcase(s, r) + and not fnmatch.fnmatchcase(s, a)), None)] for r in patterns for a in patterns])) +`], {encoding: "utf8", timeout: 10_000}); + assert.equal(probe.status, 0, probe.stderr); + for (const [required, allowed, witness] of JSON.parse(probe.stdout) as [string, string[], string | null][]) { + if (witness !== null) assert.equal(schedulingWriteScopeAllowed(required, allowed), false, + `${JSON.stringify(witness)} escapes ${required} in ${allowed.join(",")}`); + } +});