fix(session): one writer working on one lineage keeps one recovery file

A conflict that could not be rewritten forked a recovery branch, the
controller committed to it, and the next mid-turn autosave forked again from
there: one file every 30 seconds for as long as the turn ran. A field report
carried 30 of them from a single 25-minute turn, each one a sidebar row.

Two identities decided where a conflict lands, and both were instance state.
The isolated lane was a token minted per live Session, so a resume, a runtime
rebuild, or a reopened pane — exactly the moments a conflict recurs — started
a lane of its own; fixedWriterRecoverySessionPath already keyed the lane to
the writer, but nothing outside its own test called it. Reuse of that lane
then asked whether the bytes on disk were the ones this Session last wrote,
which a Session that had just been rebuilt can never answer, so every tick
rotated the lane and allocated another file.

Both are now semantic: the lane is the writer's, and a branch may be taken
over exactly when what it holds is an ancestor of the save — the rewrite
loses nothing, whoever wrote it. Session.recoveryLane survives as the
override allocated only when the writer's lane holds a transcript this
session would not preserve.

The lineage GC fixture built its chain by driving the fan-out, so it staged
the pile explicitly instead: what older builds left on disk still has to be
reclaimable.

Cache-impact: none - session persistence only; no provider request or prompt prefix is touched
Cache-guard: internal/agent/save_recovery_fanout_test.go asserts one file across eight conflict ticks
Documentation-impact: none - no user-visible surface changed; the fan-out was never documented behaviour
This commit is contained in:
yhh committed 2026-08-19 01:18:21 -07:00
1 parent c72b223bca
commit 9ea39c24d3
5 files changed
+94 -19

No files matched your search

