|
11 | 11 | - tool-call batches never strand the loop: failures become error results |
12 | 12 | - token calibration is updated from API-reported input tokens |
13 | 13 | - 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 |
14 | 17 | """ |
15 | 18 |
|
16 | 19 | from __future__ import annotations |
@@ -54,25 +57,46 @@ def __init__( |
54 | 57 | self.error: str | None = None |
55 | 58 | self.harness_injected: bool = False |
56 | 59 | 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 |
62 | 73 |
|
63 | 74 | 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). |
65 | 76 |
|
66 | 77 | The plain event is not enough: `_start_agent` clears it before |
67 | 78 | every run, so a worker from a cancelled run that finishes late |
68 | 79 | (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. |
70 | 83 | """ |
71 | 84 | return ( |
72 | 85 | self.session.cancel_event.is_set() |
73 | 86 | or self.session.cancel_generation != self._cancel_gen |
| 87 | + or self.session.run_generation != self._run_gen |
74 | 88 | ) |
75 | 89 |
|
| 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 | + |
76 | 100 | # ------------------------------------------------------------------ |
77 | 101 | # context management |
78 | 102 | # ------------------------------------------------------------------ |
@@ -221,25 +245,73 @@ def _run_tool_round(self) -> None: |
221 | 245 | self.pending = [] |
222 | 246 | self.session.notify("tools") |
223 | 247 |
|
| 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 | + |
224 | 282 | # ------------------------------------------------------------------ |
225 | 283 | # main loop |
226 | 284 | # ------------------------------------------------------------------ |
227 | 285 | def run(self) -> str | None: |
228 | 286 | """Run the loop; returns the final assistant text (or None).""" |
229 | 287 | session = self.session |
230 | | - self._cancel_gen = session.cancel_generation |
231 | 288 | rounds = 0 |
232 | 289 | try: |
233 | 290 | return self._run(rounds) |
234 | 291 | 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() |
243 | 315 |
|
244 | 316 | def _run(self, rounds: int) -> str | None: |
245 | 317 | session = self.session |
|
0 commit comments