Skip to content

Commit 47ebf07

Browse files
committed
Fix ctrl-c history-loss bug.
1 parent 480efb9 commit 47ebf07

5 files changed

Lines changed: 303 additions & 31 deletions

File tree

python_agent_harness/agent.py

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
- tool-call batches never strand the loop: failures become error results
1212
- token calibration is updated from API-reported input tokens
1313
- sessions are auto-saved after each response
14+
- a cancelled run with no successor salvages its partial history
15+
(truncated to the last complete tool round) instead of losing it;
16+
a stale worker superseded by a newer run never touches shared state
1417
"""
1518

1619
from __future__ import annotations
@@ -54,25 +57,46 @@ def __init__(
5457
self.error: str | None = None
5558
self.harness_injected: bool = False
5659
self.supervisor = Supervisor(session)
57-
# Cancellation identity for this run, captured at run() start:
58-
# cancel() bumps the session generation, so a stale worker from
59-
# a cancelled run stays cancelled even after the next run clears
60-
# the shared event (and must not touch shared state).
61-
self._cancel_gen = 0
60+
# Cancellation identity for this run: cancel() bumps the session
61+
# generation, so a stale worker from a cancelled run stays
62+
# cancelled even after the next run clears the shared event (and
63+
# must not touch shared state). Captured at construction — the
64+
# worker thread starts right after, and a run superseded between
65+
# construction and start must not adopt the new generation.
66+
self._cancel_gen = session.cancel_generation
67+
# Run identity for this run: a newer top-level run bumps
68+
# `session.run_generation`, marking this worker stale —
69+
# superseded, so it must never touch shared state. Distinct
70+
# from cancellation: a cancelled run with no successor still
71+
# owns the session and may salvage its partial history.
72+
self._run_gen = session.run_generation
6273

6374
def _is_cancelled(self) -> bool:
64-
"""Whether THIS run was cancelled (event set or generation moved).
75+
"""Whether THIS run must stop (cancelled or superseded).
6576
6677
The plain event is not enough: `_start_agent` clears it before
6778
every run, so a worker from a cancelled run that finishes late
6879
(e.g. after a long tool call) would otherwise see it cleared and
69-
clobber the new run's `session.last_messages`.
80+
clobber the new run's `session.last_messages`. A superseded
81+
worker (a newer run bumped `run_generation`) is dead too: it
82+
must stop working and must not touch shared state.
7083
"""
7184
return (
7285
self.session.cancel_event.is_set()
7386
or self.session.cancel_generation != self._cancel_gen
87+
or self.session.run_generation != self._run_gen
7488
)
7589

90+
def _is_stale(self) -> bool:
91+
"""Whether a newer top-level run owns the session.
92+
93+
Distinct from cancelled: a cancelled run with no successor still
94+
owns the session and may salvage its partial history; a stale
95+
worker must never touch shared state (its partial history would
96+
clobber the new run's).
97+
"""
98+
return self.session.run_generation != self._run_gen
99+
76100
# ------------------------------------------------------------------
77101
# context management
78102
# ------------------------------------------------------------------
@@ -221,25 +245,73 @@ def _run_tool_round(self) -> None:
221245
self.pending = []
222246
self.session.notify("tools")
223247

248+
def _salvage_messages(self) -> list[Message]:
249+
"""Longest valid prefix of ``self.messages`` for the shared history.
250+
251+
A cancelled run may end mid-tool-round: the assistant message
252+
carrying the tool calls is present but some (or all) results are
253+
missing. Committing that as-is would hand the next turn an
254+
invalid request (a tool call without its response), so cut back
255+
to the last complete round — the model redoes the dangling work
256+
on the next turn.
257+
"""
258+
msgs = self.messages
259+
open_round: int | None = None
260+
pending: dict[str, bool] = {}
261+
for i, m in enumerate(msgs):
262+
if m.role == "assistant":
263+
if m.tool_calls:
264+
if open_round is not None:
265+
return msgs[:open_round]
266+
open_round = i
267+
pending = {tc.id: False for tc in m.tool_calls}
268+
elif open_round is not None:
269+
return msgs[:open_round]
270+
elif m.role == "tool":
271+
if m.tool_call_id in pending:
272+
pending[m.tool_call_id] = True
273+
if all(pending.values()):
274+
open_round = None
275+
pending = {}
276+
elif open_round is not None:
277+
return msgs[:open_round]
278+
if open_round is not None:
279+
return msgs[:open_round]
280+
return msgs
281+
224282
# ------------------------------------------------------------------
225283
# main loop
226284
# ------------------------------------------------------------------
227285
def run(self) -> str | None:
228286
"""Run the loop; returns the final assistant text (or None)."""
229287
session = self.session
230-
self._cancel_gen = session.cancel_generation
231288
rounds = 0
232289
try:
233290
return self._run(rounds)
234291
finally:
235-
# A cancelled run must not clobber state for the next run,
236-
# and a sub-agent must never overwrite the parent's history.
237-
if not self._is_cancelled() and self.top_level:
238-
session.last_messages = list(self.messages)
239-
# Loop finished: give the session a meaningful title from
240-
# the first real user message (one-shot; no-op when the
241-
# title already exists or generation is in flight)
242-
session.generate_session_title()
292+
# A stale worker (a newer run has started) must never touch
293+
# shared state, and a sub-agent must never overwrite the
294+
# parent's history. A merely cancelled run still owns the
295+
# session, though: commit its partial history (truncated to
296+
# the last complete tool round) so the interrupted turn is
297+
# not lost — the next turn resumes from it instead of
298+
# re-asking.
299+
if not self._is_stale() and self.top_level:
300+
salvaged = self._salvage_messages()
301+
session.last_messages = list(salvaged)
302+
if self._is_cancelled():
303+
# Persist the partial turn now: auto-save only runs
304+
# after successful responses, so without this the
305+
# interrupted turn would never reach the session
306+
# file — the next turn's save would overwrite it
307+
# without ever containing it.
308+
session.auto_save(salvaged, self.system)
309+
else:
310+
# Loop finished: give the session a meaningful title
311+
# from the first real user message (one-shot; no-op
312+
# when the title already exists or generation is in
313+
# flight)
314+
session.generate_session_title()
243315

