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,
|
||||
normalizeWatchCiIntervalSeconds,
|
||||
} from './watch-ci.js';
|
||||
import { resolveIpcDirectories } from './ipc-paths.js';
|
||||
|
||||
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
|
||||
const HOST_IPC_DIR = process.env.EJCLAW_HOST_IPC_DIR || IPC_DIR;
|
||||
const { ipcDir: IPC_DIR, hostIpcDir: HOST_IPC_DIR } =
|
||||
resolveIpcDirectories(process.env);
|
||||
const MESSAGES_DIR = path.join(HOST_IPC_DIR, 'messages');
|
||||
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 () => {
|
||||
vi.useRealTimers();
|
||||
fakeProc = createFakeProcess();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { runAgentProcessMock, writeTasksSnapshotMock } = vi.hoisted(() => ({
|
||||
const { runAgentProcessMock, writeTasksSnapshotMock, loggerDebugMock } =
|
||||
vi.hoisted(() => ({
|
||||
runAgentProcessMock: vi.fn(async () => ({
|
||||
status: 'success' as const,
|
||||
result: 'done',
|
||||
})),
|
||||
writeTasksSnapshotMock: vi.fn(),
|
||||
}));
|
||||
loggerDebugMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./provider-fallback.js', () => ({
|
||||
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 * as providerFallback from './provider-fallback.js';
|
||||
import * as codexTokenRotation from './codex-token-rotation.js';
|
||||
@@ -101,6 +112,7 @@ describe('task scheduler', () => {
|
||||
_resetSchedulerLoopForTests();
|
||||
runAgentProcessMock.mockClear();
|
||||
writeTasksSnapshotMock.mockClear();
|
||||
loggerDebugMock.mockClear();
|
||||
vi.mocked(providerFallback.markPrimaryCooldown).mockClear();
|
||||
vi.mocked(providerFallback.getActiveProvider).mockResolvedValue('claude');
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
|
||||
const task = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTaskById, updateTaskStatusTracking } from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
isWatchCiTask,
|
||||
renderWatchCiStatusMessage,
|
||||
@@ -66,7 +67,17 @@ export function createTaskStatusTracker(
|
||||
);
|
||||
persist();
|
||||
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;
|
||||
persist();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user