From dc08084c8f6c2d79abc1b0e758c5859a88042058 Mon Sep 17 00:00:00 2001 From: Adam Crocker <77861255+captcrock@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:14:58 -0500 Subject: [PATCH] TD-5230: the packaged app's ring and cursors survive the helper's slow start In the packaged Mac app the overlay helper is the frozen tstd, which unpacks itself before Python starts; its first ack took 2.4 to 5.4 s on a Mac. The sidecar waited 1 s and then switched the overlay off for the life of the process, so the packaged app often showed no ring and no agent cursor at all. The source build's helper starts in well under a second, which is why only the packaged app showed it. Found in the TD-5227 live check. The first command after a spawn is now a hide probe allowed 15 s; later commands keep the 1 s budget. The server starts the helper in a background thread at launch, so the agent's first action rarely waits. Test children of the server run with the overlay off so no test opens windows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HUthoCdmzg2hy89ssk2cSF --- DECISIONS.md | 22 ++++++ docs/tst-desk-backlog.md | 21 +++++ .../src/tst_cu_mcp/overlay/__init__.py | 7 ++ .../src/tst_cu_mcp/overlay/control.py | 7 ++ .../src/tst_cu_mcp/overlay/darwin.py | 61 +++++++++++--- mcp/tst-cu-mcp/src/tst_cu_mcp/server.py | 3 + mcp/tst-cu-mcp/tests/test_agent_cursors.py | 1 + mcp/tst-cu-mcp/tests/test_overlay_gating.py | 79 +++++++++++++++++-- mcp/tst-cu-mcp/tests/test_stdio_server.py | 3 + 9 files changed, 185 insertions(+), 19 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index abaf9a5d..c1ce74f9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -19804,3 +19804,25 @@ identity the lock-guarded `overlay_agent` call already gives). **Known limits:** an agent that alternates between a coordinate tool and an element tool can show two cursors with its color and label for up to 10 s; on Windows and Linux only agent-desktop's cursor exists. + +## 2026-11-03 — TD-5230: the overlay helper starts at launch and gets a start-up budget (Class B) + +**Decision:** tst-cu-mcp starts its macOS overlay helper in a background +thread when the server launches, and the first command after any spawn is a +`hide` probe allowed 15 seconds. Every later command keeps the 1 second ack +budget, and any failure still degrades the overlay to a quiet no-op. + +**Rationale:** the packaged helper is the frozen `tstd`, which unpacks itself +before Python runs; its first ack took 2.4 to 5.4 seconds on a Mac. With the +old 1 second budget the overlay gave up on almost every cold start, so the +packaged app often had no ring and no cursors, while the source build (whose +helper starts in well under a second) always did. Starting at launch keeps +that wait off the agent's first action; the long first budget covers an +action that arrives before the helper is up. + +**Rejected:** raising every ack budget (a hung helper would then stall each +action for seconds); a non-blocking start that drops commands until the +helper answers (the rules controller assumes a lit ring is lit, and would +need a replay path for nothing the probe does not already give); starting +the helper only when the overlay is first needed (that is the path that +failed). diff --git a/docs/tst-desk-backlog.md b/docs/tst-desk-backlog.md index 8a282f62..a5ec5c09 100644 --- a/docs/tst-desk-backlog.md +++ b/docs/tst-desk-backlog.md @@ -13042,6 +13042,27 @@ step 1 and the Mac app since TD-5228; on Windows the binary comes from --- +### TD-5230 — The packaged app's ring and cursors survive the helper's slow start +**Size:** 1 · **Depends on:** TD-4834, TD-5226, TD-5227 +**Status:** In review 2026-11-03. + +In the packaged Mac app the overlay helper is the frozen `tstd --cu-overlay`, +which unpacks itself before Python starts. Its first ack took 2.4 to 5.4 +seconds on a Mac, but the sidecar waited 1 second for it and then switched the +overlay off for the rest of the process. So the packaged app often showed no +ring and no agent cursor at all, which the source build never shows. Found in +the TD-5227 live check: an episode opened right after the sidecar started left +no helper running. + +**Acceptance criteria:** +- [x] The first command after a spawn is a `hide` probe allowed 15 seconds; later commands keep the 1 second budget +- [x] The server starts the helper in the background at launch, so the agent's first action rarely waits for it; a failed start still degrades quietly +- [x] Test children of the server run with the overlay off, so no test puts windows on the screen +- [x] Unit tests: a slow first ack is waited out (fails with the old 1 second budget), prewarm spawns once, a failed prewarm degrades +- [x] Live: the new code drives the packaged helper through a 5.4 second cold start and lights the ring on the targeted display + +--- + ### TD-5227 — Each agent has its own cursor on the real display **Size:** 5 · **Depends on:** TD-3402, TD-5220, TD-5226 **Status:** In review 2026-11-03. diff --git a/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/__init__.py b/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/__init__.py index 79c39f38..275dd704 100644 --- a/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/__init__.py +++ b/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/__init__.py @@ -92,6 +92,10 @@ def pointer(self, x: float, y: float, *, click: bool = False) -> None: """The acting agent's cursor goes to this global point (TD-5227).""" ... + def prewarm(self) -> None: + """Start the painter's helper ahead of the first action (TD-5230).""" + ... + @contextmanager def grab_hidden(self) -> Iterator[None]: """Hide the glow for the duration of one screenshot grab.""" @@ -166,6 +170,9 @@ def use_agent(self, key: str, label: str = "") -> Agent: def pointer(self, x: float, y: float, *, click: bool = False) -> None: return None + def prewarm(self) -> None: + return None + def cursor(self, agent: Agent, x: float, y: float, click: bool) -> None: return None diff --git a/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/control.py b/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/control.py index 4035e7ab..58be4981 100644 --- a/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/control.py +++ b/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/control.py @@ -205,6 +205,13 @@ def grab_hidden(self) -> Iterator[None]: with self._lock: grab.__exit__(None, None, None) + def prewarm(self) -> None: + """Start a slow-starting painter now, off the tool path (TD-5230).""" + warm = getattr(self.painter, "prewarm", None) + if callable(warm): + with contextlib.suppress(Exception): + warm() + def shutdown(self) -> None: self._halt.set() watcher = self._watcher diff --git a/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/darwin.py b/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/darwin.py index e4a07579..d564323d 100644 --- a/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/darwin.py +++ b/mcp/tst-cu-mcp/src/tst_cu_mcp/overlay/darwin.py @@ -13,6 +13,15 @@ * ``quit`` ends the helper; stdin EOF does too, so a crashed sidecar never leaves an orphaned halo behind. +Start-up (TD-5230): in the packaged app the helper is the frozen ``tstd``, +which unpacks itself before Python starts, so its first ack takes seconds +(2-5 s measured), not milliseconds. The first command after a spawn is a +``hide`` probe allowed :data:`STARTUP_ACK_TIMEOUT`; the server starts the +helper in the background at launch (:meth:`DarwinOverlay.prewarm`) so the +agent's first action rarely waits for it. Before this, the 1 s ack budget +expired on almost every cold start and the overlay switched itself off for +the life of the process, so the packaged app often showed no ring at all. + Failure rule (see the package docstring): any spawn, write, or ack trouble degrades this manager — permanently — to a no-op. A capture whose grab ack timed out still proceeds; worst case one frame contains the ring, and the @@ -25,6 +34,7 @@ import select import subprocess import sys +import threading from collections.abc import Callable, Iterator from contextlib import contextmanager from typing import Protocol @@ -44,8 +54,10 @@ #: ``show``/``hide`` are acked by the helper's reader thread on arrival. FAST_ACK_TIMEOUT = 1.0 #: ``grab_*`` wait for the helper's main thread to have acted (a tick is -#: ~33ms; the budget covers helper startup and a busy runloop). +#: ~33ms; the budget covers a busy runloop). GRAB_ACK_TIMEOUT = 3.0 +#: The first ack after a spawn: a frozen helper unpacks itself first. +STARTUP_ACK_TIMEOUT = 15.0 class Transport(Protocol): @@ -131,19 +143,46 @@ def __init__(self, spawn: Callable[[], Transport] | None = None) -> None: self._spawn = spawn or default_spawn self._transport: Transport | None = None self.degraded = False + # Serializes the prewarm thread with commands from the tool thread. + self._lock = threading.Lock() + + def prewarm(self) -> None: + """Start the helper in the background so the first action finds it up.""" + if self.degraded or self._transport is not None: + return + threading.Thread(target=self._prewarm, name="overlay-prewarm", daemon=True).start() + + def _prewarm(self) -> None: + with self._lock: + if self.degraded or self._transport is not None: + return + try: + self._transport = self._connect() + except Exception: + self._degrade() + + def _connect(self) -> Transport: + """Spawn the helper and wait, generously, for its first ack.""" + transport = self._spawn() + if not transport.send(CMD_HIDE, timeout=STARTUP_ACK_TIMEOUT): + with contextlib.suppress(Exception): + transport.close() + raise RuntimeError("overlay helper did not start") + return transport def _send(self, command: str, *, timeout: float = FAST_ACK_TIMEOUT) -> bool: - if self.degraded: + with self._lock: + if self.degraded: + return False + try: + if self._transport is None: + self._transport = self._connect() + if self._transport.send(command, timeout=timeout): + return True + except Exception: + pass + self._degrade() return False - try: - if self._transport is None: - self._transport = self._spawn() - if self._transport.send(command, timeout=timeout): - return True - except Exception: - pass - self._degrade() - return False def _degrade(self) -> None: """Give up on the glow for the life of the process.""" diff --git a/mcp/tst-cu-mcp/src/tst_cu_mcp/server.py b/mcp/tst-cu-mcp/src/tst_cu_mcp/server.py index d635f37a..344bde38 100644 --- a/mcp/tst-cu-mcp/src/tst_cu_mcp/server.py +++ b/mcp/tst-cu-mcp/src/tst_cu_mcp/server.py @@ -522,6 +522,9 @@ def run() -> None: configure_logging() safety.set_config(load_config()) + # The packaged helper takes seconds to start; begin now, not on the + # agent's first action (TD-5230). A no-op where the overlay is off. + get_overlay().prewarm() try: build_server().run(transport="stdio") finally: diff --git a/mcp/tst-cu-mcp/tests/test_agent_cursors.py b/mcp/tst-cu-mcp/tests/test_agent_cursors.py index afefe761..67f2e474 100644 --- a/mcp/tst-cu-mcp/tests/test_agent_cursors.py +++ b/mcp/tst-cu-mcp/tests/test_agent_cursors.py @@ -225,6 +225,7 @@ def close(self) -> None: painter.cursor_hide("s1") painter.cursor_hide(None) assert sent == [ + "hide", # the start-up probe (TD-5230) "cursor s1 10.0 20.2 #2F6FDE 1 Fix login", "cursor_hide s1", "cursor_hide *", diff --git a/mcp/tst-cu-mcp/tests/test_overlay_gating.py b/mcp/tst-cu-mcp/tests/test_overlay_gating.py index 8795ad0e..d8fb6439 100644 --- a/mcp/tst-cu-mcp/tests/test_overlay_gating.py +++ b/mcp/tst-cu-mcp/tests/test_overlay_gating.py @@ -97,6 +97,9 @@ def use_agent(self, key: str, label: str = "") -> Agent: def pointer(self, x: float, y: float, *, click: bool = False) -> None: self.calls.append("cursor_click" if click else "cursor_move") + def prewarm(self) -> None: + self.calls.append("prewarm") + def activity(self) -> None: self.calls.append("activity") @@ -187,11 +190,13 @@ def explode() -> Any: class TestDarwinGating: + """Every spawn starts with a ``hide`` probe that waits out the start-up.""" + def test_activity_shows_when_alive(self) -> None: transport = RecordingTransport() overlay = DarwinOverlay(spawn=lambda: transport) overlay.activity() - assert transport.commands == ["show"] + assert transport.commands == ["hide", "show"] def test_session_stays_until_end(self) -> None: transport = RecordingTransport() @@ -199,7 +204,7 @@ def test_session_stays_until_end(self) -> None: overlay.begin_session() overlay.begin_session() overlay.end_session() - assert transport.commands == ["show", "show", "hide"] + assert transport.commands == ["hide", "show", "show", "hide"] def test_killswitch_forces_hide(self, tmp_path: Path) -> None: stop = tmp_path / "STOP" @@ -207,27 +212,85 @@ def test_killswitch_forces_hide(self, tmp_path: Path) -> None: transport = RecordingTransport() overlay = DarwinOverlay(spawn=lambda: transport) overlay.activity() - assert transport.commands == ["hide"] + assert transport.commands == ["hide", "hide"] def test_notify_blocked_hides(self) -> None: transport = RecordingTransport() overlay = DarwinOverlay(spawn=lambda: transport) overlay.notify_blocked() - assert transport.commands == ["hide"] + assert transport.commands == ["hide", "hide"] def test_grab_pair_brackets_and_survives_failed_begin(self) -> None: ok = RecordingTransport() overlay = DarwinOverlay(spawn=lambda: ok) with overlay.grab_hidden(): ok.commands.append("") - assert ok.commands == ["grab_begin", "", "grab_end"] + assert ok.commands == ["hide", "grab_begin", "", "grab_end"] dead = RecordingTransport(ack=False) overlay2 = DarwinOverlay(spawn=lambda: dead) with overlay2.grab_hidden(): - pass # begin was never acked; end must not be sent either - assert dead.commands == ["grab_begin"] + pass # the helper never answered; no grab command may follow + assert dead.commands == ["hide"] assert overlay2.degraded is True + assert dead.closed is True + + def test_a_slow_start_is_waited_out_not_given_up_on(self) -> None: + """TD-5230: the frozen helper's first ack takes seconds.""" + + class SlowStart(RecordingTransport): + def __init__(self) -> None: + super().__init__() + self.timeouts: list[float] = [] + + def send(self, command: str, *, timeout: float = 1.0) -> bool: + self.timeouts.append(timeout) + self.commands.append(command) + # Only a budget that covers a cold start sees the ack. + return len(self.commands) > 1 or timeout >= 5.0 + + transport = SlowStart() + overlay = DarwinOverlay(spawn=lambda: transport) + overlay.activity() + assert overlay.degraded is False + assert transport.commands == ["hide", "show"] + assert transport.timeouts[0] >= 5.0 + assert transport.timeouts[1] <= 1.0 + + def test_prewarm_starts_the_helper_once_off_the_tool_path(self) -> None: + import threading + + spawned: list[RecordingTransport] = [] + started = threading.Event() + + def spawn() -> RecordingTransport: + transport = RecordingTransport() + spawned.append(transport) + started.set() + return transport + + overlay = DarwinOverlay(spawn=spawn) + overlay.prewarm() + assert started.wait(5.0) + overlay.activity() + overlay.prewarm() + assert len(spawned) == 1 + assert spawned[0].commands == ["hide", "show"] + + def test_a_failed_prewarm_degrades_quietly(self) -> None: + import time + + def spawn() -> RecordingTransport: + raise OSError("no window server") + + overlay = DarwinOverlay(spawn=spawn) + overlay.prewarm() + for _ in range(50): + if overlay.degraded: + break + time.sleep(0.02) + assert overlay.degraded is True + overlay.activity() # still a quiet no-op def test_set_target_names_the_display(self) -> None: transport = RecordingTransport() @@ -235,7 +298,7 @@ def test_set_target_names_the_display(self) -> None: overlay.set_target(51) overlay.set_target(None) overlay.set_target(-1) - assert transport.commands == ["target 51"] + assert transport.commands == ["hide", "target 51"] def test_shutdown_closes_transport(self) -> None: transport = RecordingTransport() diff --git a/mcp/tst-cu-mcp/tests/test_stdio_server.py b/mcp/tst-cu-mcp/tests/test_stdio_server.py index 28a7499f..dbc71f17 100644 --- a/mcp/tst-cu-mcp/tests/test_stdio_server.py +++ b/mcp/tst-cu-mcp/tests/test_stdio_server.py @@ -66,6 +66,9 @@ def child_env(*, internal: bool = False) -> dict[str, str]: """ env = dict(os.environ) env.pop(INTERNAL_ENV, None) + # The server starts its overlay helper at launch (TD-5230); a test child + # must never put windows on the developer's screen. + env["TST_CU_MCP_OVERLAY"] = "0" if internal: env[INTERNAL_ENV] = "1" return env