Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion internal/session/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -2953,7 +2953,17 @@ func (i *Instance) ensureClaudeSessionIDFromDisk() {
// transcripts (e.g. a removed-then-recreated review session) cannot hijack
// a sibling's conversation. The Restart prelude already does this for
// #1147; this closes the same gap on the Start path.
if i.adoptExplicitClaudeSessionID("session_id_flag_explicit") {
explicitSessionID := i.adoptExplicitClaudeSessionID("session_id_flag_explicit")
if i.Tool == "claude" && i.ClaudeSessionID != "" {
if restored, err := RestoreOrphanedConversationBackup(i, GetClaudeConfigDirForInstance(i)); err == nil && restored != "" {
Comment on lines +2957 to +2958

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect Claude-compatible tool values and account-switch migration call sites.
rg -n -C3 'func IsClaudeCompatible|IsClaudeCompatible\(|Tool == "claude"|RestoreOrphanedConversationBackup|MigrateConversationFrom' internal/session

Repository: asheshgoplani/agent-deck

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== migrate.go (restore helper) ==\n'
sed -n '1,180p' internal/session/migrate.go

printf '\n== instance.go (Start/restart restore call sites) ==\n'
sed -n '2948,2990p' internal/session/instance.go
sed -n '5968,5990p' internal/session/instance.go

printf '\n== userconfig.go (IsClaudeCompatible) ==\n'
sed -n '3028,3058p' internal/session/userconfig.go

printf '\n== config dir resolver references ==\n'
rg -n -C2 'func GetClaudeConfigDirForInstance|GetClaudeConfigDir\(' internal/session

Repository: asheshgoplani/agent-deck

Length of output: 27954


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== call sites for MigrateConversation / RestoreOrphanedConversationBackup ==\n'
rg -n -C2 'MigrateConversation\(|MigrateConversationFrom\(|RestoreOrphanedConversationBackup\(' internal/session

printf '\n== exact claude-gates near resume/migration ==\n'
rg -n -C2 'Tool == "claude".*(RestoreOrphanedConversationBackup|MigrateConversation)|RestoreOrphanedConversationBackup\(.*Tool != "claude"|MigrateConversationFrom\(.*Tool != "claude"' internal/session

printf '\n== custom Claude-compatible semantics around sessions ==\n'
rg -n -C2 'IsClaudeCompatible\(.*\).*session|custom tool.*Claude-compatible|full Claude functionality' internal/session/userconfig.go internal/session/instance.go internal/session/*.go

Repository: asheshgoplani/agent-deck

Length of output: 8133


Extend orphan backup recovery to Claude-compatible tools (internal/session/instance.go:2957-2958, 5977-5978). Both resume paths are hard-gated to i.Tool == "claude", so custom Claude-compatible wrappers can still miss .jsonl.bak-* recovery and resume empty after the account-switch loss path. Use IsClaudeCompatible(i.Tool) here or broaden RestoreOrphanedConversationBackup to accept compatible tools.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/instance.go` around lines 2957 - 2958, The orphaned
conversation backup recovery is hard-gated to only the exact “claude” tool, so
Claude-compatible wrappers can skip .jsonl.bak-* restoration and resume empty.
Update the resume/recovery checks around RestoreOrphanedConversationBackup and
the related Claude session restore path to use IsClaudeCompatible(i.Tool) (or
otherwise allow compatible tools) while preserving the existing ClaudeSessionID
and backup-dir behavior. Make sure the logic in instance.go consistently treats
compatible tools the same as claude for backup recovery.

Source: Path instructions

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Don’t silently drop restore failures before resume.

If RestoreOrphanedConversationBackup fails while the live <id>.jsonl is missing, the code proceeds into claude --resume with no warning. Log the error at minimum; on Restart(), consider returning before respawn to avoid an empty-history resume.

Suggested error handling
-		if restored, err := RestoreOrphanedConversationBackup(i, GetClaudeConfigDirForInstance(i)); err == nil && restored != "" {
+		if restored, err := RestoreOrphanedConversationBackup(i, GetClaudeConfigDirForInstance(i)); err != nil {
+			sessionLog.Warn("resume: orphaned conversation backup restore failed",
+				slog.String("instance_id", i.ID),
+				slog.String("claude_session_id", i.ClaudeSessionID),
+				slog.String("reason", "orphan_bak_restore"),
+				slog.String("error", err.Error()))
+		} else if restored != "" {
-			if restored, rErr := RestoreOrphanedConversationBackup(i, GetClaudeConfigDirForInstance(i)); rErr == nil && restored != "" {
+			if restored, rErr := RestoreOrphanedConversationBackup(i, GetClaudeConfigDirForInstance(i)); rErr != nil {
+				sessionLog.Warn("resume: orphaned conversation backup restore failed",
+					slog.String("instance_id", i.ID),
+					slog.String("claude_session_id", i.ClaudeSessionID),
+					slog.String("reason", "orphan_bak_restore_restart"),
+					slog.String("error", rErr.Error()))
+			} else if restored != "" {

Also applies to: 5978-5978

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 2958-2962: A log/format call (log.Print/Printf/Println, the Fatal/Panic variants, fmt.Sprintf, or a structured logger's Info/Warn/Error/Debug method) is given a message built by concatenating a string literal with a non-literal value such as request data. Unsanitized, attacker-controlled input written to logs enables log forging / CRLF injection: an attacker can inject newlines to spoof log entries or break log parsers. Do not concatenate raw input into the log message; pass it as a separate structured field/argument (e.g. 'log.Printf("user: %s", user)' or 'logger.Info("login", "user", user)') and strip or escape newline characters first.
Context: sessionLog.Info("resume: restored orphaned conversation backup id="+i.ClaudeSessionID+" reason=orphan_bak_restore",
slog.String("instance_id", i.ID),
slog.String("claude_session_id", i.ClaudeSessionID),
slog.String("path", restored),
slog.String("reason", "orphan_bak_restore"))
Note: [CWE-117] Improper Output Neutralization for Logs.

(log-injection-request-data-concat-go)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/instance.go` at line 2958, RestoreOrphanedConversationBackup
failures are being ignored before resume, which can lead to an empty-history
claude --resume with no visibility. Update the restore-and-resume path in the
instance session flow around RestoreOrphanedConversationBackup to log any
returned error when the live conversation JSONL is missing, and in Restart()
consider stopping the respawn/resume path early if restore fails so we don’t
proceed blindly. Use the existing resume logic and backup restore helpers to
surface the failure clearly.

sessionLog.Info("resume: restored orphaned conversation backup id="+i.ClaudeSessionID+" reason=orphan_bak_restore",
slog.String("instance_id", i.ID),
slog.String("claude_session_id", i.ClaudeSessionID),
slog.String("path", restored),
slog.String("reason", "orphan_bak_restore"))
Comment on lines +2959 to +2963

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Keep structured log messages constant.

i.ClaudeSessionID is already emitted as claude_session_id; concatenating it into the message reintroduces the log-forging pattern flagged by static analysis.

Suggested log cleanup
-			sessionLog.Info("resume: restored orphaned conversation backup id="+i.ClaudeSessionID+" reason=orphan_bak_restore",
+			sessionLog.Info("resume: restored orphaned conversation backup",
-				sessionLog.Info("resume: restored orphaned conversation backup id="+i.ClaudeSessionID+" reason=orphan_bak_restore_restart",
+				sessionLog.Info("resume: restored orphaned conversation backup",

Also applies to: 5979-5983

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/instance.go` around lines 2959 - 2963, Keep the session log
message constant in the restore path and avoid concatenating i.ClaudeSessionID
into the text, since it is already emitted via the structured claude_session_id
field. Update the sessionLog.Info call in the orphan backup restore flow to use
a fixed message and rely on the existing slog fields for instance_id,
claude_session_id, path, and reason. Apply the same cleanup to the other
referenced restore log site as well so both calls follow the same
structured-logging pattern.

Source: Linters/SAST tools

}
}
if explicitSessionID {
return
}
if i.ClaudeSessionID != "" {
Expand Down Expand Up @@ -5959,6 +5969,20 @@ func (i *Instance) Restart() error {

// If Claude session with known ID AND tmux session exists, use respawn-pane.
if IsClaudeCompatible(i.Tool) && i.ClaudeSessionID != "" && i.tmuxSession != nil && i.tmuxSession.Exists() {
// A known-ID restart resumes straight to `claude --resume <uuid>`
// without passing through the Start-path prelude. If the live
// <id>.jsonl was lost to the #1533 account-switch bug but an
// orphaned <id>.jsonl.bak-<epoch> remains, restore it here so the
// resume finds its history instead of starting empty.
if i.Tool == "claude" {
if restored, rErr := RestoreOrphanedConversationBackup(i, GetClaudeConfigDirForInstance(i)); rErr == nil && restored != "" {
sessionLog.Info("resume: restored orphaned conversation backup id="+i.ClaudeSessionID+" reason=orphan_bak_restore_restart",
slog.String("instance_id", i.ID),
slog.String("claude_session_id", i.ClaudeSessionID),
slog.String("path", restored),
slog.String("reason", "orphan_bak_restore_restart"))
}
}
resumeCmd, containerName, err := i.prepareCommand(i.buildClaudeResumeCommand())
if err != nil {
return err
Expand Down
74 changes: 72 additions & 2 deletions internal/session/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
if src == "" || dst == "" {
return "", fmt.Errorf("source and target config dirs must be non-empty")
}
if filepath.Clean(src) == filepath.Clean(dst) {
if filepath.Clean(src) == filepath.Clean(dst) || resolveRealPath(src) == resolveRealPath(dst) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== migrate.go outline ==\n'
ast-grep outline internal/session/migrate.go --view expanded || true

printf '\n== search for resolveRealPath ==\n'
rg -n "resolveRealPath|filepath.Clean\\(src\\)|filepath.Clean\\(dst\\)" internal/session -S || true

printf '\n== read migrate.go relevant slices ==\n'
sed -n '1,260p' internal/session/migrate.go | cat -n

printf '\n== read any helper definitions around resolveRealPath ==\n'
rg -n "func resolveRealPath|os\\.Lstat|EvalSymlinks|RealPath" internal/session -S || true

Repository: asheshgoplani/agent-deck

Length of output: 20665


Detect bind-mount aliases before migrating. resolveRealPath() only follows symlinks, so two paths to the same mounted config dir can still pass this guard. That lets migration run against the live conversation file and can clobber it when the target file doesn’t already exist; compare existing dirs by file identity first, then fall back to the current string check for missing paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/migrate.go` at line 55, The current alias guard in
migrate.go only compares cleaned paths and resolveRealPath(), which misses
bind-mount aliases and can let migration target the live conversation file.
Update the migration path check around the existing filepath.Clean and
resolveRealPath logic to detect same-file/directory identity first for existing
paths, then fall back to the current string-based comparison when either path is
missing. Use the migrate flow and resolveRealPath helper as the anchor points
for the fix.

Source: Path instructions

return "", nil
}

Expand Down Expand Up @@ -82,19 +82,69 @@
return "", fmt.Errorf("create target project dir: %w", err)
}
dstFile := filepath.Join(dstProjDir, sid+".jsonl")
bak := ""
if fileIsRegular(dstFile) {
// Backup before any destructive write (2026-06-04 incident, S2).
bak := fmt.Sprintf("%s.bak-%d", dstFile, time.Now().Unix())
bak = fmt.Sprintf("%s.bak-%d", dstFile, time.Now().Unix())
if err := os.Rename(dstFile, bak); err != nil {
return "", fmt.Errorf("backup existing conversation: %w", err)
}
}
if err := copyFileVerified(srcFile, dstFile); err != nil {
if bak != "" {
_ = os.Remove(dstFile)
if restoreErr := os.Rename(bak, dstFile); restoreErr != nil {
return "", fmt.Errorf("%w (restore backup failed: %v)", err, restoreErr)
}
}
return "", err
Comment on lines 93 to 100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove partial live files after failed copies.

copyFileVerified can create/truncate dstFile/live before returning an error. On failure without a backup, or during restore fallback, the partial .jsonl remains and later resume/restore paths will treat it as valid because fileIsRegular succeeds.

Proposed fix
 if err := copyFileVerified(srcFile, dstFile); err != nil {
+	_ = os.Remove(dstFile)
 	if bak != "" {
-		_ = os.Remove(dstFile)
 		if restoreErr := os.Rename(bak, dstFile); restoreErr != nil {
 			return "", fmt.Errorf("%w (restore backup failed: %v)", err, restoreErr)
 		}
 	}
 	return "", err
 }
 if err := copyFileVerified(bak, live); err != nil {
+	_ = os.Remove(live)
 	return "", err
 }

Also applies to: 142-144

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/migrate.go` around lines 93 - 100, The failed copy path in
migrate.go leaves a partial destination file behind, especially in the
copyFileVerified and restore-fallback flow. Update the error handling in the
migration routine that uses copyFileVerified, os.Remove, and os.Rename so any
partially written dstFile/live file is deleted on all failure paths, including
when no backup exists and when restoring bak fails. Use the migration helper(s)
around copyFileVerified and the backup restore logic to ensure resume/restore
checks won’t see a truncated .jsonl as a valid file.

}
return dstFile, nil
}

// RestoreOrphanedConversationBackup restores a conversation whose live
// <id>.jsonl went missing but whose most recent <id>.jsonl.bak-<epoch>
// orphan still exists in the project dir (the #1533 data-loss residue).
// It is a no-op when a live <id>.jsonl is already present, when inst has
// no ClaudeSessionID, or when no matching .bak- orphan exists. Returns the
// restored path (or "" for no-op) and any error.
func RestoreOrphanedConversationBackup(inst *Instance, configDir string) (string, error) {
if inst == nil || inst.Tool != "claude" || inst.ClaudeSessionID == "" || strings.TrimSpace(configDir) == "" {
return "", nil
}

cfgDir := ExpandPath(strings.TrimSpace(configDir))
projectPath := inst.EffectiveWorkingDir()
resolvedPath := projectPath
if resolved, err := filepath.EvalSymlinks(projectPath); err == nil {
resolvedPath = resolved
}
encodedPath := ConvertToClaudeDirName(resolvedPath)
if encodedPath == "" {
encodedPath = "-"
}
projDir := filepath.Join(cfgDir, "projects", encodedPath)
live := filepath.Join(projDir, inst.ClaudeSessionID+".jsonl")
if fileIsRegular(live) {
return "", nil
}

bak, err := newestConversationBackup(projDir, inst.ClaudeSessionID)
if err != nil {
return "", err
}
if bak == "" {
return "", nil
}
if err := os.Rename(bak, live); err == nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return live, nil
}
if err := copyFileVerified(bak, live); err != nil {
return "", err
}
return live, nil
}

// newestConversationFile returns the most recently modified UUID-named
// conversation file in projDir (and its session id), skipping agent-*.jsonl.
// Unlike findActiveSessionIDExcluding it has no recency cutoff: a conversation
Expand Down Expand Up @@ -123,6 +173,26 @@
return path, sessionID
}

func newestConversationBackup(projDir, sessionID string) (string, error) {
files, err := filepath.Glob(filepath.Join(projDir, sessionID+".jsonl.bak-*"))
if err != nil {
return "", fmt.Errorf("glob orphaned conversation backups: %w", err)
}
var newest string
var newestMod time.Time
for _, file := range files {
info, err := os.Stat(file)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
if err != nil || !info.Mode().IsRegular() {
continue
}
if newest == "" || info.ModTime().After(newestMod) {
newest = file
newestMod = info.ModTime()
}
}
return newest, nil
}
Comment on lines +176 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,260p' internal/session/migrate.go

Repository: asheshgoplani/agent-deck

Length of output: 7853


🏁 Script executed:

rg -n "parseConversationBackupEpoch|newestConversationBackup|jsonl\.bak-|backup" internal/session -S

Repository: asheshgoplani/agent-deck

Length of output: 6821


🏁 Script executed:

ast-grep outline internal/session/migrate.go --view expanded

Repository: asheshgoplani/agent-deck

Length of output: 798


🏁 Script executed:

sed -n '1,380p' internal/session/migrate_test.go

Repository: asheshgoplani/agent-deck

Length of output: 13903


🏁 Script executed:

sed -n '250,360p' internal/session/instance.go

Repository: asheshgoplani/agent-deck

Length of output: 6363


🏁 Script executed:

sed -n '250,360p' internal/session/migrate_test.go

Repository: asheshgoplani/agent-deck

Length of output: 4416


🏁 Script executed:

sed -n '5930,6005p' internal/session/instance.go

Repository: asheshgoplani/agent-deck

Length of output: 3816


🏁 Script executed:

python3 - <<'PY'
import os, tempfile, time, pathlib, re

with tempfile.TemporaryDirectory() as d:
    sid = "abc123"
    older = pathlib.Path(d) / f"{sid}.jsonl.bak-100"
    newer = pathlib.Path(d) / f"{sid}.jsonl.bak-200"
    older.write_text("older suffix, newer mtime\n")
    newer.write_text("newer suffix, older mtime\n")

    now = time.time()
    os.utime(older, (now, now))          # make suffix-100 look newer on disk
    os.utime(newer, (now - 3600, now - 3600))

    files = [str(older), str(newer)]

    # current helper behavior: pick newest ModTime
    newest = ""
    newest_mtime = -1
    for f in files:
        st = os.stat(f)
        if newest == "" or st.st_mtime > newest_mtime:
            newest = f
            newest_mtime = st.st_mtime

    # suffix-based behavior: pick largest epoch
    def epoch(path):
        m = re.search(r"\.bak-(\d+)$", path)
        return int(m.group(1)) if m else -1
    suffix_newest = max(files, key=epoch)

    print("mtime चयन:", pathlib.Path(newest).name)
    print("suffix चयन:", pathlib.Path(suffix_newest).name)
PY

Repository: asheshgoplani/agent-deck

Length of output: 227


Select the newest backup by its .bak-<epoch> suffix

newestConversationBackup currently picks by file mtime, but these backups are renamed files and can keep the source conversation’s mtime. With multiple .bak-* files, that can restore an older recovery point; parse the suffix epoch and choose the highest one instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/migrate.go` around lines 176 - 194, Update
newestConversationBackup so it no longer selects the backup by os.Stat ModTime;
instead, parse the numeric epoch from each sessionID+".jsonl.bak-<epoch>"
filename returned by filepath.Glob and choose the highest suffix value. Keep the
existing globbing and regular-file checks, but use the suffix as the comparison
key so the newest recovery point is returned even when mtimes are preserved by
rename.


// copyFileVerified copies src to dst (0600, matching Claude's conversation
// files) and verifies the written size matches the source.
func copyFileVerified(src, dst string) error {
Expand Down
190 changes: 190 additions & 0 deletions internal/session/migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ func writeConversation(t *testing.T, cfgDir, projectPath, sid, content string) s
return path
}

// writeConversationBackup creates <cfgDir>/projects/<encoded>/<sid>.jsonl.bak-<suffix>.
func writeConversationBackup(t *testing.T, cfgDir, projectPath, sid, suffix, content string) string {
t.Helper()
dir := filepath.Join(cfgDir, "projects", ConvertToClaudeDirName(projectPath))
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, sid+".jsonl.bak-"+suffix)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}

func resolvedClaudeProjectPath(t *testing.T, projectPath string) string {
t.Helper()
resolvedPath := projectPath
if resolved, err := filepath.EvalSymlinks(projectPath); err == nil {
resolvedPath = resolved
}
return resolvedPath
}

func TestMigrateConversationFrom_HappyPath(t *testing.T) {
src, dst, project := t.TempDir(), t.TempDir(), t.TempDir()
inst := migTestInstance(t, project)
Expand Down Expand Up @@ -94,6 +117,36 @@ func TestMigrateConversationFrom_SameDirNoOp(t *testing.T) {
}
}

func TestMigrateConversationFrom_SameRealDirViaSymlinkNoOp(t *testing.T) {
realCfg, linkParent, project := t.TempDir(), t.TempDir(), t.TempDir()
linkCfg := filepath.Join(linkParent, "linked-claude")
if err := os.Symlink(realCfg, linkCfg); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
inst := migTestInstance(t, project)
live := writeConversation(t, realCfg, project, migTestSID, migTestLines)

migrated, err := MigrateConversationFrom(inst, linkCfg, realCfg)
if err != nil {
t.Fatalf("same-real-dir migration should be a silent no-op, got %v", err)
}
if migrated != "" {
t.Errorf("no-op should return empty path, got %q", migrated)
}
got, err := os.ReadFile(live)
if err != nil {
t.Fatalf("live conversation missing after no-op: %v", err)
}
if string(got) != migTestLines {
t.Errorf("live conversation content changed")
}
projDir := filepath.Join(realCfg, "projects", ConvertToClaudeDirName(project))
baks, _ := filepath.Glob(filepath.Join(projDir, migTestSID+".jsonl.bak-*"))
if len(baks) != 0 {
t.Fatalf("same-real-dir no-op created backups: %v", baks)
}
}

func TestMigrateConversationFrom_MissingSourceErrors(t *testing.T) {
src, dst, project := t.TempDir(), t.TempDir(), t.TempDir()
inst := migTestInstance(t, project)
Expand Down Expand Up @@ -176,6 +229,143 @@ func TestMigrateConversationFrom_BacksUpConflictingDestination(t *testing.T) {
}
}

func TestMigrateConversationFrom_RestoresDestinationBackupOnCopyFailure(t *testing.T) {
src, dst, project := t.TempDir(), t.TempDir(), t.TempDir()
inst := migTestInstance(t, project)
srcFile := writeConversation(t, src, project, migTestSID, migTestLines)
dstFile := writeConversation(t, dst, project, migTestSID, "original destination\n")

if err := os.Chmod(srcFile, 0); err != nil {
t.Fatal(err)
}
defer func() {
_ = os.Chmod(srcFile, 0o600)
}()
if f, err := os.Open(srcFile); err == nil {
_ = f.Close()
t.Skip("chmod did not make the source unreadable on this platform")
}

if _, err := MigrateConversationFrom(inst, src, dst); err == nil {
t.Fatal("expected copy failure")
}
got, err := os.ReadFile(dstFile)
if err != nil {
t.Fatalf("destination was not restored after failed copy: %v", err)
}
if string(got) != "original destination\n" {
t.Fatalf("destination content = %q, want original", string(got))
}
baks, _ := filepath.Glob(filepath.Join(filepath.Dir(dstFile), migTestSID+".jsonl.bak-*"))
if len(baks) != 0 {
t.Fatalf("failed migration left orphan backups: %v", baks)
}
}

func TestRestoreOrphanedConversationBackup_RestoresOrphan(t *testing.T) {
cfgDir, project := t.TempDir(), t.TempDir()
inst := migTestInstance(t, project)
claudeProject := resolvedClaudeProjectPath(t, project)
bak := writeConversationBackup(t, cfgDir, claudeProject, migTestSID, "100", "orphaned backup\n")
live := filepath.Join(filepath.Dir(bak), migTestSID+".jsonl")

restored, err := RestoreOrphanedConversationBackup(inst, cfgDir)
if err != nil {
t.Fatalf("RestoreOrphanedConversationBackup: %v", err)
}
if restored != live {
t.Fatalf("restored path = %q, want %q", restored, live)
}
got, err := os.ReadFile(live)
if err != nil {
t.Fatalf("restored live conversation not readable: %v", err)
}
if string(got) != "orphaned backup\n" {
t.Fatalf("restored content = %q, want backup content", string(got))
}
}

func TestRestoreOrphanedConversationBackup_UsesEffectiveWorkingDirForMultiRepo(t *testing.T) {
cfgDir, project, multiRepoDir := t.TempDir(), t.TempDir(), t.TempDir()
inst := migTestInstance(t, project)
inst.MultiRepoEnabled = true
inst.MultiRepoTempDir = multiRepoDir

resolvedMultiRepoDir := resolvedClaudeProjectPath(t, multiRepoDir)
bak := writeConversationBackup(t, cfgDir, resolvedMultiRepoDir, migTestSID, "100", "multi-repo backup\n")
live := filepath.Join(filepath.Dir(bak), migTestSID+".jsonl")

restored, err := RestoreOrphanedConversationBackup(inst, cfgDir)
if err != nil {
t.Fatalf("RestoreOrphanedConversationBackup: %v", err)
}
if restored != live {
t.Fatalf("restored path = %q, want %q", restored, live)
}
if got, err := os.ReadFile(live); err != nil || string(got) != "multi-repo backup\n" {
t.Fatalf("restored live conversation content mismatch (err=%v, content=%q)", err, string(got))
}

projectLive := filepath.Join(cfgDir, "projects", ConvertToClaudeDirName(project), migTestSID+".jsonl")
if fileIsRegular(projectLive) {
t.Fatalf("restore used ProjectPath instead of EffectiveWorkingDir: %s", projectLive)
}
}

func TestRestoreOrphanedConversationBackup_NewestBackupWinsByMtime(t *testing.T) {
cfgDir, project := t.TempDir(), t.TempDir()
inst := migTestInstance(t, project)
claudeProject := resolvedClaudeProjectPath(t, project)
older := writeConversationBackup(t, cfgDir, claudeProject, migTestSID, "999", "older backup\n")
newer := writeConversationBackup(t, cfgDir, claudeProject, migTestSID, "111", "newer backup\n")
oldTime := time.Now().Add(-time.Hour)
newTime := time.Now()
if err := os.Chtimes(older, oldTime, oldTime); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(newer, newTime, newTime); err != nil {
t.Fatal(err)
}

restored, err := RestoreOrphanedConversationBackup(inst, cfgDir)
if err != nil {
t.Fatalf("RestoreOrphanedConversationBackup: %v", err)
}
if !strings.HasSuffix(restored, migTestSID+".jsonl") {
t.Fatalf("restored path = %q, want live jsonl", restored)
}
got, err := os.ReadFile(restored)
if err != nil {
t.Fatalf("restored live conversation not readable: %v", err)
}
if string(got) != "newer backup\n" {
t.Fatalf("restored content = %q, want newest backup content", string(got))
}
}

func TestRestoreOrphanedConversationBackup_NoOpWhenLivePresent(t *testing.T) {
cfgDir, project := t.TempDir(), t.TempDir()
inst := migTestInstance(t, project)
claudeProject := resolvedClaudeProjectPath(t, project)
live := writeConversation(t, cfgDir, claudeProject, migTestSID, "live conversation\n")
writeConversationBackup(t, cfgDir, claudeProject, migTestSID, "100", "stale backup\n")

restored, err := RestoreOrphanedConversationBackup(inst, cfgDir)
if err != nil {
t.Fatalf("RestoreOrphanedConversationBackup: %v", err)
}
if restored != "" {
t.Fatalf("expected no-op with live conversation present, got %q", restored)
}
got, err := os.ReadFile(live)
if err != nil {
t.Fatalf("live conversation not readable: %v", err)
}
if string(got) != "live conversation\n" {
t.Fatalf("live conversation was overwritten: %q", string(got))
}
}

func TestMigrateConversationFrom_NonClaudeToolRejected(t *testing.T) {
src, dst, project := t.TempDir(), t.TempDir(), t.TempDir()
inst := migTestInstance(t, project)
Expand Down
Loading