fix(control): a fork inherits its source's context projection

A conversation branched from a compacted session reopened on the full
history. createBranchPrefix copied the transcript, the branch metadata and
the checkpoints but never the projection sidecar, and resumeInto binds with
loadSidecar=true, so the fork found no sidecar and visibleByFullScan handed
the model the whole transcript. Recovery forks and 1.x continuations already
go through InheritCompactionState; the user-facing branch path never called it.

BranchAt now inherits inside the branch's own save window, next to the
checkpoint copy, so a branch is never published with a fold it cannot read.
Controller.Branch, which swaps panes in place, binds with loadSidecar=true so
the same-pane path loads what it just inherited.

The inherit helper keeps its fail-soft contract: no source sidecar, a corrupt
or unsupported one, a projection whose covered prefix reaches past the fork
point, and a failed write all leave the fork on the full history. Recall
budget, blocked fields and a non-applied receipt belong to the source
session's runtime generation, so the copy clears them; the projection,
generation, lineage key and an applied receipt still travel.
This commit is contained in:
F committed 2026-10-29 14:34:49 +08:00
1 parent 396b2c6b6d
commit 02bf796dcc
5 files changed
+291 -1

No files matched your search

+3
View File
@@ -118,6 +118,9 @@ func (c *Controller) createBranchPrefix(src []provider.Message, name string) (*s
if err := c.checkpoints.storeRef().CopyConversationPrefixTo(ckptDir(path), sessionstore.BranchID(path), len(src)); err != nil {
return err
}
// A fold may travel only while its digest ends inside the branch;
// history after the fork point must never leak into its summary.
sessionstore.InheritCompactionState(c.SessionPath(), path, child.Messages)
if name != "" {
return sessionstore.RenameSession(path, name)
}
@@ -0,0 +1,181 @@
package control
import (
"bytes"
"os"
"strings"
"testing"
"time"
"reasonix/internal/contract/provider"
"reasonix/internal/state/sessionstore"
"reasonix/internal/state/store"
)
func seedBranchProjection(t *testing.T, path string, canonical []provider.Message, covered int, version uint64) sessionstore.CompactionState {
t.Helper()
st := sessionstore.CompactionState{
SchemaVersion: sessionstore.CompactionStateSchemaCurrent,
Generation: 1,
Projection: sessionstore.ContextProjection{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "<compaction-summary>\nfolded branch summary\n</compaction-summary>"},
},
ProjectionVersion: version,
CoveredCount: covered,
CoveredPrefixHash: sessionstore.CoveredPrefixHash(canonical, covered),
},
UpdatedAt: time.Now().UTC(),
}
if err := sessionstore.SaveCompactionState(path, st); err != nil {
t.Fatal(err)
}
return st
}
func branchProjectionVisibleText(msgs []provider.Message) string {
var b strings.Builder
for _, msg := range msgs {
b.WriteString(msg.Content)
b.WriteByte('\n')
}
return b.String()
}
func TestBranchAtWithoutCompactionDoesNotCreateProjection(t *testing.T) {
c, _, _ := runTwoTurns(t)
turns := orderedTurns(t, c)
path, err := c.BranchAt(turns[len(turns)-1], "no compaction")
if err != nil {
t.Fatalf("BranchAt without compaction: %v", err)
}
if _, ok, err := sessionstore.LoadCompactionState(path); err != nil || ok {
t.Fatalf("branch without a source fold has sidecar ok=%v err=%v", ok, err)
}
}
func TestBranchAtInheritsCoveredProjectionAndUsesItOnOpen(t *testing.T) {
c, ag, _ := runTwoTurns(t)
parent := c.SessionPath()
canonical := ag.Session().Snapshot()
turns := orderedTurns(t, c)
branchTurn := turns[0]
branchEnd, ok := c.checkpoints.endOfTurn(branchTurn, len(canonical))
if !ok || branchEnd < 2 {
t.Fatalf("branch boundary %d ok=%v, want room for a folded prefix and a live tail", branchEnd, ok)
}
covered := branchEnd - 1
seedBranchProjection(t, parent, canonical, covered, 3)
path, err := c.BranchAt(branchTurn, "folded")
if err != nil {
t.Fatalf("BranchAt: %v", err)
}
st, ok, err := sessionstore.LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("branch projection ok=%v err=%v", ok, err)
}
if st.Projection.CoveredCount != covered || st.Projection.ProjectionVersion != 3 ||
st.Projection.CoveredPrefixHash != sessionstore.CoveredPrefixHash(canonical[:branchEnd], covered) {
t.Fatalf("inherited projection = %+v, want covered=%d version=3", st.Projection, covered)
}
loaded, err := sessionstore.LoadSession(path)
if err != nil {
t.Fatal(err)
}
c.Resume(loaded, path)
if got := ag.ProjectionVersion(); got != 3 {
t.Fatalf("ProjectionVersion after opening branch = %d, want 3", got)
}
if dropped := ag.ProjectionDropped(); dropped != "" {
t.Fatalf("inherited projection dropped: %s", dropped)
}
visible := branchProjectionVisibleText(ag.ModelVisibleMessages())
if !strings.Contains(visible, "folded branch summary") {
t.Fatalf("opened branch does not carry the fold:\n%s", visible)
}
if strings.Contains(visible, "first prompt") {
t.Fatalf("folded user turn is still model-visible:\n%s", visible)
}
if !strings.Contains(visible, "first answer") {
t.Fatalf("canonical tail after CoveredCount is missing:\n%s", visible)
}
}
func TestBranchAtDoesNotInheritProjectionPastForkPoint(t *testing.T) {
c, ag, _ := runTwoTurns(t)
canonical := ag.Session().Snapshot()
seedBranchProjection(t, c.SessionPath(), canonical, len(canonical), 4)
turns := orderedTurns(t, c)
path, err := c.BranchAt(turns[0], "before the fold")
if err != nil {
t.Fatalf("BranchAt before the fold: %v", err)
}
if _, ok, err := sessionstore.LoadCompactionState(path); err != nil || ok {
t.Fatalf("projection covering turns after the fork was inherited: ok=%v err=%v", ok, err)
}
}
func TestBranchLoadsInheritedProjectionInTheSamePane(t *testing.T) {
c, ag, _ := runTwoTurns(t)
parent := c.SessionPath()
canonical := ag.Session().Snapshot()
seedBranchProjection(t, parent, canonical, len(canonical), 5)
sourceBefore, err := os.ReadFile(store.SessionContext(parent))
if err != nil {
t.Fatal(err)
}
path, err := c.Branch("same pane")
if err != nil {
t.Fatalf("Branch: %v", err)
}
if got := ag.ProjectionVersion(); got != 5 {
t.Fatalf("ProjectionVersion after Branch = %d, want the inherited 5", got)
}
if dropped := ag.ProjectionDropped(); dropped != "" {
t.Fatalf("inherited projection dropped in the same pane: %s", dropped)
}
visible := branchProjectionVisibleText(ag.ModelVisibleMessages())
if !strings.Contains(visible, "folded branch summary") || strings.Contains(visible, "second prompt") {
t.Fatalf("same-pane branch is not using the folded view:\n%s", visible)
}
sourceAfter, err := os.ReadFile(store.SessionContext(parent))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(sourceBefore, sourceAfter) {
t.Fatal("Branch changed the source session's projection sidecar")
}
if _, ok, err := sessionstore.LoadCompactionState(path); err != nil || !ok {
t.Fatalf("branch projection missing after Branch: ok=%v err=%v", ok, err)
}
}
func TestBranchAtUnreadableProjectionFallsBackWithoutFailing(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{name: "corrupt json", body: "not json"},
{name: "unsupported schema", body: `{"schema_version":999}`},
} {
t.Run(tc.name, func(t *testing.T) {
c, _, _ := runTwoTurns(t)
if err := os.WriteFile(store.SessionContext(c.SessionPath()), []byte(tc.body), 0o644); err != nil {
t.Fatal(err)
}
turns := orderedTurns(t, c)
path, err := c.BranchAt(turns[len(turns)-1], "fallback")
if err != nil {
t.Fatalf("BranchAt with unusable source projection: %v", err)
}
if _, ok, err := sessionstore.LoadCompactionState(path); err != nil || ok {
t.Fatalf("fallback branch should have no projection: ok=%v err=%v", ok, err)
}
})
}
}
@@ -255,7 +255,7 @@ func (c *Controller) Branch(name string) (string, error) {
c.sessionPath = newPath
c.guardianPath = guardian.PathFor(newPath)
c.mu.Unlock()
c.bindExecutorProjection(newPath, false)
c.bindExecutorProjection(newPath, true)
c.ResetPlannerSession()
c.setActiveJobSession(newPath)
c.rebindCheckpoints(newPath)
@@ -89,6 +89,8 @@ func (s *Session) writeRecoveryBranchAtPath(
// InheritCompactionState gives targetPath the context projection saved beside
// sourcePath when it still covers msgs, written in this build's schema. It
// reports whether targetPath now holds one; sourcePath's file is only read.
// The projection is durable content, but recall budget and blocked/failed
// suppression belong to the source session's runtime generation.
func InheritCompactionState(sourcePath, targetPath string, msgs []provider.Message) bool {
if _, ok, err := LoadCompactionState(targetPath); err == nil && ok {
return true
@@ -103,6 +105,12 @@ func InheritCompactionState(sourcePath, targetPath string, msgs []provider.Messa
CoveredPrefixHash(msgs, n) != st.Projection.CoveredPrefixHash {
return false
}
st.Recall = RecallLedger{}
st.BlockedInputHash = ""
st.BlockedReason = ""
if st.LastReceipt != nil && st.LastReceipt.Status != "applied" {
st.LastReceipt = nil
}
if err := SaveCompactionState(targetPath, st); err != nil {
slog.Warn("session: context projection not inherited", "path", targetPath, "err", err)
return false
@@ -0,0 +1,98 @@
package sessionstore
import (
"bytes"
"os"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/provider"
)
func TestInheritCompactionStateKeepsProjectionButResetsSessionRuntimeState(t *testing.T) {
canonical := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "folded question"},
{Role: provider.RoleAssistant, Content: "live answer"},
}
for _, tc := range []struct {
name string
receipt *ContextMaintenanceReceipt
legacyBlock bool
wantReceipt bool
}{
{
name: "applied receipt is durable metadata",
receipt: &ContextMaintenanceReceipt{Status: "applied", Action: "summary", SummaryHash: "digest"},
wantReceipt: true,
},
{
name: "blocked receipt is source-session suppression",
receipt: &ContextMaintenanceReceipt{Status: "blocked", Action: "summary", Reason: "source failed"},
},
{
name: "legacy blocked fields are source-session suppression",
legacyBlock: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
dir := testenv.TempDir(t)
source := NewSessionPath(dir, "source")
target := NewSessionPath(dir, "target")
st := CompactionState{
SchemaVersion: CompactionStateSchemaCurrent,
PromptCacheKey: "source|lineage",
Generation: 7,
Recall: RecallLedger{Generation: 7, SpentTokens: 900},
LastReceipt: tc.receipt,
Projection: ContextProjection{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "<compaction-summary>\nsummary\n</compaction-summary>"}},
ProjectionVersion: 4,
CoveredCount: 2,
CoveredPrefixHash: CoveredPrefixHash(canonical, 2),
},
UpdatedAt: time.Now().UTC(),
}
if tc.legacyBlock {
st.BlockedInputHash = "source input"
st.BlockedReason = "source blocked"
}
if err := SaveCompactionState(source, st); err != nil {
t.Fatal(err)
}
sourceBefore, err := os.ReadFile(ContextStatePath(source))
if err != nil {
t.Fatal(err)
}
if !InheritCompactionState(source, target, canonical) {
t.Fatal("valid projection was not inherited")
}
got, ok, err := LoadCompactionState(target)
if err != nil || !ok {
t.Fatalf("load inherited state: ok=%v err=%v", ok, err)
}
if got.Projection.CoveredCount != 2 || got.Projection.ProjectionVersion != 4 ||
got.Projection.CoveredPrefixHash != CoveredPrefixHash(canonical, 2) {
t.Fatalf("projection changed during inheritance: %+v", got.Projection)
}
if got.Generation != 7 || got.PromptCacheKey != "source|lineage" {
t.Fatalf("durable lineage metadata changed: %+v", got)
}
if got.Recall != (RecallLedger{}) || got.BlockedInputHash != "" || got.BlockedReason != "" {
t.Fatalf("source runtime state leaked into target: %+v", got)
}
if (got.LastReceipt != nil) != tc.wantReceipt {
t.Fatalf("LastReceipt = %+v, wantReceipt=%v", got.LastReceipt, tc.wantReceipt)
}
sourceAfter, err := os.ReadFile(ContextStatePath(source))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(sourceBefore, sourceAfter) {
t.Fatal("inheritance modified the source sidecar")
}
})
}
}