From 0a56c1445faffff2e9c677593b083ea204510356 Mon Sep 17 00:00:00 2001 From: timkjr Date: Mon, 26 Oct 2026 19:51:05 -0500 Subject: [PATCH] fix: don't point the Ralph @fix_plan.md watcher at a remote session's directory A remote-SSH session's workingDir is a path on the remote host, but setupSessionListeners() handed it to the Ralph tracker, whose RalphFixPlanWatcher reads and fs.watch()es it on the LOCAL disk. Where the path does not exist locally (the usual case), every server start logged [RalphFixPlanWatcher] Could not watch @fix_plan.md: Error: ENOENT ... once per remote session; where a same-named local directory did exist, it watched the wrong tree. Setting the path also made the tracker treat that unreadable file as authoritative (isFileAuthoritative is "a path is set"), which switched off its output-based todo completion for remote sessions. Skip the tracker's working dir for remote sessions, next to the existing external-CLI skip. --- src/web/server.ts | 6 +- test/ralph-fix-plan-remote.test.ts | 109 +++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 test/ralph-fix-plan-remote.test.ts diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c4..76b3090e1 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1989,8 +1989,10 @@ export class WebServer extends EventEmitter { this.runSummaryTrackers.set(session.id, summaryTracker); summaryTracker.recordSessionStarted(session.mode, session.workingDir); - // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs) - if (!isExternalCliMode(session.mode)) { + // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs). + // A remote session's workingDir is a path on the remote host, and the watcher reads and + // fs.watch()es it on the LOCAL disk, so it is skipped there too. + if (!isExternalCliMode(session.mode) && !session.remote) { session.ralphTracker.setWorkingDir(session.workingDir); } diff --git a/test/ralph-fix-plan-remote.test.ts b/test/ralph-fix-plan-remote.test.ts new file mode 100644 index 000000000..6bd6291e8 --- /dev/null +++ b/test/ralph-fix-plan-remote.test.ts @@ -0,0 +1,109 @@ +/** + * The Ralph tracker's `@fix_plan.md` watcher must never be pointed at a REMOTE + * session's working directory. + * + * A remote-SSH session's `workingDir` is a path on the remote host. The watcher + * reads and `fs.watch`es it on the LOCAL disk, where it usually does not exist, + * so every server start logged + * `[RalphFixPlanWatcher] Could not watch @fix_plan.md: Error: ENOENT ...` once per + * remote session (and where a same-named local directory did exist, it watched + * the wrong tree). Setting the path also made the tracker treat the unreadable + * file as authoritative (`isFileAuthoritative`), which switched off its + * output-based todo completion for every remote session. `setupSessionListeners()` already skips external CLI modes for + * the same reason (the tracker is not supported there); a remote location is the + * other case with no local file to watch. + * + * Drives the real `WebServer.setupSessionListeners()`, like + * discard-partially-built-session.test.ts. + */ +import { mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { safeRmHomeTree } from './mocks/test-helpers.js'; + +import { WebServer } from '../src/web/server.js'; +import { Session } from '../src/session.js'; +import { TmuxManager } from '../src/tmux-manager.js'; + +interface ServerInternals { + registerSessionWithLayout(session: Session): Promise; + setupSessionListeners(session: Session): Promise; + discardPartiallyBuiltSession(sessionId: string): Promise; +} + +const WORKSPACE = join(homedir(), '.codeman-test-ralph-remote'); +// A path on the remote host: nothing exists there locally. +const REMOTE_PATH = '/nonexistent-remote-host-path/homelab-infrastructure'; +const SESSION_ID = 'f0e1d2c3b4a59687'; + +let server: WebServer; +let internals: ServerInternals; +let mux: TmuxManager; + +function buildSession(remote: boolean): Session { + return new Session({ + id: SESSION_ID, + workingDir: remote ? REMOTE_PATH : WORKSPACE, + mode: 'claude', + name: remote ? 'remote session' : 'local session', + mux, + useMux: true, + ...(remote + ? { + remote: { + hostId: 'proxx', + label: 'proxx', + host: 'proxx.example.lan', + username: 'user', + remotePath: REMOTE_PATH, + owned: true, + }, + } + : {}), + }); +} + +beforeAll(() => { + mkdirSync(WORKSPACE, { recursive: true }); + server = new WebServer(0, false, true); + internals = server as unknown as ServerInternals; + mux = new TmuxManager(); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {}); +}); + +afterAll(async () => { + await server.stop().catch(() => {}); + safeRmHomeTree(WORKSPACE); +}); + +describe('Ralph @fix_plan.md watcher and the session location', () => { + it('does not watch a remote session working directory on the local disk', async () => { + const session = buildSession(true); + const setWorkingDir = vi.spyOn(session.ralphTracker, 'setWorkingDir'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + + expect(setWorkingDir).not.toHaveBeenCalled(); + expect(log.mock.calls.some((args) => String(args[0]).includes('Could not watch @fix_plan.md'))).toBe(false); + // A fix-plan path that can never be read must not make the file "authoritative": + // that turns off the tracker's output-based todo completion for the session. + expect(session.ralphTracker.isFileAuthoritative).toBe(false); + }); + + it('still watches a local session working directory', async () => { + const session = buildSession(false); + const setWorkingDir = vi.spyOn(session.ralphTracker, 'setWorkingDir'); + + await internals.registerSessionWithLayout(session); + await internals.setupSessionListeners(session); + + expect(setWorkingDir).toHaveBeenCalledWith(WORKSPACE); + }); +});