Skip to content

Commit 6f2ca15

Browse files
committed
fix(sync): key ownership baselines and rehydration on stored attribution
Two more paths still derived a machine instead of carrying the stored one. Rehydrated reconciliation candidates dropped candidate.Machine when building their DiscoveredFile, so workers recomputed attribution from the physical source path. That is wrong for providers whose source can sit outside the labeled root it was configured under, and it could retire a valid labeled baseline and lose later deletions. Both rehydration branches now carry the candidate's machine, matching what baselineReconciliationCandidates already expects. Ownership baselines keyed on the freshly parsed machine, but prepareSessionWrite preserves an existing archive row's label, and it works on a copy that the caller never sees. After a relabel the baseline could land under a machine no session row holds, leaving the source untombstonable. Writes now record the machine they persisted under and baselines key on it. The DB-backed streaming path resolves the stored machine for the same reason; that lookup short-circuits unless the agent actually has labeled roots, so unlabeled setups add no queries. The added test pins that a relabel keeps both the session and its baseline on the original machine. It documents the invariant but does not by itself reproduce the reported pending-write mismatch; see the PR discussion.
1 parent 4cde40b commit 6f2ca15

2 files changed

Lines changed: 124 additions & 3 deletions

File tree

internal/sync/engine.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,30 @@ func (e *Engine) reconciliationOwnershipMachines(
689689
return machines
690690
}
691691

