From 24d93d5c784ed663e4a552130e646be8da05b112 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 25 Mar 2026 21:40:34 +0900 Subject: [PATCH] fix: fail fast on missing host IPC dir for task runtimes --- runners/agent-runner/src/ipc-mcp-stdio.ts | 5 +- runners/agent-runner/src/ipc-paths.ts | 29 ++++++++ runners/agent-runner/test/ipc-paths.test.ts | 45 ++++++++++++ src/agent-runner.test.ts | 45 ++++++++++++ src/task-scheduler.test.ts | 79 ++++++++++++++++++++- src/task-status-tracker.ts | 13 +++- 6 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 runners/agent-runner/src/ipc-paths.ts create mode 100644 runners/agent-runner/test/ipc-paths.test.ts diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index e7b581e..2618dbc 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -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'); diff --git a/runners/agent-runner/src/ipc-paths.ts b/runners/agent-runner/src/ipc-paths.ts new file mode 100644 index 0000000..c9ee8e2 --- /dev/null +++ b/runners/agent-runner/src/ipc-paths.ts @@ -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, + }; +} diff --git a/runners/agent-runner/test/ipc-paths.test.ts b/runners/agent-runner/test/ipc-paths.test.ts new file mode 100644 index 0000000..6f96c61 --- /dev/null +++ b/runners/agent-runner/test/ipc-paths.test.ts @@ -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', + }); + }); +}); diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index 71b9a47..1cfa1a1 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -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(); diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts index 9b7bdb3..5db8696 100644 --- a/src/task-scheduler.test.ts +++ b/src/task-scheduler.test.ts @@ -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 = { diff --git a/src/task-status-tracker.ts b/src/task-status-tracker.ts index e00faef..e0dbfe3 100644 --- a/src/task-status-tracker.ts +++ b/src/task-status-tracker.ts @@ -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(); }