fix(agent): canonicalise paths in parallel-batch planner to prevent same-file concurrent mutation

_extract_parallel_scope_path used Path.cwd() (process cwd) instead of the
tool's actual execution cwd, and os.path.abspath() instead of os.path.realpath(),
so symlink aliases and relative/absolute path pairs that resolve to the same
physical file were treated as distinct targets and placed in the same parallel
segment. On case-insensitive platforms (Windows) os.path.normcase() was also
absent, allowing Foo.txt and foo.txt to race.

Changes:
- agent/tool_dispatch_helpers.py: introduce _canonical_path(raw_path,
  execution_cwd) applying expanduser->abspath->realpath->normcase; thread
  execution_cwd through _extract_parallel_scope_path and
  _plan_tool_batch_segments
- agent/tool_executor.py: pass get_active_env(effective_task_id).cwd as
  execution_cwd to _plan_tool_batch_segments; add pathlib.Path import
- run_agent.py: pass active env cwd to _plan_tool_batch_segments at the
  second call site inside _execute_tool_calls
- tests/run_agent/test_tool_batch_segmentation.py: add 5 regression tests
  covering relative/absolute same target, symlink alias, execution_cwd vs
  process cwd, symlink parent + nonexistent write target, and Windows
  case-insensitive alias (skipped on non-Windows)

Fixes a file-corruption / lost-update race introduced by the mixed
tool-batch segmentation feature (perf commit #64460).
This commit is contained in:
sprmn24
2026-07-14 23:40:23 +03:00
committed by Teknium
parent b4e4b5a43e
commit 9a21d0e3f2
4 changed files with 166 additions and 14 deletions
+38 -11
View File
@@ -102,7 +102,7 @@ def _is_mcp_tool_parallel_safe(tool_name: str) -> bool:
return False
def _plan_tool_batch_segments(tool_calls) -> List[tuple]:
def _plan_tool_batch_segments(tool_calls, *, execution_cwd: Optional[Path] = None) -> List[tuple]:
"""Split a tool-call batch into ordered ``(kind, calls)`` segments.
``kind`` is ``"parallel"`` (a maximal contiguous run of parallel-safe
@@ -173,7 +173,7 @@ def _plan_tool_batch_segments(tool_calls) -> List[tuple]:
continue
if tool_name in _PATH_SCOPED_TOOLS:
scoped_path = _extract_parallel_scope_path(tool_name, function_args)
scoped_path = _extract_parallel_scope_path(tool_name, function_args, execution_cwd=execution_cwd)
if scoped_path is None:
_add_sequential(tool_call)
continue
@@ -217,8 +217,34 @@ def _should_parallelize_tool_batch(tool_calls) -> bool:
return len(segments) == 1 and segments[0][0] == "parallel"
def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optional[Path]:
"""Return the normalized file target for path-scoped tools."""
def _canonical_path(raw_path: str, execution_cwd: Optional[Path] = None) -> Path:
"""Return a canonical, OS-aware path for overlap detection.
Uses ``os.path.realpath`` to resolve symlinks on existing path components
and ``os.path.normcase`` for case-insensitive platforms (Windows).
Falls back to ``Path.cwd()`` when *execution_cwd* is not supplied.
"""
expanded = Path(raw_path).expanduser()
base = execution_cwd if execution_cwd is not None else Path.cwd()
candidate = expanded if expanded.is_absolute() else base / expanded
# realpath resolves symlinks on path components that exist; for
# not-yet-created files it canonicalises as far as possible.
resolved = os.path.normcase(os.path.realpath(os.path.abspath(str(candidate))))
return Path(resolved)
def _extract_parallel_scope_path(
tool_name: str,
function_args: dict,
execution_cwd: Optional[Path] = None,
) -> Optional[Path]:
"""Return the canonical file target for path-scoped tools.
*execution_cwd* should be the working directory that the tool will
actually use at runtime. When omitted the process cwd is used,
which may differ from the tool execution environment on some
platforms (e.g. WSL, sandboxed sub-processes).
"""
if tool_name not in _PATH_SCOPED_TOOLS:
return None
@@ -226,16 +252,16 @@ def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optiona
if not isinstance(raw_path, str) or not raw_path.strip():
return None
expanded = Path(raw_path).expanduser()
if expanded.is_absolute():
return Path(os.path.abspath(str(expanded)))
# Avoid resolve(); the file may not exist yet.
return Path(os.path.abspath(str(Path.cwd() / expanded)))
return _canonical_path(raw_path, execution_cwd)
def _paths_overlap(left: Path, right: Path) -> bool:
"""Return True when two paths may refer to the same subtree."""
"""Return True when two paths may refer to the same subtree.
Both *left* and *right* must already be canonical (as returned by
``_extract_parallel_scope_path`` / ``_canonical_path``) so that
symlink aliases and case differences are already normalised.
"""
left_parts = left.parts
right_parts = right.parts
if not left_parts or not right_parts:
@@ -613,6 +639,7 @@ __all__ = [
"_is_destructive_command",
"_plan_tool_batch_segments",
"_should_parallelize_tool_batch",
"_canonical_path",
"_extract_parallel_scope_path",
"_paths_overlap",
"_is_multimodal_tool_result",