244316
def _run(self, rounds: int) -> str | None:
245317
session = self.session

python_agent_harness/agent_session.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ def __init__(
130130
# worker from a cancelled run can tell it was cancelled even
131131
# after the next run clears the shared event.
132132
self.cancel_generation = 0
133+
# Monotonic run identity: bumped when a new top-level run starts
134+
# (tui._start_agent). A worker whose captured value no longer
135+
# matches is stale — superseded by a newer run — and must never
136+
# touch shared state. Unlike cancel_generation this is NOT
137+
# bumped by cancel(): a cancelled run with no successor still
138+
# owns the session and may salvage its partial history.
139+
self.run_generation = 0
133140
self._skill_dir = self._find_skill_dir()
134141

135142
# TUI hooks (overridden by the UI)
@@ -564,6 +571,10 @@ def compact_conversation(self) -> tuple[bool, str]:
564571
from .prompts import last_user_request, read_prompt_file
565572
from .models import Message as Msg
566573

574+
# Replacing the conversation is a new epoch: invalidate any
575+
# worker still winding down from a cancelled run, or its
576+
# salvaged-history commit would clobber the compacted buffer.
577+
self.run_generation += 1
567578
messages = self.last_messages or []
568579
if not messages:
569580
return False, "Nothing to compact."
@@ -607,6 +618,10 @@ def summarize_conversation(self) -> str:
607618
from .prompts import read_prompt_file
608619
from .models import Message as Msg
609620

621+
# Appending to the shared conversation is a new epoch: invalidate
622+
# any worker still winding down from a cancelled run, or its
623+
# salvaged-history commit would clobber the appended summary.
624+
self.run_generation += 1
610625
messages = self.last_messages or []
611626
if not messages:
612627
return "Nothing to summarize."

python_agent_harness/tui.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,12 @@ def _start_agent(
719719
"""
720720
self.stream_text = ""
721721
self.status = " running"
722+
# A new top-level run starts here: invalidate any worker still
723+
# unwinding from a previous run — from this point on it is stale
724+
# and must never touch shared state. Bump before clearing the
725+
# event so there is no instant where an old worker sees "not
726+
# cancelled".
727+
self.session.run_generation += 1
722728
self.session.cancel_event.clear()
723729
self._data_event.clear()
724730
self._history_dirty = True
@@ -836,13 +842,14 @@ def _run_agent(
836842
top_level=True,
837843
system=system or self.session.system_prompt,
838844
)
839-
# Only the current run may update shared state; a cancelled
840-
# worker that finishes late must not clobber the next run.
841-
if (
842-
seq == self.run_seq
843-
and not self.session.cancel_event.is_set()
844-
and self.session.last_messages
845-
):
845+
# Only the current run may update shared state: a stale
846+
# worker (a newer run started — `run_seq` advanced) must not
847+
# clobber the next run. A cancelled run with no successor
848+
# is still current, so it adopts its salvaged partial
849+
# history and the interrupted turn is not lost (the seq
850+
# check is the staleness guard; the cancel event no longer
851+
# blocks the adoption).
852+
if seq == self.run_seq and self.session.last_messages:
846853
self.conversation_history = list(self.session.last_messages)
847854
except Exception as e: # noqa: BLE001
848855
if seq == self.run_seq:
@@ -902,6 +909,10 @@ def _handle_slash(self, line: str) -> bool:
902909
elif cmd == "/restore":
903910
self._run_restore(arg)
904911
elif cmd == "/clear":
912+
# Replacing the conversation is a new epoch: invalidate any
913+
# worker still winding down from a cancelled run, or its
914+
# salvaged-history commit would resurrect what we just wiped.
915+
self.session.run_generation += 1
905916
self.conversation_history = []
906917
self.session.last_messages = []
907918
self.session.clear_todos()
@@ -1117,7 +1128,10 @@ def _run_restore(self, arg: str) -> None:
11171128
title = title_from_filename(path)
11181129
if title:
11191130
self.session.store.title = title
1120-
# Replace conversation history
1131+
# Replace conversation history: a new epoch. Invalidate any
1132+
# worker still winding down from a cancelled run so its salvaged
1133+
# history can't clobber the restored session.
1134+
self.session.run_generation += 1
11211135
self.conversation_history = messages
11221136
self.session.last_messages = list(messages)
11231137
self.session.clear_todos()

0 commit comments

Comments
 (0)