Skip to content
Merged
2 changes: 2 additions & 0 deletions bench/journeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,8 @@ func Journeys() []Journey {
journeys = append(journeys, issue2906Journeys()...)
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()...)
Expand Down
2 changes: 2 additions & 0 deletions bench/journeys_id_collision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ func journeySources() []journeySource {
{"journeys_issue2906.go", issue2906Journeys()},
{"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()},
Expand Down
120 changes: 120 additions & 0 deletions bench/journeys_issue_3557.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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
}

configPath := filepath.Join(sandbox.Home, ".config", "opencode")
targetPath := filepath.Join(sandbox.Root, "missing-external-opencode-config")
if err := os.Symlink(targetPath, configPath); err != nil {
if !errors.Is(err, windowsErrorPrivilegeNotHeld) {
return err
}
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 := 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-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)
}
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 {
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: 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 {
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 issue3557Journeys() []Journey {
return []Journey{{
ID: "j117-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},
},
}}
}
134 changes: 134 additions & 0 deletions bench/journeys_issue_3561.go
Original file line number Diff line number Diff line change
@@ -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},
},
}}
}
2 changes: 2 additions & 0 deletions bench/review_declarations.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ var coreJourneyReviewModes = map[string]ReviewPrecondition{
"j29-cherry-pick-in-progress": reviewOptedIn,
"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,
Expand Down
2 changes: 2 additions & 0 deletions bench/testdata/journeys.manifest
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ j113-correction-removes-candidate-only-path
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
Expand Down
74 changes: 73 additions & 1 deletion internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,12 +406,53 @@ 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) {
// 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
}
if lstatErr != 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, lstatErr),
}
}
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)}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 {
Expand All @@ -430,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")
Expand Down
Loading
Loading