fix: eliminate session state leakage across parallel Claude Code sessions

When multiple Claude Code sessions run in the same directory, mode state
(ultrawork, ralph, autopilot, etc.) leaked between sessions because state
files fell back to legacy shared paths. This change enforces session-scoped
state isolation: when a session_id is known, legacy state is invisible.

Key changes:
- persistent-mode.cjs: add isSessionMatch() helper, remove legacy fallback
  in readStateFileWithSession() when sessionId present, apply to all 8 modes
- session-start.mjs: restore only from session-scoped paths when session_id
  exists, legacy paths only when session_id absent
- ralph/loop.ts: readRalphState() and isUltraQAActive() no legacy fallback
- ultraqa/index.ts: readUltraQAState() no legacy fallback
- autopilot/state.ts: readAutopilotState() session-scoped only, thread
  sessionId through cancel.ts, validation.ts, enforcement.ts; fix missing
  sessionId propagation in transitionRalphToUltraQA -> updateExecution
- state-tools.ts: warnings added for missing session_id, schema descriptions
  updated
- Tests updated to match session-first behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yeachan-Heo
2026-02-08 03:44:39 +00:00
parent d457f62263
commit aae0ea2df3
44 changed files with 457 additions and 303 deletions

View File

@@ -292,19 +292,21 @@ async function main() {
}
// Check for ultrawork state - only restore if session matches (issue #311)
// Check session-scoped path first, then legacy paths
// Session-scoped ONLY when session_id exists — no legacy fallback
let ultraworkState = null;
if (sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) {
// Session-scoped ONLY — no legacy fallback
ultraworkState = readJsonFile(join(directory, '.omc', 'state', 'sessions', sessionId, 'ultrawork-state.json'));
}
if (!ultraworkState) {
// Validate session identity
if (ultraworkState && ultraworkState.session_id && ultraworkState.session_id !== sessionId) {
ultraworkState = null;
}
} else {
// No session_id — legacy behavior for backward compat
ultraworkState = readJsonFile(join(directory, '.omc', 'state', 'ultrawork-state.json'));
}
if (!ultraworkState) {
ultraworkState = readJsonFile(join(homedir(), '.omc', 'state', 'ultrawork-state.json'));
}
if (ultraworkState?.active && (!ultraworkState.session_id || ultraworkState.session_id === sessionId)) {
if (ultraworkState?.active) {
messages.push(`<session-restore>
[ULTRAWORK MODE RESTORED]
@@ -321,16 +323,21 @@ Continue working in ultrawork mode until all tasks are complete.
}
// Check for ralph loop state
// Check session-scoped path first, then legacy paths
// Session-scoped ONLY when session_id exists — no legacy fallback
let ralphState = null;
if (sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) {
// Session-scoped ONLY — no legacy fallback
ralphState = readJsonFile(join(directory, '.omc', 'state', 'sessions', sessionId, 'ralph-state.json'));
}
if (!ralphState) {
// Validate session identity
if (ralphState && ralphState.session_id && ralphState.session_id !== sessionId) {
ralphState = null;
}
} else {
// No session_id — legacy behavior for backward compat
ralphState = readJsonFile(join(directory, '.omc', 'state', 'ralph-state.json'));
}
if (!ralphState) {
ralphState = readJsonFile(join(directory, '.omc', 'ralph-state.json'));
if (!ralphState) {
ralphState = readJsonFile(join(directory, '.omc', 'ralph-state.json'));
}
}
if (ralphState?.active) {
messages.push(`<session-restore>