From 0a56c1445faffff2e9c677593b083ea204510356 Mon Sep 17 00:00:00 2001 From: timkjr Date: Mon, 26 Oct 2026 19:51:05 -0500 Subject: [PATCH 1/2] 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 2b3e582c..76b3090e 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 00000000..6bd6291e --- /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); + }); +}); From 74fc3ea6ae51bfa99e38d3ae7b86fbb30b1a4a26 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 28 Oct 2026 09:07:28 +0100 Subject: [PATCH 2/2] fix: apply the review items for #722 at merge Note in docs/remote-sessions.md that the @fix_plan.md auto-load does not run for remote sessions, and rewrap the test's header comment. Co-Authored-By: Claude Opus 5.5 --- docs/remote-sessions.md | 5 +++++ test/ralph-fix-plan-remote.test.ts | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index 2d80397b..3deb1e7d 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -429,6 +429,11 @@ path but the SESSION (`session.remote`): a remote session never falls back to lo `fs`, and a local session never opens an ssh connection — including for attachment records, which are keyed to the session that registered them. +**Local watchers skip a remote session.** The Ralph tracker's `@fix_plan.md` auto-load +(and the `fs.watch` behind it) never runs for a remote session: `setupSessionListeners()` +(`server.ts`) does not hand the tracker a remote `workingDir`, because the watcher reads +the LOCAL disk, where that path is missing or a different tree. + ## Wake-on-LAN from user input A durable remote session survives an SSH drop (COD-104/108), but nothing brought the diff --git a/test/ralph-fix-plan-remote.test.ts b/test/ralph-fix-plan-remote.test.ts index 6bd6291e..9127ac0f 100644 --- a/test/ralph-fix-plan-remote.test.ts +++ b/test/ralph-fix-plan-remote.test.ts @@ -9,9 +9,10 @@ * 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. + * output-based todo completion for every remote session. + * `setupSessionListeners()` already skips external CLI modes (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.