Skip to content

Commit f4591d7

Browse files
committed
fix(agent): let a same-content foreign stamp keep rewrite ownership
A mirror runtime healing the revision ledger over identical content (same-content save recording a missed revision) advanced the revision under its own writer id. The next owned rewrite on this side — a cancelled turn's truncation or an auto-compaction — then read as a stale-runtime conflict and forked a recovery branch, even though the on-disk transcript was byte-for-byte what this session last persisted. ownsPersistedState now consults the ledger's recorded content digest: when the baseline digest, the on-disk transcript digest, and the ledger digest all describe the same bytes, a foreign revision stamp is bookkeeping over content this session owns, and rewriting it destroys nothing of the stamper's. At worst the conflict relocates to the stamper's next divergent save, where its own in-memory history forks a recovery branch as usual — no content-loss path opens. A stamp whose digest disagrees with the on-disk transcript still revokes ownership: that is the aftermath of a save whose bytes landed but whose record failed, the bytes cannot be attributed to a writer (event writer ids are per-process, not per-session), and only the conservative conflict path preserves them.
1 parent 895972d commit f4591d7

2 files changed

Lines changed: 76 additions & 9 deletions

File tree

internal/agent/save.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
440440
}
441441
return decision, nil
442442
}
443-
if allowOwnedRewrite && s.ownsPersistedState(path, existingDigest, currentRevision, nextVersion) {
443+
if allowOwnedRewrite && s.ownsPersistedState(path, existingDigest, currentRevision, currentLedgerDigest, nextVersion) {
444444
return snapshotWriteDecision{revision: currentRevision, repairLog: current.eventLogDamaged}, nil
445445
}
446446
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) {
@@ -677,7 +677,7 @@ func firstNonEmpty(values ...string) string {
677677
return ""
678678
}
679679

680-
func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, nextVersion uint64) bool {
680+
func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, existingLedgerDigest string, nextVersion uint64) bool {
681681
state := s.persistState(path)
682682
if !state.ok || state.version > nextVersion || !bytes.Equal(existingDigest[:], state.digest[:]) {
683683
return false
@@ -689,11 +689,21 @@ func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]by
689689
// A disk ledger with no recorded revision is the mirror case: recorded
690690
// revisions start at 1, so revision 0 means the sidecar was deleted or
691691
// rebuilt by a listing-only writer after this session's save. An absent
692-
// claim cannot revoke the ownership the digest+version match proves; a
693-
// foreign claim (recorded revision that differs from the baseline) still
694-
// does, keeping stale controllers from force-rewinding a ledger another
695-
// runtime has since stamped.
696-
return !state.revisionKnown || existingRevision == 0 || state.revision == existingRevision
692+
// claim cannot revoke the ownership the digest+version match proves.
693+
if !state.revisionKnown || existingRevision == 0 || state.revision == existingRevision {
694+
return true
695+
}
696+
// A foreign revision stamp whose recorded digest still describes these
697+
// exact bytes (a same-content heal or no-op record by another runtime)
698+
// vouches for no content of its own: the transcript is byte-for-byte what
699+
// this session last persisted, so rewriting it destroys nothing of
700+
// theirs — at worst the conflict moves to the stamper's next divergent
701+
// save, where its in-memory history forks a recovery branch as usual.
702+
// A stamp that disagrees with the on-disk transcript (or a legacy stamp
703+
// with no digest) keeps revoking ownership: that is the aftermath of a
704+
// save whose bytes and record split, the bytes cannot be attributed, and
705+
// only the conservative conflict path preserves both sides.
706+
return existingLedgerDigest == digestString(existingDigest)
697707
}
698708

699709
func (s *Session) persistState(path string) sessionPersistState {

internal/agent/save_test.go

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,7 @@ func TestSaveSnapshotRejectsStalePrefixAfterSystemPromptRefresh(t *testing.T) {
697697
}
698698
}
699699

700-
func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
700+
func TestSaveRewriteAllowsRewriteOverSameContentForeignStamp(t *testing.T) {
701701
path := filepath.Join(t.TempDir(), "session.jsonl")
702702
base := NewSession("sys")
703703
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
@@ -710,6 +710,9 @@ func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
710710
if err != nil {
711711
t.Fatalf("LoadSession stale: %v", err)
712712
}
713+
// Another runtime healed the ledger over identical content: the revision
714+
// advanced under a foreign writer id, but the recorded digest still
715+
// describes the exact bytes this session loaded and owns.
713716
meta, ok, err := LoadBranchMeta(path)
714717
if err != nil || !ok {
715718
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
@@ -720,13 +723,67 @@ func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
720723
t.Fatalf("bump revision: %v", err)
721724
}
722725

726+
stale.Replace([]provider.Message{
727+
{Role: provider.RoleSystem, Content: "sys"},
728+
{Role: provider.RoleUser, Content: "summarized first"},
729+
})
730+
if err := stale.SaveRewrite(path); err != nil {
731+
t.Fatalf("SaveRewrite over same-content stamp: %v", err)
732+
}
733+
734+
loaded, err := LoadSession(path)
735+
if err != nil {
736+
t.Fatalf("LoadSession: %v", err)
737+
}
738+
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "summarized first" {
739+
t.Fatalf("tail after owned rewrite = %q, want summarized first", got)
740+
}
741+
advanced, ok, err := LoadBranchMeta(path)
742+
if err != nil || !ok {
743+
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
744+
}
745+
if advanced.Revision != meta.Revision+1 {
746+
t.Fatalf("revision after rewrite = %d, want %d", advanced.Revision, meta.Revision+1)
747+
}
748+
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
749+
t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
750+
}
751+
}
752+
753+
func TestSaveRewriteRejectsForeignStampForUnattributedBytes(t *testing.T) {
754+
path := filepath.Join(t.TempDir(), "session.jsonl")
755+
base := NewSession("sys")
756+
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
757+
base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
758+
if err := base.Save(path); err != nil {
759+
t.Fatalf("Save base: %v", err)
760+
}
761+
762+
stale, err := LoadSession(path)
763+
if err != nil {
764+
t.Fatalf("LoadSession stale: %v", err)
765+
}
766+
// A foreign stamp whose digest disagrees with the on-disk transcript is
767+
// the aftermath of a save whose bytes and record split — the transcript
768+
// cannot be attributed, so the rewrite must fall to the conflict path.
769+
meta, ok, err := LoadBranchMeta(path)
770+
if err != nil || !ok {
771+
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
772+
}
773+
meta.Revision++
774+
meta.WriterID = "other-writer"
775+
meta.ContentDigest = "0000000000000000000000000000000000000000000000000000000000000000"
776+
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
777+
t.Fatalf("stamp foreign digest: %v", err)
778+
}
779+
723780
stale.Replace([]provider.Message{
724781
{Role: provider.RoleSystem, Content: "sys"},
725782
{Role: provider.RoleUser, Content: "summarized first"},
726783
})
727784
err = stale.SaveRewrite(path)
728785
if !errors.Is(err, ErrSessionSnapshotConflict) {
729-
t.Fatalf("SaveRewrite revision conflict err = %v, want ErrSessionSnapshotConflict", err)
786+
t.Fatalf("SaveRewrite unattributed stamp err = %v, want ErrSessionSnapshotConflict", err)
730787
}
731788
var conflict *SessionSnapshotConflictError
732789
if !errors.As(err, &conflict) || conflict.Kind != SessionSnapshotConflictDiverged {

0 commit comments

Comments
 (0)