Merge pull request #2712 from Yeachan-Heo/fix/issue-2710-omc-state-root

Fix OMC state root resolution for nested working directories
This commit is contained in:
Bellman
2026-04-18 17:15:31 +09:00
committed by GitHub
2 changed files with 64 additions and 10 deletions

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, mkdtempSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { writeModeState, readModeState, clearModeStateFile } from '../mode-state-io.js';
@@ -56,6 +57,18 @@ describe('mode-state-io', () => {
expect(existsSync(join(tempDir, '.omc', 'state'))).toBe(true);
});
it('should resolve writes to the git worktree root when called from a subdirectory', () => {
const nestedDir = join(tempDir, 'nested', 'cwd');
mkdirSync(nestedDir, { recursive: true });
execSync('git init', { cwd: tempDir, stdio: 'pipe' });
const result = writeModeState('autopilot', { phase: 'exec' }, nestedDir);
expect(result).toBe(true);
expect(existsSync(join(tempDir, '.omc', 'state', 'autopilot-state.json'))).toBe(true);
expect(existsSync(join(nestedDir, '.omc', 'state', 'autopilot-state.json'))).toBe(false);
});
it('should write file with 0o600 permissions', () => {
writeModeState('ralph', { active: true }, tempDir);
const filePath = join(tempDir, '.omc', 'state', 'ralph-state.json');
@@ -153,6 +166,23 @@ describe('mode-state-io', () => {
expect(result.phase).toBe('running');
});
it('should read state from the git worktree root when given a subdirectory', () => {
const nestedDir = join(tempDir, 'nested', 'cwd');
mkdirSync(nestedDir, { recursive: true });
execSync('git init', { cwd: tempDir, stdio: 'pipe' });
const stateDir = join(tempDir, '.omc', 'state');
mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'ralph-state.json'),
JSON.stringify({ active: true, _meta: { mode: 'ralph', written_at: '2026-01-01T00:00:00Z' } }),
);
const result = readModeState('ralph', nestedDir);
expect(result).not.toBeNull();
expect(result!.active).toBe(true);
});
it('should read from session path when sessionId is provided', () => {
const sessionDir = join(tempDir, '.omc', 'state', 'sessions', 'pid-999-2000');
mkdirSync(sessionDir, { recursive: true });
@@ -200,6 +230,22 @@ describe('mode-state-io', () => {
// clearModeStateFile
// -----------------------------------------------------------------------
describe('clearModeStateFile', () => {
it('should clear state from the git worktree root when given a subdirectory', () => {
const nestedDir = join(tempDir, 'nested', 'cwd');
mkdirSync(nestedDir, { recursive: true });
execSync('git init', { cwd: tempDir, stdio: 'pipe' });
const stateDir = join(tempDir, '.omc', 'state');
mkdirSync(stateDir, { recursive: true });
const filePath = join(stateDir, 'ralph-state.json');
writeFileSync(filePath, JSON.stringify({ active: true }));
const result = clearModeStateFile('ralph', nestedDir);
expect(result).toBe(true);
expect(existsSync(filePath)).toBe(false);
expect(existsSync(join(nestedDir, '.omc', 'state', 'ralph-state.json'))).toBe(false);
});
it('should delete the legacy state file', () => {
const stateDir = join(tempDir, '.omc', 'state');
mkdirSync(stateDir, { recursive: true });

View File

@@ -15,6 +15,7 @@ import {
ensureSessionStateDir,
ensureOmcDir,
listSessionIds,
getWorktreeRoot,
} from './worktree-paths.js';
import { atomicWriteJsonSync } from './atomic-write.js';
@@ -49,13 +50,18 @@ export function canClearStateForSession(
// Internal helpers
// ---------------------------------------------------------------------------
function resolveStateRoot(directory?: string): string {
const baseDir = directory || process.cwd();
return getWorktreeRoot(baseDir) || baseDir;
}
/**
* Resolve the state file path for a given mode.
* When sessionId is provided, returns the session-scoped path.
* Otherwise returns the legacy (global) path.
*/
function resolveFile(mode: string, directory?: string, sessionId?: string): string {
const baseDir = directory || process.cwd();
const baseDir = resolveStateRoot(directory);
if (sessionId) {
return resolveSessionStatePath(mode, sessionId, baseDir);
}
@@ -63,7 +69,7 @@ function resolveFile(mode: string, directory?: string, sessionId?: string): stri
}
function getLegacyStateCandidates(mode: string, directory?: string): string[] {
const baseDir = directory || process.cwd();
const baseDir = resolveStateRoot(directory);
const normalizedName = mode.endsWith('-state') ? mode : `${mode}-state`;
return [
@@ -87,13 +93,14 @@ export function findSessionOwnedStateFiles(
directory?: string,
): string[] {
const matches = new Set<string>();
const expectedPath = resolveSessionStatePath(mode, sessionId, directory);
const baseDir = resolveStateRoot(directory);
const expectedPath = resolveSessionStatePath(mode, sessionId, baseDir);
if (existsSync(expectedPath)) {
matches.add(expectedPath);
}
for (const sid of listSessionIds(directory)) {
const candidatePath = resolveSessionStatePath(mode, sid, directory);
for (const sid of listSessionIds(baseDir)) {
const candidatePath = resolveSessionStatePath(mode, sid, baseDir);
if (!existsSync(candidatePath)) {
continue;
}
@@ -131,7 +138,7 @@ export function writeModeState(
sessionId?: string,
): boolean {
try {
const baseDir = directory || process.cwd();
const baseDir = resolveStateRoot(directory);
if (sessionId) {
ensureSessionStateDir(sessionId, baseDir);
} else {
@@ -199,6 +206,7 @@ export function clearModeStateFile(
sessionId?: string,
): boolean {
let success = true;
const baseDir = resolveStateRoot(directory);
const unlinkIfPresent = (filePath: string): void => {
if (!existsSync(filePath)) {
return;
@@ -214,18 +222,18 @@ export function clearModeStateFile(
if (sessionId) {
unlinkIfPresent(resolveFile(mode, directory, sessionId));
} else {
for (const legacyPath of getLegacyStateCandidates(mode, directory)) {
for (const legacyPath of getLegacyStateCandidates(mode, baseDir)) {
unlinkIfPresent(legacyPath);
}
for (const sid of listSessionIds(directory)) {
unlinkIfPresent(resolveSessionStatePath(mode, sid, directory));
for (const sid of listSessionIds(baseDir)) {
unlinkIfPresent(resolveSessionStatePath(mode, sid, baseDir));
}
}
// Ghost-legacy cleanup: if sessionId provided, also check legacy path
if (sessionId) {
for (const legacyPath of getLegacyStateCandidates(mode, directory)) {
for (const legacyPath of getLegacyStateCandidates(mode, baseDir)) {
if (!existsSync(legacyPath)) {
continue;
}