From 007c069051828713d3b82f684508735e907e5732 Mon Sep 17 00:00:00 2001 From: duang777 Date: Fri, 13 Nov 2026 17:45:31 +0800 Subject: [PATCH] fix(leases): treat a recursive glob segment as matching zero directories Lease write-scope overlap evaluated `**` as a plain fnmatch star, so the `**/` segment demanded at least one directory separator. `src/**/*.py` therefore did not overlap `src/a.py`, and `**/*.py` did not overlap `setup.py`, even though a recursive scope denotes files directly under its literal prefix as well. Two exclusive hard leases (or two atomic Todo claims) over `src/**/*.py` and `src/a.py` were both granted, so two agents could write the same file under "exclusive" leases. Read a whole-segment `**/` as zero or more directories in the folded lease overlap matcher. The case-sensitive coverage view keeps its existing plain fnmatch interpretation, and glob/glob and literal-directory comparison are unchanged. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: duang777 --- .../work_items/task_lease_acquire_decision.ts | 17 +++++++++-- .../test_canonical_lease_scopes.py | 16 +++++++++++ tests/control_plane/test_task_lease.py | 3 ++ .../task_lease_directory_scopes.test.ts | 28 +++++++++++++++++++ 4 files changed, 62 insertions(+), 2 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 1c61171cf1..fb381a7af8 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_decision.ts +++ b/loopx/control_plane/work_items/task_lease_acquire_decision.ts @@ -133,7 +133,7 @@ function singleCodePoint(value: string): string | null { type GlobToken = | {kind: "literal"; folded: string} - | {kind: "star"} + | {kind: "star"; directories?: true} | {kind: "question"} | {kind: "class"; body: string; negated: boolean; members?: CharacterClass}; @@ -149,6 +149,14 @@ function globTokens(pattern: string, fold = true): GlobToken[] { const character = pattern[index]; if (character === "*") { flushLiteral(); + // Lease overlap reads a whole-segment `**/` as zero or more directories, + // so `src/**/*.py` also reaches `src/a.py`. The case-sensitive coverage + // view (fold === false) keeps plain fnmatch stars. + if (fold && pattern.startsWith("**/", index) && (index === 0 || pattern[index - 1] === "/")) { + tokens.push({kind: "star", directories: true}); + index += 2; + continue; + } tokens.push({kind: "star"}); continue; } @@ -390,7 +398,12 @@ function fnmatchcase(value: string, pattern: string, descendants = false, fold = result = valueIndex === symbols.length; } else { const token = tokens[tokenIndex]; - if (token.kind === "star") { + if (token.kind === "star" && token.directories) { + result = match(valueIndex, tokenIndex + 1); + for (let end = valueIndex + 1; !result && end <= symbols.length; end += 1) { + result = symbols[end - 1] === "/" && match(end, tokenIndex + 1); + } + } else if (token.kind === "star") { result = match(valueIndex, tokenIndex + 1) || (valueIndex < symbols.length && match(valueIndex + 1, tokenIndex)); } else if (valueIndex === symbols.length) { diff --git a/tests/control_plane/test_canonical_lease_scopes.py b/tests/control_plane/test_canonical_lease_scopes.py index e4284234cb..dd869aa01e 100644 --- a/tests/control_plane/test_canonical_lease_scopes.py +++ b/tests/control_plane/test_canonical_lease_scopes.py @@ -114,6 +114,22 @@ def test_case_aliases_remain_exclusive_write_scopes(scope_goal): assert current() == before +@pytest.mark.parametrize("first_scope,second_scope", [ + ("src/**/*.ts", "src/main.ts"), + ("src/main.ts", "src/**/*.ts"), +]) +def test_recursive_glob_conflicts_with_direct_child_scope(scope_goal, first_scope, second_scope): + cli, current = scope_goal + cli("task-lease", "acquire", "--todo-id", "todo_scope_a", "--owner", "agent-a", + "--idempotency-key", "recursive-a", "--expected-version", "0", "--write-scope", first_scope) + before = current() + rejected = cli("task-lease", "acquire", "--todo-id", "todo_scope_b", "--owner", "agent-b", + "--idempotency-key", "recursive-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"), diff --git a/tests/control_plane/test_task_lease.py b/tests/control_plane/test_task_lease.py index c68659c863..302f995bf0 100644 --- a/tests/control_plane/test_task_lease.py +++ b/tests/control_plane/test_task_lease.py @@ -50,6 +50,9 @@ def test_local_lease_reader_preserves_bytes_and_types_corruption(tmp_path: Path, (["docs/a*.md"], ["docs/ab.md"], True), (["docs/**"], ["docs/sub/**"], True), (["**"], ["loopx/cli.py"], True), + (["src/**/*.py"], ["src/a.py"], True), + (["**/*.py"], ["setup.py"], True), + (["src/**/*.py"], ["docs/a.py"], False), (["docs/a.md"], ["docs/b.md"], False), ([], ["docs/a.md"], False), (["src//main.ts"], ["src/./main.ts"], True), diff --git a/tests/control_plane_ts/task_lease_directory_scopes.test.ts b/tests/control_plane_ts/task_lease_directory_scopes.test.ts index 18115904bc..f979d60f98 100644 --- a/tests/control_plane_ts/task_lease_directory_scopes.test.ts +++ b/tests/control_plane_ts/task_lease_directory_scopes.test.ts @@ -87,3 +87,31 @@ test("directory matching retains stored spelling and existing authority boundari assert.deepEqual(request, original); assert.deepEqual(comparisonWriteScopes(["src/"]), ["src/"]); }); + +// A whole-segment `**/` spans zero or more directories, so each pattern below +// reaches the concrete file in the other scope. +for (const [pattern, file] of [ + ["src/**/*.py", "src/a.py"], + ["src/**/x.py", "src/x.py"], + ["**/*.py", "setup.py"], + ["**/test_*.py", "test_a.py"], + ["src/**/api/**/*.py", "src/api/a.py"], + ["SRC/**/*.PY", "src/a.py"], +]) { + test(`recursive glob ${pattern} overlaps ${file}`, () => { + assert.equal(overlaps(pattern, file), true); + assert.equal(overlaps(file, pattern), true); + }); +} + +for (const [pattern, file] of [ + ["src/**/*.py", "docs/a.py"], + ["src/**/*.py", "src/a.md"], + ["src/**/*.py", "srcx.py"], + ["src/a**/x.py", "src/x.py"], +]) { + test(`recursive glob ${pattern} stays disjoint from ${file}`, () => { + assert.equal(overlaps(pattern, file), false); + assert.equal(overlaps(file, pattern), false); + }); +}