From 9072f4aedd50eed621abcdb86f0d4a75e118d556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:39:13 +0000 Subject: [PATCH 1/9] fix(doctor): avoid sync remedy for dangling symlinks --- bench/journeys.go | 1 + bench/journeys_id_collision_test.go | 1 + bench/journeys_issue_3557.go | 112 +++++++++++++ bench/review_declarations.go | 1 + bench/testdata/journeys.manifest | 1 + internal/cli/doctor.go | 21 ++- internal/cli/doctor_test.go | 252 ++++++++++++++++++++++++++++ 7 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 bench/journeys_issue_3557.go diff --git a/bench/journeys.go b/bench/journeys.go index 71c5d056c8..97720ead8c 100644 --- a/bench/journeys.go +++ b/bench/journeys.go @@ -705,6 +705,7 @@ func Journeys() []Journey { journeys = append(journeys, issue2906Journeys()...) journeys = append(journeys, issue2138Journeys()...) journeys = append(journeys, issue3043Journeys()...) + journeys = append(journeys, issue3557Journeys()...) journeys = append(journeys, repositoryContextJourneys()...) journeys = append(journeys, providerCaptureRetryJourneys()...) journeys = append(journeys, capturedProviderValidatorJourneys()...) diff --git a/bench/journeys_id_collision_test.go b/bench/journeys_id_collision_test.go index c54bb313aa..9bb3f28e2d 100644 --- a/bench/journeys_id_collision_test.go +++ b/bench/journeys_id_collision_test.go @@ -55,6 +55,7 @@ func journeySources() []journeySource { {"journeys_issue2906.go", issue2906Journeys()}, {"journeys_issue_2138.go", issue2138Journeys()}, {"journeys_issue_3043.go", issue3043Journeys()}, + {"journeys_issue_3557.go", issue3557Journeys()}, {"journeys_repository_context.go", repositoryContextJourneys()}, {"journeys_provider_capture.go", providerCaptureRetryJourneys()}, {"journeys_captured_provider_validator.go", capturedProviderValidatorJourneys()}, diff --git a/bench/journeys_issue_3557.go b/bench/journeys_issue_3557.go new file mode 100644 index 0000000000..ed5295631c --- /dev/null +++ b/bench/journeys_issue_3557.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func issue3557DanglingSymlinkFixture(sandbox *Sandbox) error { + if err := baseRepo(sandbox); err != nil { + return err + } + + configPath := filepath.Join(sandbox.Home, ".config", "opencode") + targetPath := filepath.Join(sandbox.Root, "missing-external-opencode-config") + if err := os.Symlink(targetPath, configPath); err != nil { + // Symlinks are a filesystem capability, not a failed journey setup. The + // next step uses the corpus Skip mechanism to record unsupported. + sandbox.Scratch["issue-3557-symlink-unavailable"] = err.Error() + return nil + } + if err := os.MkdirAll(filepath.Join(sandbox.Home, ".claude"), 0o755); err != nil { + return err + } + statePath := filepath.Join(sandbox.Home, ".gentle-ai", "state.json") + state := `{"installed_agents":["opencode","claude-code"]}` + if err := sandbox.write(statePath, state); err != nil { + return err + } + // The extra healthy Claude entry keeps the unrelated Engram check out of the + // "no persisted configuration" branch without touching the dangling path. + claudeConfig := `{"mcpServers":{"engram":{"command":"engram-not-installed-for-issue-3557","args":[]}}}` + if err := sandbox.write(filepath.Join(sandbox.Home, ".claude.json"), claudeConfig); err != nil { + return err + } + + sandbox.Scratch["issue-3557-config"] = configPath + sandbox.Scratch["issue-3557-target"] = targetPath + sandbox.Scratch["issue-3557-state"] = statePath + sandbox.Scratch["issue-3557-state-content"] = state + sandbox.Scratch["issue-3557-link-target"] = targetPath + return nil +} + +func issue3557DoctorArgs(*Sandbox) ([]string, error) { + return []string{"doctor"}, nil +} + +func issue3557SymlinkSkip(sandbox *Sandbox) string { + if reason := sandbox.Scratch["issue-3557-symlink-unavailable"]; reason != "" { + return "managed config symlink unavailable: " + reason + } + return "" +} + +func issue3557VerifyDoctor(sandbox *Sandbox, observation Observation) error { + if observation.ExitCode != 0 { + return fmt.Errorf("gentle-ai doctor exited %d: %s", observation.ExitCode, firstLine(observation.Stderr)) + } + if strings.Contains(observation.Stdout, "gentle-ai sync") { + return fmt.Errorf("doctor recommended the unrunnable sync recovery: %s", observation.Stdout) + } + for _, want := range []string{sandbox.Scratch["issue-3557-config"], "inspect", "gentle-ai doctor"} { + if !strings.Contains(observation.Stdout, want) { + return fmt.Errorf("doctor output missing %q: %s", want, observation.Stdout) + } + } + + configPath := sandbox.Scratch["issue-3557-config"] + info, err := os.Lstat(configPath) + if err != nil { + return fmt.Errorf("lstat managed config symlink after doctor: %w", err) + } + if info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("doctor changed managed config path into %s", info.Mode()) + } + linkTarget, err := os.Readlink(configPath) + if err != nil { + return fmt.Errorf("read managed config symlink after doctor: %w", err) + } + if linkTarget != sandbox.Scratch["issue-3557-link-target"] { + return fmt.Errorf("doctor changed symlink target from %q to %q", sandbox.Scratch["issue-3557-link-target"], linkTarget) + } + if _, err := os.Lstat(sandbox.Scratch["issue-3557-target"]); !os.IsNotExist(err) { + return fmt.Errorf("doctor changed the missing external target: %v", err) + } + state, err := os.ReadFile(sandbox.Scratch["issue-3557-state"]) + if err != nil { + return fmt.Errorf("read state after doctor: %w", err) + } + if string(state) != sandbox.Scratch["issue-3557-state-content"] { + return fmt.Errorf("doctor changed state.json") + } + if _, err := os.Lstat(filepath.Join(sandbox.Home, ".gentle-ai", "backups")); !os.IsNotExist(err) { + return fmt.Errorf("doctor created backup metadata: %v", err) + } + return nil +} + +func issue3557Journeys() []Journey { + return []Journey{{ + ID: "j3557-doctor-dangling-managed-config", + Review: reviewUntouched, + Title: "Doctor identifies a dangling managed config symlink without recommending sync", + Source: "https://github.com/Gentleman-Programming/gentle-ai/issues/3557", + Steps: []Step{ + {Name: "fixture: dangling OpenCode managed config symlink", Fixture: issue3557DanglingSymlinkFixture}, + {Name: "doctor reports manual repair and preserves the filesystem", Skip: issue3557SymlinkSkip, Args: issue3557DoctorArgs, After: issue3557VerifyDoctor}, + }, + }} +} diff --git a/bench/review_declarations.go b/bench/review_declarations.go index 8effe624e6..0f87eb4318 100644 --- a/bench/review_declarations.go +++ b/bench/review_declarations.go @@ -48,6 +48,7 @@ var coreJourneyReviewModes = map[string]ReviewPrecondition{ "j29-cherry-pick-in-progress": reviewOptedIn, "j30-kill-switch-flipped-mid-review": reviewOptedIn, "j3043-opencode-managed-background-activation": reviewUntouched, + "j3557-doctor-dangling-managed-config": reviewUntouched, "j31-nonsense-mode-value": reviewUntouched, "j32-recovery-of-a-recovery": reviewOptedIn, "j33-escalate-then-recover": reviewOptedIn, diff --git a/bench/testdata/journeys.manifest b/bench/testdata/journeys.manifest index a72c409ca8..18efe06a0f 100644 --- a/bench/testdata/journeys.manifest +++ b/bench/testdata/journeys.manifest @@ -40,6 +40,7 @@ j3043-opencode-managed-background-activation j31-nonsense-mode-value j34-abandon-then-start-again j35-correction-budget-exactly-zero +j3557-doctor-dangling-managed-config j36-contract-right-name-wrong-version j40-sdd-attempt-reset-after-drift j41-kill-switch-versus-sdd-pre-verify diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 0d8ec4465a..a753812556 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -406,14 +406,33 @@ func checkStateJSON(homeDir string) CheckResult { } var missing []string + var dangling []string for _, agentID := range s.InstalledAgents { if dir := agentConfigDir(homeDir, agentID); dir != "" { - if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { + info, lstatErr := os.Lstat(dir) + if os.IsNotExist(lstatErr) { missing = append(missing, agentID) + continue + } + if lstatErr != nil { + continue + } + if info.Mode()&os.ModeSymlink != 0 { + if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { + dangling = append(dangling, dir) + } } } } + if len(dangling) > 0 { + detail := fmt.Sprintf("state lists %d agent(s) whose managed config paths are dangling symlinks: %s; inspect or repair these paths manually, then re-run 'gentle-ai doctor'", len(dangling), strings.Join(dangling, ", ")) + if len(missing) > 0 { + detail += "; genuinely absent config dirs: " + strings.Join(missing, ", ") + } + return CheckResult{Name: id, Status: CheckStatusWarn, Detail: detail} + } + if len(missing) > 0 { return CheckResult{ Name: id, diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 9b3c02cf5b..5cdc319f8d 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -347,6 +347,91 @@ func TestCheckStateJSON_AgentConfigDirMissing(t *testing.T) { if !strings.Contains(got.Detail, "config dirs are missing") { t.Errorf("unexpected detail: %s", got.Detail) } + if got.Remedy == nil || got.Remedy.ID != doctor.RemedySync { + t.Fatalf("absent config dir must retain sync remedy, got %+v", got.Remedy) + } +} + +func TestCheckStateJSON_AgentConfigDirDanglingSymlink(t *testing.T) { + homeDir := t.TempDir() + stateDir := filepath.Join(homeDir, ".gentle-ai") + if err := os.MkdirAll(filepath.Join(homeDir, ".config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + configDir := filepath.Join(homeDir, ".config", "opencode") + missingTarget := filepath.Join(t.TempDir(), "missing-opencode-config") + if err := os.Symlink(missingTarget, configDir); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + payload := `{"installed_agents":["opencode"]}` + if err := os.WriteFile(filepath.Join(stateDir, "state.json"), []byte(payload), 0o644); err != nil { + t.Fatal(err) + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for dangling config symlink, got %s: %s", got.Status, got.Detail) + } + if got.Remedy != nil && got.Remedy.ID == doctor.RemedySync { + t.Fatalf("dangling config symlink must not recommend sync: %+v", got.Remedy) + } + if !strings.Contains(got.Detail, configDir) { + t.Fatalf("detail must identify dangling managed path %q, got %q", configDir, got.Detail) + } + if !strings.Contains(got.Detail, "inspect") || !strings.Contains(got.Detail, "gentle-ai doctor") { + t.Fatalf("detail must provide manual inspection and doctor rerun guidance, got %q", got.Detail) + } + if strings.Contains(got.Detail, "gentle-ai sync") { + t.Fatalf("dangling config symlink must not recommend sync, got %q", got.Detail) + } + info, err := os.Lstat(configDir) + if err != nil { + t.Fatalf("Lstat dangling config symlink: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("doctor changed dangling config path into non-symlink: mode %v", info.Mode()) + } +} + +func TestCheckStateJSON_DanglingAndAbsentConfigDirsSuppressSync(t *testing.T) { + homeDir := t.TempDir() + stateDir := filepath.Join(homeDir, ".gentle-ai") + configRoot := filepath.Join(homeDir, ".config") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(configRoot, 0o755); err != nil { + t.Fatal(err) + } + configDir := filepath.Join(configRoot, "opencode") + missingTarget := filepath.Join(t.TempDir(), "missing-opencode-config") + if err := os.Symlink(missingTarget, configDir); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + if err := os.WriteFile(filepath.Join(stateDir, "state.json"), []byte(`{"installed_agents":["opencode","claude-code"]}`), 0o644); err != nil { + t.Fatal(err) + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for mixed dangling/absent config dirs, got %s: %s", got.Status, got.Detail) + } + if got.Remedy != nil { + t.Fatalf("mixed dangling/absent config dirs must suppress sync, got %+v", got.Remedy) + } + for _, want := range []string{configDir, "genuinely absent config dirs: claude-code", "inspect or repair", "gentle-ai doctor"} { + if !strings.Contains(got.Detail, want) { + t.Fatalf("mixed result missing %q: %s", want, got.Detail) + } + } + if strings.Contains(got.Detail, "gentle-ai sync") { + t.Fatalf("mixed dangling/absent result must not recommend sync: %s", got.Detail) + } } func TestCheckStateJSON_OK(t *testing.T) { @@ -370,6 +455,9 @@ func TestCheckStateJSON_OK(t *testing.T) { if got.Status != CheckStatusPass { t.Errorf("expected pass, got %s: %s", got.Status, got.Detail) } + if got.Remedy != nil { + t.Fatalf("real config directory must not have a remedy, got %+v", got.Remedy) + } } func TestCheckInstalledAssetVersion_MatchingPass(t *testing.T) { @@ -794,6 +882,170 @@ Status: healthy } } +func TestRunDoctor_DanglingConfigSymlinkIsReadOnly(t *testing.T) { + origLookPath := lookPathFn + origAvail := availableBytesFn + origHTTP := httpGetFn + origPathDirs := pathDirsFn + origHomeDir := osUserHomeDirDoctor + origExecutable := osExecutableDoctor + defer func() { + lookPathFn = origLookPath + availableBytesFn = origAvail + httpGetFn = origHTTP + pathDirsFn = origPathDirs + osUserHomeDirDoctor = origHomeDir + osExecutableDoctor = origExecutable + }() + + homeDir := t.TempDir() + stateDir := filepath.Join(homeDir, ".gentle-ai") + if err := os.MkdirAll(filepath.Join(homeDir, ".config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + configDir := filepath.Join(homeDir, ".config", "opencode") + missingTarget := filepath.Join(t.TempDir(), "missing-opencode-config") + if err := os.Symlink(missingTarget, configDir); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + statePath := filepath.Join(stateDir, "state.json") + if err := os.WriteFile(statePath, []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { + t.Fatal(err) + } + stateBefore, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + targetBefore, err := os.Readlink(configDir) + if err != nil { + t.Fatal(err) + } + + lookPathFn = func(name string) (string, error) { return "/usr/local/bin/" + name, nil } + availableBytesFn = func(string) (int64, error) { return 1024 * 1024 * 1024, nil } + httpGetFn = func(string, time.Duration) (int, error) { return 200, nil } + pathDirsFn = func() []string { return []string{"/usr/local/bin"} } + osUserHomeDirDoctor = func() (string, error) { return homeDir, nil } + osExecutableDoctor = func() (string, error) { return "/usr/local/bin/gentle-ai", nil } + t.Setenv(engramHealthEnvVar, "https://engram.example.test") + + var output bytes.Buffer + if err := RunDoctor(context.Background(), &output); err != nil { + t.Fatalf("RunDoctor returned error: %v", err) + } + if strings.Contains(output.String(), "gentle-ai sync") { + t.Fatalf("Doctor must not recommend sync for a dangling config symlink, got:\n%s", output.String()) + } + for _, want := range []string{configDir, "inspect", "gentle-ai doctor"} { + if !strings.Contains(output.String(), want) { + t.Fatalf("Doctor output missing %q:\n%s", want, output.String()) + } + } + + stateAfter, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(stateAfter, stateBefore) { + t.Fatalf("Doctor changed state.json") + } + targetAfter, err := os.Readlink(configDir) + if err != nil { + t.Fatalf("read dangling config symlink after Doctor: %v", err) + } + if targetAfter != targetBefore { + t.Fatalf("Doctor changed dangling symlink target from %q to %q", targetBefore, targetAfter) + } + if _, err := os.Lstat(missingTarget); !os.IsNotExist(err) { + t.Fatalf("Doctor changed external target %q: err=%v", missingTarget, err) + } + if _, err := os.Lstat(filepath.Join(stateDir, "backups")); !os.IsNotExist(err) { + t.Fatalf("Doctor created backup metadata: err=%v", err) + } +} + +func TestDoctorSyncDisagreement_RealCLIBoundary(t *testing.T) { + homeDir := t.TempDir() + stateDir := filepath.Join(homeDir, ".gentle-ai") + configDir := filepath.Join(homeDir, ".config", "opencode") + missingTarget := filepath.Join(t.TempDir(), "missing-opencode-config") + if err := os.MkdirAll(filepath.Dir(configDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(missingTarget, configDir); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + statePath := filepath.Join(stateDir, "state.json") + if err := os.WriteFile(statePath, []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { + t.Fatal(err) + } + stateBefore, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + linkTargetBefore, err := os.Readlink(configDir) + if err != nil { + t.Fatal(err) + } + + // Before #3557, this exact os.Stat observation entered the missing-path + // branch and caused Doctor to recommend RemedySync. + var preFixRemedy *doctor.Remedy + if _, err := os.Stat(configDir); os.IsNotExist(err) { + preFixRemedy = doctor.NewRemedy(doctor.RemedySync, "Run 'gentle-ai sync' to restore missing config files") + } + if preFixRemedy == nil || preFixRemedy.ID != doctor.RemedySync { + t.Fatalf("dangling managed path did not reproduce the pre-fix sync recommendation: %+v", preFixRemedy) + } + doctorResult := checkStateJSON(homeDir) + if doctorResult.Status != CheckStatusWarn { + t.Fatalf("Doctor result = %s, want warn: %s", doctorResult.Status, doctorResult.Detail) + } + if doctorResult.Remedy != nil { + t.Fatalf("fixed Doctor must suppress sync for the dangling path, got %+v", doctorResult.Remedy) + } + + setSyncTestHome(t, homeDir) + syncResult, syncErr := RunSync([]string{"--agents", "opencode"}) + if syncErr == nil { + t.Fatalf("current sync unexpectedly accepted the dangling managed path: result=%+v", syncResult) + } + if syncResult.FilesChanged != 0 || len(syncResult.ChangedFiles) != 0 { + t.Fatalf("rejected sync reported managed changes: FilesChanged=%d ChangedFiles=%v", syncResult.FilesChanged, syncResult.ChangedFiles) + } + + stateAfter, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(stateAfter, stateBefore) { + t.Fatal("sync changed state.json after rejecting the dangling managed path") + } + info, err := os.Lstat(configDir) + if err != nil { + t.Fatalf("Lstat managed path after sync: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("sync replaced managed symlink with %s", info.Mode()) + } + linkTargetAfter, err := os.Readlink(configDir) + if err != nil { + t.Fatalf("Readlink managed path after sync: %v", err) + } + if linkTargetAfter != linkTargetBefore { + t.Fatalf("sync changed symlink target from %q to %q", linkTargetBefore, linkTargetAfter) + } + if _, err := os.Lstat(missingTarget); !os.IsNotExist(err) { + t.Fatalf("sync changed missing symlink target: %v", err) + } +} + func TestRunDoctor_HomeDirError(t *testing.T) { orig := osUserHomeDirDoctor defer func() { osUserHomeDirDoctor = orig }() From 5b555a2eb2fe76910ebbd2efde0dbc1dde409f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:31:02 +0000 Subject: [PATCH 2/9] fix(doctor): report unreadable managed config paths --- bench/journeys_issue_3557.go | 14 ++-- internal/cli/doctor.go | 6 +- internal/cli/doctor_test.go | 121 ++++++++++++++++------------------- 3 files changed, 69 insertions(+), 72 deletions(-) diff --git a/bench/journeys_issue_3557.go b/bench/journeys_issue_3557.go index ed5295631c..7786294c45 100644 --- a/bench/journeys_issue_3557.go +++ b/bench/journeys_issue_3557.go @@ -82,8 +82,11 @@ func issue3557VerifyDoctor(sandbox *Sandbox, observation Observation) error { if linkTarget != sandbox.Scratch["issue-3557-link-target"] { return fmt.Errorf("doctor changed symlink target from %q to %q", sandbox.Scratch["issue-3557-link-target"], linkTarget) } - if _, err := os.Lstat(sandbox.Scratch["issue-3557-target"]); !os.IsNotExist(err) { - return fmt.Errorf("doctor changed the missing external target: %v", err) + targetPath := sandbox.Scratch["issue-3557-target"] + if _, err := os.Lstat(targetPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", targetPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor changed %q: %w", targetPath, err) } state, err := os.ReadFile(sandbox.Scratch["issue-3557-state"]) if err != nil { @@ -92,8 +95,11 @@ func issue3557VerifyDoctor(sandbox *Sandbox, observation Observation) error { if string(state) != sandbox.Scratch["issue-3557-state-content"] { return fmt.Errorf("doctor changed state.json") } - if _, err := os.Lstat(filepath.Join(sandbox.Home, ".gentle-ai", "backups")); !os.IsNotExist(err) { - return fmt.Errorf("doctor created backup metadata: %v", err) + backupPath := filepath.Join(sandbox.Home, ".gentle-ai", "backups") + if _, err := os.Lstat(backupPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", backupPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor could not inspect %q: %w", backupPath, err) } return nil } diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index a753812556..4f48773e0f 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -415,7 +415,11 @@ func checkStateJSON(homeDir string) CheckResult { continue } if lstatErr != nil { - continue + return CheckResult{ + Name: id, + Status: CheckStatusWarn, + Detail: fmt.Sprintf("managed config path %s could not be inspected: %v; inspect or repair it manually, then re-run 'gentle-ai doctor'", dir, lstatErr), + } } if info.Mode()&os.ModeSymlink != 0 { if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 5cdc319f8d..af5c527322 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -352,24 +352,62 @@ func TestCheckStateJSON_AgentConfigDirMissing(t *testing.T) { } } -func TestCheckStateJSON_AgentConfigDirDanglingSymlink(t *testing.T) { +func TestCheckStateJSON_ManagedConfigPathUnreadable(t *testing.T) { homeDir := t.TempDir() stateDir := filepath.Join(homeDir, ".gentle-ai") - if err := os.MkdirAll(filepath.Join(homeDir, ".config"), 0o755); err != nil { + if err := os.MkdirAll(stateDir, 0o755); err != nil { t.Fatal(err) } - if err := os.MkdirAll(stateDir, 0o755); err != nil { + configRoot := filepath.Join(homeDir, ".config") + if err := os.WriteFile(configRoot, []byte("not a directory"), 0o644); err != nil { t.Fatal(err) } - configDir := filepath.Join(homeDir, ".config", "opencode") - missingTarget := filepath.Join(t.TempDir(), "missing-opencode-config") - if err := os.Symlink(missingTarget, configDir); err != nil { + if err := os.WriteFile(filepath.Join(stateDir, "state.json"), []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(configRoot, "opencode") + _, statErr := os.Lstat(configPath) + if statErr == nil { + t.Fatal("unreadable managed config path unexpectedly exists") + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for unreadable managed config path, got %s: %s", got.Status, got.Detail) + } + for _, want := range []string{configPath, statErr.Error(), "inspect or repair", "gentle-ai doctor"} { + if !strings.Contains(got.Detail, want) { + t.Fatalf("unreadable path result missing %q: %s", want, got.Detail) + } + } + if got.Remedy != nil && got.Remedy.ID == doctor.RemedySync { + t.Fatalf("unreadable managed config path must not recommend sync: %+v", got.Remedy) + } +} + +func setupDanglingOpenCodeFixture(t *testing.T, statePayload string) (homeDir, configDir, targetPath string) { + t.Helper() + homeDir = t.TempDir() + configDir = filepath.Join(homeDir, ".config", "opencode") + if err := os.MkdirAll(filepath.Dir(configDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(homeDir, ".gentle-ai"), 0o755); err != nil { + t.Fatal(err) + } + targetPath = filepath.Join(t.TempDir(), "missing-opencode-config") + if err := os.Symlink(targetPath, configDir); err != nil { t.Skipf("symlink creation unavailable: %v", err) } - payload := `{"installed_agents":["opencode"]}` - if err := os.WriteFile(filepath.Join(stateDir, "state.json"), []byte(payload), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(homeDir, ".gentle-ai", "state.json"), []byte(statePayload), 0o644); err != nil { t.Fatal(err) } + return homeDir, configDir, targetPath +} + +func TestCheckStateJSON_AgentConfigDirDanglingSymlink(t *testing.T) { + homeDir, configDir, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`) got := checkStateJSON(homeDir) @@ -398,23 +436,7 @@ func TestCheckStateJSON_AgentConfigDirDanglingSymlink(t *testing.T) { } func TestCheckStateJSON_DanglingAndAbsentConfigDirsSuppressSync(t *testing.T) { - homeDir := t.TempDir() - stateDir := filepath.Join(homeDir, ".gentle-ai") - configRoot := filepath.Join(homeDir, ".config") - if err := os.MkdirAll(stateDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(configRoot, 0o755); err != nil { - t.Fatal(err) - } - configDir := filepath.Join(configRoot, "opencode") - missingTarget := filepath.Join(t.TempDir(), "missing-opencode-config") - if err := os.Symlink(missingTarget, configDir); err != nil { - t.Skipf("symlink creation unavailable: %v", err) - } - if err := os.WriteFile(filepath.Join(stateDir, "state.json"), []byte(`{"installed_agents":["opencode","claude-code"]}`), 0o644); err != nil { - t.Fatal(err) - } + homeDir, configDir, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode","claude-code"]}`) got := checkStateJSON(homeDir) @@ -898,23 +920,9 @@ func TestRunDoctor_DanglingConfigSymlinkIsReadOnly(t *testing.T) { osExecutableDoctor = origExecutable }() - homeDir := t.TempDir() - stateDir := filepath.Join(homeDir, ".gentle-ai") - if err := os.MkdirAll(filepath.Join(homeDir, ".config"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(stateDir, 0o755); err != nil { - t.Fatal(err) - } - configDir := filepath.Join(homeDir, ".config", "opencode") - missingTarget := filepath.Join(t.TempDir(), "missing-opencode-config") - if err := os.Symlink(missingTarget, configDir); err != nil { - t.Skipf("symlink creation unavailable: %v", err) - } - statePath := filepath.Join(stateDir, "state.json") - if err := os.WriteFile(statePath, []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { - t.Fatal(err) - } + homeDir, configDir, missingTarget := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`) + statePath := filepath.Join(homeDir, ".gentle-ai", "state.json") + stateDir := filepath.Dir(statePath) stateBefore, err := os.ReadFile(statePath) if err != nil { t.Fatal(err) @@ -968,23 +976,8 @@ func TestRunDoctor_DanglingConfigSymlinkIsReadOnly(t *testing.T) { } func TestDoctorSyncDisagreement_RealCLIBoundary(t *testing.T) { - homeDir := t.TempDir() - stateDir := filepath.Join(homeDir, ".gentle-ai") - configDir := filepath.Join(homeDir, ".config", "opencode") - missingTarget := filepath.Join(t.TempDir(), "missing-opencode-config") - if err := os.MkdirAll(filepath.Dir(configDir), 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(stateDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(missingTarget, configDir); err != nil { - t.Skipf("symlink creation unavailable: %v", err) - } - statePath := filepath.Join(stateDir, "state.json") - if err := os.WriteFile(statePath, []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { - t.Fatal(err) - } + homeDir, configDir, missingTarget := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`) + statePath := filepath.Join(homeDir, ".gentle-ai", "state.json") stateBefore, err := os.ReadFile(statePath) if err != nil { t.Fatal(err) @@ -994,14 +987,8 @@ func TestDoctorSyncDisagreement_RealCLIBoundary(t *testing.T) { t.Fatal(err) } - // Before #3557, this exact os.Stat observation entered the missing-path - // branch and caused Doctor to recommend RemedySync. - var preFixRemedy *doctor.Remedy - if _, err := os.Stat(configDir); os.IsNotExist(err) { - preFixRemedy = doctor.NewRemedy(doctor.RemedySync, "Run 'gentle-ai sync' to restore missing config files") - } - if preFixRemedy == nil || preFixRemedy.ID != doctor.RemedySync { - t.Fatalf("dangling managed path did not reproduce the pre-fix sync recommendation: %+v", preFixRemedy) + if _, err := os.Stat(configDir); !os.IsNotExist(err) { + t.Fatalf("dangling managed path stat error = %v, want not-exist", err) } doctorResult := checkStateJSON(homeDir) if doctorResult.Status != CheckStatusWarn { From a8fc18d60f1b7f2e23e4f458107f0d07cfbb8074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:22:47 +0000 Subject: [PATCH 3/9] ci: retry flaky unit test From c846fcb1aafa99364f4fee16bfad6f321278f925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:49:58 +0000 Subject: [PATCH 4/9] test(bench): expose doctor state drift --- bench/journeys_issue_3557.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bench/journeys_issue_3557.go b/bench/journeys_issue_3557.go index 7786294c45..8c6b76e045 100644 --- a/bench/journeys_issue_3557.go +++ b/bench/journeys_issue_3557.go @@ -93,7 +93,7 @@ func issue3557VerifyDoctor(sandbox *Sandbox, observation Observation) error { return fmt.Errorf("read state after doctor: %w", err) } if string(state) != sandbox.Scratch["issue-3557-state-content"] { - return fmt.Errorf("doctor changed state.json") + return fmt.Errorf("doctor changed state.json: got %q, want %q", string(state), sandbox.Scratch["issue-3557-state-content"]) } backupPath := filepath.Join(sandbox.Home, ".gentle-ai", "backups") if _, err := os.Lstat(backupPath); err == nil { From 47b18a94a36d197ccd857a7ccc735fcc3ef41088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:28:39 +0000 Subject: [PATCH 5/9] test(bench): isolate doctor state assertions --- bench/journeys_issue_3557.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bench/journeys_issue_3557.go b/bench/journeys_issue_3557.go index 8c6b76e045..0b7a2d17df 100644 --- a/bench/journeys_issue_3557.go +++ b/bench/journeys_issue_3557.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "time" ) func issue3557DanglingSymlinkFixture(sandbox *Sandbox) error { @@ -24,7 +25,8 @@ func issue3557DanglingSymlinkFixture(sandbox *Sandbox) error { return err } statePath := filepath.Join(sandbox.Home, ".gentle-ai", "state.json") - state := `{"installed_agents":["opencode","claude-code"]}` + // Seed the startup cooldown so this journey isolates Doctor-owned effects. + state := fmt.Sprintf(`{"installed_agents":["opencode","claude-code"],"last_update_check":%q}`, time.Now().UTC().Format(time.RFC3339Nano)) if err := sandbox.write(statePath, state); err != nil { return err } From 68c292167917fbcab1ea7eaa829e5a5eb203c547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:21:30 +0000 Subject: [PATCH 6/9] fix(doctor): warn on managed config symlink loops --- internal/cli/doctor.go | 2 ++ internal/cli/doctor_test.go | 22 ++++++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 4f48773e0f..fc881fc4e3 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -424,6 +424,8 @@ func checkStateJSON(homeDir string) CheckResult { if info.Mode()&os.ModeSymlink != 0 { if _, statErr := os.Stat(dir); os.IsNotExist(statErr) { dangling = append(dangling, dir) + } else if statErr != nil { + return CheckResult{Name: id, Status: CheckStatusWarn, Detail: fmt.Sprintf("managed config symlink target %s could not be inspected: %v; inspect or repair it manually, then re-run 'gentle-ai doctor'", dir, statErr)} } } } diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index af5c527322..551ae04584 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -386,7 +386,7 @@ func TestCheckStateJSON_ManagedConfigPathUnreadable(t *testing.T) { } } -func setupDanglingOpenCodeFixture(t *testing.T, statePayload string) (homeDir, configDir, targetPath string) { +func setupDanglingOpenCodeFixture(t *testing.T, statePayload, symlinkTarget string) (homeDir, configDir, targetPath string) { t.Helper() homeDir = t.TempDir() configDir = filepath.Join(homeDir, ".config", "opencode") @@ -396,7 +396,10 @@ func setupDanglingOpenCodeFixture(t *testing.T, statePayload string) (homeDir, c if err := os.MkdirAll(filepath.Join(homeDir, ".gentle-ai"), 0o755); err != nil { t.Fatal(err) } - targetPath = filepath.Join(t.TempDir(), "missing-opencode-config") + if symlinkTarget == "" { + symlinkTarget = filepath.Join(t.TempDir(), "missing-opencode-config") + } + targetPath = symlinkTarget if err := os.Symlink(targetPath, configDir); err != nil { t.Skipf("symlink creation unavailable: %v", err) } @@ -407,7 +410,7 @@ func setupDanglingOpenCodeFixture(t *testing.T, statePayload string) (homeDir, c } func TestCheckStateJSON_AgentConfigDirDanglingSymlink(t *testing.T) { - homeDir, configDir, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`) + homeDir, configDir, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`, "") got := checkStateJSON(homeDir) @@ -435,8 +438,15 @@ func TestCheckStateJSON_AgentConfigDirDanglingSymlink(t *testing.T) { } } +func TestCheckStateJSON_AgentConfigDirSymlinkLoop(t *testing.T) { + homeDir, _, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`, "opencode") + if got := checkStateJSON(homeDir); got.Status != CheckStatusWarn || !strings.Contains(got.Detail, "could not be inspected") || strings.Contains(got.Detail, "dangling symlinks") { + t.Fatalf("expected inspection warning for config symlink loop, got %s: %s", got.Status, got.Detail) + } +} + func TestCheckStateJSON_DanglingAndAbsentConfigDirsSuppressSync(t *testing.T) { - homeDir, configDir, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode","claude-code"]}`) + homeDir, configDir, _ := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode","claude-code"]}`, "") got := checkStateJSON(homeDir) @@ -920,7 +930,7 @@ func TestRunDoctor_DanglingConfigSymlinkIsReadOnly(t *testing.T) { osExecutableDoctor = origExecutable }() - homeDir, configDir, missingTarget := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`) + homeDir, configDir, missingTarget := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`, "") statePath := filepath.Join(homeDir, ".gentle-ai", "state.json") stateDir := filepath.Dir(statePath) stateBefore, err := os.ReadFile(statePath) @@ -976,7 +986,7 @@ func TestRunDoctor_DanglingConfigSymlinkIsReadOnly(t *testing.T) { } func TestDoctorSyncDisagreement_RealCLIBoundary(t *testing.T) { - homeDir, configDir, missingTarget := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`) + homeDir, configDir, missingTarget := setupDanglingOpenCodeFixture(t, `{"installed_agents":["opencode"]}`, "") statePath := filepath.Join(homeDir, ".gentle-ai", "state.json") stateBefore, err := os.ReadFile(statePath) if err != nil { From a5ff61535d614d0f183f73876151f2ffb6a7ebeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:15:13 +0000 Subject: [PATCH 7/9] fix(bench): propagate unexpected symlink setup errors --- bench/journeys_issue_3557.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bench/journeys_issue_3557.go b/bench/journeys_issue_3557.go index c8c126712e..1e9b5de874 100644 --- a/bench/journeys_issue_3557.go +++ b/bench/journeys_issue_3557.go @@ -1,13 +1,17 @@ package main import ( + "errors" "fmt" "os" "path/filepath" "strings" + "syscall" "time" ) +const windowsErrorPrivilegeNotHeld = syscall.Errno(1314) + func issue3557DanglingSymlinkFixture(sandbox *Sandbox) error { if err := baseRepo(sandbox); err != nil { return err @@ -16,8 +20,9 @@ func issue3557DanglingSymlinkFixture(sandbox *Sandbox) error { configPath := filepath.Join(sandbox.Home, ".config", "opencode") targetPath := filepath.Join(sandbox.Root, "missing-external-opencode-config") if err := os.Symlink(targetPath, configPath); err != nil { - // Symlinks are a filesystem capability, not a failed journey setup. The - // next step uses the corpus Skip mechanism to record unsupported. + if !errors.Is(err, windowsErrorPrivilegeNotHeld) { + return err + } sandbox.Scratch["issue-3557-symlink-unavailable"] = err.Error() return nil } @@ -25,13 +30,10 @@ func issue3557DanglingSymlinkFixture(sandbox *Sandbox) error { return err } statePath := filepath.Join(sandbox.Home, ".gentle-ai", "state.json") - // Seed the startup cooldown so this journey isolates Doctor-owned effects. state := fmt.Sprintf(`{"installed_agents":["opencode","claude-code"],"last_update_check":%q}`, time.Now().UTC().Format(time.RFC3339Nano)) if err := sandbox.write(statePath, state); err != nil { return err } - // The extra healthy Claude entry keeps the unrelated Engram check out of the - // "no persisted configuration" branch without touching the dangling path. claudeConfig := `{"mcpServers":{"engram":{"command":"engram-not-installed-for-issue-3557","args":[]}}}` if err := sandbox.write(filepath.Join(sandbox.Home, ".claude.json"), claudeConfig); err != nil { return err @@ -45,9 +47,7 @@ func issue3557DanglingSymlinkFixture(sandbox *Sandbox) error { return nil } -func issue3557DoctorArgs(*Sandbox) ([]string, error) { - return []string{"doctor"}, nil -} +func issue3557DoctorArgs(*Sandbox) ([]string, error) { return []string{"doctor"}, nil } func issue3557SymlinkSkip(sandbox *Sandbox) string { if reason := sandbox.Scratch["issue-3557-symlink-unavailable"]; reason != "" { From 3e4671fdc4d8159ad251ff326755be7e36c64e26 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Thu, 27 Aug 2026 17:29:24 +0200 Subject: [PATCH 8/9] fix(doctor): classify dangling ancestor symlinks A managed config path whose ancestor (e.g. ~/.config) is a dangling symlink Lstats as ENOENT and was classified as missing, wrongly enabling the sync remedy that cannot restore a path behind a broken link. When the final path is absent, walk its ancestors below the home directory: an existing ancestor symlink with a missing target now classifies the path as dangling, ancestor inspection errors reuse the unreadable treatment, and genuinely absent chains keep the sync remedy. --- internal/cli/doctor.go | 47 +++++++++++++++++ internal/cli/doctor_test.go | 100 ++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index fc881fc4e3..94b7c70107 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -411,6 +411,22 @@ func checkStateJSON(homeDir string) CheckResult { if dir := agentConfigDir(homeDir, agentID); dir != "" { info, lstatErr := os.Lstat(dir) if os.IsNotExist(lstatErr) { + // A missing final path is only genuinely missing when its + // ancestors resolve: a dangling ancestor symlink (e.g. + // ~/.config pointing at a removed target) also yields ENOENT + // here, and sync cannot restore a path behind a broken link. + ancestor, ancestorErr := danglingAncestor(homeDir, dir) + if ancestorErr != nil { + return CheckResult{ + Name: id, + Status: CheckStatusWarn, + Detail: fmt.Sprintf("managed config path %s could not be inspected: %v; inspect or repair it manually, then re-run 'gentle-ai doctor'", dir, ancestorErr), + } + } + if ancestor != "" { + dangling = append(dangling, fmt.Sprintf("%s (dangling ancestor symlink %s)", dir, ancestor)) + continue + } missing = append(missing, agentID) continue } @@ -455,6 +471,37 @@ func checkStateJSON(homeDir string) CheckResult { } } +// danglingAncestor reports the nearest existing ancestor of path — walking +// upward but staying strictly below homeDir — that is a symlink whose target +// is missing. It returns "" when every existing ancestor resolves (the path is +// then genuinely missing) and an error when an ancestor exists but cannot be +// inspected, mirroring the unreadable treatment of the final path. Ancestors +// at or above homeDir are never inspected: they are not managed by gentle-ai, +// and a broken home directory would have failed the state read already. +func danglingAncestor(homeDir, path string) (string, error) { + home := filepath.Clean(homeDir) + boundary := home + string(filepath.Separator) + for ancestor := filepath.Dir(filepath.Clean(path)); ancestor != home && strings.HasPrefix(ancestor, boundary); ancestor = filepath.Dir(ancestor) { + info, err := os.Lstat(ancestor) + if os.IsNotExist(err) { + continue // ancestor missing too; keep walking up + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink == 0 { + return "", nil // nearest existing ancestor is real; path is genuinely missing + } + if _, err := os.Stat(ancestor); os.IsNotExist(err) { + return ancestor, nil + } else if err != nil { + return "", err + } + return "", nil // symlink resolves; path is genuinely missing below it + } + return "", nil +} + // agentConfigDir returns the expected config directory for a known agent ID. func agentConfigDir(homeDir, agentID string) string { cfgBase := filepath.Join(homeDir, ".config") diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 551ae04584..b14e8022b9 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -466,6 +466,106 @@ func TestCheckStateJSON_DanglingAndAbsentConfigDirsSuppressSync(t *testing.T) { } } +func TestCheckStateJSON_DanglingAncestorSymlinkSuppressesSync(t *testing.T) { + homeDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(homeDir, ".gentle-ai"), 0o755); err != nil { + t.Fatal(err) + } + ancestor := filepath.Join(homeDir, ".config") + missingTarget := filepath.Join(t.TempDir(), "missing-config-root") + if err := os.Symlink(missingTarget, ancestor); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + if err := os.WriteFile(filepath.Join(homeDir, ".gentle-ai", "state.json"), []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { + t.Fatal(err) + } + configDir := filepath.Join(ancestor, "opencode") + if _, err := os.Lstat(configDir); !os.IsNotExist(err) { + t.Fatalf("managed path under dangling ancestor: lstat err = %v, want not-exist", err) + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for dangling ancestor symlink, got %s: %s", got.Status, got.Detail) + } + if got.Remedy != nil && got.Remedy.ID == doctor.RemedySync { + t.Fatalf("dangling ancestor symlink must not recommend sync: %+v", got.Remedy) + } + for _, want := range []string{configDir, "dangling ancestor symlink " + ancestor, "inspect", "gentle-ai doctor"} { + if !strings.Contains(got.Detail, want) { + t.Fatalf("dangling ancestor result missing %q: %s", want, got.Detail) + } + } + if strings.Contains(got.Detail, "gentle-ai sync") || strings.Contains(got.Detail, "config dirs are missing") { + t.Fatalf("dangling ancestor must not be classified as missing: %s", got.Detail) + } + info, err := os.Lstat(ancestor) + if err != nil { + t.Fatalf("Lstat dangling ancestor symlink after doctor: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("doctor changed dangling ancestor into non-symlink: mode %v", info.Mode()) + } + if _, err := os.Lstat(missingTarget); !os.IsNotExist(err) { + t.Fatalf("doctor touched the missing ancestor target %q: err = %v", missingTarget, err) + } +} + +func TestCheckStateJSON_AbsentAncestorChainKeepsSyncRemedy(t *testing.T) { + homeDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(homeDir, ".gentle-ai"), 0o755); err != nil { + t.Fatal(err) + } + // windsurf lives two levels below home (~/.codeium/windsurf); neither + // level exists, so the path is genuinely missing and sync must stay. + if err := os.WriteFile(filepath.Join(homeDir, ".gentle-ai", "state.json"), []byte(`{"installed_agents":["windsurf"]}`), 0o644); err != nil { + t.Fatal(err) + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for absent config dir, got %s: %s", got.Status, got.Detail) + } + if !strings.Contains(got.Detail, "config dirs are missing") { + t.Fatalf("absent ancestor chain must classify as missing, got %s", got.Detail) + } + if got.Remedy == nil || got.Remedy.ID != doctor.RemedySync { + t.Fatalf("absent ancestor chain must retain sync remedy, got %+v", got.Remedy) + } +} + +func TestCheckStateJSON_HealthySymlinkAncestorKeepsSyncRemedy(t *testing.T) { + homeDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(homeDir, ".gentle-ai"), 0o755); err != nil { + t.Fatal(err) + } + realConfigRoot := filepath.Join(t.TempDir(), "real-config-root") + if err := os.MkdirAll(realConfigRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realConfigRoot, filepath.Join(homeDir, ".config")); err != nil { + t.Skipf("symlink creation unavailable: %v", err) + } + // ~/.config resolves fine; only the opencode dir underneath is absent. + if err := os.WriteFile(filepath.Join(homeDir, ".gentle-ai", "state.json"), []byte(`{"installed_agents":["opencode"]}`), 0o644); err != nil { + t.Fatal(err) + } + + got := checkStateJSON(homeDir) + + if got.Status != CheckStatusWarn { + t.Fatalf("expected warn for absent config dir, got %s: %s", got.Status, got.Detail) + } + if !strings.Contains(got.Detail, "config dirs are missing") { + t.Fatalf("healthy symlink ancestor must classify as missing, got %s", got.Detail) + } + if got.Remedy == nil || got.Remedy.ID != doctor.RemedySync { + t.Fatalf("healthy symlink ancestor must retain sync remedy, got %+v", got.Remedy) + } +} + func TestCheckStateJSON_OK(t *testing.T) { homeDir := t.TempDir() stateDir := filepath.Join(homeDir, ".gentle-ai") From 97e97aaa89110d0c71f415907b26898bebd75637 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Thu, 27 Aug 2026 17:29:24 +0200 Subject: [PATCH 9/9] test(bench): drive dangling-ancestor doctor journey j118-doctor-dangling-config-ancestor replaces the sandbox ~/.config directory with a symlink to a missing target and proves the doctor names the dangling ancestor, never recommends sync, and leaves the symlink, its target, state.json, and the backups path untouched. --- bench/journeys.go | 1 + bench/journeys_id_collision_test.go | 1 + bench/journeys_issue_3561.go | 134 ++++++++++++++++++++++++++++ bench/review_declarations.go | 1 + bench/testdata/journeys.manifest | 1 + 5 files changed, 138 insertions(+) create mode 100644 bench/journeys_issue_3561.go diff --git a/bench/journeys.go b/bench/journeys.go index a576f40f6d..570d1a164a 100644 --- a/bench/journeys.go +++ b/bench/journeys.go @@ -816,6 +816,7 @@ func Journeys() []Journey { journeys = append(journeys, issue2138Journeys()...) journeys = append(journeys, issue3043Journeys()...) journeys = append(journeys, issue3557Journeys()...) + journeys = append(journeys, issue3561Journeys()...) journeys = append(journeys, repositoryContextJourneys()...) journeys = append(journeys, providerCaptureRetryJourneys()...) journeys = append(journeys, capturedProviderValidatorJourneys()...) diff --git a/bench/journeys_id_collision_test.go b/bench/journeys_id_collision_test.go index 3b86b1c334..b7104b2879 100644 --- a/bench/journeys_id_collision_test.go +++ b/bench/journeys_id_collision_test.go @@ -58,6 +58,7 @@ func journeySources() []journeySource { {"journeys_issue_2138.go", issue2138Journeys()}, {"journeys_issue_3043.go", issue3043Journeys()}, {"journeys_issue_3557.go", issue3557Journeys()}, + {"journeys_issue_3561.go", issue3561Journeys()}, {"journeys_repository_context.go", repositoryContextJourneys()}, {"journeys_provider_capture.go", providerCaptureRetryJourneys()}, {"journeys_captured_provider_validator.go", capturedProviderValidatorJourneys()}, diff --git a/bench/journeys_issue_3561.go b/bench/journeys_issue_3561.go new file mode 100644 index 0000000000..b03e233470 --- /dev/null +++ b/bench/journeys_issue_3561.go @@ -0,0 +1,134 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +func issue3561DanglingAncestorFixture(sandbox *Sandbox) error { + if err := baseRepo(sandbox); err != nil { + return err + } + + // The sandbox pre-creates ~/.config as a real directory; replace it with + // a symlink whose target does not exist so the managed opencode path + // (~/.config/opencode) sits below a dangling ANCESTOR while not being a + // symlink itself. + ancestorPath := filepath.Join(sandbox.Home, ".config") + targetPath := filepath.Join(sandbox.Root, "missing-external-config-root") + if err := os.Remove(ancestorPath); err != nil { + return err + } + if err := os.Symlink(targetPath, ancestorPath); err != nil { + if !errors.Is(err, windowsErrorPrivilegeNotHeld) { + return err + } + if mkdirErr := os.MkdirAll(ancestorPath, 0o755); mkdirErr != nil { + return mkdirErr + } + sandbox.Scratch["issue-3561-symlink-unavailable"] = err.Error() + return nil + } + if err := os.MkdirAll(filepath.Join(sandbox.Home, ".claude"), 0o755); err != nil { + return err + } + statePath := filepath.Join(sandbox.Home, ".gentle-ai", "state.json") + state := fmt.Sprintf(`{"installed_agents":["opencode","claude-code"],"last_update_check":%q}`, time.Now().UTC().Format(time.RFC3339Nano)) + if err := sandbox.write(statePath, state); err != nil { + return err + } + claudeConfig := `{"mcpServers":{"engram":{"command":"engram-not-installed-for-issue-3561","args":[]}}}` + if err := sandbox.write(filepath.Join(sandbox.Home, ".claude.json"), claudeConfig); err != nil { + return err + } + + sandbox.Scratch["issue-3561-ancestor"] = ancestorPath + sandbox.Scratch["issue-3561-config"] = filepath.Join(ancestorPath, "opencode") + sandbox.Scratch["issue-3561-target"] = targetPath + sandbox.Scratch["issue-3561-state"] = statePath + sandbox.Scratch["issue-3561-state-content"] = state + sandbox.Scratch["issue-3561-link-target"] = targetPath + return nil +} + +func issue3561DoctorArgs(*Sandbox) ([]string, error) { return []string{"doctor"}, nil } + +func issue3561SymlinkSkip(sandbox *Sandbox) string { + if reason := sandbox.Scratch["issue-3561-symlink-unavailable"]; reason != "" { + return "managed config ancestor symlink unavailable: " + reason + } + return "" +} + +func issue3561VerifyDoctor(sandbox *Sandbox, observation Observation) error { + if observation.ExitCode != 0 { + return fmt.Errorf("gentle-ai doctor exited %d: %s", observation.ExitCode, firstLine(observation.Stderr)) + } + if strings.Contains(observation.Stdout, "gentle-ai sync") { + return fmt.Errorf("doctor recommended the unrunnable sync recovery: %s", observation.Stdout) + } + ancestorPath := sandbox.Scratch["issue-3561-ancestor"] + for _, want := range []string{sandbox.Scratch["issue-3561-config"], "dangling ancestor symlink " + ancestorPath, "inspect", "gentle-ai doctor"} { + if !strings.Contains(observation.Stdout, want) { + return fmt.Errorf("doctor output missing %q: %s", want, observation.Stdout) + } + } + + info, err := os.Lstat(ancestorPath) + if err != nil { + return fmt.Errorf("lstat config ancestor symlink after doctor: %w", err) + } + if info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("doctor changed config ancestor into %s", info.Mode()) + } + linkTarget, err := os.Readlink(ancestorPath) + if err != nil { + return fmt.Errorf("read config ancestor symlink after doctor: %w", err) + } + if linkTarget != sandbox.Scratch["issue-3561-link-target"] { + return fmt.Errorf("doctor changed ancestor symlink target from %q to %q", sandbox.Scratch["issue-3561-link-target"], linkTarget) + } + targetPath := sandbox.Scratch["issue-3561-target"] + if _, err := os.Lstat(targetPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", targetPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor changed %q: %w", targetPath, err) + } + configPath := sandbox.Scratch["issue-3561-config"] + if _, err := os.Lstat(configPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", configPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor changed %q: %w", configPath, err) + } + state, err := os.ReadFile(sandbox.Scratch["issue-3561-state"]) + if err != nil { + return fmt.Errorf("read state after doctor: %w", err) + } + if string(state) != sandbox.Scratch["issue-3561-state-content"] { + return fmt.Errorf("doctor changed state.json: got %q, want %q", string(state), sandbox.Scratch["issue-3561-state-content"]) + } + backupPath := filepath.Join(sandbox.Home, ".gentle-ai", "backups") + if _, err := os.Lstat(backupPath); err == nil { + return fmt.Errorf("doctor unexpectedly created %q", backupPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("doctor could not inspect %q: %w", backupPath, err) + } + return nil +} + +func issue3561Journeys() []Journey { + return []Journey{{ + ID: "j118-doctor-dangling-config-ancestor", + Review: reviewUntouched, + Title: "Doctor identifies a dangling ancestor of a managed config path without recommending sync", + Source: "https://github.com/Gentleman-Programming/gentle-ai/pull/3561", + Steps: []Step{ + {Name: "fixture: dangling ~/.config ancestor over the OpenCode managed path", Fixture: issue3561DanglingAncestorFixture}, + {Name: "doctor reports manual repair and preserves the filesystem", Skip: issue3561SymlinkSkip, Args: issue3561DoctorArgs, After: issue3561VerifyDoctor}, + }, + }} +} diff --git a/bench/review_declarations.go b/bench/review_declarations.go index 2d949b51cf..31e58f36a8 100644 --- a/bench/review_declarations.go +++ b/bench/review_declarations.go @@ -51,6 +51,7 @@ var coreJourneyReviewModes = map[string]ReviewPrecondition{ "j30-kill-switch-flipped-mid-review": reviewOptedIn, "j3043-opencode-managed-background-activation": reviewUntouched, "j117-doctor-dangling-managed-config": reviewUntouched, + "j118-doctor-dangling-config-ancestor": reviewUntouched, "j31-nonsense-mode-value": reviewUntouched, "j32-recovery-of-a-recovery": reviewOptedIn, "j33-escalate-then-recover": reviewOptedIn, diff --git a/bench/testdata/journeys.manifest b/bench/testdata/journeys.manifest index 9beda2fcdd..c04405d1c7 100644 --- a/bench/testdata/journeys.manifest +++ b/bench/testdata/journeys.manifest @@ -14,6 +14,7 @@ j114-last-reviewer-capture-closes-and-burns j115-recovery-selector-is-collected-before-authorization j116-codex-committed-correction-runs-returned-status-continuation j117-doctor-dangling-managed-config +j118-doctor-dangling-config-ancestor j12-rejected-capture-then-recapture j13-next-transition-runs-verbatim j14-abandon-needs-a-hand-built-token