fix claude turn teardown and start CI watchers immediately

This commit is contained in:
Eyejoker
2026-03-23 08:55:48 +09:00
parent 7100a13b39
commit 42d721a9ec
11 changed files with 232 additions and 11 deletions

View File

@@ -394,7 +394,11 @@ async function runQuery(
mcpServerPath: string, mcpServerPath: string,
containerInput: ContainerInput, containerInput: ContainerInput,
sdkEnv: Record<string, string | undefined>, sdkEnv: Record<string, string | undefined>,
): Promise<{ newSessionId?: string; closedDuringQuery: boolean }> { ): Promise<{
newSessionId?: string;
closedDuringQuery: boolean;
terminalResultObserved: boolean;
}> {
const stream = new MessageStream(); const stream = new MessageStream();
stream.push(prompt); stream.push(prompt);
@@ -422,6 +426,7 @@ async function runQuery(
let newSessionId: string | undefined; let newSessionId: string | undefined;
let messageCount = 0; let messageCount = 0;
let resultCount = 0; let resultCount = 0;
let terminalResultObserved = false;
// Discover additional directories // Discover additional directories
const extraDirs: string[] = []; const extraDirs: string[] = [];
@@ -554,6 +559,15 @@ async function runQuery(
newSessionId newSessionId
}); });
} }
// Single-turn runtimes must terminate the query after the first
// terminal result. Leaving the message stream open can keep the SDK
// query alive indefinitely, which pins the host queue after a reply.
terminalResultObserved = true;
ipcPolling = false;
stream.end();
log('Terminal result observed, ending query stream');
break;
} }
} }
@@ -561,7 +575,7 @@ async function runQuery(
log( log(
`Query done. Messages: ${messageCount}, results: ${resultCount}, closedDuringQuery: ${closedDuringQuery}`, `Query done. Messages: ${messageCount}, results: ${resultCount}, closedDuringQuery: ${closedDuringQuery}`,
); );
return { newSessionId, closedDuringQuery }; return { newSessionId, closedDuringQuery, terminalResultObserved };
} }
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -726,8 +740,10 @@ async function main(): Promise<void> {
sessionId = queryResult.newSessionId; sessionId = queryResult.newSessionId;
} }
if (!queryResult.closedDuringQuery) { if (!queryResult.closedDuringQuery && !queryResult.terminalResultObserved) {
writeOutput({ status: 'success', result: null, newSessionId: sessionId }); writeOutput({ status: 'success', result: null, newSessionId: sessionId });
} else if (queryResult.terminalResultObserved) {
log('Terminal result already emitted, exiting single-turn runtime');
} else { } else {
log('Close sentinel consumed during query, exiting'); log('Close sentinel consumed during query, exiting');
} }

View File

@@ -17,8 +17,9 @@ import {
} from './watch-ci.js'; } from './watch-ci.js';
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc'; const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
const MESSAGES_DIR = path.join(IPC_DIR, 'messages'); const HOST_IPC_DIR = process.env.EJCLAW_HOST_IPC_DIR || IPC_DIR;
const TASKS_DIR = path.join(IPC_DIR, 'tasks'); const MESSAGES_DIR = path.join(HOST_IPC_DIR, 'messages');
const TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
// Context from environment variables (set by the agent runner) // Context from environment variables (set by the agent runner)
const chatJid = process.env.EJCLAW_CHAT_JID!; const chatJid = process.env.EJCLAW_CHAT_JID!;

View File