692+
// storedSourceMachine returns the machine an already-ingested session at path
693+
// was admitted under, or "" when the archive holds none. Attribution is
694+
// immutable, so a stored row outranks the configured label whenever the two
695+
// disagree. It short-circuits unless the agent actually has labeled roots, so
696+
// the common unlabeled setup adds no per-source queries.
697+
func (e *Engine) storedSourceMachine(
698+
agent parser.AgentType, path string,
699+
) string {
700+
if len(e.sourceMachines[agent]) == 0 || path == "" {
701+
return ""
702+
}
703+
ids, err := e.db.ListSessionIDsByFilePath(path, string(agent))
704+
if err != nil {
705+
return ""
706+
}
707+
for _, id := range ids {
708+
session, err := e.db.GetSession(context.Background(), id)
709+
if err == nil && session != nil && session.Machine != "" {
710+
return session.Machine
711+
}
712+
}
713+
return ""
714+
}
715+
692716
func pathWithinRoot(path, root string) bool {
693717
root = filepath.Clean(root)
694718
rel, err := filepath.Rel(root, path)
@@ -4501,6 +4525,7 @@ func (e *Engine) rehydrateReconciliationPage(
45014525
files = append(files, parser.DiscoveredFile{
45024526
Path: candidate.Path, Project: source.ProjectHint,
45034527
Agent: candidate.Provider, ForceParse: forceCandidate,
4528+
Machine: candidate.Machine,
45044529
ProviderSource: &source, ProviderProcess: true,
45054530
})
45064531
continue
@@ -4527,6 +4552,10 @@ func (e *Engine) rehydrateReconciliationPage(
45274552
files = append(files, parser.DiscoveredFile{
45284553
Path: candidate.Path, Project: source.ProjectHint,
45294554
Agent: candidate.Provider, ForceParse: forceCandidate,
4555+
// Carry the candidate's stored attribution: recomputing it from the
4556+
// physical path is wrong for providers whose source can sit outside
4557+
// the labeled root it was configured under.
4558+
Machine: candidate.Machine,
45304559
ProviderSource: &source, ProviderProcess: true,
45314560
})
45324561
}
@@ -6656,10 +6685,17 @@ func (e *Engine) syncProviderDBBacked(
66566685
if path == "" {
66576686
return nil
66586687
}
6688+
storedPath := e.effectiveSourcePath(path)
6689+
machine := e.machineForProviderSource(agent, source, path)
6690+
// An already-ingested session keeps the label it was admitted under,
6691+
// so the baseline must follow the row rather than current config.
6692+
if stored := e.storedSourceMachine(agent, storedPath); stored != "" {
6693+
machine = stored
6694+
}
66596695
baselines = append(baselines, machineSessionSource{
6660-
Machine: e.machineForProviderSource(agent, source, path),
6696+
Machine: machine,
66616697
Source: db.SessionSourcePath{
6662-
Agent: string(agent), FilePath: e.effectiveSourcePath(path),
6698+
Agent: string(agent), FilePath: storedPath,
66636699
},
66646700
})
66656701
if len(baselines) == reconciliationPageSize {
@@ -7463,8 +7499,14 @@ func (e *Engine) baselinePendingWriteSources(
74637499
)
74647500
for i, write := range pending {
74657501
path := e.effectiveSourcePath(write.sess.File.Path)
7502+
// Key on what was persisted, not on what the parser proposed: an
7503+
// already-ingested session keeps its original label through a relabel.
7504+
machine := write.persistedMachine
7505+
if machine == "" {
7506+
machine = write.sess.Machine
7507+
}
74667508
source := machineSessionSource{
7467-
Machine: write.sess.Machine,
7509+
Machine: machine,
74687510
Source: db.SessionSourcePath{
74697511
Agent: string(write.sess.Agent), FilePath: path,
74707512
},
@@ -10663,6 +10705,12 @@ type pendingWrite struct {
1066310705
// baselineEligible is set by collectAndBatch only when the complete source
1066410706
// outcome is safe to make deletion-eligible after this write succeeds.
1066510707
baselineEligible bool
10708+
// persistedMachine is the machine the session was actually written under.
10709+
// prepareSessionWrite preserves an existing archive row's attribution, so
10710+
// after a label edit this differs from sess.Machine. Ownership baselines
10711+
// must key on it or they land under a machine no session row holds, and
10712+
// the source can never be tombstoned when it later disappears.
10713+
persistedMachine string
1066610714
// storageTrustPath/State/Snap promote the session's OpenCode
1066710715
// storage-gate trust after its batch is confirmed fully written.
1066810716
// Empty for everything else.
@@ -10912,6 +10960,7 @@ func (e *Engine) writeBatchWithOutcome(
1091210960
}
1091310961
continue
1091410962
}
10963+
batch[i].persistedMachine = s.Machine
1091510964

1091610965
// Detect stale parser version BEFORE UpsertSession
1091710966
// overwrites it. Existing message rows from an
@@ -11887,6 +11936,7 @@ func (e *Engine) writeBatchBulkWithOutcome(
1188711936
}
1188811937
continue
1188911938
}
11939+
batch[pendingIndex].persistedMachine = s.Machine
1189011940
replaceMessages := shouldReplaceFullParseMessages(
1189111941
pw, forceReplace, false, false,
1189211942
)

internal/sync/session_source_machine_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,74 @@ func activeSessionMachines(t *testing.T, database *db.DB) map[string]string {
520520
}
521521
return out
522522
}
523+
524+
// TestBaselineFollowsPersistedMachineAfterRelabel pins the ownership baseline
525+
// to the machine a session was actually written under. prepareSessionWrite
526+
// preserves the original label, so keying the baseline off the freshly parsed
527+
// (configured) machine strands it under a machine no session row holds, and the
528+
// source can never be tombstoned once it disappears.
529+
func TestBaselineFollowsPersistedMachineAfterRelabel(t *testing.T) {
530+
archiveRoot := t.TempDir()
531+
archivePath := writeSessionSourceClaudeFile(t, archiveRoot, "archive-session.jsonl")
532+
database := openTestDB(t)
533+
534+
newEngine := func(machine string) *Engine {
535+
return NewEngine(database, EngineConfig{
536+
AgentDirs: map[parser.AgentType][]string{
537+
parser.AgentClaude: {archiveRoot},
538+
},
539+
SourceMachines: map[parser.AgentType]map[string]string{
540+
parser.AgentClaude: {archiveRoot: machine},
541+
},
542+
Machine: "localbox",
543+
})
544+
}
545+
546+
first := newEngine("archivebox")
547+
t.Cleanup(first.Close)
548+
require.False(t, first.SyncAll(context.Background(), nil).Aborted)
549+
550+
// Append to the source so the relabeled pass actually reparses and rewrites
551+
// it. An unchanged file is skipped, which never exercises the write path.
552+
appendSessionSourceClaudeMessage(t, archivePath)
553+
554+
// Relabel the root and resync. The session keeps "archivebox"; the baseline
555+
// must land there too, not under the newly configured "renamedbox".
556+
relabeled := newEngine("renamedbox")
557+
t.Cleanup(relabeled.Close)
558+
require.False(t, relabeled.SyncAll(context.Background(), nil).Aborted)
559+
560+
require.Equal(t, "archivebox",
561+
activeSessionMachines(t, database)["archive-session"])
562+
563+
ownershipFor := func(machine string) []db.SessionSourceOwnership {
564+
rows, err := database.ListActiveSessionSourceOwnershipScopesPage(
565+
context.Background(), machine, string(parser.AgentClaude),
566+
[]db.StoredSourcePathHintScope{{Path: archiveRoot}},
567+
db.SessionSourceCursor{},
568+
)
569+
require.NoError(t, err)
570+
return rows
571+
}
572+
573+
stranded := ownershipFor("renamedbox")
574+
assert.Empty(t, stranded,
575+
"the baseline must not be keyed under a label no session row holds")
576+
577+
owned := ownershipFor("archivebox")
578+
require.Len(t, owned, 1,
579+
"the baseline must follow the persisted machine")
580+
assert.Equal(t, archivePath, owned[0].FilePath)
581+
}
582+
583+
// appendSessionSourceClaudeMessage grows an existing Claude transcript so the
584+
// next sync sees a changed source instead of skipping it.
585+
func appendSessionSourceClaudeMessage(t *testing.T, path string) {
586+
t.Helper()
587+
builder := testjsonl.NewSessionBuilder()
588+
builder.AddClaudeUser("2026-07-01T10:00:00Z", "hello")
589+
builder.AddClaudeAssistant("2026-07-01T10:00:01Z", "hi")
590+
builder.AddClaudeUser("2026-07-01T10:00:02Z", "more")
591+
builder.AddClaudeAssistant("2026-07-01T10:00:03Z", "sure")
592+
require.NoError(t, os.WriteFile(path, []byte(builder.String()), 0o600))
593+
}

0 commit comments

Comments
 (0)