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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user