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.
This commit is contained in:
SivanCola committed 2026-07-06 14:05:08 +08:00
1 parent 895972d792
commit f4591d7c1c
2 files changed
+76 -9

No files matched your search

+17 -7
View File
@@ -440,7 +440,7 @@ func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextD
}
return decision, nil
}
if allowOwnedRewrite && s.ownsPersistedState(path, existingDigest, currentRevision, nextVersion) {
if allowOwnedRewrite && s.ownsPersistedState(path, existingDigest, currentRevision, currentLedgerDigest, nextVersion) {
return snapshotWriteDecision{revision: currentRevision, repairLog: current.eventLogDamaged}, nil
}
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) {
@@ -677,7 +677,7 @@ func firstNonEmpty(values ...string) string {
return ""
}
func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, nextVersion uint64) bool {
func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, existingLedgerDigest string, nextVersion uint64) bool {
state := s.persistState(path)
if !state.ok || state.version > nextVersion || !bytes.Equal(existingDigest[:], state.digest[:]) {
return false
@@ -689,11 +689,21 @@ func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]by
// A disk ledger with no recorded revision is the mirror case: recorded
// revisions start at 1, so revision 0 means the sidecar was deleted or
// rebuilt by a listing-only writer after this session's save. An absent
// claim cannot revoke the ownership the digest+version match proves; a
// foreign claim (recorded revision that differs from the baseline) still
// does, keeping stale controllers from force-rewinding a ledger another
// runtime has since stamped.
return !state.revisionKnown || existingRevision == 0 || state.revision == existingRevision
// claim cannot revoke the ownership the digest+version match proves.
if !state.revisionKnown || existingRevision == 0 || state.revision == existingRevision {
return true
}
// A foreign revision stamp whose recorded digest still describes these
// exact bytes (a same-content heal or no-op record by another runtime)
// vouches for no content of its own: the transcript is byte-for-byte what
// this session last persisted, so rewriting it destroys nothing of
// theirs — at worst the conflict moves to the stamper's next divergent
// save, where its in-memory history forks a recovery branch as usual.
// A stamp that disagrees with the on-disk transcript (or a legacy stamp
// with no digest) keeps revoking ownership: that is the aftermath of a
// save whose bytes and record split, the bytes cannot be attributed, and
// only the conservative conflict path preserves both sides.
return existingLedgerDigest == digestString(existingDigest)
}
func (s *Session) persistState(path string) sessionPersistState {
+59 -2
View File
@@ -697,7 +697,7 @@ func TestSaveSnapshotRejectsStalePrefixAfterSystemPromptRefresh(t *testing.T) {
}
}
func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
func TestSaveRewriteAllowsRewriteOverSameContentForeignStamp(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
base := NewSession("sys")
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
@@ -710,6 +710,9 @@ func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
if err != nil {
t.Fatalf("LoadSession stale: %v", err)
}
// Another runtime healed the ledger over identical content: the revision
// advanced under a foreign writer id, but the recorded digest still
// describes the exact bytes this session loaded and owns.
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
@@ -720,13 +723,67 @@ func TestSaveRewriteRejectsRevisionCASConflict(t *testing.T) {
t.Fatalf("bump revision: %v", err)
}
stale.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "summarized first"},
})
if err := stale.SaveRewrite(path); err != nil {
t.Fatalf("SaveRewrite over same-content stamp: %v", err)
}
loaded, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "summarized first" {
t.Fatalf("tail after owned rewrite = %q, want summarized first", got)
}
advanced, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta advanced ok=%v err=%v", ok, err)
}
if advanced.Revision != meta.Revision+1 {
t.Fatalf("revision after rewrite = %d, want %d", advanced.Revision, meta.Revision+1)
}
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after owned rewrite = %v err=%v, want none", matches, err)
}
}
func TestSaveRewriteRejectsForeignStampForUnattributedBytes(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
base := NewSession("sys")
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
base.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := base.Save(path); err != nil {
t.Fatalf("Save base: %v", err)
}
stale, err := LoadSession(path)
if err != nil {
t.Fatalf("LoadSession stale: %v", err)
}
// A foreign stamp whose digest disagrees with the on-disk transcript is
// the aftermath of a save whose bytes and record split — the transcript
// cannot be attributed, so the rewrite must fall to the conflict path.
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
meta.Revision++
meta.WriterID = "other-writer"
meta.ContentDigest = "0000000000000000000000000000000000000000000000000000000000000000"
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
t.Fatalf("stamp foreign digest: %v", err)
}
stale.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "summarized first"},
})
err = stale.SaveRewrite(path)
if !errors.Is(err, ErrSessionSnapshotConflict) {
t.Fatalf("SaveRewrite revision conflict err = %v, want ErrSessionSnapshotConflict", err)
t.Fatalf("SaveRewrite unattributed stamp err = %v, want ErrSessionSnapshotConflict", err)
}
var conflict *SessionSnapshotConflictError
if !errors.As(err, &conflict) || conflict.Kind != SessionSnapshotConflictDiverged {