fix(state): close parallel fixture and fallback regressions

This commit is contained in:
gaebal-gajae
2026-08-27 01:09:43 +00:00
parent 65ab56c446
commit ba5ad7e03e
15 changed files with 327 additions and 131 deletions

View File

@@ -5,15 +5,15 @@
"provenance": {
"base": "05c800f40d1ad53b42a78609d2667ef4f726808b",
"planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398",
"head": "e31a814c2ee55de6a6540af54d62ced006261277",
"sourceSha256": "7397d78ce6a03abcf5b089266a659b84ff536e6a7a9b2b1924baaff8fd4fe881",
"head": "65ab56c446bef0ce4be13c99f67774e240e0e600",
"sourceSha256": "eb26480f8a2e2f4ecb721713473e9da46473fbcc2d863825e7a66dcbf0535aef",
"generatedAt": null,
"generator": "scripts/generate-inventory-graph.mjs"
},
"base": "05c800f40d1ad53b42a78609d2667ef4f726808b",
"planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398",
"head": "e31a814c2ee55de6a6540af54d62ced006261277",
"sourceSha256": "7397d78ce6a03abcf5b089266a659b84ff536e6a7a9b2b1924baaff8fd4fe881",
"head": "65ab56c446bef0ce4be13c99f67774e240e0e600",
"sourceSha256": "eb26480f8a2e2f4ecb721713473e9da46473fbcc2d863825e7a66dcbf0535aef",
"counts": {
"public": {
"skills": 32,
@@ -41538,6 +41538,11 @@
"to": "external:fs",
"kind": "imports"
},
{
"from": "scripts/lib/state-root.cjs",
"to": "external:os",
"kind": "imports"
},
{
"from": "scripts/lib/state-root.cjs",
"to": "external:path",
@@ -41563,6 +41568,11 @@
"to": "external:fs",
"kind": "imports"
},
{
"from": "scripts/lib/state-root.mjs",
"to": "external:os",
"kind": "imports"
},
{
"from": "scripts/lib/state-root.mjs",
"to": "external:path",
@@ -46548,11 +46558,6 @@
"to": "external:child_process",
"kind": "imports"
},
{
"from": "src/__tests__/pre-tool-enforcer.test.ts",
"to": "external:crypto",
"kind": "imports"
},
{
"from": "src/__tests__/pre-tool-enforcer.test.ts",
"to": "external:fs",
@@ -46578,6 +46583,11 @@
"to": "scripts/lib/pre-tool-enforcer-preflight.mjs",
"kind": "imports"
},
{
"from": "src/__tests__/pre-tool-enforcer.test.ts",
"to": "src/lib/worktree-paths.ts",
"kind": "imports"
},
{
"from": "src/__tests__/preemptive-compaction-hook.test.ts",
"to": "external:child_process",
@@ -48528,6 +48538,11 @@
"to": "external:vitest",
"kind": "imports"
},
{
"from": "src/__tests__/team-ops-task-locking.test.ts",
"to": "src/lib/worktree-paths.ts",
"kind": "imports"
},
{
"from": "src/__tests__/team-ops-task-locking.test.ts",
"to": "src/team/team-ops.ts",
@@ -60428,6 +60443,11 @@
"to": "src/hooks/subagent-tracker/index.ts",
"kind": "imports"
},
{
"from": "src/hooks/skill-state/index.ts",
"to": "src/lib/atomic-write.ts",
"kind": "imports"
},
{
"from": "src/hooks/skill-state/index.ts",
"to": "src/lib/mode-state-io.ts",
@@ -76286,11 +76306,11 @@
],
"stats": {
"nodeCount": 6831,
"edgeCount": 7170,
"importEdgeCount": 5886,
"edgeCount": 7174,
"importEdgeCount": 5890,
"registerEdgeCount": 53
}
},
"inventorySha256": "0fa1d8055ac3c243f65c169a83bda6d9c7fae7684b1d559bb464baf525cd54f2",
"manifestSha256": "5c76b2772b83aae1321649209f73e392a3041bce1550c2a67f13e454b16adca9"
"manifestSha256": "171d43b2091fdf48aab14c385d97613e24f458568097c98a967cc670c69c6a3c"
}

View File

