fix: fail fast on missing host IPC dir for task runtimes
This commit is contained in:
@@ -15,9 +15,10 @@ import {
|
|||||||
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
normalizeWatchCiIntervalSeconds,
|
normalizeWatchCiIntervalSeconds,
|
||||||
} from './watch-ci.js';
|
} from './watch-ci.js';
|
||||||
|
import { resolveIpcDirectories } from './ipc-paths.js';
|
||||||
|
|
||||||
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
|
const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } =
|
||||||
const HOST_IPC_DIR = process.env.EJCLAW_HOST_IPC_DIR || IPC_DIR;
|
resolveIpcDirectories(process.env);
|
||||||
const MESSAGES_DIR = path.join(HOST_IPC_DIR, 'messages');
|
const MESSAGES_DIR = path.join(HOST_IPC_DIR, 'messages');
|
||||||
const TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
|
const TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
|
||||||
|
|
||||||
|
|||||||
29
runners/agent-runner/src/ipc-paths.ts
Normal file
29
runners/agent-runner/src/ipc-paths.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export interface ResolvedIpcDirectories {
|
||||||
|
ipcDir: string;
|
||||||
|
hostIpcDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTaskScopedIpcDir(ipcDir: string): boolean {
|
||||||
|
const normalized = path.posix.normalize(ipcDir.replaceAll('\\', '/'));
|
||||||
|
return /\/tasks\/[^/]+\/?$/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveIpcDirectories(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
): ResolvedIpcDirectories {
|
||||||
|
const ipcDir = env.EJCLAW_IPC_DIR || '/workspace/ipc';
|
||||||
|
const hostIpcDir = env.EJCLAW_HOST_IPC_DIR;
|
||||||
|
|
||||||
|
if (!hostIpcDir && isTaskScopedIpcDir(ipcDir)) {
|
||||||
|
throw new Error(
|
||||||
|
'EJCLAW_HOST_IPC_DIR is required for task-scoped IPC runtimes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipcDir,
|
||||||
|
hostIpcDir: hostIpcDir || ipcDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
runners/agent-runner/test/ipc-paths.test.ts
Normal file
45
runners/agent-runner/test/ipc-paths.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isTaskScopedIpcDir,
|
||||||
|
resolveIpcDirectories,
|
||||||
|
} from '../src/ipc-paths.js';
|
||||||
|
|
||||||
|
describe('ipc path helpers', () => {
|
||||||
|
it('detects task-scoped IPC directories', () => {
|
||||||
|
expect(isTaskScopedIpcDir('/data/ipc/group/tasks/task-123')).toBe(true);
|
||||||
|
expect(isTaskScopedIpcDir('C:\\ipc\\group\\tasks\\task-123')).toBe(true);
|
||||||
|
expect(isTaskScopedIpcDir('/data/ipc/group')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails fast when task-scoped IPC is missing host IPC dir', () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveIpcDirectories({
|
||||||
|
EJCLAW_IPC_DIR: '/data/ipc/group/tasks/task-123',
|
||||||
|
}),
|
||||||
|
).toThrow(/EJCLAW_HOST_IPC_DIR is required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows group-scoped IPC to fall back to the local IPC dir', () => {
|
||||||
|
expect(
|
||||||
|
resolveIpcDirectories({
|
||||||
|
EJCLAW_IPC_DIR: '/data/ipc/group',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ipcDir: '/data/ipc/group',
|
||||||
|
hostIpcDir: '/data/ipc/group',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps host and task IPC dirs distinct when both are provided', () => {
|
||||||
|
expect(
|
||||||
|
resolveIpcDirectories({
|
||||||
|
EJCLAW_IPC_DIR: '/data/ipc/group/tasks/task-123',
|
||||||
|
EJCLAW_HOST_IPC_DIR: '/data/ipc/group',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ipcDir: '/data/ipc/group/tasks/task-123',
|
||||||
|
hostIpcDir: '/data/ipc/group',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -332,6 +332,51 @@ describe('agent-runner timeout behavior', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes EJCLAW_HOST_IPC_DIR into task-scoped codex MCP config', async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
fakeProc = createFakeProcess();
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
|
||||||
|
const str = String(p);
|
||||||
|
return (
|
||||||
|
str.includes('dist/index.js') ||
|
||||||
|
str.includes('dist/ipc-mcp-stdio.js') ||
|
||||||
|
str.endsWith('/.codex/config.toml')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const codexGroup: RegisteredGroup = {
|
||||||
|
...testGroup,
|
||||||
|
agentType: 'codex',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultPromise = runAgentProcess(
|
||||||
|
codexGroup,
|
||||||
|
{
|
||||||
|
...testInput,
|
||||||
|
isScheduledTask: true,
|
||||||
|
runtimeTaskId: 'task-codex-watch',
|
||||||
|
useTaskScopedSession: false,
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
fakeProc.emit('close', 0);
|
||||||
|
const result = await resultPromise;
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
|
||||||
|
const tomlWrite = [...vi.mocked(fs.writeFileSync).mock.calls]
|
||||||
|
.reverse()
|
||||||
|
.find((call) => String(call[0]).endsWith('/.codex/config.toml'));
|
||||||
|
expect(String(tomlWrite?.[1])).toContain(
|
||||||
|
'EJCLAW_IPC_DIR = "/tmp/ejclaw-test-data/ipc/test-group/tasks/task-codex-watch"',
|
||||||
|
);
|
||||||
|
expect(String(tomlWrite?.[1])).toContain(
|
||||||
|
'EJCLAW_HOST_IPC_DIR = "/tmp/ejclaw-test-data/ipc/test-group"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps shared session history for group-context task runtimes while isolating IPC', async () => {
|
it('keeps shared session history for group-context task runtimes while isolating IPC', async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
fakeProc = createFakeProcess();
|
fakeProc = createFakeProcess();
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const { runAgentProcessMock, writeTasksSnapshotMock } = vi.hoisted(() => ({
|
const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
runAgentProcessMock: vi.fn(async () => ({
|
runAgentProcessMock: vi.fn(async () => ({
|
||||||
status: 'success' as const,
|
status: 'success' as const,
|
||||||
result: 'done',
|
result: 'done',
|
||||||
})),
|
})),
|
||||||
writeTasksSnapshotMock: vi.fn(),
|
writeTasksSnapshotMock: vi.fn(),
|
||||||
}));
|
loggerDebugMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./provider-fallback.js', () => ({
|
vi.mock('./provider-fallback.js', () => ({
|
||||||
detectFallbackTrigger: vi.fn((error?: string | null) => {
|
detectFallbackTrigger: vi.fn((error?: string | null) => {
|
||||||
@@ -79,6 +81,15 @@ vi.mock('./agent-runner.js', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('./logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: loggerDebugMock,
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
||||||
import * as providerFallback from './provider-fallback.js';
|
import * as providerFallback from './provider-fallback.js';
|
||||||
import * as codexTokenRotation from './codex-token-rotation.js';
|
import * as codexTokenRotation from './codex-token-rotation.js';
|
||||||
@@ -101,6 +112,7 @@ describe('task scheduler', () => {
|
|||||||
_resetSchedulerLoopForTests();
|
_resetSchedulerLoopForTests();
|
||||||
runAgentProcessMock.mockClear();
|
runAgentProcessMock.mockClear();
|
||||||
writeTasksSnapshotMock.mockClear();
|
writeTasksSnapshotMock.mockClear();
|
||||||
|
loggerDebugMock.mockClear();
|
||||||
vi.mocked(providerFallback.markPrimaryCooldown).mockClear();
|
vi.mocked(providerFallback.markPrimaryCooldown).mockClear();
|
||||||
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
|
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
|
||||||
vi.mocked(providerFallback.isFallbackEnabled).mockReturnValue(true);
|
vi.mocked(providerFallback.isFallbackEnabled).mockReturnValue(true);
|
||||||
@@ -1018,6 +1030,69 @@ Check the run.
|
|||||||
expect(secondState?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
|
expect(secondState?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('logs and falls back to sending a new watcher status message when edit fails', async () => {
|
||||||
|
vi.setSystemTime(new Date('2026-03-19T07:00:00.000Z'));
|
||||||
|
createTask({
|
||||||
|
id: 'task-watch-status-edit-fail',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
chat_jid: 'shared@g.us',
|
||||||
|
agent_type: 'claude-code',
|
||||||
|
prompt: `
|
||||||
|
[BACKGROUND CI WATCH]
|
||||||
|
|
||||||
|
Watch target:
|
||||||
|
PR #77 checks
|
||||||
|
|
||||||
|
Task ID:
|
||||||
|
task-watch-status-edit-fail
|
||||||
|
|
||||||
|
Check instructions:
|
||||||
|
Check the run.
|
||||||
|
`.trim(),
|
||||||
|
schedule_type: 'interval',
|
||||||
|
schedule_value: '60000',
|
||||||
|
context_mode: 'isolated',
|
||||||
|
next_run: new Date(Date.now() - 60_000).toISOString(),
|
||||||
|
status: 'active',
|
||||||
|
status_message_id: 'msg-old',
|
||||||
|
status_started_at: '2026-03-19T07:00:00.000Z',
|
||||||
|
created_at: '2026-03-19T07:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendTrackedMessage = vi.fn(async () => 'msg-new');
|
||||||
|
const editTrackedMessage = vi.fn(async () => {
|
||||||
|
throw new Error('discord edit failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const tracker = createTaskStatusTracker(
|
||||||
|
getTaskById('task-watch-status-edit-fail')!,
|
||||||
|
{
|
||||||
|
sendTrackedMessage,
|
||||||
|
editTrackedMessage,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await tracker.update('waiting', '2026-03-19T07:04:10.000Z');
|
||||||
|
|
||||||
|
expect(loggerDebugMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
taskId: 'task-watch-status-edit-fail',
|
||||||
|
chatJid: 'shared@g.us',
|
||||||
|
statusMessageId: 'msg-old',
|
||||||
|
phase: 'waiting',
|
||||||
|
}),
|
||||||
|
'Failed to edit watcher status message, falling back to send',
|
||||||
|
);
|
||||||
|
expect(sendTrackedMessage).toHaveBeenCalledWith(
|
||||||
|
'shared@g.us',
|
||||||
|
expect.stringContaining(`${TASK_STATUS_MESSAGE_PREFIX}CI 감시 중:`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedTask = getTaskById('task-watch-status-edit-fail');
|
||||||
|
expect(updatedTask?.status_message_id).toBe('msg-new');
|
||||||
|
expect(updatedTask?.status_started_at).toBe('2026-03-19T07:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
|
||||||
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
||||||
const task = {
|
const task = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getTaskById, updateTaskStatusTracking } from './db.js';
|
import { getTaskById, updateTaskStatusTracking } from './db.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
isWatchCiTask,
|
isWatchCiTask,
|
||||||
renderWatchCiStatusMessage,
|
renderWatchCiStatusMessage,
|
||||||
@@ -66,7 +67,17 @@ export function createTaskStatusTracker(
|
|||||||
);
|
);
|
||||||
persist();
|
persist();
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
taskId: task.id,
|
||||||
|
chatJid: task.chat_jid,
|
||||||
|
statusMessageId,
|
||||||
|
phase,
|
||||||
|
err,
|
||||||
|
},
|
||||||
|
'Failed to edit watcher status message, falling back to send',
|
||||||
|
);
|
||||||
statusMessageId = null;
|
statusMessageId = null;
|
||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user