From 9d3d205f968d3fff225ccd113731612c7e57c7c3 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Sat, 24 Oct 2026 17:44:02 +0800 Subject: [PATCH 1/6] fix(leases): treat case aliases as overlapping write scopes Hard-lease scope comparison was case-sensitive even though the contract requires host/path case aliases to remain overlapping. Two agents could therefore acquire active exclusive leases for one physical file on default case-insensitive Windows and macOS filesystems. Case-fold only admission matching, including literals, prefixes and glob character classes, while leaving retained scopes and request identity in their original spelling. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 40 +++++++++++-------- .../test_canonical_lease_scopes.py | 16 ++++++++ .../task_lease_workspace.test.ts | 20 ++++++++++ 3 files changed, 60 insertions(+), 16 deletions(-) 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 d116285b90..0996d9efe1 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -144,25 +144,30 @@ function classMatch( pattern: string, start: number, value: string, + foldCase: boolean, ): { end: number; matches: boolean } | null { const end = characterClassEnd(pattern, start); if (end < 0) return null; let body = pattern.slice(start + 1, end); const negated = body.startsWith("!"); if (negated) body = body.slice(1); + const foldedValue = foldCase ? value.toLowerCase() : value; let matches = false; for (let index = 0; index < body.length; index += 1) { if (index + 2 < body.length && body[index + 1] === "-") { - if (body[index] <= value && value <= body[index + 2]) matches = true; + if ((body[index] <= value && value <= body[index + 2]) || + (foldCase && body[index].toLowerCase() <= foldedValue && + foldedValue <= body[index + 2].toLowerCase())) matches = true; index += 2; - } else if (body[index] === value) { + } else if (body[index] === value || + (foldCase && body[index].toLowerCase() === foldedValue)) { matches = true; } } return { end, matches: negated ? !matches : matches }; } -function fnmatchcase(value: string, pattern: string): boolean { +function fnmatchcase(value: string, pattern: string, foldCase = false): boolean { const memo = new Map(); const match = (valueIndex: number, patternIndex: number): boolean => { const key = `${valueIndex}:${patternIndex}`; @@ -179,12 +184,13 @@ function fnmatchcase(value: string, pattern: string): boolean { } else if (pattern[patternIndex] === "?") { result = match(valueIndex + 1, patternIndex + 1); } else if (pattern[patternIndex] === "[") { - const characterClass = classMatch(pattern, patternIndex, value[valueIndex]); + const characterClass = classMatch(pattern, patternIndex, value[valueIndex], foldCase); result = characterClass === null ? value[valueIndex] === "[" && match(valueIndex + 1, patternIndex + 1) : characterClass.matches && match(valueIndex + 1, characterClass.end + 1); } else { - result = value[valueIndex] === pattern[patternIndex] && + result = (value[valueIndex] === pattern[patternIndex] || + (foldCase && value[valueIndex].toLowerCase() === pattern[patternIndex].toLowerCase())) && match(valueIndex + 1, patternIndex + 1); } memo.set(key, result); @@ -201,7 +207,9 @@ function scopeLiteralPrefix(scope: string): string { } function scopePairOverlaps(left: string, right: string): boolean { - if (left === right) return true; + const equal = (first: string, second: string) => first === second || + first.toLowerCase() === second.toLowerCase(); + if (equal(left, right)) return true; if (["*", "**", "./"].includes(left) || ["*", "**", "./"].includes(right)) { return true; } @@ -209,24 +217,24 @@ function scopePairOverlaps(left: string, right: string): boolean { const rightGlob = ["*", "?", "["].some((token) => right.includes(token)); if (leftGlob && !rightGlob) { const prefix = scopeLiteralPrefix(left); - return fnmatchcase(right, left) || - (prefix.endsWith("/") && right.replace(/\/$/u, "") === prefix.replace(/\/$/u, "")); + return fnmatchcase(right, left, true) || + (prefix.endsWith("/") && equal(right.replace(/\/$/u, ""), prefix.replace(/\/$/u, ""))); } if (rightGlob && !leftGlob) { const prefix = scopeLiteralPrefix(right); - return fnmatchcase(left, right) || - (prefix.endsWith("/") && left.replace(/\/$/u, "") === prefix.replace(/\/$/u, "")); + return fnmatchcase(left, right, true) || + (prefix.endsWith("/") && equal(left.replace(/\/$/u, ""), prefix.replace(/\/$/u, ""))); } if (leftGlob && rightGlob) { - const leftPrefix = scopeLiteralPrefix(left); - const rightPrefix = scopeLiteralPrefix(right); + const leftPrefix = scopeLiteralPrefix(left).toLowerCase(); + const rightPrefix = scopeLiteralPrefix(right).toLowerCase(); return !leftPrefix || !rightPrefix || leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix); } - const leftRoot = left.replace(/\/$/u, ""); - const rightRoot = right.replace(/\/$/u, ""); - return (left.endsWith("/") && right.startsWith(`${leftRoot}/`)) || - (right.endsWith("/") && left.startsWith(`${rightRoot}/`)); + const leftRoot = left.replace(/\/$/u, "").toLowerCase(); + const rightRoot = right.replace(/\/$/u, "").toLowerCase(); + return (left.endsWith("/") && right.toLowerCase().startsWith(`${leftRoot}/`)) || + (right.endsWith("/") && left.toLowerCase().startsWith(`${rightRoot}/`)); } function writeScopesOverlap(left: readonly string[], right: readonly string[]): boolean { diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index e32eb40a88..ffa0f46dde 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -98,6 +98,22 @@ def test_authored_alias_declaration_can_be_claimed_without_revision(scope_goal): assert cli("todo", "list", "--todo-id", todo_id)["todo"]["required_write_scopes"] == ALIASES +def test_case_aliases_remain_exclusive_write_scopes(scope_goal): + cli, current = scope_goal + acquire = ("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "case-a", "--expected-version", "0", "--write-scope", "src/Main.ts") + assert cli(*acquire)["acquired"] + assert cli(*acquire)["idempotent"] + changed = cli(*acquire[:-1], "src/main.ts", expected_exit=1) + assert changed["error_code"] == "coordination_operation_identity_mismatch" + before = current() + rejected = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "case-b", "--expected-version", "0", "--write-scope", "src/main.ts", + expected_exit=1) + assert rejected["error_code"] == "write_scope_conflict" + assert current() == before + + @pytest.mark.parametrize("scope,target", [ ("src/[]/./]x", "src/.x"), ("src/[]/./]x", "src/]x"), diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 741c7dccbc..bb043cceb7 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -270,6 +270,26 @@ test("scope comparison canonicalizes aliases while request identity keeps its sp true, ); }); +test("scope comparison folds case aliases without rewriting retained or request spelling", () => { + assert.deepEqual(normalizeWriteScopes(["src/Main.ts"]), ["src/Main.ts"]); + assert.deepEqual(comparisonWriteScopes(["src/Main.ts"]), ["src/Main.ts"]); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/Main.ts"], + right: ["src/main.ts"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[A-Z]ain.ts"], + right: ["src/main.ts"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[A-z]x"], + right: ["src/_x"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/Main.ts"], + right: ["docs/main.ts"], + }).overlap, false); +}); test("scope comparison and matching share literal right-bracket character classes", () => { for (const [scope, target] of [ ["src/[]/./]x", "src/.x"], From a1d5142dc68f9d9055adde28fe34db487d1a2915 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Sat, 24 Oct 2026 18:53:08 +0800 Subject: [PATCH 2/6] fix(leases): fold case within glob character classes Folding mixed-case range endpoints could still miss case aliases such as C inside [A-b], so two exclusive leases could remain active. Test the original character and its single-code-unit case variants against the unmodified class instead. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 27 +++++++++++-------- .../test_canonical_lease_scopes.py | 11 ++++++++ .../task_lease_workspace.test.ts | 16 +++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) 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 0996d9efe1..3ea689bcbc 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -151,19 +151,24 @@ function classMatch( let body = pattern.slice(start + 1, end); const negated = body.startsWith("!"); if (negated) body = body.slice(1); - const foldedValue = foldCase ? value.toLowerCase() : value; - let matches = false; - for (let index = 0; index < body.length; index += 1) { - if (index + 2 < body.length && body[index + 1] === "-") { - if ((body[index] <= value && value <= body[index + 2]) || - (foldCase && body[index].toLowerCase() <= foldedValue && - foldedValue <= body[index + 2].toLowerCase())) matches = true; - index += 2; - } else if (body[index] === value || - (foldCase && body[index].toLowerCase() === foldedValue)) { - matches = true; + const contains = (candidate: string) => { + for (let index = 0; index < body.length; index += 1) { + if (index + 2 < body.length && body[index + 1] === "-") { + if (body[index] <= candidate && candidate <= body[index + 2]) return true; + index += 2; + } else if (body[index] === candidate) { + return true; + } + } + return false; + }; + const candidates = new Set([value]); + if (foldCase) { + for (const candidate of [value.toLowerCase(), value.toUpperCase()]) { + if (candidate.length === 1) candidates.add(candidate); } } + const matches = [...candidates].some(contains); return { end, matches: negated ? !matches : matches }; } diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index ffa0f46dde..283278872c 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -118,6 +118,7 @@ def test_case_aliases_remain_exclusive_write_scopes(scope_goal): ("src/[]/./]x", "src/.x"), ("src/[]/./]x", "src/]x"), ("src/[!]/./]x", "src/ax"), + ("src/[A-b]x", "src/cx"), ]) def test_glob_class_members_keep_exclusive_scope_coverage(scope_goal, scope, target): cli, current = scope_goal @@ -128,3 +129,13 @@ def test_glob_class_members_keep_exclusive_scope_coverage(scope_goal, scope, tar rejected = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", "--idempotency-key", "class-b", "--write-scope", target, "--expected-version", "0", expected_exit=1) assert rejected["error_code"] == "write_scope_conflict" and current() == before + + +def test_case_folded_negated_glob_class_excludes_case_variant(scope_goal): + cli, _ = scope_goal + acquired = cli("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "case-class-a", "--write-scope", "src/[!A-b]x", "--expected-version", "0") + assert acquired["acquired"] + allowed = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "case-class-b", "--write-scope", "src/cx", "--expected-version", "0") + assert allowed["acquired"] diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index bb043cceb7..b0b7145bf0 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -285,6 +285,22 @@ test("scope comparison folds case aliases without rewriting retained or request left: ["src/[A-z]x"], right: ["src/_x"], }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[A-b]x"], + right: ["src/cx"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[!A-b]x"], + right: ["src/cx"], + }).overlap, false); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/Main/"], + right: ["src/main/file.ts"], + }).overlap, true); + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/Main/[a-z]*.ts"], + right: ["src/main/[A-Z]*.ts"], + }).overlap, true); assert.equal(evaluateTaskLeaseWriteScopesOverlap({ left: ["src/Main.ts"], right: ["docs/main.ts"], From 98578120a18ca763b3bece16733d247b7175a953 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Mon, 26 Oct 2026 17:01:47 +0800 Subject: [PATCH 3/6] fix(leases): fold Unicode case aliases in write scopes Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 131 ++++++++++++------ .../test_canonical_lease_scopes.py | 48 ++++++- .../task_lease_workspace.test.ts | 13 ++ 3 files changed, 151 insertions(+), 41 deletions(-) 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 3ea689bcbc..1c808a740b 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -140,17 +140,56 @@ function optionalInteger(value: unknown, label: string): number | null { return value; } -function classMatch( - pattern: string, - start: number, - value: string, - foldCase: boolean, -): { end: number; matches: boolean } | null { - const end = characterClassEnd(pattern, start); - if (end < 0) return null; - let body = pattern.slice(start + 1, end); - const negated = body.startsWith("!"); - if (negated) body = body.slice(1); +/** Host case aliases include Unicode folds whose length changes, such as ß -> ss. */ +function caseFold(value: string): string { + return value.toUpperCase().toLowerCase(); +} + +type GlobToken = + | {kind: "literal"; literal: string; folded: string} + | {kind: "star"} + | {kind: "question"} + | {kind: "class"; body: string; negated: boolean}; + +function globTokens(pattern: string): GlobToken[] { + const tokens: GlobToken[] = []; + let literal = ""; + const flushLiteral = () => { + if (literal.length === 0) return; + tokens.push({kind: "literal", literal, folded: caseFold(literal)}); + literal = ""; + }; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + if (character === "*") { + flushLiteral(); + tokens.push({kind: "star"}); + continue; + } + if (character === "?") { + flushLiteral(); + tokens.push({kind: "question"}); + continue; + } + if (character === "[") { + const end = characterClassEnd(pattern, index); + if (end >= 0) { + flushLiteral(); + let body = pattern.slice(index + 1, end); + const negated = body.startsWith("!"); + if (negated) body = body.slice(1); + tokens.push({kind: "class", body, negated}); + index = end; + continue; + } + } + literal += character; + } + flushLiteral(); + return tokens; +} + +function classMatch(body: string, value: string, foldCase: boolean): boolean { const contains = (candidate: string) => { for (let index = 0; index < body.length; index += 1) { if (index + 2 < body.length && body[index + 1] === "-") { @@ -164,39 +203,51 @@ function classMatch( }; const candidates = new Set([value]); if (foldCase) { - for (const candidate of [value.toLowerCase(), value.toUpperCase()]) { - if (candidate.length === 1) candidates.add(candidate); + for (const candidate of [value.toLowerCase(), value.toUpperCase(), caseFold(value)]) { + if ([...candidate].length === 1) candidates.add(candidate); } } - const matches = [...candidates].some(contains); - return { end, matches: negated ? !matches : matches }; + return [...candidates].some(contains); } function fnmatchcase(value: string, pattern: string, foldCase = false): boolean { + const tokens = globTokens(pattern); const memo = new Map(); - const match = (valueIndex: number, patternIndex: number): boolean => { - const key = `${valueIndex}:${patternIndex}`; + const match = (valueIndex: number, tokenIndex: number): boolean => { + const key = `${valueIndex}:${tokenIndex}`; const cached = memo.get(key); if (cached !== undefined) return cached; let result: boolean; - if (patternIndex === pattern.length) { + if (tokenIndex === tokens.length) { result = valueIndex === value.length; - } else if (pattern[patternIndex] === "*") { - result = match(valueIndex, patternIndex + 1) || - (valueIndex < value.length && match(valueIndex + 1, patternIndex)); - } else if (valueIndex === value.length) { - result = false; - } else if (pattern[patternIndex] === "?") { - result = match(valueIndex + 1, patternIndex + 1); - } else if (pattern[patternIndex] === "[") { - const characterClass = classMatch(pattern, patternIndex, value[valueIndex], foldCase); - result = characterClass === null - ? value[valueIndex] === "[" && match(valueIndex + 1, patternIndex + 1) - : characterClass.matches && match(valueIndex + 1, characterClass.end + 1); } else { - result = (value[valueIndex] === pattern[patternIndex] || - (foldCase && value[valueIndex].toLowerCase() === pattern[patternIndex].toLowerCase())) && - match(valueIndex + 1, patternIndex + 1); + const token = tokens[tokenIndex]; + if (token.kind === "star") { + result = match(valueIndex, tokenIndex + 1) || + (valueIndex < value.length && match(valueIndex + 1, tokenIndex)); + } else if (valueIndex === value.length) { + result = false; + } else if (token.kind === "question") { + result = match(valueIndex + 1, tokenIndex + 1); + } else if (token.kind === "class") { + const matches = classMatch(token.body, value[valueIndex], foldCase); + result = (token.negated ? !matches : matches) && + match(valueIndex + 1, tokenIndex + 1); + } else { + result = false; + for (let end = valueIndex + 1; end <= value.length; end += 1) { + const candidate = value.slice(valueIndex, end); + const folded = caseFold(candidate); + if (foldCase ? folded.length > token.folded.length : candidate.length > token.literal.length) { + break; + } + if ((foldCase ? folded === token.folded : candidate === token.literal) && + match(end, tokenIndex + 1)) { + result = true; + break; + } + } + } } memo.set(key, result); return result; @@ -213,7 +264,7 @@ function scopeLiteralPrefix(scope: string): string { function scopePairOverlaps(left: string, right: string): boolean { const equal = (first: string, second: string) => first === second || - first.toLowerCase() === second.toLowerCase(); + caseFold(first) === caseFold(second); if (equal(left, right)) return true; if (["*", "**", "./"].includes(left) || ["*", "**", "./"].includes(right)) { return true; @@ -231,15 +282,15 @@ function scopePairOverlaps(left: string, right: string): boolean { (prefix.endsWith("/") && equal(left.replace(/\/$/u, ""), prefix.replace(/\/$/u, ""))); } if (leftGlob && rightGlob) { - const leftPrefix = scopeLiteralPrefix(left).toLowerCase(); - const rightPrefix = scopeLiteralPrefix(right).toLowerCase(); + const leftPrefix = caseFold(scopeLiteralPrefix(left)); + const rightPrefix = caseFold(scopeLiteralPrefix(right)); return !leftPrefix || !rightPrefix || leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix); } - const leftRoot = left.replace(/\/$/u, "").toLowerCase(); - const rightRoot = right.replace(/\/$/u, "").toLowerCase(); - return (left.endsWith("/") && right.toLowerCase().startsWith(`${leftRoot}/`)) || - (right.endsWith("/") && left.toLowerCase().startsWith(`${rightRoot}/`)); + const leftRoot = caseFold(left.replace(/\/$/u, "")); + const rightRoot = caseFold(right.replace(/\/$/u, "")); + return (left.endsWith("/") && caseFold(right).startsWith(`${leftRoot}/`)) || + (right.endsWith("/") && caseFold(left).startsWith(`${rightRoot}/`)); } function writeScopesOverlap(left: readonly string[], right: readonly string[]): boolean { diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index 283278872c..7fb8c45e58 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -39,7 +39,7 @@ def cli(*args, cwd=None, expected_exit=0): selected_repo = Path(os.environ.get("LOOPX_LEASE_SCOPE_CONSUMER", REPO)) if cwd is None else cwd child = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", *args, "--goal-id", goal], - cwd=selected_repo, capture_output=True, text=True, timeout=60, check=False) + cwd=selected_repo, capture_output=True, text=True, encoding="utf-8", timeout=60, check=False) assert child.returncode == expected_exit, child.stdout + child.stderr return json.loads(child.stdout) @@ -114,6 +114,52 @@ def test_case_aliases_remain_exclusive_write_scopes(scope_goal): assert current() == before +@pytest.mark.parametrize("first_scope,second_scope", [ + ("src/Σ.ts", "src/ς.ts"), + ("src/ß.ts", "src/SS.ts"), +]) +def test_unicode_case_aliases_remain_exclusive_write_scopes(scope_goal, first_scope, second_scope): + cli, current = scope_goal + acquired = cli("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "unicode-a", "--expected-version", "0", "--write-scope", first_scope) + assert acquired["lease"]["write_scopes"] == [first_scope] + before = current() + rejected = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "unicode-b", "--expected-version", "0", "--write-scope", second_scope, + expected_exit=1) + assert rejected["error_code"] == "write_scope_conflict" + assert current() == before + + +@pytest.mark.parametrize("first_scope,second_scope", [ + ("src/Σ.ts", "src/ς.ts"), + ("src/ß.ts", "src/SS.ts"), +]) +def test_unicode_case_aliases_conflict_at_atomic_todo_claim(scope_goal, first_scope, second_scope): + cli, current = scope_goal + repository = "git:github.com/example/project" + + def add_todo(label, scope): + return cli("todo", "add", "--role", "agent", "--priority", "P1", "--text", f"Unicode scope {label}", + "--task-repository", repository, "--required-write-scope", scope, + "--operation-id", f"unicode-add-{label}")["todo_id"] + + first = add_todo("a", first_scope) + claimed = cli("todo", "claim", "--todo-id", first, "--claimed-by", "agent-a", "--agent-id", "agent-a", + "--claim-operation-id", "unicode-claim-a", "--task-lease-idempotency-key", "unicode-claim-a", + "--task-lease-expected-version", "0") + assert claimed["ok"] + assert cli("task-lease", "inspect", "--todo-id", first)["lease"]["write_scopes"] == [first_scope] + + second = add_todo("b", second_scope) + before = current() + rejected = cli("todo", "claim", "--todo-id", second, "--claimed-by", "agent-b", "--agent-id", "agent-b", + "--claim-operation-id", "unicode-claim-b", "--task-lease-idempotency-key", "unicode-claim-b", + "--task-lease-expected-version", "0", expected_exit=1) + assert rejected["error_code"] == "write_scope_conflict" + assert current() == before + + @pytest.mark.parametrize("scope,target", [ ("src/[]/./]x", "src/.x"), ("src/[]/./]x", "src/]x"), diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index b0b7145bf0..0483749543 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -305,6 +305,19 @@ test("scope comparison folds case aliases without rewriting retained or request left: ["src/Main.ts"], right: ["docs/main.ts"], }).overlap, false); + for (const [left, right] of [ + ["src/Σ.ts", "src/ς.ts"], + ["src/ß.ts", "src/SS.ts"], + ["src/Σ/", "src/ς/file.ts"], + ["src/ß/*.ts", "src/SS/file.ts"], + ["src/[Σ]x", "src/ςx"], + ]) { + assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`); + } + assert.equal(evaluateTaskLeaseWriteScopesOverlap({ + left: ["src/[!Σ]x"], + right: ["src/ςx"], + }).overlap, false); }); test("scope comparison and matching share literal right-bracket character classes", () => { for (const [scope, target] of [ From 10415dcfcf0c6ce53b30cf68e9af32a61dbd5626 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Mon, 26 Oct 2026 20:42:21 +0800 Subject: [PATCH 4/6] fix(leases): fold Unicode case aliases inside glob character classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Character-class membership compared the input against the class body one way only, so a class member that folds to the compared text (a class member ς against Σ) missed, and a member whose fold changes length ([ß] against SS) could never match because the class branch consumed exactly one unit. Membership is now a symmetric fold equivalence over both literals and single-code-point range variants, negation applies after that union, and a class may consume the number of units its fold needs. Original range intervals, negated semantics, stored spelling and request identity are unchanged. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 83 ++++++++++++++----- .../test_canonical_lease_scopes.py | 49 +++++++++++ .../task_lease_workspace.test.ts | 13 ++- 3 files changed, 122 insertions(+), 23 deletions(-) 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 1c808a740b..8d830a17bd 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -189,25 +189,68 @@ function globTokens(pattern: string): GlobToken[] { return tokens; } -function classMatch(body: string, value: string, foldCase: boolean): boolean { - const contains = (candidate: string) => { - for (let index = 0; index < body.length; index += 1) { - if (index + 2 < body.length && body[index + 1] === "-") { - if (body[index] <= candidate && candidate <= body[index + 2]) return true; - index += 2; - } else if (body[index] === candidate) { - return true; - } +type CharacterClass = { + literals: readonly string[]; + ranges: readonly (readonly [string, string])[]; +}; + +function characterClass(body: string): CharacterClass { + const literals: string[] = []; + const ranges: Array = []; + for (let index = 0; index < body.length; index += 1) { + if (index + 2 < body.length && body[index + 1] === "-") { + ranges.push([body[index], body[index + 2]]); + index += 2; + } else { + literals.push(body[index]); } - return false; - }; - const candidates = new Set([value]); - if (foldCase) { - for (const candidate of [value.toLowerCase(), value.toUpperCase(), caseFold(value)]) { - if ([...candidate].length === 1) candidates.add(candidate); + } + return {literals, ranges}; +} + +/** Single-character case variants, so a range keeps its original interval. */ +function singleCharacterVariants(value: string): string[] { + const variants = new Set(); + for (const candidate of [value, value.toLowerCase(), value.toUpperCase(), caseFold(value)]) { + if ([...candidate].length === 1) variants.add(candidate); + } + return [...variants]; +} + +/** + * Class membership is a symmetric fold equivalence: a member and the compared text + * match when their canonical fold is equal. That covers non-reciprocal Unicode + * aliases (a class member ς against Σ) and folds that change length (ß against SS). + * Ranges keep their original interval and only ever consume one unit. + */ +function classMemberMatches(body: string, candidate: string, foldCase: boolean): boolean { + const {literals, ranges} = characterClass(body); + const folded = foldCase ? caseFold(candidate) : candidate; + for (const literal of literals) { + if (literal === candidate || (foldCase && caseFold(literal) === folded)) return true; + } + if ([...candidate].length !== 1) return false; + for (const [low, high] of ranges) { + for (const variant of (foldCase ? singleCharacterVariants(candidate) : [candidate])) { + if (low <= variant && variant <= high) return true; + } + } + return false; +} + +/** + * A class consumes one unit by default. Case folding may consume several when the + * compared text folds to the same string as a member (ß/SS, ffi/FFI). + */ +function classMatchLengths(body: string, value: string, start: number, foldCase: boolean): number[] { + const lengths: number[] = []; + const maximum = foldCase ? 3 : 1; + for (let length = 1; length <= maximum && start + length <= value.length; length += 1) { + if (classMemberMatches(body, value.slice(start, start + length), foldCase)) { + lengths.push(length); } } - return [...candidates].some(contains); + return lengths; } function fnmatchcase(value: string, pattern: string, foldCase = false): boolean { @@ -230,9 +273,11 @@ function fnmatchcase(value: string, pattern: string, foldCase = false): boolean } else if (token.kind === "question") { result = match(valueIndex + 1, tokenIndex + 1); } else if (token.kind === "class") { - const matches = classMatch(token.body, value[valueIndex], foldCase); - result = (token.negated ? !matches : matches) && - match(valueIndex + 1, tokenIndex + 1); + // Negation applies after the union of every fold-equivalent candidate. + const lengths = classMatchLengths(token.body, value, valueIndex, foldCase); + result = token.negated + ? lengths.length === 0 && match(valueIndex + 1, tokenIndex + 1) + : lengths.some((length) => match(valueIndex + length, tokenIndex + 1)); } else { result = false; for (let end = valueIndex + 1; end <= value.length; end += 1) { diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index 7fb8c45e58..55347c0299 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -165,6 +165,8 @@ def add_todo(label, scope): ("src/[]/./]x", "src/]x"), ("src/[!]/./]x", "src/ax"), ("src/[A-b]x", "src/cx"), + ("src/[ς].ts", "src/Σ.ts"), + ("src/[ß].ts", "src/SS.ts"), ]) def test_glob_class_members_keep_exclusive_scope_coverage(scope_goal, scope, target): cli, current = scope_goal @@ -177,6 +179,38 @@ def test_glob_class_members_keep_exclusive_scope_coverage(scope_goal, scope, tar assert rejected["error_code"] == "write_scope_conflict" and current() == before +@pytest.mark.parametrize("scope,target", [ + ("src/[ς].ts", "src/Σ.ts"), + ("src/[ß].ts", "src/SS.ts"), +]) +def test_unicode_glob_class_aliases_conflict_at_atomic_todo_claim(scope_goal, scope, target): + cli, current = scope_goal + repository = "git:github.com/example/project" + + def add_todo(label, write_scope): + return cli("todo", "add", "--role", "agent", "--priority", "P1", + "--text", f"Unicode class scope {label}", "--task-repository", repository, + "--required-write-scope", write_scope, + "--operation-id", f"unicode-class-add-{label}")["todo_id"] + + first = add_todo("a", scope) + claimed = cli("todo", "claim", "--todo-id", first, "--claimed-by", "agent-a", "--agent-id", "agent-a", + "--claim-operation-id", "unicode-class-claim-a", + "--task-lease-idempotency-key", "unicode-class-claim-a", + "--task-lease-expected-version", "0") + assert claimed["ok"] + assert cli("task-lease", "inspect", "--todo-id", first)["lease"]["write_scopes"] == [scope] + + second = add_todo("b", target) + before = current() + rejected = cli("todo", "claim", "--todo-id", second, "--claimed-by", "agent-b", "--agent-id", "agent-b", + "--claim-operation-id", "unicode-class-claim-b", + "--task-lease-idempotency-key", "unicode-class-claim-b", + "--task-lease-expected-version", "0", expected_exit=1) + assert rejected["error_code"] == "write_scope_conflict" + assert current() == before + + def test_case_folded_negated_glob_class_excludes_case_variant(scope_goal): cli, _ = scope_goal acquired = cli("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", @@ -185,3 +219,18 @@ def test_case_folded_negated_glob_class_excludes_case_variant(scope_goal): allowed = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", "--idempotency-key", "case-class-b", "--write-scope", "src/cx", "--expected-version", "0") assert allowed["acquired"] + + +@pytest.mark.parametrize("scope,target", [ + ("src/[!Σ]x", "src/ςx"), + ("src/[!ς]x", "src/Σx"), + ("src/[!ß]x", "src/SSx"), +]) +def test_case_folded_negated_glob_class_excludes_unicode_alias(scope_goal, scope, target): + cli, _ = scope_goal + acquired = cli("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "unicode-negated-a", "--write-scope", scope, "--expected-version", "0") + assert acquired["acquired"] + allowed = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "unicode-negated-b", "--write-scope", target, "--expected-version", "0") + assert allowed["acquired"] diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index 0483749543..a1f7dfeb92 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -311,13 +311,18 @@ test("scope comparison folds case aliases without rewriting retained or request ["src/Σ/", "src/ς/file.ts"], ["src/ß/*.ts", "src/SS/file.ts"], ["src/[Σ]x", "src/ςx"], + ["src/[ς]x", "src/Σx"], + ["src/[ß]x", "src/SSx"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`); } - assert.equal(evaluateTaskLeaseWriteScopesOverlap({ - left: ["src/[!Σ]x"], - right: ["src/ςx"], - }).overlap, false); + for (const [left, right] of [ + ["src/[!Σ]x", "src/ςx"], + ["src/[!ς]x", "src/Σx"], + ["src/[!ß]x", "src/SSx"], + ]) { + assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, false, `${left} excludes ${right}`); + } }); test("scope comparison and matching share literal right-bracket character classes", () => { for (const [scope, target] of [ From 09f9bf3834e968716fa7a02c4f92d03aac492b76 Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Tue, 27 Oct 2026 15:46:43 +0800 Subject: [PATCH 5/6] fix(leases): fold one-element ranges and keep negated class continuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class written as a one-element range ([ς-ς], [ß-ß]) is a literal member, so it must accept the same canonical-fold aliases as a literal: Σ/ς and SS/ß. The range branch only compared single-character variants of the candidate, so the other spelling still acquired a second exclusive lease. Negation also treated any fold-equivalent multi-character candidate as a class match, so [!ß] consumed SS and left nothing for the following literal S. A class still consumes one character; only single-character membership decides negation, and longer folds remain available to the next token. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 14 +++++++++++--- .../control_plane_ts/task_lease_workspace.test.ts | 5 +++++ 2 files changed, 16 insertions(+), 3 deletions(-) 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 8d830a17bd..578e4c56c3 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -229,8 +229,14 @@ function classMemberMatches(body: string, candidate: string, foldCase: boolean): for (const literal of literals) { if (literal === candidate || (foldCase && caseFold(literal) === folded)) return true; } - if ([...candidate].length !== 1) return false; for (const [low, high] of ranges) { + // A one-element range is a literal written in range syntax, so it has to + // accept the same multi-character folds (ß/SS) a literal member does. + if (low === high) { + if (low === candidate || (foldCase && caseFold(low) === folded)) return true; + continue; + } + if ([...candidate].length !== 1) continue; for (const variant of (foldCase ? singleCharacterVariants(candidate) : [candidate])) { if (low <= variant && variant <= high) return true; } @@ -273,10 +279,12 @@ function fnmatchcase(value: string, pattern: string, foldCase = false): boolean } else if (token.kind === "question") { result = match(valueIndex + 1, tokenIndex + 1); } else if (token.kind === "class") { - // Negation applies after the union of every fold-equivalent candidate. const lengths = classMatchLengths(token.body, value, valueIndex, foldCase); + // Negation only excludes a single character: a longer fold candidate + // (SS for ß) is two ordinary characters to a class, so it must remain + // available for the following tokens. result = token.negated - ? lengths.length === 0 && match(valueIndex + 1, tokenIndex + 1) + ? !lengths.includes(1) && match(valueIndex + 1, tokenIndex + 1) : lengths.some((length) => match(valueIndex + length, tokenIndex + 1)); } else { result = false; diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index a1f7dfeb92..ca9b4c140f 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -313,6 +313,9 @@ test("scope comparison folds case aliases without rewriting retained or request ["src/[Σ]x", "src/ςx"], ["src/[ς]x", "src/Σx"], ["src/[ß]x", "src/SSx"], + ["src/[ς-ς].ts", "src/Σ.ts"], + ["src/[ß-ß].ts", "src/SS.ts"], + ["src/[!ß]S.ts", "src/SS.ts"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`); } @@ -320,6 +323,8 @@ test("scope comparison folds case aliases without rewriting retained or request ["src/[!Σ]x", "src/ςx"], ["src/[!ς]x", "src/Σx"], ["src/[!ß]x", "src/SSx"], + ["src/[!ß]S.ts", "src/S.ts"], + ["src/[ß-ß].ts", "src/S.ts"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, false, `${left} excludes ${right}`); } From 10209f8cd97396eeeda0105f2eb4d696f74caabe Mon Sep 17 00:00:00 2001 From: JasonBuildAI Date: Tue, 27 Oct 2026 20:48:22 +0800 Subject: [PATCH 6/6] fix(leases): fold every member of a Unicode range in write scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class membership compared only the candidate's own case variants against a range, so a general interval missed both alias shapes it had to cover. `[ρ-ς]` did not cover `Σ`: the variants of Σ are Σ and σ, and neither is a member, although Σ and the member ς are the same file. `[ß-à]` dropped `SS` because a multi-character candidate was skipped before the range was checked. Walk the range's code points and compare each member's canonical fold with the candidate's. That keeps the original interval and gives both shapes the same symmetric comparison single-character members already had, so `[ρ-ς]` covers `Σ` and `[ß-à]` consumes `SS` as ß. A one-element range is covered by the same path. Negated classes keep excluding exactly one consumed character, so `[!ß]S.ts` still continues into `SS.ts`, while `[!ρ-ς]` now excludes `Σ` instead of admitting it as an uncovered path. An interval too wide to walk falls back to the candidate's own case variants. Signed-off-by: JasonBuildAI --- .../work_items/task_lease_acquire_decision.ts | 41 +++++++++++++++---- .../task_lease_workspace.test.ts | 4 ++ 2 files changed, 37 insertions(+), 8 deletions(-) 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 578e4c56c3..b70704f594 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -217,11 +217,28 @@ function singleCharacterVariants(value: string): string[] { return [...variants]; } +/** A range wider than this is compared by case variants instead of walked. */ +const RANGE_MEMBER_LIMIT = 4096; + +/** Code points a range covers, or null when the interval is too wide to walk. */ +function rangeMembers(low: string, high: string): string[] | null { + const start = low.codePointAt(0); + const end = high.codePointAt(0); + if (start === undefined || end === undefined || start > end) return []; + if (end - start + 1 > RANGE_MEMBER_LIMIT) return null; + const members: string[] = []; + for (let code = start; code <= end; code += 1) { + members.push(String.fromCodePoint(code)); + } + return members; +} + /** * Class membership is a symmetric fold equivalence: a member and the compared text * match when their canonical fold is equal. That covers non-reciprocal Unicode * aliases (a class member ς against Σ) and folds that change length (ß against SS). - * Ranges keep their original interval and only ever consume one unit. + * Every range member is compared by its fold, so a general interval keeps its + * original bounds without losing either kind of alias. */ function classMemberMatches(body: string, candidate: string, foldCase: boolean): boolean { const {literals, ranges} = characterClass(body); @@ -230,15 +247,23 @@ function classMemberMatches(body: string, candidate: string, foldCase: boolean): if (literal === candidate || (foldCase && caseFold(literal) === folded)) return true; } for (const [low, high] of ranges) { - // A one-element range is a literal written in range syntax, so it has to - // accept the same multi-character folds (ß/SS) a literal member does. - if (low === high) { - if (low === candidate || (foldCase && caseFold(low) === folded)) return true; + if (!foldCase) { + if ([...candidate].length === 1 && low <= candidate && candidate <= high) return true; + continue; + } + const members = rangeMembers(low, high); + if (members === null) { + // Walking a very wide interval on every comparison would dominate the + // decision, so fall back to the candidate's own case variants. Ordinary + // scopes use short intervals, which take the exact path below. + if ([...candidate].length !== 1) continue; + for (const variant of singleCharacterVariants(candidate)) { + if (low <= variant && variant <= high) return true; + } continue; } - if ([...candidate].length !== 1) continue; - for (const variant of (foldCase ? singleCharacterVariants(candidate) : [candidate])) { - if (low <= variant && variant <= high) return true; + for (const member of members) { + if (caseFold(member) === folded) return true; } } return false; diff --git a/tests/control_plane_ts/task_lease_workspace.test.ts b/tests/control_plane_ts/task_lease_workspace.test.ts index ca9b4c140f..2880ae4901 100644 --- a/tests/control_plane_ts/task_lease_workspace.test.ts +++ b/tests/control_plane_ts/task_lease_workspace.test.ts @@ -316,6 +316,9 @@ test("scope comparison folds case aliases without rewriting retained or request ["src/[ς-ς].ts", "src/Σ.ts"], ["src/[ß-ß].ts", "src/SS.ts"], ["src/[!ß]S.ts", "src/SS.ts"], + ["src/[ρ-ς].ts", "src/Σ.ts"], + ["src/[ρ-ς].ts", "src/σ.ts"], + ["src/[ß-à].ts", "src/SS.ts"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, true, `${left} overlaps ${right}`); } @@ -325,6 +328,7 @@ test("scope comparison folds case aliases without rewriting retained or request ["src/[!ß]x", "src/SSx"], ["src/[!ß]S.ts", "src/S.ts"], ["src/[ß-ß].ts", "src/S.ts"], + ["src/[!ρ-ς].ts", "src/Σ.ts"], ]) { assert.equal(evaluateTaskLeaseWriteScopesOverlap({left: [left], right: [right]}).overlap, false, `${left} excludes ${right}`); }