@@ -60,6 +60,7 @@ function buildBaseRunnerEnv(args: {
isMain: boolean; isMain: boolean;
groupDir: string; groupDir: string;
groupIpcDir: string; groupIpcDir: string;
hostIpcDir: string;
globalDir: string; globalDir: string;
groupSessionsDir: string; groupSessionsDir: string;
agentType: AgentType; agentType: AgentType;
@@ -89,6 +90,7 @@ function buildBaseRunnerEnv(args: {
HOME: os.homedir(), HOME: os.homedir(),
EJCLAW_GROUP_DIR: args.groupDir, EJCLAW_GROUP_DIR: args.groupDir,
EJCLAW_IPC_DIR: args.groupIpcDir, EJCLAW_IPC_DIR: args.groupIpcDir,
EJCLAW_HOST_IPC_DIR: args.hostIpcDir,
EJCLAW_GLOBAL_DIR: args.globalDir, EJCLAW_GLOBAL_DIR: args.globalDir,
...(args.group.workDir ? { EJCLAW_WORK_DIR: args.group.workDir } : {}), ...(args.group.workDir ? { EJCLAW_WORK_DIR: args.group.workDir } : {}),
EJCLAW_CHAT_JID: args.chatJid, EJCLAW_CHAT_JID: args.chatJid,
@@ -329,6 +331,7 @@ export function prepareGroupEnvironment(
const groupIpcDir = runtimeTaskId const groupIpcDir = runtimeTaskId
? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId) ? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId)
: resolveGroupIpcPath(group.folder); : resolveGroupIpcPath(group.folder);
const hostIpcDir = resolveGroupIpcPath(group.folder);
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
@@ -388,6 +391,7 @@ export function prepareGroupEnvironment(
isMain, isMain,
groupDir, groupDir,
groupIpcDir, groupIpcDir,
hostIpcDir,
globalDir, globalDir,
groupSessionsDir, groupSessionsDir,
agentType, agentType,

View File

@@ -325,6 +325,9 @@ describe('agent-runner timeout behavior', () => {
expect(spawnEnv?.EJCLAW_IPC_DIR).toBe( expect(spawnEnv?.EJCLAW_IPC_DIR).toBe(
'/tmp/ejclaw-test-data/ipc/test-group/tasks/task-123', '/tmp/ejclaw-test-data/ipc/test-group/tasks/task-123',
); );
expect(spawnEnv?.EJCLAW_HOST_IPC_DIR).toBe(
'/tmp/ejclaw-test-data/ipc/test-group',
);
expect(spawnEnv?.CLAUDE_CONFIG_DIR).toBe( expect(spawnEnv?.CLAUDE_CONFIG_DIR).toBe(
'/tmp/ejclaw-test-data/sessions/test-group/tasks/task-123/.claude', '/tmp/ejclaw-test-data/sessions/test-group/tasks/task-123/.claude',
); );
@@ -356,6 +359,9 @@ describe('agent-runner timeout behavior', () => {
expect(spawnEnv?.EJCLAW_IPC_DIR).toBe( expect(spawnEnv?.EJCLAW_IPC_DIR).toBe(
'/tmp/ejclaw-test-data/ipc/test-group/tasks/task-watch-group', '/tmp/ejclaw-test-data/ipc/test-group/tasks/task-watch-group',
); );
expect(spawnEnv?.EJCLAW_HOST_IPC_DIR).toBe(
'/tmp/ejclaw-test-data/ipc/test-group',
);
expect(spawnEnv?.CLAUDE_CONFIG_DIR).toBe( expect(spawnEnv?.CLAUDE_CONFIG_DIR).toBe(
'/tmp/ejclaw-test-data/sessions/test-group/.claude', '/tmp/ejclaw-test-data/sessions/test-group/.claude',
); );

View File

@@ -28,6 +28,7 @@ describe('GroupQueue', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.clearAllMocks();
queue = new GroupQueue(); queue = new GroupQueue();
}); });
@@ -87,6 +88,38 @@ describe('GroupQueue', () => {
await vi.advanceTimersByTimeAsync(10); await vi.advanceTimersByTimeAsync(10);
}); });
it('does not pipe follow-up messages after stdin close was requested', async () => {
const ipcDir = '/tmp/ejclaw-test-data/ipc/group-folder';
let releaseRun!: (value: boolean) => void;
const blocker = new Promise<boolean>((resolve) => {
releaseRun = resolve;
});
const processMessages = vi.fn(async () => await blocker);
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us', ipcDir);
await vi.advanceTimersByTimeAsync(10);
vi.mocked(fs.mkdirSync).mockClear();
vi.mocked(fs.writeFileSync).mockClear();
vi.mocked(fs.renameSync).mockClear();
queue.closeStdin('group1@g.us');
vi.mocked(fs.mkdirSync).mockClear();
vi.mocked(fs.writeFileSync).mockClear();
vi.mocked(fs.renameSync).mockClear();
expect(queue.sendMessage('group1@g.us', '후속 메시지')).toBe(false);
expect(fs.mkdirSync).not.toHaveBeenCalled();
expect(fs.writeFileSync).not.toHaveBeenCalled();
expect(fs.renameSync).not.toHaveBeenCalled();
releaseRun(true);
await vi.advanceTimersByTimeAsync(10);
});
// --- Global concurrency limit --- // --- Global concurrency limit ---
it('respects global concurrency limit', async () => { it('respects global concurrency limit', async () => {

View File

@@ -209,7 +209,12 @@ export class GroupQueue {
sendMessage(groupJid: string, text: string): boolean { sendMessage(groupJid: string, text: string): boolean {
const state = this.getGroup(groupJid); const state = this.getGroup(groupJid);
if (!state.active || !state.ipcDir || state.isTaskProcess) { if (
!state.active ||
!state.ipcDir ||
state.isTaskProcess ||
state.closingStdin
) {
logger.debug( logger.debug(
{ {
groupJid, groupJid,
@@ -217,6 +222,7 @@ export class GroupQueue {
active: state.active, active: state.active,
ipcDir: state.ipcDir, ipcDir: state.ipcDir,
isTaskProcess: state.isTaskProcess, isTaskProcess: state.isTaskProcess,
closingStdin: state.closingStdin,
}, },
'Cannot pipe follow-up message to active agent', 'Cannot pipe follow-up message to active agent',
); );

View File

@@ -58,7 +58,7 @@ import {
shouldDropMessage, shouldDropMessage,
} from './sender-allowlist.js'; } from './sender-allowlist.js';
import { createMessageRuntime } from './message-runtime.js'; import { createMessageRuntime } from './message-runtime.js';
import { startSchedulerLoop } from './task-scheduler.js'; import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
import { startUnifiedDashboard } from './unified-dashboard.js'; import { startUnifiedDashboard } from './unified-dashboard.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -409,6 +409,7 @@ async function main(): Promise<void> {
if (!channel) throw new Error(`No channel for JID: ${jid}`); if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text); return channel.sendMessage(jid, text);
}, },
nudgeScheduler: nudgeSchedulerLoop,
registeredGroups: () => registeredGroups, registeredGroups: () => registeredGroups,
registerGroup, registerGroup,
syncGroups: async (force: boolean) => { syncGroups: async (force: boolean) => {

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { import {
_initTestDatabase, _initTestDatabase,
@@ -53,6 +53,7 @@ beforeEach(() => {
deps = { deps = {
sendMessage: async () => {}, sendMessage: async () => {},
nudgeScheduler: vi.fn(),
registeredGroups: () => groups, registeredGroups: () => groups,
registerGroup: (jid, group) => { registerGroup: (jid, group) => {
groups[jid] = group; groups[jid] = group;
@@ -549,6 +550,42 @@ describe('schedule_task schedule types', () => {
expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000); expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000);
}); });
it('starts watch_ci interval tasks immediately and nudges the scheduler', async () => {
const before = Date.now();
await processTaskIpc(
{
type: 'schedule_task',
prompt: `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 123456
Task ID:
task-watch-ci
Check instructions:
Check the run.
`.trim(),
schedule_type: 'interval',
schedule_value: '60000',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks).toHaveLength(1);
expect(tasks[0].schedule_type).toBe('interval');
const nextRun = new Date(tasks[0].next_run!).getTime();
expect(nextRun).toBeGreaterThanOrEqual(before - 1000);
expect(nextRun).toBeLessThanOrEqual(Date.now() + 1000);
expect(deps.nudgeScheduler).toHaveBeenCalledTimes(1);
});
it('rejects invalid interval (non-numeric)', async () => { it('rejects invalid interval (non-numeric)', async () => {
await processTaskIpc( await processTaskIpc(
{ {

View File

@@ -13,10 +13,12 @@ import { AvailableGroup } from './agent-runner.js';
import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
import { isValidGroupFolder } from './group-folder.js'; import { isValidGroupFolder } from './group-folder.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { isWatchCiTask } from './task-watch-status.js';
import { RegisteredGroup } from './types.js'; import { RegisteredGroup } from './types.js';
export interface IpcDeps { export interface IpcDeps {
sendMessage: (jid: string, text: string) => Promise<void>; sendMessage: (jid: string, text: string) => Promise<void>;
nudgeScheduler?: () => void;
registeredGroups: () => Record<string, RegisteredGroup>; registeredGroups: () => Record<string, RegisteredGroup>;
registerGroup: (jid: string, group: RegisteredGroup) => void; registerGroup: (jid: string, group: RegisteredGroup) => void;
syncGroups: (force: boolean) => Promise<void>; syncGroups: (force: boolean) => Promise<void>;
@@ -256,7 +258,9 @@ export async function processTaskIpc(
); );
break; break;
} }
nextRun = new Date(Date.now() + ms).toISOString(); nextRun = isWatchCiTask({ prompt: data.prompt })
? new Date().toISOString()
: new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') { } else if (scheduleType === 'once') {
const date = new Date(data.schedule_value); const date = new Date(data.schedule_value);
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
@@ -299,6 +303,9 @@ export async function processTaskIpc(
}, },
'Task created via IPC', 'Task created via IPC',
); );
if (nextRun && new Date(nextRun).getTime() <= Date.now()) {
deps.nudgeScheduler?.();
}
} }
break; break;

View File

@@ -28,6 +28,7 @@ import {
computeNextRun, computeNextRun,
extractWatchCiTarget, extractWatchCiTarget,
isWatchCiTask, isWatchCiTask,
nudgeSchedulerLoop,
renderWatchCiStatusMessage, renderWatchCiStatusMessage,
startSchedulerLoop, startSchedulerLoop,
} from './task-scheduler.js'; } from './task-scheduler.js';
@@ -231,6 +232,65 @@ Check the run.
expect(enqueueTask.mock.calls[0][1]).toBe('task-watch-group'); expect(enqueueTask.mock.calls[0][1]).toBe('task-watch-group');
}); });
it('picks up newly due tasks immediately when nudged', async () => {
const enqueueTask = vi.fn();
startSchedulerLoop({
serviceAgentType: 'codex',
registeredGroups: () => ({
'shared@g.us': {
name: 'Shared',
folder: 'shared-group',
trigger: '@Codex',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'codex',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage: async () => {},
});
await vi.advanceTimersByTimeAsync(10);
expect(enqueueTask).not.toHaveBeenCalled();
createTask({
id: 'task-watch-immediate',
group_folder: 'shared-group',
chat_jid: 'shared@g.us',
agent_type: 'codex',
prompt: `
[BACKGROUND CI WATCH]
Watch target:
GitHub Actions run 654321
Task ID:
task-watch-immediate
Check instructions:
Check the run.
`.trim(),
schedule_type: 'interval',
schedule_value: '60000',
context_mode: 'isolated',
next_run: new Date(Date.now() - 1000).toISOString(),
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
nudgeSchedulerLoop();
await vi.advanceTimersByTimeAsync(10);
expect(enqueueTask).toHaveBeenCalledTimes(1);
expect(enqueueTask).toHaveBeenCalledWith(
'shared@g.us::task:task-watch-immediate',
'task-watch-immediate',
expect.any(Function),
);
});
it('uses dedicated IPC but shared session state for group-context CI watchers', async () => { it('uses dedicated IPC but shared session state for group-context CI watchers', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString(); const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({ createTask({

View File

@@ -331,6 +331,30 @@ async function runTask(
} }
let schedulerRunning = false; let schedulerRunning = false;
let schedulerTimer: ReturnType<typeof setTimeout> | null = null;
let schedulerLoopFn: (() => Promise<void>) | null = null;
let schedulerTickInFlight = false;
let schedulerTickPending = false;
function scheduleSchedulerTick(delayMs: number): void {
if (!schedulerRunning || !schedulerLoopFn) return;
if (schedulerTimer) {
clearTimeout(schedulerTimer);
}
schedulerTimer = setTimeout(() => {
schedulerTimer = null;
void schedulerLoopFn?.();
}, delayMs);
}
export function nudgeSchedulerLoop(): void {
if (!schedulerRunning || !schedulerLoopFn) return;
if (schedulerTickInFlight) {
schedulerTickPending = true;
return;
}
scheduleSchedulerTick(0);
}
export function startSchedulerLoop(deps: SchedulerDependencies): void { export function startSchedulerLoop(deps: SchedulerDependencies): void {
if (schedulerRunning) { if (schedulerRunning) {
@@ -341,6 +365,12 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
logger.info('Scheduler loop started'); logger.info('Scheduler loop started');
const loop = async () => { const loop = async () => {
if (schedulerTickInFlight) {
schedulerTickPending = true;
return;
}
schedulerTickInFlight = true;
try { try {
const dueTasks = getDueTasks(deps.serviceAgentType || SERVICE_AGENT_TYPE); const dueTasks = getDueTasks(deps.serviceAgentType || SERVICE_AGENT_TYPE);
if (dueTasks.length > 0) { if (dueTasks.length > 0) {
@@ -362,15 +392,35 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
} }
} catch (err) { } catch (err) {
logger.error({ err }, 'Error in scheduler loop'); logger.error({ err }, 'Error in scheduler loop');
} finally {
schedulerTickInFlight = false;
} }
setTimeout(loop, SCHEDULER_POLL_INTERVAL); if (!schedulerRunning) {
return;
}
if (schedulerTickPending) {
schedulerTickPending = false;
scheduleSchedulerTick(0);
return;
}
scheduleSchedulerTick(SCHEDULER_POLL_INTERVAL);
}; };
loop(); schedulerLoopFn = loop;
void loop();
} }
/** @internal - for tests only. */ /** @internal - for tests only. */
export function _resetSchedulerLoopForTests(): void { export function _resetSchedulerLoopForTests(): void {
schedulerRunning = false; schedulerRunning = false;
schedulerLoopFn = null;
schedulerTickInFlight = false;
schedulerTickPending = false;
if (schedulerTimer) {
clearTimeout(schedulerTimer);
schedulerTimer = null;
}
} }