fix(session): tolerate truncated resume tail (#1312)

* fix(session): tolerate truncated resume tail

* test(session): cover truncated-only resume tail
This commit is contained in:
衡洋洋
2026-09-17 10:29:23 +08:00
committed by GitHub
parent 0b17f2e21c
commit 4e59c7e815
2 changed files with 114 additions and 3 deletions
+7 -3
View File
@@ -127,14 +127,18 @@ func loadResumeState(repoDir, sessionID string, skipUnparseable bool) (*ResumeSt
reader := bufio.NewReader(f)
for {
line, readErr := reader.ReadBytes('\n')
if readErr == io.EOF {
// Bytes returned alongside EOF have no trailing newline: they are an
// unterminated record from a torn write. Drop and stop without parsing.
break
}
if len(line) > 0 {
if err := state.applyResumeLine(line); err != nil && !skipUnparseable {
return nil, err
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return nil, fmt.Errorf("read resume session %q: %w", sessionID, readErr)
}
+107
View File
@@ -731,6 +731,113 @@ func TestLoadResumeState_FailThenRedone(t *testing.T) {
}
}
func TestLoadResumeState_TruncatedFinalRecord_Ignored(t *testing.T) {
setTestHome(t, t.TempDir())
repoDir := "/test/torn-write"
sessionID := "torn-session"
path, err := SessionFilePath(repoDir, sessionID)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
// Two complete records followed by a truncated llm_response with no trailing newline.
buf := append(mustJSON(t, resumeRecord{
Type: "session_start",
SessionID: sessionID,
ReviewMode: ReviewModeCommit,
DiffCommit: "abc123",
}), '\n')
buf = append(buf, append(mustJSON(t, resumeRecord{
Type: "review_item_done",
FilePath: "main.go",
Fingerprint: "fp-main",
}), '\n')...)
buf = append(buf, []byte(`{"type":"llm_response","sessionId":"torn-session","filePath":"main.go"`)...)
if err := os.WriteFile(path, buf, 0600); err != nil {
t.Fatal(err)
}
state, err := LoadResumeState(repoDir, sessionID)
if err != nil {
t.Fatalf("torn final record must not fail the load: %v", err)
}
if _, ok := state.Item("fp-main"); !ok {
t.Error("completed checkpoint before the torn record must be recovered")
}
if state.CompletedCount() != 1 {
t.Errorf("CompletedCount = %d, want 1", state.CompletedCount())
}
}
func TestLoadResumeState_TruncatedOnlyRecord_Ignored(t *testing.T) {
setTestHome(t, t.TempDir())
repoDir := t.TempDir()
sessionID := "truncated-only"
sessionPath, err := SessionFilePath(repoDir, sessionID)
if err != nil {
t.Fatalf("SessionFilePath: %v", err)
}
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(
sessionPath,
[]byte(`{"type":"review_item_done","fingerprint":"truncated"`),
0o600,
); err != nil {
t.Fatalf("WriteFile: %v", err)
}
state, err := LoadResumeState(repoDir, sessionID)
if err != nil {
t.Fatalf("LoadResumeState: %v", err)
}
if state.SessionID != sessionID {
t.Fatalf("SessionID = %q, want %q", state.SessionID, sessionID)
}
if len(state.Items) != 0 {
t.Fatalf("Items = %d, want empty state", len(state.Items))
}
}
func TestLoadResumeState_MalformedTerminatedFinalRecord_Fails(t *testing.T) {
setTestHome(t, t.TempDir())
repoDir := "/test/malformed-terminated"
sessionID := "malformed-session"
path, err := SessionFilePath(repoDir, sessionID)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
// A malformed newline-terminated record must still fail strict resume loading.
buf := append(mustJSON(t, resumeRecord{
Type: "review_item_done",
FilePath: "main.go",
Fingerprint: "fp-main",
}), '\n')
buf = append(buf, []byte("{bad json}\n")...)
if err := os.WriteFile(path, buf, 0600); err != nil {
t.Fatal(err)
}
if _, err := LoadResumeState(repoDir, sessionID); err == nil {
t.Fatal("a malformed newline-terminated record must still be fatal")
}
}
// --- helpers ---
func mustJSON(t *testing.T, v any) []byte {