Files
DeepSeek-Reasonix/internal/control/tool_transcript_checkpoint.go
T
esengine c5bdfb0477 fix(session): keep a killed turn's prompt and partial reply across restart
Root cause: a force-killed turn lost state in two ways. Sessions opened
from a legacy transcript write the model-visible .jsonl only on the 30s
autosave tick or at turn end, so a kill before the first tick dropped the
prompt from the model's context even though the display ledger kept it.
Event-log (v3) sessions record the prompt on admission, but streamed
assistant text reaches the log only when the message completes, so a kill
mid-stream lost the partial reply entirely.

Decision (option A): two bounded changes, no change to the cache-stable
prompt prefix.
- The agent announces a new advisory checkpoint boundary right after the
  user message is admitted. A controller without an event store saves the
  legacy transcript there (one extra save per turn); stores that already
  hold the message ignore it, and a failed save only logs.
- The controller accumulates the open stream's deltas and, on the existing
  30s autosave tick, appends an optional stream/checkpoint event carrying
  the same local-only interrupted record a cancelled stream leaves. It
  holds commitMu so the checkpoint can never land after the commit of the
  message it describes. The projection keeps the newest checkpoint of the
  open turn and drops it on any later message commit or turn boundary;
  restart recovery upserts it before turn/end interrupted. A completed
  stream therefore never shows twice, and the record stays out of
  provider-visible history.

Tests (internal/control/turn_kill_durability_test.go), each killing a
child process with os.Exit and no TurnDone, then reopening:
- TestKilledLegacyTurnKeepsItsPrompt fails on main-v2 with
  "prompt of the killed turn lost after restart"; passes with the fix.
- TestKilledTurnKeepsItsPartialOutput fails on main-v2 with
  "partial output of the killed turn not durable after reopen: 0 rows";
  passes with the fix, and the partial text stays out of ModelMessages.
- TestCompletedStreamSupersedesItsCheckpoint and
  TestKilledAfterCommittedStreamShowsItOnce fail on main-v2 with
  "no partial-output checkpoint was written while the stream was open";
  with the fix the completed reply appears exactly once, including after
  a kill between its commit and turn end. Disabling the supersede rule
  makes the latter fail with the checkpoint replacing the reply.
2026-09-25 18:49:12 -07:00

33 lines
974 B
Go

package control
import (
"context"
"log/slog"
"reasonix/internal/agent"
)
// CheckpointSession implements agent.SessionCheckpointer. The boundary is
// intentionally semantic: ordinary todo, approval, assistant and turn-end
// events remain eligible for the write-behind batch.
func (c *Controller) CheckpointSession(ctx context.Context, boundary agent.SessionCheckpointBoundary) error {
switch boundary {
case agent.CheckpointBeforeModel, agent.CheckpointBeforeTopTool:
if _, err := c.flushSessionEvents(ctx); err != nil {
return err
}
return nil
case agent.CheckpointUserAdmitted:
// An event store recorded the message when it was admitted; only a
// legacy transcript still holds it in memory alone.
if c.sessionEventStore() != nil || c.SessionPath() == "" {
return nil
}
if err := c.snapshot(false, false, false); err != nil {
slog.Warn("controller: save admitted user message", "err", err)
}
return nil
default:
return nil
}
}