Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions src/pinky_daemon/tmux_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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 "
Expand Down
118 changes: 118 additions & 0 deletions tests/test_tmux_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> 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,
Expand Down