Skip to content
Merged
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
6 changes: 4 additions & 2 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
109 changes: 109 additions & 0 deletions test/ralph-fix-plan-remote.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
}

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);
});
});
Loading