From 335c60ecdd76a7b8b9d6fa0332ac15e58059963b Mon Sep 17 00:00:00 2001 From: Jony <13896935+zyz619963502zyz@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:33:51 -0700 Subject: [PATCH] fix(skills): preserve review marks across contexts --- tests/tools/test_skill_manager_tool.py | 56 ++++++++++++++++++++++++++ tools/skill_manager_tool.py | 38 ++++++++++++----- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py index edde6441d0..10e64ea168 100644 --- a/tests/tools/test_skill_manager_tool.py +++ b/tests/tools/test_skill_manager_tool.py @@ -2,6 +2,7 @@ import json from contextlib import contextmanager +from contextvars import copy_context from pathlib import Path from unittest.mock import patch @@ -914,6 +915,61 @@ class TestCuratorConsolidationDeleteGuard: # Skill must remain active on disk — fail closed, no archive. assert (skills_root / "active-skill").exists() + def test_background_review_read_survives_copied_tool_contexts( + self, tmp_path, monkeypatch + ): + """A view in one tool worker authorizes a patch in the next worker.""" + from tools.skills_tool import skill_view + from tools.skill_manager_tool import _reset_background_review_read_marks + + _reset_background_review_read_marks() + with _curator_pass(tmp_path, monkeypatch=monkeypatch): + _create_curator_skill("reviewed", _skill_content("reviewed")) + + viewed = copy_context().run(skill_view, "reviewed") + assert json.loads(viewed)["success"] is True + + patched = copy_context().run( + skill_manage, + action="patch", + name="reviewed", + old_string="Step 1: Do the thing.", + new_string="Step 1: Do the thing safely.", + ) + assert json.loads(patched)["success"] is True + + _reset_background_review_read_marks() + + def test_background_review_read_marks_stay_isolated_between_reviews( + self, tmp_path, monkeypatch + ): + """Copied tool contexts share only their own review's read marks.""" + from tools.skills_tool import skill_view + from tools.skill_manager_tool import _reset_background_review_read_marks + + _reset_background_review_read_marks() + with _curator_pass(tmp_path, monkeypatch=monkeypatch): + _create_curator_skill("reviewed", _skill_content("reviewed")) + + first_review = copy_context() + _reset_background_review_read_marks() + second_review = copy_context() + + viewed = first_review.run(skill_view, "reviewed") + assert json.loads(viewed)["success"] is True + + blocked = second_review.run( + skill_manage, + action="patch", + name="reviewed", + old_string="Step 1: Do the thing.", + new_string="Step 1: Do the thing safely.", + ) + result = json.loads(blocked) + assert result["success"] is False + assert result.get("_read_before_write_required") is True + + _reset_background_review_read_marks() def test_background_review_support_file_overwrite_requires_that_file_read(self, tmp_path, monkeypatch): from tools.skills_tool import skill_view diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index fa462ec898..7952f4e669 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -36,6 +36,7 @@ import json import logging import re import shutil +import threading import contextvars as _ctxvars from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -52,9 +53,25 @@ from agent.skill_utils import ( logger = logging.getLogger(__name__) -_background_review_read_paths: "_ctxvars.ContextVar[frozenset[str]]" = _ctxvars.ContextVar( - "background_review_read_paths", default=frozenset() -) +class _BackgroundReviewReadMarks: + """Read marks shared by copied tool contexts within one review run.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._paths: set[str] = set() + + def add(self, path: str) -> None: + with self._lock: + self._paths.add(path) + + def contains(self, path: str) -> bool: + with self._lock: + return path in self._paths + + +_background_review_read_paths: ( + "_ctxvars.ContextVar[Optional[_BackgroundReviewReadMarks]]" +) = _ctxvars.ContextVar("background_review_read_paths", default=None) def mark_background_review_skill_read(path: Path) -> None: @@ -77,9 +94,11 @@ def mark_background_review_skill_read(path: Path) -> None: resolved = str(path.resolve()) except Exception: resolved = str(path) - current = set(_background_review_read_paths.get()) - current.add(resolved) - _background_review_read_paths.set(frozenset(current)) + marks = _background_review_read_paths.get() + if marks is None: + marks = _BackgroundReviewReadMarks() + _background_review_read_paths.set(marks) + marks.add(resolved) def _background_review_has_read(path: Path) -> bool: @@ -87,12 +106,13 @@ def _background_review_has_read(path: Path) -> bool: resolved = str(path.resolve()) except Exception: resolved = str(path) - return resolved in _background_review_read_paths.get() + marks = _background_review_read_paths.get() + return marks is not None and marks.contains(resolved) def _reset_background_review_read_marks() -> None: - """Test helper: clear read-before-write marks for the current context.""" - _background_review_read_paths.set(frozenset()) + """Start a fresh, isolated read set for the current review context.""" + _background_review_read_paths.set(_BackgroundReviewReadMarks()) # Import security scanner — external hub installs always get scanned; # agent-created skills only get scanned when skills.guard_agent_created is on.