@@ -11,10 +11,11 @@
'use strict';
const { join, basename } = require('path');
const { join, basename, dirname, resolve } = require('path');
const { existsSync } = require('fs');
const { createHash } = require('crypto');
const { execFileSync } = require('child_process');
const { homedir } = require('os');
/**
* Resolve the .omc root directory, respecting OMC_STATE_DIR.
@@ -41,14 +42,25 @@ async function resolveOmcStateRoot(directory) {
const customDir = process.env.OMC_STATE_DIR;
if (customDir) {
let gitRoot = null;
try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null; } catch {}
try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || null; } catch {}
if (!gitRoot) return join(customDir, 'non-git');
let source = gitRoot;
try { source = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: gitRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || gitRoot; } catch {}
try { source = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: gitRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || gitRoot; } catch {}
const hash = createHash('sha256').update(source).digest('hex').slice(0, 16);
return join(customDir, `${basename(gitRoot).replace(/[^a-zA-Z0-9_-]/g, '_')}-${hash}`);
}
return join(directory, '.omc');
let gitRoot = null;
try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || null; } catch {}
if (gitRoot) return join(gitRoot, '.omc');
let cursor = resolve(directory);
const home = resolve(homedir());
while (true) {
if (existsSync(join(cursor, '.omc-workspace'))) return join(cursor, '.omc');
const parent = dirname(cursor);
if (parent === cursor || cursor === home) break;
cursor = parent;
}
return join(home, '.omc');
}
/**

View File

@@ -18,10 +18,11 @@
* in production (CLAUDE_PLUGIN_ROOT is always set).
*/
import { join, basename } from 'path';
import { join, basename, dirname, resolve } from 'path';
import { existsSync } from 'fs';
import { createHash } from 'crypto';
import { execFileSync } from 'child_process';
import { homedir } from 'os';
import { pathToFileURL } from 'url';
/**
@@ -48,14 +49,25 @@ export async function resolveOmcStateRoot(directory) {
const customDir = process.env.OMC_STATE_DIR;
if (customDir) {
let gitRoot = null;
try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null; } catch {}
try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || null; } catch {}
if (!gitRoot) return join(customDir, 'non-git');
let source = gitRoot;
try { source = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: gitRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || gitRoot; } catch {}
try { source = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: gitRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || gitRoot; } catch {}
const hash = createHash('sha256').update(source).digest('hex').slice(0, 16);
return join(customDir, `${basename(gitRoot).replace(/[^a-zA-Z0-9_-]/g, '_')}-${hash}`);
}
return join(directory, '.omc');
let gitRoot = null;
try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || null; } catch {}
if (gitRoot) return join(gitRoot, '.omc');
let cursor = resolve(directory);
const home = resolve(homedir());
while (true) {
if (existsSync(join(cursor, '.omc-workspace'))) return join(cursor, '.omc');
const parent = dirname(cursor);
if (parent === cursor || cursor === home) break;
cursor = parent;
}
return join(home, '.omc');
}
/**

View File

@@ -51,6 +51,29 @@ vi.mock('../hooks/notepad/index.js', () => ({
setPriorityContext: vi.fn(),
}));
// Keep bridge integration focused on delegation and task tracking. The bridge's
// prompt-prerequisite reader resolves runtime state roots through git, which is
// intentionally unavailable in this suite's mocked filesystem. Stub that
// unrelated stateful surface so each integration case observes only its own
// enforcement/task inputs.
vi.mock('../hooks/prompt-prerequisites/index.js', () => ({
activatePromptPrerequisiteState: vi.fn(),
buildPromptPrerequisiteDenyReason: vi.fn(() => ''),
buildPromptPrerequisiteReminder: vi.fn(() => ''),
clearPromptPrerequisiteState: vi.fn(),
getPromptPrerequisiteConfig: vi.fn(() => ({
enabled: false,
blockingTools: [],
executionKeywords: [],
sectionNames: {},
})),
isPromptPrerequisiteBlockingTool: vi.fn(() => false),
parsePromptPrerequisiteSections: vi.fn(),
readPromptPrerequisiteState: vi.fn(() => null),
recordPromptPrerequisiteProgress: vi.fn(() => null),
shouldEnforcePromptPrerequisites: vi.fn(() => false),
}));
import { existsSync, readFileSync } from 'fs';
const mockExistsSync = vi.mocked(existsSync);
const mockReadFileSync = vi.mocked(readFileSync);

View File

@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
@@ -15,31 +15,57 @@ function runKeywordDetector(
env: NodeJS.ProcessEnv = {},
detectorPath = SCRIPT_PATH,
) {
const raw = execFileSync(NODE, [detectorPath], {
input: JSON.stringify({
hook_event_name: 'UserPromptSubmit',
cwd,
session_id: sessionId,
prompt,
}),
encoding: 'utf-8',
env: {
...process.env,
NODE_ENV: 'test',
OMC_SKIP_HOOKS: '',
...env,
},
timeout: 15000,
}).trim();
// Script hooks resolve non-git state through the workspace marker or HOME;
// keep both the marker-backed project state and hook-owned user files out of
// the checkout and the runner's real home directory.
const homeDir = mkdtempSync(join(tmpdir(), 'keyword-detector-home-'));
const markerPath = cwd !== process.cwd() ? join(cwd, '.omc-workspace') : null;
const addedMarker = markerPath !== null && !existsSync(markerPath);
if (addedMarker) writeFileSync(markerPath, '');
return JSON.parse(raw) as {
continue: boolean;
suppressOutput?: boolean;
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
};
const effectiveHome = env.HOME || homeDir;
const childEnv = {
...process.env,
NODE_ENV: 'test',
DISABLE_OMC: '',
OMC_SKIP_HOOKS: '',
OMC_TEAM_WORKER: '',
OMC_STATE_DIR: cwd === process.cwd() ? join(homeDir, 'omc-state') : '',
CLAUDE_PLUGIN_ROOT: '',
HOME: effectiveHome,
USERPROFILE: env.USERPROFILE || effectiveHome,
CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR || join(effectiveHome, '.claude'),
...env,
};
try {
const raw = execFileSync(NODE, [detectorPath], {
cwd,
input: JSON.stringify({
hook_event_name: 'UserPromptSubmit',
cwd,
session_id: sessionId,
prompt,
}),
encoding: 'utf-8',
env: childEnv,
timeout: 15000,
}).trim();
return JSON.parse(raw) as {
continue: boolean;
suppressOutput?: boolean;
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
};
};
} finally {
if (addedMarker) {
try { unlinkSync(markerPath); } catch { /* best effort */ }
}
rmSync(homeDir, { recursive: true, force: true });
}
}
function getRalplanStatePath(cwd: string, sessionId: string) {

View File

@@ -6,7 +6,7 @@
import { describe, it, expect } from 'vitest';
import { execSync } from 'child_process';
import { join } from 'path';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import process from 'process';
import { detectAnnouncedBackgroundLaunch, detectBashFailure, detectWriteFailure, isBackgroundToolInvocation, isClaudeCodeWriteSuccess, isNonZeroExitWithOutput, summarizeAgentResult } from '../../scripts/post-tool-verifier.mjs';
@@ -38,14 +38,56 @@ function runPostToolVerifier(input, env = {}) {
return runHookScript(SCRIPT_PATH, input, env);
}
function scopedHookEnvironment(cwd, env) {
// Script hooks resolve non-git state through the workspace marker or HOME;
// keep both the marker-backed project state and hook-owned user files out of
// the checkout and the runner's real home directory.
const homeDir = mkdtempSync(join(tmpdir(), 'post-tool-verifier-home-'));
const markerPath = cwd && cwd !== process.cwd() ? join(cwd, '.omc-workspace') : null;
const addedMarker = markerPath && !existsSync(markerPath);
if (addedMarker) writeFileSync(markerPath, '');
const effectiveHome = env.HOME || homeDir;
const childEnv = {
...process.env,
NODE_ENV: 'test',
DISABLE_OMC: '',
OMC_SKIP_HOOKS: '',
OMC_QUIET: '0',
OMC_STATE_DIR: '',
CLAUDE_PLUGIN_ROOT: '',
HOME: effectiveHome,
USERPROFILE: env.USERPROFILE || effectiveHome,
CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR || join(effectiveHome, '.claude'),
...env,
};
return {
childEnv,
cleanup() {
if (addedMarker) {
try { unlinkSync(markerPath); } catch { /* best effort */ }
}
rmSync(homeDir, { recursive: true, force: true });
},
};
}
function runHookScript(scriptPath, input, env = {}) {
const stdout = execSync(`node "${scriptPath}"`, {
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, NODE_ENV: 'test', ...env },
});
return JSON.parse(stdout.trim());
const cwd = typeof input?.cwd === 'string' && input.cwd.length > 0 ? input.cwd : process.cwd();
const fixture = scopedHookEnvironment(cwd, env);
try {
const stdout = execSync(`node "${scriptPath}"`, {
cwd,
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 5000,
env: fixture.childEnv,
});
return JSON.parse(stdout.trim());
} finally {
fixture.cleanup();
}
}
function withTempDir(fn) {

View File

@@ -1,7 +1,6 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { basename, dirname, join } from 'path';
import { createHash } from 'crypto';
import { dirname, join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.unmock('child_process');
@@ -10,9 +9,16 @@ vi.unmock('node:child_process');
import { execFileSync } from 'child_process';
// @ts-expect-error Local hook helper is a JS module loaded directly by the tests.
import { evaluateAgentHeavyPreflight } from '../../scripts/lib/pre-tool-enforcer-preflight.mjs';
import { clearWorktreeCache, getOmcRoot } from '../lib/worktree-paths.js';
const SCRIPT_PATH = join(process.cwd(), 'scripts', 'pre-tool-enforcer.mjs');
function makeGitTemp(prefix: string): string {
const directory = mkdtempSync(join(tmpdir(), prefix));
execFileSync('git', ['init'], { cwd: directory, stdio: 'pipe' });
return directory;
}
function runPreToolEnforcer(input: Record<string, unknown>): Record<string, unknown> {
return runPreToolEnforcerWithEnv(input);
}
@@ -31,10 +37,13 @@ function runPreToolEnforcerWithEnv(
env: {
...process.env,
HOME: homeDir,
USERPROFILE: homeDir,
CLAUDE_CONFIG_DIR: join(homeDir, '.claude'),
NODE_ENV: 'test',
DISABLE_OMC: '',
OMC_SKIP_HOOKS: '',
OMC_STATE_DIR: '',
CLAUDE_PLUGIN_ROOT: '',
// Advisory verbosity: unset it so a contributor running with OMC_QUIET
// exported does not silence the advisories these tests assert on.
// The OMC_QUIET suites pass their own value via `env`, which wins below.
@@ -88,7 +97,7 @@ describe('pre-tool-enforcer advisory throttling (issue #3163)', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'pre-tool-enforcer-advisory-throttle-'));
tempDir = makeGitTemp('pre-tool-enforcer-advisory-throttle-');
});
afterEach(() => {
@@ -252,7 +261,7 @@ describe('pre-tool-enforcer fallback gating (issue #970)', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'pre-tool-enforcer-'));
tempDir = makeGitTemp('pre-tool-enforcer-');
});
afterEach(() => {
@@ -2144,7 +2153,7 @@ describe('pre-tool-enforcer force-agent-delegation enforcement', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'pre-tool-enforcer-fad-'));
tempDir = makeGitTemp('pre-tool-enforcer-fad-');
});
afterEach(() => {
@@ -2306,7 +2315,7 @@ describe('pre-tool-enforcer agents.<name>.model injection (issue #3242)', () =>
let xdgConfigHome: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'pre-tool-enforcer-agent-model-'));
tempDir = makeGitTemp('pre-tool-enforcer-agent-model-');
xdgConfigHome = join(tempDir, 'xdg-config');
mkdirSync(join(xdgConfigHome, 'claude-omc'), { recursive: true });
});
@@ -2441,7 +2450,7 @@ describe('pre-tool-enforcer skill vs agent namespace guard (issue #3667)', () =>
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'pre-tool-enforcer-skill-agent-'));
tempDir = makeGitTemp('pre-tool-enforcer-skill-agent-');
});
afterEach(() => {
@@ -2798,7 +2807,7 @@ describe('pre-tool-enforcer session-scoped agent tracking (issue #3732)', () =>
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'pre-tool-enforcer-session-tracking-'));
tempDir = makeGitTemp('pre-tool-enforcer-session-tracking-');
});
afterEach(() => {
@@ -2900,30 +2909,41 @@ describe('pre-tool-enforcer session-scoped agent tracking (issue #3732)', () =>
it('resolves the session-scoped tracking read through OMC_STATE_DIR centralized state', () => {
const sessionId = 'session-3732-centralized';
const centralRoot = mkdtempSync(join(tmpdir(), 'pre-tool-enforcer-central-'));
const stateRoot = join(centralRoot, `${basename(tempDir)}-${createHash('sha256').update(tempDir).digest('hex').slice(0, 16)}`);
writeJson(join(stateRoot, 'state', 'sessions', sessionId, 'subagent-tracking-state.json'), {
agents: [
{ agent_id: 'z1', agent_type: 'oh-my-claudecode:executor', status: 'running' },
],
total_spawned: 4,
total_completed: 3,
total_failed: 0,
last_updated: new Date().toISOString(),
});
const previousStateDir = process.env.OMC_STATE_DIR;
let output: Record<string, unknown>;
try {
process.env.OMC_STATE_DIR = centralRoot;
clearWorktreeCache();
const stateRoot = getOmcRoot(centralRoot);
writeJson(join(stateRoot, 'state', 'sessions', sessionId, 'subagent-tracking-state.json'), {
session_id: sessionId,
agents: [
{ agent_id: 'z1', agent_type: 'oh-my-claudecode:executor', status: 'running' },
],
total_spawned: 4,
total_completed: 3,
total_failed: 0,
last_updated: new Date().toISOString(),
});
const output = runPreToolEnforcerWithEnv(
{
tool_name: 'Task',
cwd: tempDir,
session_id: sessionId,
toolInput: {
subagent_type: 'oh-my-claudecode:executor',
description: 'issue #3732 centralized regression',
output = runPreToolEnforcerWithEnv(
{
tool_name: 'Task',
cwd: centralRoot,
session_id: sessionId,
toolInput: {
subagent_type: 'oh-my-claudecode:executor',
description: 'issue #3732 centralized regression',
},
},
},
{ OMC_STATE_DIR: centralRoot },
);
{ OMC_STATE_DIR: centralRoot },
);
} finally {
if (previousStateDir === undefined) delete process.env.OMC_STATE_DIR;
else process.env.OMC_STATE_DIR = previousStateDir;
clearWorktreeCache();
rmSync(centralRoot, { recursive: true, force: true });
}
const advisory = (output.hookSpecificOutput as Record<string, unknown>).additionalContext as string;
// The canonical resolver (not manual join(stateDir, ...)) routes the read

View File

@@ -11,9 +11,9 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
import { mkdirSync, mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { tmpdir } from 'os';
// ============================================================================
// Module-level mock for worktree-paths (required before any state-tool imports)
@@ -516,12 +516,18 @@ import {
describe('SMOKE: State Cancel Cleanup — session-scoped I/O (issue #1143)', () => {
let testDir: string;
let omcDir: string;
let previousHome: string | undefined;
let previousUserProfile: string | undefined;
let previousStateDir: string | undefined;
beforeEach(() => {
testDir = join(
homedir(),
`smoke-state-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE;
previousStateDir = process.env.OMC_STATE_DIR;
testDir = mkdtempSync(join(tmpdir(), 'smoke-state-'));
process.env.HOME = testDir;
process.env.USERPROFILE = testDir;
delete process.env.OMC_STATE_DIR;
omcDir = join(testDir, '.omc');
mkdirSync(omcDir, { recursive: true });
mockGetOmcRoot.mockReturnValue(omcDir);
@@ -529,6 +535,13 @@ describe('SMOKE: State Cancel Cleanup — session-scoped I/O (issue #1143)', ()
afterEach(() => {
if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = previousUserProfile;
if (previousStateDir === undefined) delete process.env.OMC_STATE_DIR;
else process.env.OMC_STATE_DIR = previousStateDir;
mockGetOmcRoot.mockReset();
});
// Helper: call a tool handler with merged defaults

View File

@@ -128,8 +128,8 @@ describe('OMC_STATE_DIR state-root resolution (issue #2532)', () => {
fakeProject = join(tempDir, 'project');
fakeStateDir = join(tempDir, 'centralized-state');
mkdirSync(fakeProject, { recursive: true });
// session-start validateCwd requires a real workspace anchor (.git / .omc-workspace)
mkdirSync(join(fakeProject, '.git'), { recursive: true });
// Hook probes require valid Git metadata rather than an empty .git dir.
execFileSync('git', ['init'], { cwd: fakeProject, stdio: 'pipe' });
mkdirSync(fakeStateDir, { recursive: true });
process.env.HOME = tempDir;
process.env.USERPROFILE = tempDir;

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { clearWorktreeCache, getOmcRoot } from "../lib/worktree-paths.js";
// ---------------------------------------------------------------------------
// BUG 3: team-ops teamCreateTask must use locking for task ID generation
@@ -9,12 +10,22 @@ import { tmpdir } from "os";
describe('team-ops teamCreateTask locking', () => {
let tempDir: string;
let previousHome: string | undefined;
let previousUserProfile: string | undefined;
let previousStateDir: string | undefined;
const teamName = 'lock-test-team';
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'team-ops-lock-test-'));
previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE;
previousStateDir = process.env.OMC_STATE_DIR;
process.env.HOME = tempDir;
process.env.USERPROFILE = tempDir;
delete process.env.OMC_STATE_DIR;
clearWorktreeCache();
// Set up minimal team config
const root = join(tempDir, '.omc', 'state', 'team', teamName);
const root = join(getOmcRoot(tempDir), 'state', 'team', teamName);
mkdirSync(join(root, 'tasks'), { recursive: true });
writeFileSync(join(root, 'config.json'), JSON.stringify({
name: teamName,
@@ -35,6 +46,13 @@ describe('team-ops teamCreateTask locking', () => {
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = previousUserProfile;
if (previousStateDir === undefined) delete process.env.OMC_STATE_DIR;
else process.env.OMC_STATE_DIR = previousStateDir;
clearWorktreeCache();
});
it('teamCreateTask source uses locking around task creation', () => {

View File

@@ -37,13 +37,14 @@
*/
import { existsSync } from 'fs';
import { readFileSync, unlinkSync } from 'fs';
import { atomicWriteJsonSync } from '../../lib/atomic-write.js';
import {
canClearStateForSession,
clearStateFileLockedIf,
readModeStateWithMeta,
writeStateFileLocked,
withStateFileMutationLock,
writeModeState,
writeStateFileLockedCreateIf,
} from '../../lib/mode-state-io.js';
import {
resolveStatePath,
@@ -608,37 +609,36 @@ export function writeSkillActiveStateCopies(
);
}
const result = writeStateFileLockedCreateIf(
rootPath,
() => true,
current => {
const merged = mergeSharedSkillLedger(current, rootState, sessionId);
return {
...merged,
_meta: {
written_at: new Date().toISOString(),
mode: SKILL_ACTIVE_STATE_MODE,
},
};
},
);
if (result !== 'written') return false;
const merged = mergeSharedSkillLedger(
readModeStateWithMeta<Record<string, unknown>>(SKILL_ACTIVE_STATE_MODE, directory),
rootState,
sessionId,
);
if (isEmptyV2(merged)) {
const cleared = clearStateFileLockedIf(rootPath, current => isEmptyV2(normalizeToV2(current)));
return cleared !== 'failed' && (cleared !== 'skipped' || !existsSync(rootPath));
}
return true;
const transaction = withStateFileMutationLock(rootPath, () => {
let current: Record<string, unknown> | null = null;
if (existsSync(rootPath)) {
try { current = JSON.parse(readFileSync(rootPath, 'utf8')) as Record<string, unknown>; }
catch { return false; }
}
const merged = mergeSharedSkillLedger(current, rootState, sessionId);
if (isEmptyV2(merged)) {
if (existsSync(rootPath)) unlinkSync(rootPath);
return true;
}
atomicWriteJsonSync(rootPath, {
...merged,
_meta: { written_at: new Date().toISOString(), mode: SKILL_ACTIVE_STATE_MODE },
});
return true;
});
return transaction.acquired && transaction.value === true;
};
// A session copy authenticates the mutation. Only mirror it to the root
// copy after the session-owned write succeeds.
if (!writeSessionState()) {
return false;
// Serialize the paired session/root read-modify-write as one logical
// transaction. The per-file locks remain in place for callers that touch a
// single copy, while this transaction lock prevents two sessions using this
// helper from interleaving their session authentication and root merge.
if (sessionId) {
const transaction = withStateFileMutationLock(`${rootPath}.transaction`, () => {
if (!writeSessionState()) return false;
return writeRootState();
});
return transaction.acquired && transaction.value === true;
}
return writeRootState();
}

View File

@@ -1166,6 +1166,18 @@ function discoverStateFile(path: string, extra: Partial<StateFileDiscovery> = {}
}
}
function hasAuthenticatedCompletionEvidence(path: string, sessionId: string): boolean {
try {
const evidence = JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
return evidence.session_id === sessionId
&& typeof evidence.ended_at === 'string'
&& evidence.ended_at.trim().length > 0
&& Number.isFinite(Date.parse(evidence.ended_at));
} catch {
return false;
}
}
export function findSessionOwnedStateCandidates(
mode: string,
sessionId: string,
@@ -1212,7 +1224,7 @@ export function findCompletedSessionStateCandidates(
for (const sid of listSessionIds(baseDir)) {
if (requesterSessionId && sid === requesterSessionId) continue;
const completionEvidencePath = join(getOmcRoot(baseDir), 'sessions', `${sid}.json`);
if (!existsSync(completionEvidencePath)) continue;
if (!hasAuthenticatedCompletionEvidence(completionEvidencePath, sid)) continue;
const candidatePath = resolveSessionStatePath(mode, sid, baseDir);
const candidate = discoverStateFile(candidatePath, { completedSessionId: sid, completionEvidencePath });
if (candidate?.state.active === true && candidate.ownerSessionId === sid) matches.push(candidate);

View File

@@ -51,19 +51,19 @@ export async function resolveOmcStateRoot(directory) {
if (customDir) {
let gitRoot = null;
try {
gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || null;
} catch {}
if (!gitRoot) return join(customDir, 'non-git');
let source = gitRoot;
try {
source = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: gitRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || gitRoot;
source = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: gitRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || gitRoot;
} catch {}
const hash = createHash('sha256').update(source).digest('hex').slice(0, 16);
const dirName = basename(gitRoot).replace(/[^a-zA-Z0-9_-]/g, '_');
return join(customDir, `${dirName}-${hash}`);
}
let gitRoot = null;
try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null; } catch {}
try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || null; } catch {}
if (gitRoot) return join(gitRoot, '.omc');
let cursor = resolve(directory);
while (true) {

View File

@@ -66,6 +66,7 @@ function fixture(kind) {
const transcript = join(claudeConfigDir, 'projects', `${sessionId}.jsonl`);
mkdirSync(dirname(transcript), { recursive: true });
mkdirSync(project, { recursive: true });
execFileSync('git', ['init'], { cwd: project, stdio: 'pipe' });
writeFileSync(transcript, '');
const statePath = join(project, '.omc', 'state', 'sessions', sessionId, 'autopilot-state.json');
mkdirSync(dirname(statePath), { recursive: true });
@@ -156,7 +157,7 @@ function invoke(f, input = {}, extraEnv = {}) {
cwd: f.project,
input: JSON.stringify({ hook_event_name: 'Stop', session_id: f.sessionId, cwd: f.project, transcript_path: f.transcript, ...input }),
encoding: 'utf8',
env: { ...process.env, HOME: f.home, USERPROFILE: f.home, CLAUDE_CONFIG_DIR: f.claudeConfigDir, OMC_PERSISTENT_MODE_TIMEOUT_MS: '3000', ...extraEnv },
env: { ...process.env, HOME: f.home, USERPROFILE: f.home, CLAUDE_CONFIG_DIR: f.claudeConfigDir, OMC_STATE_DIR: '', OMC_PERSISTENT_MODE_TIMEOUT_MS: '3000', ...extraEnv },
});
return JSON.parse(stdout.trim());
}
@@ -165,7 +166,7 @@ function invokeAsync(f, input = {}, extraEnv = {}) {
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [f.hook], {
cwd: f.project,
env: { ...process.env, HOME: f.home, USERPROFILE: f.home, CLAUDE_CONFIG_DIR: f.claudeConfigDir, OMC_PERSISTENT_MODE_TIMEOUT_MS: '3000', ...extraEnv },
env: { ...process.env, HOME: f.home, USERPROFILE: f.home, CLAUDE_CONFIG_DIR: f.claudeConfigDir, OMC_STATE_DIR: '', OMC_PERSISTENT_MODE_TIMEOUT_MS: '3000', ...extraEnv },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';

View File

@@ -6,10 +6,7 @@ export default defineConfig({
globals: true,
environment: 'node',
testTimeout: 30000,
// State-root fixture tests intentionally scope HOME/OMC_STATE_DIR per test.
// Run files serially so those process-wide variables cannot race across
// concurrent Vitest workers and reintroduce shared-root contamination.
fileParallelism: false,
include: [
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'tests/**/*.bench.ts',