fix claude turn teardown and start CI watchers immediately
This commit is contained in:
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user