+11 -9
View File
@@ -119,19 +119,21 @@ func (s *Session) writeRecoveryBranchIsolated(
}
// isolatedRecoverySessionPath picks where this conflict lands: a sibling that
// already holds the identical transcript when there is one, otherwise this
// Session's own lane. The lane is returned either way, so a collision rotates
// it and the next attempt gets a fresh file.
// already holds the identical transcript, otherwise this writer's lane. An
// empty lane means the writer's own, which a resume, a rebuild, or a reopened
// pane keeps — keying it to the live Session gave each of them a file. The
// lane is returned either way, so a collision rotates it.
func (s *Session) isolatedRecoverySessionPath(originalPath, digestText string, taken map[string]bool) (string, string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.recoveryLane == "" {
s.recoveryLane = newSessionWriterID()
}
lane := s.recoveryLane
s.mu.Unlock()
if sibling, ok := siblingRecoveryBranchWithDigest(originalPath, digestText); ok && !taken[sibling] {
return sibling, s.recoveryLane
return sibling, lane
}
return recoverySessionPathForLane(originalPath, s.recoveryLane), s.recoveryLane
if lane == "" {
return fixedWriterRecoverySessionPath(originalPath), lane
}
return recoverySessionPathForLane(originalPath, lane), lane
}
func (s *Session) rotateRecoveryLane(current string) {
+6 -4
View File
@@ -9,10 +9,11 @@ import (
"reasonix/internal/provider"
)
// forkChain builds root ← A ← B ← C. Recovery lanes are per live Session, so
// each reopened pane conflicts into a file of its own, carrying the transcript
// as of that turn. The root moves on to content no runtime had, so parent
// coverage never holds for any of them.
// forkChain builds root ← A ← B ← C: the pile a lane-per-Session build left
// behind, each file carrying the transcript as of that turn. A live writer now
// keeps one lane, so the chain is staged with an explicit lane per turn — the
// GC still has to clean up what is already on disk. The root moves on to
// content no runtime had, so parent coverage never holds for any of them.
func forkChain(t *testing.T, dir string) (root string, branches []string) {
t.Helper()
root = filepath.Join(dir, "session.jsonl")
@@ -41,6 +42,7 @@ func forkChain(t *testing.T, dir string) (root string, branches []string) {
}
live.Add(provider.Message{Role: provider.RoleUser, Content: "turn"})
live.Add(provider.Message{Role: provider.RoleAssistant, Content: "reply"})
live.recoveryLane = newSessionWriterID()
info, err := live.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: parent})
if err != nil {
t.Fatalf("conflict %d: %v", turn, err)
@@ -1,8 +1,11 @@
package agent
import (
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"testing"
"reasonix/internal/provider"
@@ -108,3 +111,68 @@ func TestIdenticalConflictContentReusesOneBranch(t *testing.T) {
t.Errorf("identical content wrote two branches:\n %s\n %s", first.Path, second.Path)
}
}
// conflictRecoveryTick mirrors control.recoverSnapshotConflict: fork while the
// chain has room, then keep landing in this writer's isolated lane.
func conflictRecoveryTick(t *testing.T, s *Session, path string) RecoveryBranchInfo {
t.Helper()
info, err := s.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
if err == nil {
return info
}
if !errors.Is(err, ErrSessionRecoveryDepthExceeded) {
t.Fatalf("recovery tick on %s: %v", filepath.Base(path), err)
}
info, err = s.SaveConflictRecoveryBranch(RecoveryBranchOptions{OriginalPath: path})
if err != nil {
t.Fatalf("isolated recovery tick on %s: %v", filepath.Base(path), err)
}
return info
}
// The live-turn shape of the fan-out: the canonical file keeps being written
// from outside while one writer works on, so every tick conflicts over content
// that grew since the last — identical-digest reuse cannot collapse those, and
// each tick runs on a new Session. One writer, one lineage, one file. The
// report behind this test gained a sidebar row every 30s for 25 minutes.
func TestGrowingConflictsFromOneWriterStayInOneLane(t *testing.T) {
dir := t.TempDir()
canonical := filepath.Join(dir, "session.jsonl")
seed := []provider.Message{
{Role: provider.RoleUser, Content: "你好"},
{Role: provider.RoleAssistant, Content: "hi"},
}
rewriteTranscriptOutsideReasonix(t, canonical, seed...)
live := append([]provider.Message(nil), seed...)
target := canonical
branches := map[string]bool{}
for i := range 8 {
live = append(live, provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("turn output %d", i)})
session := NewSession("sys")
for _, m := range live {
session.Add(m)
}
rewriteTranscriptOutsideReasonix(t, canonical, append(append([]provider.Message(nil), seed...),
provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("别处写进来的第 %d 版", i)})...)
if err := session.SaveSnapshot(target); err == nil {
continue // the branch already holds this writer's work; nothing to fork
} else if !errors.Is(err, ErrSessionSnapshotConflict) {
t.Fatalf("tick %d: save = %v, want a conflict", i, err)
}
info := conflictRecoveryTick(t, session, target)
branches[info.Path] = true
// The controller commits to the branch it just wrote.
target = info.Path
}
if len(branches) > 1 {
names := make([]string, 0, len(branches))
for p := range branches {
names = append(names, filepath.Base(p))
}
sort.Strings(names)
t.Errorf("8 conflicts from one writer on one lineage produced %d recovery files, want 1:\n %s",
len(branches), strings.Join(names, "\n "))
}
}
+6 -3
View File
@@ -35,7 +35,8 @@ func (s *Session) writeRecoveryBranchAtPath(
}
defer unlockFile()
if loaded, loadErr := loadSessionUnlocked(path); loadErr == nil && loaded != nil {
existingDigest, digestErr := digestSessionMessages(loaded.Snapshot())
existing := loaded.Snapshot()
existingDigest, digestErr := digestSessionMessages(existing)
if digestErr != nil {
return RecoveryBranchInfo{}, false, digestErr
}
@@ -48,8 +49,10 @@ func (s *Session) writeRecoveryBranchAtPath(
s.markPersisted(path, digest, version, meta.Revision, rewriteVersion)
return RecoveryBranchInfo{Path: path, Digest: digestText, Existing: true, Meta: meta, Preview: preview, Turns: turns}, false, nil
}
state := s.persistState(path)
if !state.ok || !bytes.Equal(state.digest[:], existingDigest[:]) {
// Taking over a branch is safe exactly when it holds an ancestor of
// this save: the rewrite keeps every turn already there. Asking who
// wrote it instead rotated the lane on every tick of a growing turn.
if !messagesHavePrefix(msgs, existing) && !messagesHavePrefixWithCompatibleSystem(msgs, existing) {
return RecoveryBranchInfo{}, true, nil
}
} else if loadErr != nil && !os.IsNotExist(loadErr) {
+3 -3
View File
@@ -66,9 +66,9 @@ type Session struct {
// on, saves fail closed without a live authority rather than forking
// recovery under a stale controller.
authRequired bool
// recoveryLane is a session-instance identity, allocated lazily on the
// first true conflict. It bounds repeated saves by this live controller to
// one recovery file without letting a replacement controller overwrite it.
// recoveryLane overrides the writer-wide isolated lane, and is allocated
// only when that lane already holds a transcript this session would not
// preserve. Empty is the normal state: one writer, one recovery file.
recoveryLane string
}