From b4151072748f496babc9d1a22146955869f2183c Mon Sep 17 00:00:00 2001 From: Engineer Date: Fri, 31 Jul 2026 13:11:57 +0200 Subject: [PATCH] tmux: keep fresh-context intent until a turn completes (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit context_restart launched fresh, but if the fresh REPL wedged before completing its first turn the inflight watchdog force_restarted it at 600s — past the 180s respawn grace — and the relaunch resolved --continue, which resumes the newest transcript on disk: the very one the restart was meant to leave behind. The window was never cleared. Two halves, each insufficient alone: 1. Sticky fresh intent: armed on a force-fresh launch, disarmed only by a correlated post-fresh turn completion. Without it the intent is burned by the first force_restart. 2. Explicit --resume instead of --continue when the session has a bound transcript; if that transcript does not exist on disk (the fresh REPL never wrote one) launch fresh rather than resume another conversation. No recorded bind keeps legacy --continue. --- src/pinky_daemon/tmux_session.py | 66 +++++++++++++++-- tests/test_tmux_session.py | 118 +++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 4 deletions(-) diff --git a/src/pinky_daemon/tmux_session.py b/src/pinky_daemon/tmux_session.py index 347df5ed..68b70a45 100644 --- a/src/pinky_daemon/tmux_session.py +++ b/src/pinky_daemon/tmux_session.py @@ -1366,6 +1366,17 @@ def __init__( self._fresh_context_respawn_grace_until: float = 0.0 self._fresh_context_respawn_epoch_seq: int = 0 self._fresh_context_respawn_epoch: int = 0 + # #352 — the same intent as the grace window above, but with no + # deadline: armed on a force-fresh launch, disarmed only when a + # post-fresh turn actually completes. The time-boxed grace expires + # at 180s; the inflight watchdog fires at 600s, so a fresh REPL + # that wedges before its first turn was relaunched warm and + # resumed the pre-restart transcript. + self._fresh_context_intent_pending: bool = False + # Last transcript path bound for this session (SessionStart hook or + # discovery). Lets a relaunch resume THAT conversation explicitly + # instead of letting ``--continue`` pick the newest file on disk. + self._active_transcript_path: Path | None = None # Daemon-clock lower bound for status evidence belonging to the # currently-running tmux/Claude process. Status evidence older than # this launch is unknown, never restart proof. @@ -2843,9 +2854,11 @@ async def _spawn(): self._fresh_context_respawn_grace_until = ( time.monotonic() + FRESH_CONTEXT_RESPAWN_GRACE_SEC ) + self._fresh_context_intent_pending = True _log( f"tmux[{self.agent_name}]: armed post-fresh respawn grace " - f"for {FRESH_CONTEXT_RESPAWN_GRACE_SEC:.0f}s" + f"for {FRESH_CONTEXT_RESPAWN_GRACE_SEC:.0f}s " + f"(fresh intent sticky until first completed turn)" ) # Auth-relay (#205): if enabled + configured, start a flag-gated @@ -2985,10 +2998,42 @@ def _build_claude_cmd(self) -> str: in_fresh_grace = ( time.monotonic() < self._fresh_context_respawn_grace_until ) - force_fresh = force_fresh_once or in_fresh_grace + # #352: the deadline-free half of the same intent. The grace window + # expires at 180s but the inflight watchdog only force-restarts at + # 600s, so a fresh REPL that wedges before completing its first turn + # used to be relaunched warm — resuming the very transcript the + # context_restart was meant to leave behind. Stay fresh until a turn + # actually completes on the fresh REPL. + fresh_intent_pending = self._fresh_context_intent_pending + force_fresh = force_fresh_once or in_fresh_grace or fresh_intent_pending has_prior = self._has_prior_transcript() use_continue = has_prior and not force_fresh + # Resolve HOW to resume (#352). ``--continue`` means "newest + # transcript in this project dir", which is a guess that goes wrong + # exactly when it matters. When this session has a bound transcript, + # resume it by path; when the bound transcript does not exist on + # disk (the fresh REPL never wrote one), resuming anything would + # mean resuming somebody else's conversation — go fresh instead. + # No recorded bind at all (e.g. first launch after a daemon + # restart) keeps the legacy ``--continue`` behavior. + resume_path: Path | None = None + if use_continue and self._active_transcript_path is not None: + active = self._active_transcript_path + try: + active_exists = active.exists() + except OSError: + active_exists = False + if active_exists: + resume_path = active + else: + use_continue = False + _log( + f"tmux[{self.agent_name}]: bound transcript {active} is " + f"missing — launching fresh instead of resuming another " + f"conversation" + ) + # Record launch mode on the session so ``connect()`` can derive # the wake reason post-spawn (force_fresh / restart_reason both # influence orientation copy). Read-only afterward. @@ -3000,7 +3045,10 @@ def _build_claude_cmd(self) -> str: parts = ["claude"] if use_continue: - parts.append("--continue") + if resume_path is not None: + parts.extend(["--resume", str(resume_path)]) + else: + parts.append("--continue") parts.append("--dangerously-skip-permissions") # Optional model override. if self._config.model: @@ -3056,7 +3104,9 @@ def _build_claude_cmd(self) -> str: f"mode={'continue' if use_continue else 'fresh'} " f"force_fresh={force_fresh} " f"fresh_grace={in_fresh_grace} " - f"prior_transcript={has_prior}" + f"fresh_intent_pending={fresh_intent_pending} " + f"prior_transcript={has_prior} " + f"resume_path={resume_path or '-'}" ) return cmd @@ -3776,6 +3826,10 @@ def set_transcript_path(self, path: Path | str) -> None: and external sends queue behind — FIFO preserved (Murzik #571 review). """ + # Record the bind before the tailer guard (#352): the path is what + # a later relaunch resumes explicitly, and it must be known even if + # the tailer is momentarily absent (stopped mid-respawn). + self._active_transcript_path = Path(path) if self._tailer is None: return seek_to_start = ( @@ -4666,6 +4720,10 @@ async def _handle_turn_complete(self, response: TurnResponse) -> None: ) self._fresh_context_respawn_grace_until = 0.0 self._fresh_context_respawn_epoch = 0 + # #352: this completion is the proof the fresh REPL is alive + # and owns a real transcript — the only thing that disarms the + # sticky fresh intent. + self._fresh_context_intent_pending = False if grace_was_active: _log( f"tmux[{self.agent_name}]: first correlated post-fresh " diff --git a/tests/test_tmux_session.py b/tests/test_tmux_session.py index 63dd635a..87584e3c 100644 --- a/tests/test_tmux_session.py +++ b/tests/test_tmux_session.py @@ -6877,6 +6877,124 @@ async def test_prefresh_inflight_completion_does_not_end_respawn_grace(self): assert ss._fresh_context_respawn_epoch == 7 +class TestStickyFreshContextIntent: + """#352 — the fresh-context intent must survive the watchdog. + + Observed failure: ``context_restart`` launches fresh, the fresh REPL + never completes a turn (so its transcript file is never created), the + inflight watchdog fires at 600s — past the 180s respawn grace — and + the force_restart relaunch resolves ``--continue``, which Claude Code + resumes onto the newest transcript *on disk*: the pre-restart one. + The context window is never actually cleared. + """ + + @pytest.mark.asyncio + async def test_fresh_intent_survives_grace_expiry_until_a_turn_completes( + self, monkeypatch + ): + ss, _ = _make_session() + ss._has_prior_transcript = lambda: True + ss._config.force_fresh_context_once = True + + await ss.connect() + try: + # Watchdog territory: well past FRESH_CONTEXT_RESPAWN_GRACE_SEC, + # and no post-fresh turn ever completed. + base = _time.monotonic() + monkeypatch.setattr( + "pinky_daemon.tmux_session.time.monotonic", + lambda: base + 700.0, + ) + + parts = shlex.split(ss._build_claude_cmd()) + + assert "--continue" not in parts, ( + "a fresh REPL that never completed a turn must not be " + "relaunched with --continue — that resumes the " + "pre-restart transcript (#352)" + ) + assert "--resume" not in parts + assert ss._last_launch_forced_fresh is True + finally: + await ss.disconnect() + + @pytest.mark.asyncio + async def test_completed_post_fresh_turn_releases_the_fresh_intent( + self, monkeypatch + ): + ss, _ = _make_session() + ss._has_prior_transcript = lambda: True + ss._config.force_fresh_context_once = True + + await ss.connect() + try: + ss._state_machine._state = SessionState.CONNECTED + _seed_inflight( + ss, + internal=True, + fresh_context_epoch=ss._fresh_context_respawn_epoch, + ) + await ss._handle_turn_complete(_turn_response(text="wake complete")) + + base = _time.monotonic() + monkeypatch.setattr( + "pinky_daemon.tmux_session.time.monotonic", + lambda: base + 700.0, + ) + + parts = shlex.split(ss._build_claude_cmd()) + + assert "--continue" in parts, ( + "once the fresh REPL has completed a turn the intent is " + "satisfied — later respawns must resume warm" + ) + assert ss._last_launch_forced_fresh is False + finally: + await ss.disconnect() + + def test_warm_relaunch_resumes_the_bound_transcript_by_path(self, tmp_path): + ss, _ = _make_session() + ss._has_prior_transcript = lambda: True + transcript = tmp_path / "59201989.jsonl" + transcript.write_text('{"type":"user"}\n') + ss.set_transcript_path(transcript) + + parts = shlex.split(ss._build_claude_cmd()) + + assert parts.count("--resume") == 1 + assert str(transcript) in parts + assert "--continue" not in parts, ( + "an explicit --resume must replace --continue when the " + "active transcript is known (#352)" + ) + assert ss._last_launch_used_continue is True + + def test_relaunch_is_fresh_when_the_bound_transcript_does_not_exist( + self, tmp_path + ): + """The decisive case: the fresh REPL's transcript was never written.""" + ss, _ = _make_session() + # Older transcripts DO exist on disk — that is exactly what + # ``--continue`` would latch onto. + ss._has_prior_transcript = lambda: True + ss.set_transcript_path(tmp_path / "5543e594.jsonl") + + parts = shlex.split(ss._build_claude_cmd()) + + assert "--continue" not in parts + assert "--resume" not in parts + assert ss._last_launch_used_continue is False + + def test_unknown_active_transcript_keeps_legacy_continue(self): + """Daemon restart: no bind was ever recorded for this session.""" + ss, _ = _make_session() + ss._has_prior_transcript = lambda: True + + parts = shlex.split(ss._build_claude_cmd()) + + assert "--continue" in parts + + class TestWakePromptEnqueueOnConnect: """Wake-prompt assembly + enqueue is the parent defect from #543. These tests pin that ``connect()`` actually injects the wake prompt,