refactor: rename container references to agent across codebase
- container-runner.ts → agent-runner.ts - ContainerInput/Output → AgentInput/Output - ContainerConfig → AgentConfig - runContainerAgent → runAgentProcess - CONTAINER_TIMEOUT → AGENT_TIMEOUT (with env fallback) - MAX_CONCURRENT_CONTAINERS → MAX_CONCURRENT_AGENTS - containerName → processName, isTaskContainer → isTaskProcess - DB column container_config kept as-is (backwards compat)
This commit is contained in:
@@ -2,14 +2,14 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
// Sentinel markers must match container-runner.ts
|
||||
// Sentinel markers must match agent-runner.ts
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
|
||||
// Mock config
|
||||
vi.mock('./config.js', () => ({
|
||||
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
||||
CONTAINER_TIMEOUT: 1800000, // 30min
|
||||
AGENT_MAX_OUTPUT_SIZE: 10485760,
|
||||
AGENT_TIMEOUT: 1800000, // 30min
|
||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||
IDLE_TIMEOUT: 1800000, // 30min
|
||||
@@ -89,7 +89,7 @@ vi.mock('child_process', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { runContainerAgent, ContainerOutput } from './container-runner.js';
|
||||
import { runAgentProcess, AgentOutput } from './agent-runner.js';
|
||||
import type { RegisteredGroup } from './types.js';
|
||||
|
||||
const testGroup: RegisteredGroup = {
|
||||
@@ -108,13 +108,13 @@ const testInput = {
|
||||
|
||||
function emitOutputMarker(
|
||||
proc: ReturnType<typeof createFakeProcess>,
|
||||
output: ContainerOutput,
|
||||
output: AgentOutput,
|
||||
) {
|
||||
const json = JSON.stringify(output);
|
||||
proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`);
|
||||
}
|
||||
|
||||
describe('container-runner timeout behavior', () => {
|
||||
describe('agent-runner timeout behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fakeProc = createFakeProcess();
|
||||
@@ -126,7 +126,7 @@ describe('container-runner timeout behavior', () => {
|
||||
|
||||
it('timeout after output resolves as success', async () => {
|
||||
const onOutput = vi.fn(async () => {});
|
||||
const resultPromise = runContainerAgent(
|
||||
const resultPromise = runAgentProcess(
|
||||
testGroup,
|
||||
testInput,
|
||||
() => {},
|
||||
@@ -146,7 +146,7 @@ describe('container-runner timeout behavior', () => {
|
||||
// Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
|
||||
await vi.advanceTimersByTimeAsync(1830000);
|
||||
|
||||
// Emit close event (as if container was stopped by the timeout)
|
||||
// Emit close event (as if agent was stopped by the timeout)
|
||||
fakeProc.emit('close', 137);
|
||||
|
||||
// Let the promise resolve
|
||||
@@ -162,7 +162,7 @@ describe('container-runner timeout behavior', () => {
|
||||
|
||||
it('timeout with no output resolves as error', async () => {
|
||||
const onOutput = vi.fn(async () => {});
|
||||
const resultPromise = runContainerAgent(
|
||||
const resultPromise = runAgentProcess(
|
||||
testGroup,
|
||||
testInput,
|
||||
() => {},
|
||||
@@ -185,7 +185,7 @@ describe('container-runner timeout behavior', () => {
|
||||
|
||||
it('normal exit after output resolves as success', async () => {
|
||||
const onOutput = vi.fn(async () => {});
|
||||
const resultPromise = runContainerAgent(
|
||||
const resultPromise = runAgentProcess(
|
||||
testGroup,
|
||||
testInput,
|
||||
() => {},
|
||||
@@ -8,8 +8,8 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
AGENT_MAX_OUTPUT_SIZE,
|
||||
AGENT_TIMEOUT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
@@ -24,7 +24,7 @@ import { RegisteredGroup } from './types.js';
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
|
||||
export interface ContainerInput {
|
||||
export interface AgentInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
groupFolder: string;
|
||||
@@ -35,7 +35,7 @@ export interface ContainerInput {
|
||||
agentType?: 'claude-code' | 'codex';
|
||||
}
|
||||
|
||||
export interface ContainerOutput {
|
||||
export interface AgentOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
newSessionId?: string;
|
||||
@@ -137,8 +137,8 @@ function prepareGroupEnvironment(
|
||||
|
||||
// Additional mount directories (validated)
|
||||
const extraDirs: string[] = [];
|
||||
if (group.containerConfig?.additionalMounts) {
|
||||
for (const mount of group.containerConfig.additionalMounts) {
|
||||
if (group.agentConfig?.additionalMounts) {
|
||||
for (const mount of group.agentConfig.additionalMounts) {
|
||||
if (fs.existsSync(mount.hostPath)) {
|
||||
extraDirs.push(mount.hostPath);
|
||||
}
|
||||
@@ -212,12 +212,12 @@ function prepareGroupEnvironment(
|
||||
|
||||
// Codex model/effort configuration (per-group overrides global)
|
||||
const codexModel =
|
||||
group.containerConfig?.codexModel ||
|
||||
group.agentConfig?.codexModel ||
|
||||
envVars.CODEX_MODEL ||
|
||||
process.env.CODEX_MODEL;
|
||||
if (codexModel) env.CODEX_MODEL = codexModel;
|
||||
const codexEffort =
|
||||
group.containerConfig?.codexEffort ||
|
||||
group.agentConfig?.codexEffort ||
|
||||
envVars.CODEX_EFFORT ||
|
||||
process.env.CODEX_EFFORT;
|
||||
if (codexEffort) env.CODEX_EFFORT = codexEffort;
|
||||
@@ -321,23 +321,23 @@ NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')}
|
||||
const val = envVars[key as keyof typeof envVars] || process.env[key];
|
||||
if (val) env[key] = val;
|
||||
}
|
||||
if (group.containerConfig?.claudeModel) {
|
||||
env.CLAUDE_MODEL = group.containerConfig.claudeModel;
|
||||
if (group.agentConfig?.claudeModel) {
|
||||
env.CLAUDE_MODEL = group.agentConfig.claudeModel;
|
||||
}
|
||||
if (group.containerConfig?.claudeEffort) {
|
||||
env.CLAUDE_EFFORT = group.containerConfig.claudeEffort;
|
||||
if (group.agentConfig?.claudeEffort) {
|
||||
env.CLAUDE_EFFORT = group.agentConfig.claudeEffort;
|
||||
}
|
||||
}
|
||||
|
||||
return { env, groupDir, runnerDir };
|
||||
}
|
||||
|
||||
export async function runContainerAgent(
|
||||
export async function runAgentProcess(
|
||||
group: RegisteredGroup,
|
||||
input: ContainerInput,
|
||||
onProcess: (proc: ChildProcess, containerName: string) => void,
|
||||
onOutput?: (output: ContainerOutput) => Promise<void>,
|
||||
): Promise<ContainerOutput> {
|
||||
input: AgentInput,
|
||||
onProcess: (proc: ChildProcess, processName: string) => void,
|
||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||
): Promise<AgentOutput> {
|
||||
const startTime = Date.now();
|
||||
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
||||
group,
|
||||
@@ -400,7 +400,7 @@ export async function runContainerAgent(
|
||||
const chunk = data.toString();
|
||||
|
||||
if (!stdoutTruncated) {
|
||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
|
||||
const remaining = AGENT_MAX_OUTPUT_SIZE - stdout.length;
|
||||
if (chunk.length > remaining) {
|
||||
stdout += chunk.slice(0, remaining);
|
||||
stdoutTruncated = true;
|
||||
@@ -426,7 +426,7 @@ export async function runContainerAgent(
|
||||
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
|
||||
|
||||
try {
|
||||
const parsed: ContainerOutput = JSON.parse(jsonStr);
|
||||
const parsed: AgentOutput = JSON.parse(jsonStr);
|
||||
if (parsed.newSessionId) {
|
||||
newSessionId = parsed.newSessionId;
|
||||
}
|
||||
@@ -450,7 +450,7 @@ export async function runContainerAgent(
|
||||
if (line) logger.debug({ agent: group.folder }, line);
|
||||
}
|
||||
if (stderrTruncated) return;
|
||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
|
||||
const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length;
|
||||
if (chunk.length > remaining) {
|
||||
stderr += chunk.slice(0, remaining);
|
||||
stderrTruncated = true;
|
||||
@@ -461,7 +461,7 @@ export async function runContainerAgent(
|
||||
|
||||
let timedOut = false;
|
||||
let hadStreamingOutput = false;
|
||||
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
||||
const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT;
|
||||
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
|
||||
|
||||
const killOnTimeout = () => {
|
||||
@@ -596,7 +596,7 @@ export async function runContainerAgent(
|
||||
const lines = stdout.trim().split('\n');
|
||||
jsonLine = lines[lines.length - 1];
|
||||
}
|
||||
const output: ContainerOutput = JSON.parse(jsonLine);
|
||||
const output: AgentOutput = JSON.parse(jsonLine);
|
||||
logger.info(
|
||||
{ group: group.name, duration, status: output.status },
|
||||
'Agent completed',
|
||||
@@ -32,19 +32,19 @@ export const DATA_DIR = path.resolve(
|
||||
process.env.NANOCLAW_DATA_DIR || path.join(PROJECT_ROOT, 'data'),
|
||||
);
|
||||
|
||||
export const CONTAINER_TIMEOUT = parseInt(
|
||||
process.env.CONTAINER_TIMEOUT || '1800000',
|
||||
export const AGENT_TIMEOUT = parseInt(
|
||||
process.env.AGENT_TIMEOUT || process.env.CONTAINER_TIMEOUT || '1800000',
|
||||
10,
|
||||
);
|
||||
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
|
||||
process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||
export const AGENT_MAX_OUTPUT_SIZE = parseInt(
|
||||
process.env.AGENT_MAX_OUTPUT_SIZE || process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760',
|
||||
10,
|
||||
); // 10MB default
|
||||
export const IPC_POLL_INTERVAL = 1000;
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
|
||||
export const MAX_CONCURRENT_CONTAINERS = Math.max(
|
||||
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep agent alive after last result
|
||||
export const MAX_CONCURRENT_AGENTS = Math.max(
|
||||
1,
|
||||
parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
|
||||
parseInt(process.env.MAX_CONCURRENT_AGENTS || process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5,
|
||||
);
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
|
||||
@@ -600,7 +600,7 @@ export function getRegisteredGroup(
|
||||
folder: row.folder,
|
||||
trigger: row.trigger_pattern,
|
||||
added_at: row.added_at,
|
||||
containerConfig: row.container_config
|
||||
agentConfig: row.container_config
|
||||
? JSON.parse(row.container_config)
|
||||
: undefined,
|
||||
requiresTrigger:
|
||||
@@ -624,7 +624,7 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
||||
group.folder,
|
||||
group.trigger,
|
||||
group.added_at,
|
||||
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
|
||||
group.agentConfig ? JSON.stringify(group.agentConfig) : null,
|
||||
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
|
||||
group.isMain ? 1 : 0,
|
||||
group.agentType || 'claude-code',
|
||||
@@ -659,7 +659,7 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
folder: row.folder,
|
||||
trigger: row.trigger_pattern,
|
||||
added_at: row.added_at,
|
||||
containerConfig: row.container_config
|
||||
agentConfig: row.container_config
|
||||
? JSON.parse(row.container_config)
|
||||
: undefined,
|
||||
requiresTrigger:
|
||||
|
||||
@@ -5,7 +5,7 @@ import { GroupQueue } from './group-queue.js';
|
||||
// Mock config to control concurrency limit
|
||||
vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||
MAX_CONCURRENT_CONTAINERS: 2,
|
||||
MAX_CONCURRENT_AGENTS: 2,
|
||||
}));
|
||||
|
||||
// Mock fs operations used by sendMessage/closeStdin
|
||||
@@ -36,7 +36,7 @@ describe('GroupQueue', () => {
|
||||
|
||||
// --- Single group at a time ---
|
||||
|
||||
it('only runs one container per group at a time', async () => {
|
||||
it('only runs one agent per group at a time', async () => {
|
||||
let concurrentCount = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('GroupQueue', () => {
|
||||
// Let promises settle
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Only 2 should be active (MAX_CONCURRENT_CONTAINERS = 2)
|
||||
// Only 2 should be active (MAX_CONCURRENT_AGENTS = 2)
|
||||
expect(maxActive).toBe(2);
|
||||
expect(activeCount).toBe(2);
|
||||
|
||||
@@ -280,7 +280,7 @@ describe('GroupQueue', () => {
|
||||
|
||||
// --- Idle preemption ---
|
||||
|
||||
it('does NOT preempt active container when not idle', async () => {
|
||||
it('does NOT preempt active agent when not idle', async () => {
|
||||
const fs = await import('fs');
|
||||
let resolveProcess: () => void;
|
||||
|
||||
@@ -301,15 +301,15 @@ describe('GroupQueue', () => {
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'agent-1',
|
||||
'test-group',
|
||||
);
|
||||
|
||||
// Enqueue a task while container is active but NOT idle
|
||||
// Enqueue a task while agent is active but NOT idle
|
||||
const taskFn = vi.fn(async () => {});
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
|
||||
// _close should NOT have been written (container is working, not idle)
|
||||
// _close should NOT have been written (agent is working, not idle)
|
||||
const writeFileSync = vi.mocked(fs.default.writeFileSync);
|
||||
const closeWrites = writeFileSync.mock.calls.filter(
|
||||
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
|
||||
@@ -320,7 +320,7 @@ describe('GroupQueue', () => {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
|
||||
it('preempts idle container when task is enqueued', async () => {
|
||||
it('preempts idle agent when task is enqueued', async () => {
|
||||
const fs = await import('fs');
|
||||
let resolveProcess: () => void;
|
||||
|
||||
@@ -341,7 +341,7 @@ describe('GroupQueue', () => {
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'agent-1',
|
||||
'test-group',
|
||||
);
|
||||
queue.notifyIdle('group1@g.us');
|
||||
@@ -353,7 +353,7 @@ describe('GroupQueue', () => {
|
||||
const taskFn = vi.fn(async () => {});
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
|
||||
// _close SHOULD have been written (container is idle)
|
||||
// _close SHOULD have been written (agent is idle)
|
||||
const closeWrites = writeFileSync.mock.calls.filter(
|
||||
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
|
||||
);
|
||||
@@ -380,11 +380,11 @@ describe('GroupQueue', () => {
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'agent-1',
|
||||
'test-group',
|
||||
);
|
||||
|
||||
// Container becomes idle
|
||||
// Agent becomes idle
|
||||
queue.notifyIdle('group1@g.us');
|
||||
|
||||
// A new user message arrives — resets idleWaiting
|
||||
@@ -406,7 +406,7 @@ describe('GroupQueue', () => {
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
});
|
||||
|
||||
it('sendMessage returns false for task containers so user messages queue up', async () => {
|
||||
it('sendMessage returns false for task agents so user messages queue up', async () => {
|
||||
let resolveTask: () => void;
|
||||
|
||||
const taskFn = vi.fn(async () => {
|
||||
@@ -415,17 +415,17 @@ describe('GroupQueue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Start a task (sets isTaskContainer = true)
|
||||
// Start a task (sets isTaskProcess = true)
|
||||
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'agent-1',
|
||||
'test-group',
|
||||
);
|
||||
|
||||
// sendMessage should return false — user messages must not go to task containers
|
||||
// sendMessage should return false — user messages must not go to task agents
|
||||
const result = queue.sendMessage('group1@g.us', 'hello');
|
||||
expect(result).toBe(false);
|
||||
|
||||
@@ -454,7 +454,7 @@ describe('GroupQueue', () => {
|
||||
queue.registerProcess(
|
||||
'group1@g.us',
|
||||
{} as any,
|
||||
'container-1',
|
||||
'agent-1',
|
||||
'test-group',
|
||||
);
|
||||
|
||||
@@ -469,7 +469,7 @@ describe('GroupQueue', () => {
|
||||
);
|
||||
expect(closeWrites).toHaveLength(0);
|
||||
|
||||
// Now container becomes idle — should preempt because task is pending
|
||||
// Now agent becomes idle — should preempt because task is pending
|
||||
writeFileSync.mockClear();
|
||||
queue.notifyIdle('group1@g.us');
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, MAX_CONCURRENT_CONTAINERS } from './config.js';
|
||||
import { DATA_DIR, MAX_CONCURRENT_AGENTS } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
interface QueuedTask {
|
||||
@@ -17,12 +17,12 @@ const BASE_RETRY_MS = 5000;
|
||||
interface GroupState {
|
||||
active: boolean;
|
||||
idleWaiting: boolean;
|
||||
isTaskContainer: boolean;
|
||||
isTaskProcess: boolean;
|
||||
runningTaskId: string | null;
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: QueuedTask[];
|
||||
process: ChildProcess | null;
|
||||
containerName: string | null;
|
||||
processName: string | null;
|
||||
groupFolder: string | null;
|
||||
retryCount: number;
|
||||
}
|
||||
@@ -41,12 +41,12 @@ export class GroupQueue {
|
||||
state = {
|
||||
active: false,
|
||||
idleWaiting: false,
|
||||
isTaskContainer: false,
|
||||
isTaskProcess: false,
|
||||
runningTaskId: null,
|
||||
pendingMessages: false,
|
||||
pendingTasks: [],
|
||||
process: null,
|
||||
containerName: null,
|
||||
processName: null,
|
||||
groupFolder: null,
|
||||
retryCount: 0,
|
||||
};
|
||||
@@ -64,18 +64,18 @@ export class GroupQueue {
|
||||
|
||||
const state = this.getGroup(groupJid);
|
||||
|
||||
// Pre-set groupFolder so sendMessage can pipe IPC while container starts
|
||||
// Pre-set groupFolder so sendMessage can pipe IPC while agent process starts
|
||||
if (groupFolder && !state.groupFolder) {
|
||||
state.groupFolder = groupFolder;
|
||||
}
|
||||
|
||||
if (state.active) {
|
||||
state.pendingMessages = true;
|
||||
logger.debug({ groupJid }, 'Container active, message queued');
|
||||
logger.debug({ groupJid }, 'Agent active, message queued');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
|
||||
if (this.activeCount >= MAX_CONCURRENT_AGENTS) {
|
||||
state.pendingMessages = true;
|
||||
if (!this.waitingGroups.includes(groupJid)) {
|
||||
this.waitingGroups.push(groupJid);
|
||||
@@ -112,11 +112,11 @@ export class GroupQueue {
|
||||
if (state.idleWaiting) {
|
||||
this.closeStdin(groupJid);
|
||||
}
|
||||
logger.debug({ groupJid, taskId }, 'Container active, task queued');
|
||||
logger.debug({ groupJid, taskId }, 'Agent active, task queued');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
|
||||
if (this.activeCount >= MAX_CONCURRENT_AGENTS) {
|
||||
state.pendingTasks.push({ id: taskId, groupJid, fn });
|
||||
if (!this.waitingGroups.includes(groupJid)) {
|
||||
this.waitingGroups.push(groupJid);
|
||||
@@ -137,18 +137,18 @@ export class GroupQueue {
|
||||
registerProcess(
|
||||
groupJid: string,
|
||||
proc: ChildProcess,
|
||||
containerName: string,
|
||||
processName: string,
|
||||
groupFolder?: string,
|
||||
): void {
|
||||
const state = this.getGroup(groupJid);
|
||||
state.process = proc;
|
||||
state.containerName = containerName;
|
||||
state.processName = processName;
|
||||
if (groupFolder) state.groupFolder = groupFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the container as idle-waiting (finished work, waiting for IPC input).
|
||||
* If tasks are pending, preempt the idle container immediately.
|
||||
* Mark the agent process as idle-waiting (finished work, waiting for IPC input).
|
||||
* If tasks are pending, preempt the idle agent process immediately.
|
||||
*/
|
||||
notifyIdle(groupJid: string): void {
|
||||
const state = this.getGroup(groupJid);
|
||||
@@ -159,12 +159,12 @@ export class GroupQueue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a follow-up message to the active container via IPC file.
|
||||
* Returns true if the message was written, false if no active container.
|
||||
* Send a follow-up message to the active agent process via IPC file.
|
||||
* Returns true if the message was written, false if no active agent process.
|
||||
*/
|
||||
sendMessage(groupJid: string, text: string): boolean {
|
||||
const state = this.getGroup(groupJid);
|
||||
if (!state.active || !state.groupFolder || state.isTaskContainer)
|
||||
if (!state.active || !state.groupFolder || state.isTaskProcess)
|
||||
return false;
|
||||
state.idleWaiting = false; // Agent is about to receive work, no longer idle
|
||||
|
||||
@@ -183,7 +183,7 @@ export class GroupQueue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the active container to wind down by writing a close sentinel.
|
||||
* Signal the active agent process to wind down by writing a close sentinel.
|
||||
*/
|
||||
closeStdin(groupJid: string): void {
|
||||
const state = this.getGroup(groupJid);
|
||||
@@ -205,13 +205,13 @@ export class GroupQueue {
|
||||
const state = this.getGroup(groupJid);
|
||||
state.active = true;
|
||||
state.idleWaiting = false;
|
||||
state.isTaskContainer = false;
|
||||
state.isTaskProcess = false;
|
||||
state.pendingMessages = false;
|
||||
this.activeCount++;
|
||||
|
||||
logger.debug(
|
||||
{ groupJid, reason, activeCount: this.activeCount },
|
||||
'Starting container for group',
|
||||
'Starting agent process for group',
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -229,7 +229,7 @@ export class GroupQueue {
|
||||
} finally {
|
||||
state.active = false;
|
||||
state.process = null;
|
||||
state.containerName = null;
|
||||
state.processName = null;
|
||||
state.groupFolder = null;
|
||||
this.activeCount--;
|
||||
this.drainGroup(groupJid);
|
||||
@@ -240,7 +240,7 @@ export class GroupQueue {
|
||||
const state = this.getGroup(groupJid);
|
||||
state.active = true;
|
||||
state.idleWaiting = false;
|
||||
state.isTaskContainer = true;
|
||||
state.isTaskProcess = true;
|
||||
state.runningTaskId = task.id;
|
||||
this.activeCount++;
|
||||
|
||||
@@ -255,10 +255,10 @@ export class GroupQueue {
|
||||
logger.error({ groupJid, taskId: task.id, err }, 'Error running task');
|
||||
} finally {
|
||||
state.active = false;
|
||||
state.isTaskContainer = false;
|
||||
state.isTaskProcess = false;
|
||||
state.runningTaskId = null;
|
||||
state.process = null;
|
||||
state.containerName = null;
|
||||
state.processName = null;
|
||||
state.groupFolder = null;
|
||||
this.activeCount--;
|
||||
this.drainGroup(groupJid);
|
||||
@@ -323,7 +323,7 @@ export class GroupQueue {
|
||||
private drainWaiting(): void {
|
||||
while (
|
||||
this.waitingGroups.length > 0 &&
|
||||
this.activeCount < MAX_CONCURRENT_CONTAINERS
|
||||
this.activeCount < MAX_CONCURRENT_AGENTS
|
||||
) {
|
||||
const nextJid = this.waitingGroups.shift()!;
|
||||
const state = this.getGroup(nextJid);
|
||||
@@ -352,19 +352,19 @@ export class GroupQueue {
|
||||
async shutdown(_gracePeriodMs: number): Promise<void> {
|
||||
this.shuttingDown = true;
|
||||
|
||||
// Count active containers but don't kill them — they'll finish on their own
|
||||
// via idle timeout or container timeout. The --rm flag cleans them up on exit.
|
||||
// This prevents WhatsApp reconnection restarts from killing working agents.
|
||||
const activeContainers: string[] = [];
|
||||
// Count active agent processes but don't kill them — they'll finish on their own
|
||||
// via idle timeout or agent timeout.
|
||||
// This prevents reconnection restarts from killing working agents.
|
||||
const activeProcesses: string[] = [];
|
||||
for (const [jid, state] of this.groups) {
|
||||
if (state.process && !state.process.killed && state.containerName) {
|
||||
activeContainers.push(state.containerName);
|
||||
if (state.process && !state.process.killed && state.processName) {
|
||||
activeProcesses.push(state.processName);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ activeCount: this.activeCount, detachedContainers: activeContainers },
|
||||
'GroupQueue shutting down (containers detached, not killed)',
|
||||
{ activeCount: this.activeCount, detachedProcesses: activeProcesses },
|
||||
'GroupQueue shutting down (agent processes detached, not killed)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
40
src/index.ts
40
src/index.ts
@@ -14,11 +14,11 @@ import {
|
||||
getRegisteredChannelNames,
|
||||
} from './channels/registry.js';
|
||||
import {
|
||||
ContainerOutput,
|
||||
runContainerAgent,
|
||||
AgentOutput,
|
||||
runAgentProcess,
|
||||
writeGroupsSnapshot,
|
||||
writeTasksSnapshot,
|
||||
} from './container-runner.js';
|
||||
} from './agent-runner.js';
|
||||
import {
|
||||
getAllChats,
|
||||
getAllRegisteredGroups,
|
||||
@@ -121,7 +121,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||
* Get available groups list for the agent.
|
||||
* Returns groups ordered by most recent activity.
|
||||
*/
|
||||
export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
|
||||
export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] {
|
||||
const chats = getAllChats();
|
||||
const registeredJids = new Set(Object.keys(registeredGroups));
|
||||
|
||||
@@ -237,7 +237,7 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
idleTimer = setTimeout(() => {
|
||||
logger.debug(
|
||||
{ group: group.name },
|
||||
'Idle timeout, closing container stdin',
|
||||
'Idle timeout, closing agent stdin',
|
||||
);
|
||||
queue.closeStdin(chatJid);
|
||||
}, IDLE_TIMEOUT);
|
||||
@@ -307,12 +307,12 @@ async function runAgent(
|
||||
group: RegisteredGroup,
|
||||
prompt: string,
|
||||
chatJid: string,
|
||||
onOutput?: (output: ContainerOutput) => Promise<void>,
|
||||
onOutput?: (output: AgentOutput) => Promise<void>,
|
||||
): Promise<'success' | 'error'> {
|
||||
const isMain = group.isMain === true;
|
||||
const sessionId = sessions[group.folder];
|
||||
|
||||
// Update tasks snapshot for container to read (filtered by group)
|
||||
// Update tasks snapshot for agent to read (filtered by group)
|
||||
const tasks = getAllTasks();
|
||||
writeTasksSnapshot(
|
||||
group.folder,
|
||||
@@ -339,7 +339,7 @@ async function runAgent(
|
||||
|
||||
// Wrap onOutput to track session ID from streamed results
|
||||
const wrappedOnOutput = onOutput
|
||||
? async (output: ContainerOutput) => {
|
||||
? async (output: AgentOutput) => {
|
||||
if (output.newSessionId) {
|
||||
sessions[group.folder] = output.newSessionId;
|
||||
setSession(group.folder, output.newSessionId);
|
||||
@@ -349,7 +349,7 @@ async function runAgent(
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const output = await runContainerAgent(
|
||||
const output = await runAgentProcess(
|
||||
group,
|
||||
{
|
||||
prompt,
|
||||
@@ -359,8 +359,8 @@ async function runAgent(
|
||||
isMain,
|
||||
assistantName: ASSISTANT_NAME,
|
||||
},
|
||||
(proc, containerName) =>
|
||||
queue.registerProcess(chatJid, proc, containerName, group.folder),
|
||||
(proc, processName) =>
|
||||
queue.registerProcess(chatJid, proc, processName, group.folder),
|
||||
wrappedOnOutput,
|
||||
);
|
||||
|
||||
@@ -372,7 +372,7 @@ async function runAgent(
|
||||
if (output.status === 'error') {
|
||||
logger.error(
|
||||
{ group: group.name, error: output.error },
|
||||
'Container agent error',
|
||||
'Agent process error',
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
@@ -461,9 +461,9 @@ async function startMessageLoop(): Promise<void> {
|
||||
);
|
||||
|
||||
if (loopCmdMsg) {
|
||||
// Only close active container if the sender is authorized — otherwise an
|
||||
// Only close active agent if the sender is authorized — otherwise an
|
||||
// untrusted user could kill in-flight work by sending /compact (DoS).
|
||||
// closeStdin no-ops internally when no container is active.
|
||||
// closeStdin no-ops internally when no agent is active.
|
||||
if (
|
||||
isSessionCommandAllowed(
|
||||
isMainGroup,
|
||||
@@ -473,7 +473,7 @@ async function startMessageLoop(): Promise<void> {
|
||||
queue.closeStdin(chatJid);
|
||||
}
|
||||
// Enqueue so processGroupMessages handles auth + cursor advancement.
|
||||
// Don't pipe via IPC — slash commands need a fresh container with
|
||||
// Don't pipe via IPC — slash commands need a fresh agent with
|
||||
// string prompt (not MessageStream) for SDK recognition.
|
||||
queue.enqueueMessageCheck(chatJid);
|
||||
continue;
|
||||
@@ -510,19 +510,19 @@ async function startMessageLoop(): Promise<void> {
|
||||
if (queue.sendMessage(chatJid, formatted)) {
|
||||
logger.debug(
|
||||
{ chatJid, count: messagesToSend.length },
|
||||
'Piped messages to active container',
|
||||
'Piped messages to active agent',
|
||||
);
|
||||
lastAgentTimestamp[chatJid] =
|
||||
messagesToSend[messagesToSend.length - 1].timestamp;
|
||||
saveState();
|
||||
// Show typing indicator while the container processes the piped message
|
||||
// Show typing indicator while the agent processes the piped message
|
||||
channel
|
||||
.setTyping?.(chatJid, true)
|
||||
?.catch((err) =>
|
||||
logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
|
||||
);
|
||||
} else {
|
||||
// No active container — enqueue for a new one
|
||||
// No active agent — enqueue for a new one
|
||||
queue.enqueueMessageCheck(chatJid, group.folder);
|
||||
}
|
||||
}
|
||||
@@ -628,8 +628,8 @@ async function main(): Promise<void> {
|
||||
registeredGroups: () => registeredGroups,
|
||||
getSessions: () => sessions,
|
||||
queue,
|
||||
onProcess: (groupJid, proc, containerName, groupFolder) =>
|
||||
queue.registerProcess(groupJid, proc, containerName, groupFolder),
|
||||
onProcess: (groupJid, proc, processName, groupFolder) =>
|
||||
queue.registerProcess(groupJid, proc, processName, groupFolder),
|
||||
sendMessage: async (jid, rawText) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
|
||||
import { AvailableGroup } from './container-runner.js';
|
||||
import { AvailableGroup } from './agent-runner.js';
|
||||
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
@@ -170,7 +170,7 @@ export async function processTaskIpc(
|
||||
folder?: string;
|
||||
trigger?: string;
|
||||
requiresTrigger?: boolean;
|
||||
containerConfig?: RegisteredGroup['containerConfig'];
|
||||
agentConfig?: RegisteredGroup['agentConfig'];
|
||||
},
|
||||
sourceGroup: string, // Verified identity from IPC directory
|
||||
isMain: boolean, // Verified from directory path
|
||||
@@ -438,7 +438,7 @@ export async function processTaskIpc(
|
||||
folder: data.folder,
|
||||
trigger: data.trigger,
|
||||
added_at: new Date().toISOString(),
|
||||
containerConfig: data.containerConfig,
|
||||
agentConfig: data.agentConfig,
|
||||
requiresTrigger: data.requiresTrigger,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function isSessionCommandAllowed(
|
||||
return isMainGroup || isFromMe;
|
||||
}
|
||||
|
||||
/** Minimal agent result interface — matches the subset of ContainerOutput used here. */
|
||||
/** Minimal agent result interface — matches the subset of AgentOutput used here. */
|
||||
export interface AgentResult {
|
||||
status: 'success' | 'error';
|
||||
result?: string | object | null;
|
||||
|
||||
@@ -4,10 +4,10 @@ import fs from 'fs';
|
||||
|
||||
import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
|
||||
import {
|
||||
ContainerOutput,
|
||||
runContainerAgent,
|
||||
AgentOutput,
|
||||
runAgentProcess,
|
||||
writeTasksSnapshot,
|
||||
} from './container-runner.js';
|
||||
} from './agent-runner.js';
|
||||
import {
|
||||
getAllTasks,
|
||||
getDueTasks,
|
||||
@@ -69,7 +69,7 @@ export interface SchedulerDependencies {
|
||||
onProcess: (
|
||||
groupJid: string,
|
||||
proc: ChildProcess,
|
||||
containerName: string,
|
||||
processName: string,
|
||||
groupFolder: string,
|
||||
) => void;
|
||||
sendMessage: (jid: string, text: string) => Promise<void>;
|
||||
@@ -129,7 +129,7 @@ async function runTask(
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tasks snapshot for container to read (filtered by group)
|
||||
// Update tasks snapshot for agent to read (filtered by group)
|
||||
const isMain = group.isMain === true;
|
||||
const tasks = getAllTasks();
|
||||
writeTasksSnapshot(
|
||||
@@ -154,7 +154,7 @@ async function runTask(
|
||||
const sessionId =
|
||||
task.context_mode === 'group' ? sessions[task.group_folder] : undefined;
|
||||
|
||||
// After the task produces a result, close the container promptly.
|
||||
// After the task produces a result, close the agent promptly.
|
||||
// Tasks are single-turn — no need to wait IDLE_TIMEOUT (30 min) for the
|
||||
// query loop to time out. A short delay handles any final MCP calls.
|
||||
const TASK_CLOSE_DELAY_MS = 10000;
|
||||
@@ -163,13 +163,13 @@ async function runTask(
|
||||
const scheduleClose = () => {
|
||||
if (closeTimer) return; // already scheduled
|
||||
closeTimer = setTimeout(() => {
|
||||
logger.debug({ taskId: task.id }, 'Closing task container after result');
|
||||
logger.debug({ taskId: task.id }, 'Closing task agent after result');
|
||||
deps.queue.closeStdin(task.chat_jid);
|
||||
}, TASK_CLOSE_DELAY_MS);
|
||||
};
|
||||
|
||||
try {
|
||||
const output = await runContainerAgent(
|
||||
const output = await runAgentProcess(
|
||||
group,
|
||||
{
|
||||
prompt: task.prompt,
|
||||
@@ -180,9 +180,9 @@ async function runTask(
|
||||
isScheduledTask: true,
|
||||
assistantName: ASSISTANT_NAME,
|
||||
},
|
||||
(proc, containerName) =>
|
||||
deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),
|
||||
async (streamedOutput: ContainerOutput) => {
|
||||
(proc, processName) =>
|
||||
deps.onProcess(task.chat_jid, proc, processName, task.group_folder),
|
||||
async (streamedOutput: AgentOutput) => {
|
||||
if (streamedOutput.result) {
|
||||
result = streamedOutput.result;
|
||||
// Forward result to user (sendMessage handles formatting)
|
||||
|
||||
@@ -81,17 +81,28 @@ function syncToSessionDirs(credsPath: string): void {
|
||||
const groups = fs.readdirSync(sessionsDir);
|
||||
let synced = 0;
|
||||
for (const group of groups) {
|
||||
const dest = path.join(sessionsDir, group, '.claude', '.credentials.json');
|
||||
const dest = path.join(
|
||||
sessionsDir,
|
||||
group,
|
||||
'.claude',
|
||||
'.credentials.json',
|
||||
);
|
||||
if (fs.existsSync(path.dirname(dest))) {
|
||||
fs.copyFileSync(credsPath, dest);
|
||||
synced++;
|
||||
}
|
||||
}
|
||||
if (synced > 0) {
|
||||
logger.info({ count: synced }, 'Synced credentials to session directories');
|
||||
logger.info(
|
||||
{ count: synced },
|
||||
'Synced credentials to session directories',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'Failed to sync credentials to sessions');
|
||||
logger.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'Failed to sync credentials to sessions',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface AdditionalMount {
|
||||
readonly?: boolean; // Default: true for safety
|
||||
}
|
||||
|
||||
export interface ContainerConfig {
|
||||
export interface AgentConfig {
|
||||
additionalMounts?: AdditionalMount[];
|
||||
timeout?: number; // Default: 300000 (5 minutes)
|
||||
// Per-group model/effort overrides (take precedence over global env vars)
|
||||
@@ -21,7 +21,7 @@ export interface RegisteredGroup {
|
||||
folder: string;
|
||||
trigger: string;
|
||||
added_at: string;
|
||||
containerConfig?: ContainerConfig;
|
||||
agentConfig?: AgentConfig;
|
||||
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
|
||||
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
|
||||
agentType?: AgentType;
|
||||
|
||||
Reference in New Issue
Block a user