mirror of
https://github.com/Yeachan-Heo/oh-my-claudecode.git
synced 2026-09-03 06:25:33 +08:00
fix(state): close parallel fixture and fallback regressions
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user