mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 08:04:50 -10:00
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:
1 parent
396b2c6b6d
commit
02bf796dcc
5 files changed
+291
-1
No files matched your search
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user