merge: integrate origin/main (406 commits) into live branch

Merge upstream's paired-room stabilization, dedicated message-runtime-loop,
outbound delivery refactor (ipc-outbound-delivery / deliverFormattedCanonicalMessage),
discord module split, dashboard rework, and migrations 015-018 into our branch.

Conflict policy: prefer upstream for the heavily-refactored paired-room /
message-runtime / db cluster (it supersedes our earlier loop band-aids), keep
only orthogonal unique fixes:
- codex bundled-JS launcher fix (codex-warmup.ts)
- slot-aligned Claude+Codex usage primer (usage-primer.ts) re-wired into index.ts
- MemoryHigh=3G cgroup bound, discord perms diagnostic, prompt docs
- progress null-race + silent-failure publish guards (message-turn-controller.ts)

Dropped (superseded by upstream or unwired): reviewer STEP_DONE/TASK_DONE loop
band-aids, router markdown-escape override, register tribunal-default+git-init,
restart-context rewindToSeq (unwired).

Verified: typecheck clean, build clean, vitest shows zero new regressions vs the
origin/main baseline (only pre-existing env-config failures remain).
This commit is contained in:
Codex
2026-06-08 21:15:39 +09:00
409 changed files with 53523 additions and 10332 deletions

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { resolveAttemptRetryAction } from './agent-attempt-retry.js';
describe('resolveAttemptRetryAction', () => {
it('uses the streamed Codex trigger message as the rotation message', () => {
const errorMessage =
'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header';
const action = resolveAttemptRetryAction({
provider: 'codex',
canRetryClaudeCredentials: false,
canRetryCodex: true,
attempt: {
sawOutput: false,
streamedTriggerReason: {
reason: 'auth-expired',
message: errorMessage,
} as any,
},
});
expect(action).toEqual({
kind: 'codex',
trigger: { reason: 'auth-expired' },
rotationMessage: errorMessage,
});
});
});

View File

@@ -10,6 +10,7 @@ import { getErrorMessage } from './utils.js';
export interface AttemptStreamedTrigger {
reason: AgentTriggerReason;
retryAfterMs?: number;
message?: string;
}
export interface AttemptRetryState {
@@ -131,7 +132,10 @@ export function resolveAttemptRetryAction(args: {
attempt: Pick<AttemptRetryState, 'sawOutput' | 'streamedTriggerReason'>;
rotationMessage?: string | null;
}): AttemptRetryAction {
const normalizedRotationMessage = args.rotationMessage ?? undefined;
const normalizedRotationMessage =
args.rotationMessage ??
args.attempt.streamedTriggerReason?.message ??
undefined;
const claudeTrigger = resolveClaudeRetryTrigger({
canRetryClaudeCredentials: args.canRetryClaudeCredentials,

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
classifyAgentError,
classifyClaudeAuthError,
classifyCodexAuthError,
detectClaudeProviderFailureMessage,
isClaudeOrgAccessDeniedMessage,
shouldRotateClaudeToken,
@@ -55,15 +56,54 @@ describe('agent-error-detection', () => {
expect(detectClaudeProviderFailureMessage(message)).toBe('overloaded');
});
it('classifies Claude 500 API banners as overloaded provider failures', () => {
const message =
'API Error: 500 Internal server error. This is a server-side issue, usually temporary — try again in a moment. If it persists, check status.claude.com.';
expect(classifyAgentError(message)).toEqual({
it('classifies Codex model capacity errors as overloaded', () => {
expect(
classifyAgentError(
'Selected model is at capacity. Please try a different model.',
),
).toEqual({
category: 'overloaded',
reason: 'overloaded',
});
expect(detectClaudeProviderFailureMessage(message)).toBe('overloaded');
});
it('classifies Codex reused refresh-token errors as auth-expired', () => {
expect(
classifyCodexAuthError(
'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.',
),
).toEqual({
category: 'auth-expired',
reason: 'auth-expired',
});
});
it('classifies a Codex auth-expired trigger reason as auth-expired', () => {
expect(classifyCodexAuthError('auth-expired')).toEqual({
category: 'auth-expired',
reason: 'auth-expired',
});
});
it('does not classify the internal Codex pool-unavailable sentinel as an auth failure', () => {
expect(
classifyCodexAuthError(
'auth-expired: All Codex rotation accounts unavailable; re-auth required before launching Codex',
),
).toEqual({ category: 'none', reason: '' });
expect(
classifyCodexAuthError(
'Codex rotation pool unavailable: all rotation accounts are currently dead, rate-limited, or locked',
),
).toEqual({ category: 'none', reason: '' });
});
it('classifies Codex workspace credit exhaustion as rate-limit', () => {
expect(classifyAgentError('Workspace out of credits')).toEqual({
category: 'rate-limit',
reason: '429',
retryAfterMs: undefined,
});
});
it('marks only Claude quota/auth reasons as Claude rotation reasons', () => {

View File

@@ -231,6 +231,35 @@ const NONE: AgentErrorClassification = {
reason: '',
};
export function isCodexPoolUnavailableError(
error: string | null | undefined,
): boolean {
if (!error) return false;
return (
/all\s+codex(?:\s+rotation)?\s+accounts(?:\s+are)?\s+unavailable/i.test(
error,
) || /codex\s+rotation\s+pool\s+unavailable/i.test(error)
);
}
export function isTerminalCodexAccountFailure(
error: string | null | undefined,
): boolean {
if (!error) return false;
if (isCodexPoolUnavailableError(error)) return true;
if (classifyCodexAuthError(error).category !== 'none') return true;
const lower = error.toLowerCase();
if (
classifyAgentError(error).category === 'rate-limit' &&
(lower.includes('workspace out of credits') ||
lower.includes('out of credits') ||
lower.includes('codex'))
) {
return true;
}
return false;
}
/**
* Classify an agent error string into a category.
* Handles patterns common to both Claude and Codex: 429, 503, network.
@@ -249,6 +278,8 @@ export function classifyAgentError(
lower.includes('429') ||
lower.includes('rate limit') ||
lower.includes('usage limit') ||
lower.includes('out of credits') ||
lower.includes('workspace out of credits') ||
lower.includes('hit your limit') ||
lower.includes('too many requests') ||
lower.includes('rate_limit')
@@ -267,6 +298,8 @@ export function classifyAgentError(
hasApi5xx ||
lower.includes('503') ||
lower.includes('overloaded') ||
lower.includes('selected model is at capacity') ||
lower.includes('model is at capacity') ||
((lower.includes('502') || lower.includes('bad gateway')) &&
(lower.includes('cloudflare') ||
lower.includes('<html') ||
@@ -333,14 +366,21 @@ export function classifyCodexAuthError(
error: string | null | undefined,
): AgentErrorClassification {
if (!error) return NONE;
if (isCodexPoolUnavailableError(error)) return NONE;
const lower = error.toLowerCase();
if (
lower.includes('auth-expired') ||
lower.includes('auth expired') ||
lower.includes('401') ||
lower.includes('authentication_error') ||
lower.includes('failed to authenticate') ||
lower.includes('access token could not be refreshed') ||
lower.includes('oauth token has expired') ||
lower.includes('refresh token was already used') ||
lower.includes('refresh your existing token') ||
lower.includes('log out and sign in again') ||
lower.includes('app_session_terminated') ||
lower.includes('unauthorized')
) {
return { category: 'auth-expired', reason: 'auth-expired' };

View File

@@ -53,10 +53,3 @@ export function hasAgentOutputPayload(output: {
}
return output.result !== null && output.result !== undefined;
}
export function isSilentAgentOutput(_output: {
output?: StructuredAgentOutput;
result?: string | object | null;
}): boolean {
return false;
}

View File

@@ -1,15 +1,19 @@
export {
extractMarkdownImageAttachments,
extractMediaAttachments,
extractImageTagPaths,
IMAGE_TAG_RE,
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
IPC_POLL_MS,
normalizeAgentOutput,
normalizeEjclawStructuredOutput,
normalizePublicTextOutput,
OUTPUT_END_MARKER,
OUTPUT_START_MARKER,
writeProtocolOutput,
type NormalizedRunnerOutput,
type NormalizedAgentOutput,
type RunnerOutputAttachment,
type RunnerOutputPhase,
type RunnerOutputVerdict,

View File

@@ -1,11 +1,28 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EJCLAW_ENV } from 'ejclaw-runners-shared';
const { mockReadEnvFile, mockGetActiveCodexAuthPath } = vi.hoisted(() => ({
const {
mockReadEnvFile,
mockGetActiveCodexAuthPath,
mockGetCodexAccountCount,
mockClaimCodexAuthLease,
mockFindCodexAccountIndexByAuthPath,
} = vi.hoisted(() => ({
mockReadEnvFile: vi.fn<() => Record<string, string>>(),
mockGetActiveCodexAuthPath: vi.fn<() => string | null>(),
mockGetCodexAccountCount: vi.fn<() => number>(),
mockClaimCodexAuthLease: vi.fn<
() => {
authPath: string;
accountIndex: number;
release: () => void;
} | null
>(),
mockFindCodexAccountIndexByAuthPath: vi.fn<() => number | null>(),
}));
vi.mock('./config.js', () => ({
@@ -22,11 +39,14 @@ vi.mock('./config.js', () => ({
vi.mock('./env.js', () => ({
readEnvFile: mockReadEnvFile,
getEnv: vi.fn((key: string) => undefined),
getEnv: vi.fn((_key: string) => undefined),
}));
vi.mock('./codex-token-rotation.js', () => ({
getActiveCodexAuthPath: mockGetActiveCodexAuthPath,
getCodexAccountCount: mockGetCodexAccountCount,
claimCodexAuthLease: mockClaimCodexAuthLease,
findCodexAccountIndexByAuthPath: mockFindCodexAccountIndexByAuthPath,
}));
vi.mock('./token-rotation.js', () => ({
@@ -108,6 +128,30 @@ const group: RegisteredGroup = {
agentType: 'codex',
};
function writeSkill(dir: string, name: string): void {
const skillDir = path.join(dir, name);
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${name} skill\n---\n`,
);
}
function resetRoutingMocks(): void {
vi.mocked(config.isReviewService).mockReturnValue(false);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'dc:test',
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
});
}
describe('prepareGroupEnvironment codex auth handling', () => {
let tempRoot: string;
let previousCwd: string;
@@ -133,6 +177,12 @@ describe('prepareGroupEnvironment codex auth handling', () => {
mockReadEnvFile.mockReset();
mockGetActiveCodexAuthPath.mockReset();
mockGetCodexAccountCount.mockReset();
mockGetCodexAccountCount.mockReturnValue(0);
mockClaimCodexAuthLease.mockReset();
mockClaimCodexAuthLease.mockReturnValue(null);
mockFindCodexAccountIndexByAuthPath.mockReset();
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
});
afterEach(() => {
@@ -214,6 +264,84 @@ describe('prepareGroupEnvironment codex auth handling', () => {
expect(auth).toEqual(rotatedAuth);
});
it('fails fast instead of launching Codex without OAuth when rotation accounts exist but no lease is available', () => {
const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
fs.writeFileSync(
rotatedAuthPath,
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: 'locked-token' },
}),
);
mockGetCodexAccountCount.mockReturnValue(1);
mockClaimCodexAuthLease.mockReturnValue(null);
mockGetActiveCodexAuthPath.mockReturnValue(rotatedAuthPath);
mockReadEnvFile.mockReturnValue({});
expect(() => prepareGroupEnvironment(group, false, 'dc:test')).toThrow(
/Codex rotation pool unavailable/,
);
const authPath = path.join(
tempRoot,
'sessions',
group.folder,
'services',
'codex-main',
'.codex',
'auth.json',
);
expect(fs.existsSync(authPath)).toBe(false);
});
});
describe('prepareGroupEnvironment prompt stacks', () => {
let tempRoot: string;
let previousCwd: string;
let previousOpenAiKey: string | undefined;
let previousCodexOpenAiKey: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-'));
previousCwd = process.cwd();
process.chdir(tempRoot);
process.env.EJ_TEST_ROOT = tempRoot;
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
previousOpenAiKey = process.env.OPENAI_API_KEY;
previousCodexOpenAiKey = process.env.CODEX_OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
delete process.env.CODEX_OPENAI_API_KEY;
fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
recursive: true,
});
mockReadEnvFile.mockReset();
mockGetActiveCodexAuthPath.mockReset();
mockGetCodexAccountCount.mockReset();
mockGetCodexAccountCount.mockReturnValue(0);
mockClaimCodexAuthLease.mockReset();
mockClaimCodexAuthLease.mockReturnValue(null);
mockFindCodexAccountIndexByAuthPath.mockReset();
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
});
afterEach(() => {
process.chdir(previousCwd);
delete process.env.EJ_TEST_ROOT;
delete process.env.EJ_TEST_HOME;
if (previousOpenAiKey) process.env.OPENAI_API_KEY = previousOpenAiKey;
else delete process.env.OPENAI_API_KEY;
if (previousCodexOpenAiKey) {
process.env.CODEX_OPENAI_API_KEY = previousCodexOpenAiKey;
} else {
delete process.env.CODEX_OPENAI_API_KEY;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('uses the failover owner prompt pack for codex-review when it owns an explicit failover lease', () => {
vi.mocked(config.isReviewService).mockReturnValue(true);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
@@ -368,66 +496,314 @@ describe('prepareGroupEnvironment codex auth handling', () => {
expect(segments).toEqual(['platform prompt']);
});
});
it('applies per-role codex model overrides when roomRole is supplied', () => {
mockReadEnvFile.mockReturnValue({});
describe('prepareGroupEnvironment Codex MCP room role env', () => {
let tempRoot: string;
let previousCwd: string;
const groupWithByRole: RegisteredGroup = {
...group,
agentType: 'codex',
agentConfig: {
codexModel: 'gpt-5-sonnet',
codexEffort: 'medium',
codexModelByRole: { arbiter: 'gpt-5-opus' },
codexEffortByRole: { arbiter: 'high' },
},
};
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-'));
previousCwd = process.cwd();
process.chdir(tempRoot);
const arbiter = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', {
roomRole: 'arbiter',
});
expect(arbiter.env.CODEX_MODEL).toBe('gpt-5-opus');
expect(arbiter.env.CODEX_EFFORT).toBe('high');
process.env.EJ_TEST_ROOT = tempRoot;
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });
const owner = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', {
roomRole: 'owner',
});
expect(owner.env.CODEX_MODEL).toBe('gpt-5-sonnet');
expect(owner.env.CODEX_EFFORT).toBe('medium');
const noRole = prepareGroupEnvironment(groupWithByRole, false, 'dc:test');
expect(noRole.env.CODEX_MODEL).toBe('gpt-5-sonnet');
expect(noRole.env.CODEX_EFFORT).toBe('medium');
mockReadEnvFile.mockReset();
mockGetActiveCodexAuthPath.mockReset();
mockGetCodexAccountCount.mockReset();
mockGetCodexAccountCount.mockReturnValue(0);
mockClaimCodexAuthLease.mockReset();
mockClaimCodexAuthLease.mockReturnValue(null);
mockFindCodexAccountIndexByAuthPath.mockReset();
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
resetRoutingMocks();
});
it('applies per-role claude model overrides when roomRole is supplied', () => {
afterEach(() => {
process.chdir(previousCwd);
delete process.env.EJ_TEST_ROOT;
delete process.env.EJ_TEST_HOME;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
function writeMcpServer(): void {
const mcpServerPath = path.join(
tempRoot,
'runners',
'agent-runner',
'dist',
'ipc-mcp-stdio.js',
);
fs.mkdirSync(path.dirname(mcpServerPath), { recursive: true });
fs.writeFileSync(mcpServerPath, '// test mcp server\n');
}
function readCodexConfigToml(): string {
return fs.readFileSync(
path.join(
tempRoot,
'sessions',
group.folder,
'services',
'codex-main',
'.codex',
'config.toml',
),
'utf-8',
);
}
it('writes paired owner role into the Codex MCP config env', () => {
mockReadEnvFile.mockReturnValue({});
writeMcpServer();
const groupWithByRole: RegisteredGroup = {
...group,
agentType: 'claude-code',
agentConfig: {
claudeModel: 'claude-sonnet-4-6',
claudeEffort: 'medium',
claudeModelByRole: { arbiter: 'claude-opus-4-7' },
claudeEffortByRole: { arbiter: 'high' },
},
};
const arbiter = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', {
roomRole: 'arbiter',
prepareGroupEnvironment(group, false, 'dc:test', {
roomRole: 'owner',
});
expect(arbiter.env.CLAUDE_MODEL).toBe('claude-opus-4-7');
expect(arbiter.env.CLAUDE_EFFORT).toBe('high');
const reviewer = prepareGroupEnvironment(
groupWithByRole,
expect(readCodexConfigToml()).toContain(`${EJCLAW_ENV.roomRole} = "owner"`);
});
it('omits room role from non-paired Codex MCP config env', () => {
mockReadEnvFile.mockReturnValue({});
writeMcpServer();
prepareGroupEnvironment(group, true, 'dc:test');
expect(readCodexConfigToml()).not.toContain(EJCLAW_ENV.roomRole);
});
});
describe('prepareGroupEnvironment room skill overrides', () => {
let tempRoot: string;
let previousCwd: string;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-skills-'));
previousCwd = process.cwd();
process.chdir(tempRoot);
process.env.EJ_TEST_ROOT = tempRoot;
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
recursive: true,
});
mockReadEnvFile.mockReset();
mockGetActiveCodexAuthPath.mockReset();
mockGetCodexAccountCount.mockReset();
mockGetCodexAccountCount.mockReturnValue(0);
mockClaimCodexAuthLease.mockReset();
mockClaimCodexAuthLease.mockReturnValue(null);
mockFindCodexAccountIndexByAuthPath.mockReset();
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
});
afterEach(() => {
process.chdir(previousCwd);
delete process.env.EJ_TEST_ROOT;
delete process.env.EJ_TEST_HOME;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('filters Claude session skills with room overrides at spawn time', () => {
mockReadEnvFile.mockReturnValue({});
const homeSkills = path.join(
process.env.EJ_TEST_HOME!,
'.claude',
'skills',
);
const workDir = path.join(tempRoot, 'workdir');
const runnerSkills = path.join(tempRoot, 'runners', 'skills');
writeSkill(homeSkills, 'claude-keep');
writeSkill(path.join(workDir, '.claude', 'skills'), 'workdir-keep');
writeSkill(runnerSkills, 'runner-keep');
writeSkill(runnerSkills, 'runner-off');
prepareGroupEnvironment(
{ ...group, agentType: 'claude-code', workDir },
false,
'dc:test',
{ roomRole: 'reviewer' },
{
skillOverrides: [
{
chatJid: 'dc:test',
agentType: 'claude-code',
skillScope: 'runner',
skillName: 'runner-off',
enabled: false,
createdAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
},
],
},
);
expect(reviewer.env.CLAUDE_MODEL).toBe('claude-sonnet-4-6');
expect(reviewer.env.CLAUDE_EFFORT).toBe('medium');
const sessionSkills = path.join(
tempRoot,
'sessions',
group.folder,
'services',
'codex-main',
'.claude',
'skills',
);
expect(fs.existsSync(path.join(sessionSkills, 'claude-keep'))).toBe(true);
expect(fs.existsSync(path.join(sessionSkills, 'workdir-keep'))).toBe(true);
expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true);
expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false);
});
it('uses a session-scoped Codex home when room overrides disable skills', () => {
mockReadEnvFile.mockReturnValue({});
const codexSkills = path.join(
process.env.EJ_TEST_HOME!,
'.agents',
'skills',
);
const runnerSkills = path.join(tempRoot, 'runners', 'skills');
writeSkill(codexSkills, 'codex-keep');
writeSkill(codexSkills, 'codex-off');
writeSkill(runnerSkills, 'runner-keep');
writeSkill(runnerSkills, 'runner-off');
const prepared = prepareGroupEnvironment(group, false, 'dc:test', {
skillOverrides: [
{
chatJid: 'dc:test',
agentType: 'codex',
skillScope: 'codex-user',
skillName: 'codex-off',
enabled: false,
createdAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
},
{
chatJid: 'dc:test',
agentType: 'codex',
skillScope: 'runner',
skillName: 'runner-off',
enabled: false,
createdAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
},
],
});
const sessionRoot = path.join(
tempRoot,
'sessions',
group.folder,
'services',
'codex-main',
);
const sessionHome = path.join(sessionRoot, 'home');
const sessionSkills = path.join(sessionHome, '.agents', 'skills');
expect(prepared.env.HOME).toBe(sessionHome);
expect(prepared.env.CODEX_HOME).toBe(path.join(sessionRoot, '.codex'));
expect(fs.existsSync(path.join(sessionSkills, 'codex-keep'))).toBe(true);
expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true);
expect(fs.existsSync(path.join(sessionSkills, 'codex-off'))).toBe(false);
expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false);
expect(fs.existsSync(path.join(codexSkills, 'codex-off'))).toBe(true);
});
});
describe('prepareGroupEnvironment Codex goals handling', () => {
let tempRoot: string;
let previousCwd: string;
let previousCodexGoals: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-goals-'));
previousCwd = process.cwd();
previousCodexGoals = process.env.CODEX_GOALS;
process.chdir(tempRoot);
process.env.EJ_TEST_ROOT = tempRoot;
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
delete process.env.CODEX_GOALS;
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
recursive: true,
});
mockReadEnvFile.mockReset();
mockGetActiveCodexAuthPath.mockReset();
mockGetCodexAccountCount.mockReset();
mockGetCodexAccountCount.mockReturnValue(0);
mockClaimCodexAuthLease.mockReset();
mockClaimCodexAuthLease.mockReturnValue(null);
mockFindCodexAccountIndexByAuthPath.mockReset();
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
vi.mocked(config.isReviewService).mockReturnValue(false);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'dc:test',
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
});
});
afterEach(() => {
process.chdir(previousCwd);
delete process.env.EJ_TEST_ROOT;
delete process.env.EJ_TEST_HOME;
if (previousCodexGoals) process.env.CODEX_GOALS = previousCodexGoals;
else delete process.env.CODEX_GOALS;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('keeps Codex goals disabled by default and enables them only via opt-in config', () => {
mockReadEnvFile.mockReturnValue({});
const defaultPrepared = prepareGroupEnvironment(group, false, 'dc:test');
expect(defaultPrepared.env.CODEX_GOALS).toBeUndefined();
const enabledPrepared = prepareGroupEnvironment(
{
...group,
agentConfig: {
codexGoals: true,
},
},
false,
'dc:test',
);
expect(enabledPrepared.env.CODEX_GOALS).toBe('true');
});
it('allows CODEX_GOALS env opt-in for Codex runner sessions', () => {
mockReadEnvFile.mockReturnValue({
CODEX_GOALS: 'true',
});
const prepared = prepareGroupEnvironment(group, false, 'dc:test');
expect(prepared.env.CODEX_GOALS).toBe('true');
});
it('enables goals from host ~/.codex/config.toml [features]', () => {
mockReadEnvFile.mockReturnValue({});
const homedirSpy = vi
.spyOn(os, 'homedir')
.mockReturnValue(process.env.EJ_TEST_HOME!);
fs.writeFileSync(
path.join(process.env.EJ_TEST_HOME!, '.codex', 'config.toml'),
'[features]\ngoals = true\n',
);
try {
const prepared = prepareGroupEnvironment(group, false, 'dc:test');
expect(prepared.env.CODEX_GOALS).toBe('true');
} finally {
homedirSpy.mockRestore();
}
});
});
@@ -450,6 +826,12 @@ describe('prepareReadonlySessionEnvironment codex compatibility', () => {
mockReadEnvFile.mockReset();
mockGetActiveCodexAuthPath.mockReset();
mockGetCodexAccountCount.mockReset();
mockGetCodexAccountCount.mockReturnValue(0);
mockClaimCodexAuthLease.mockReset();
mockClaimCodexAuthLease.mockReturnValue(null);
mockFindCodexAccountIndexByAuthPath.mockReset();
mockFindCodexAccountIndexByAuthPath.mockReturnValue(null);
});
afterEach(() => {
@@ -459,6 +841,37 @@ describe('prepareReadonlySessionEnvironment codex compatibility', () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('does not claim a Codex auth lease for Claude read-only sessions', () => {
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
mockGetCodexAccountCount.mockReturnValue(1);
const rotatedAuthPath = path.join(tempRoot, 'rotated-auth.json');
fs.writeFileSync(rotatedAuthPath, '{"auth_mode":"chatgpt"}\n');
const release = vi.fn();
mockClaimCodexAuthLease.mockReturnValue({
authPath: rotatedAuthPath,
accountIndex: 0,
release,
});
const sessionDir = path.join(tempRoot, 'readonly-claude-session');
const prepared = prepareReadonlySessionEnvironment({
sessionDir,
chatJid: 'dc:test',
isMain: false,
groupFolder: 'codex-test-group',
agentType: 'claude-code',
memoryBriefing: 'memory briefing',
role: 'reviewer',
});
expect(prepared.codexSessionAuth).toBeNull();
expect(mockClaimCodexAuthLease).not.toHaveBeenCalled();
expect(release).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(sessionDir, '.codex', 'auth.json'))).toBe(
false,
);
});
it('writes matching AGENTS.md and copies host codex auth/config into the role-scoped session', () => {
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
@@ -486,7 +899,7 @@ command = "node"
args = ["old-ipc.js"]
[mcp_servers.ejclaw.env]
EJCLAW_IPC_DIR = "/old/ipc"
${EJCLAW_ENV.ipcDir} = "/old/ipc"
[mcp_servers.other]
command = "node"
@@ -525,9 +938,10 @@ args = ["other.js"]
expect(toml).toContain('model = "gpt-5.4"');
expect(toml).toContain('[mcp_servers.other]');
expect(toml).toContain('[mcp_servers.ejclaw]');
expect(toml).toContain('EJCLAW_IPC_DIR = "/workspace/ipc"');
expect(toml).toContain('EJCLAW_GROUP_FOLDER = "codex-test-group"');
expect(toml).toContain('EJCLAW_WORK_DIR = "/workspace/project"');
expect(toml).toContain(`${EJCLAW_ENV.ipcDir} = "/workspace/ipc"`);
expect(toml).toContain(`${EJCLAW_ENV.groupFolder} = "codex-test-group"`);
expect(toml).toContain(`${EJCLAW_ENV.roomRole} = "reviewer"`);
expect(toml).toContain(`${EJCLAW_ENV.workDir} = "/workspace/project"`);
expect(toml).not.toContain('old-ipc.js');
expect(toml).not.toContain('"/old/ipc"');
expect(toml.match(/\[mcp_servers\.ejclaw\]/g)).toHaveLength(1);

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { EJCLAW_ENV } from 'ejclaw-runners-shared';
import {
GROUPS_DIR,
@@ -11,7 +12,15 @@ import {
} from './config.js';
import { logger } from './logger.js';
import { readEnvFile } from './env.js';
import { getActiveCodexAuthPath } from './codex-token-rotation.js';
import {
claimCodexAuthLease,
findCodexAccountIndexByAuthPath,
getActiveCodexAuthPath,
getCodexAccountCount,
type CodexAuthLease,
} from './codex-token-rotation.js';
import { readCodexFeatureFromFile } from './codex-config-features.js';
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
import {
getConfiguredClaudeTokens,
getCurrentToken,
@@ -33,6 +42,7 @@ import {
hasReviewerLease,
} from './service-routing.js';
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
import type { StoredRoomSkillOverride } from './db/rooms.js';
// writeCodexApiKeyAuth removed — Codex uses OAuth only.
// API key auth caused unintended billing.
@@ -53,6 +63,72 @@ function syncDirectoryEntries(sources: string[], destination: string): void {
}
}
type SkillSyncScope = 'codex-user' | 'claude-user' | 'runner' | 'workdir';
interface SkillSyncSource {
dir: string;
scope: SkillSyncScope;
}
function getDisabledSkillNamesByScope(
overrides: StoredRoomSkillOverride[] | undefined,
agentType: AgentType,
): Map<SkillSyncScope, Set<string>> {
const disabled = new Map<SkillSyncScope, Set<string>>();
for (const override of overrides ?? []) {
if (override.agentType !== agentType || override.enabled !== false)
continue;
if (
override.skillScope !== 'codex-user' &&
override.skillScope !== 'claude-user' &&
override.skillScope !== 'runner'
) {
continue;
}
const names = disabled.get(override.skillScope) ?? new Set<string>();
names.add(override.skillName);
disabled.set(override.skillScope, names);
}
return disabled;
}
function hasDisabledSkillOverrides(
overrides: StoredRoomSkillOverride[] | undefined,
agentType: AgentType,
): boolean {
return (overrides ?? []).some(
(override) =>
override.agentType === agentType && override.enabled === false,
);
}
function syncRoomSkillDirectories(args: {
sources: SkillSyncSource[];
destination: string;
agentType: AgentType;
overrides?: StoredRoomSkillOverride[];
}): void {
const disabledNamesByScope = getDisabledSkillNamesByScope(
args.overrides,
args.agentType,
);
fs.rmSync(args.destination, { recursive: true, force: true });
fs.mkdirSync(args.destination, { recursive: true });
for (const source of args.sources) {
if (!fs.existsSync(source.dir)) continue;
for (const entry of fs.readdirSync(source.dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (disabledNamesByScope.get(source.scope)?.has(entry.name)) continue;
const srcPath = path.join(source.dir, entry.name);
const dstPath = path.join(args.destination, entry.name);
fs.cpSync(srcPath, dstPath, { recursive: true });
}
}
}
function readOptionalPromptFile(
projectRoot: string,
filename: string,
@@ -63,44 +139,39 @@ function readOptionalPromptFile(
return prompt || undefined;
}
function ensureClaudeSessionSettings(groupSessionsDir: string): void {
const settingsFile = path.join(groupSessionsDir, 'settings.json');
if (fs.existsSync(settingsFile)) return;
fs.writeFileSync(
settingsFile,
JSON.stringify(
{
env: {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
},
null,
2,
) + '\n',
);
}
export function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
const settingsFile = path.join(sessionDir, '.claude.json');
if (fs.existsSync(settingsFile)) return;
fs.writeFileSync(settingsFile, '{}\n');
}
function syncHostCodexSessionFiles(sessionCodexDir: string): void {
export interface PreparedCodexSessionAuth {
canonicalAuthPath: string;
sessionAuthPath: string;
accountIndex: number | null;
lease?: CodexAuthLease;
}
function syncHostCodexSessionFiles(
sessionCodexDir: string,
): PreparedCodexSessionAuth | null {
const hostCodexDir = path.join(os.homedir(), '.codex');
fs.mkdirSync(sessionCodexDir, { recursive: true });
const authDst = path.join(sessionCodexDir, 'auth.json');
const rotatedAuthSrc = getActiveCodexAuthPath();
const hasRotationAccounts = getCodexAccountCount() > 0;
const lease = claimCodexAuthLease();
const rotatedAuthSrc =
lease?.authPath ?? (!hasRotationAccounts ? getActiveCodexAuthPath() : null);
const fallbackAuthSrc = path.join(hostCodexDir, 'auth.json');
const authSrc =
rotatedAuthSrc && fs.existsSync(rotatedAuthSrc)
? rotatedAuthSrc
: path.join(hostCodexDir, 'auth.json');
if (fs.existsSync(authSrc)) {
: !hasRotationAccounts && fs.existsSync(fallbackAuthSrc)
? fallbackAuthSrc
: null;
if (authSrc) {
fs.copyFileSync(authSrc, authDst);
} else if (fs.existsSync(authDst)) {
fs.unlinkSync(authDst);
@@ -113,6 +184,28 @@ function syncHostCodexSessionFiles(sessionCodexDir: string): void {
fs.copyFileSync(src, dst);
}
}
if (
!rotatedAuthSrc ||
authSrc !== rotatedAuthSrc ||
!fs.existsSync(authDst)
) {
lease?.release();
if (hasRotationAccounts) {
throw new Error(
'Codex rotation pool unavailable: all rotation accounts are currently dead, rate-limited, or locked; re-auth or clear stale state before launching Codex',
);
}
return null;
}
return {
canonicalAuthPath: rotatedAuthSrc,
sessionAuthPath: authDst,
accountIndex:
lease?.accountIndex ?? findCodexAccountIndexByAuthPath(rotatedAuthSrc),
...(lease ? { lease } : {}),
};
}
function upsertEjclawMcpServerSection(args: {
@@ -124,6 +217,7 @@ function upsertEjclawMcpServerSection(args: {
groupFolder: string;
isMain: boolean;
agentType: AgentType;
roomRole?: PairedRoomRole;
workDir?: string;
}): void {
const stripEjclawMcpServerSections = (input: string): string => {
@@ -160,19 +254,22 @@ function upsertEjclawMcpServerSection(args: {
? fs.readFileSync(args.sessionConfigPath, 'utf-8')
: '';
toml = stripEjclawMcpServerSections(toml);
const tomlEnvLine = (name: string, value: string): string =>
`${name} = ${JSON.stringify(value)}`;
const mcpSection = `
[mcp_servers.ejclaw]
command = "node"
args = [${JSON.stringify(args.mcpServerPath)}]
[mcp_servers.ejclaw.env]
EJCLAW_IPC_DIR = ${JSON.stringify(args.ipcDir)}
EJCLAW_HOST_IPC_DIR = ${JSON.stringify(args.hostIpcDir)}
EJCLAW_CHAT_JID = ${JSON.stringify(args.chatJid)}
EJCLAW_GROUP_FOLDER = ${JSON.stringify(args.groupFolder)}
EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')}
EJCLAW_AGENT_TYPE = ${JSON.stringify(args.agentType)}
${args.workDir ? `EJCLAW_WORK_DIR = ${JSON.stringify(args.workDir)}\n` : ''}
${tomlEnvLine(EJCLAW_ENV.ipcDir, args.ipcDir)}
${tomlEnvLine(EJCLAW_ENV.hostIpcDir, args.hostIpcDir)}
${tomlEnvLine(EJCLAW_ENV.chatJid, args.chatJid)}
${tomlEnvLine(EJCLAW_ENV.groupFolder, args.groupFolder)}
${tomlEnvLine(EJCLAW_ENV.isMain, args.isMain ? '1' : '0')}
${tomlEnvLine(EJCLAW_ENV.agentType, args.agentType)}
${args.roomRole ? `${tomlEnvLine(EJCLAW_ENV.roomRole, args.roomRole)}\n` : ''}
${args.workDir ? `${tomlEnvLine(EJCLAW_ENV.workDir, args.workDir)}\n` : ''}
`;
fs.writeFileSync(args.sessionConfigPath, toml.trimEnd() + '\n' + mcpSection);
}
@@ -212,18 +309,18 @@ function buildBaseRunnerEnv(args: {
: currentPath,
TZ: TIMEZONE,
HOME: os.homedir(),
EJCLAW_GROUP_DIR: args.groupDir,
EJCLAW_IPC_DIR: args.groupIpcDir,
EJCLAW_HOST_IPC_DIR: args.hostIpcDir,
EJCLAW_GLOBAL_DIR: args.globalDir,
...(args.group.workDir ? { EJCLAW_WORK_DIR: args.group.workDir } : {}),
EJCLAW_CHAT_JID: args.chatJid,
EJCLAW_GROUP_FOLDER: args.group.folder,
EJCLAW_IS_MAIN: args.isMain ? '1' : '0',
EJCLAW_AGENT_TYPE: args.agentType,
[EJCLAW_ENV.groupDir]: args.groupDir,
[EJCLAW_ENV.ipcDir]: args.groupIpcDir,
[EJCLAW_ENV.hostIpcDir]: args.hostIpcDir,
[EJCLAW_ENV.globalDir]: args.globalDir,
...(args.group.workDir ? { [EJCLAW_ENV.workDir]: args.group.workDir } : {}),
[EJCLAW_ENV.chatJid]: args.chatJid,
[EJCLAW_ENV.groupFolder]: args.group.folder,
[EJCLAW_ENV.isMain]: args.isMain ? '1' : '0',
[EJCLAW_ENV.agentType]: args.agentType,
CLAUDE_CONFIG_DIR: args.groupSessionsDir,
...(args.runtimeTaskId
? { EJCLAW_RUNTIME_TASK_ID: args.runtimeTaskId }
? { [EJCLAW_ENV.runtimeTaskId]: args.runtimeTaskId }
: {}),
};
}
@@ -232,7 +329,6 @@ function prepareClaudeEnvironment(args: {
env: Record<string, string>;
envVars: Record<string, string>;
group: RegisteredGroup;
roomRole?: PairedRoomRole;
}): void {
if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) {
args.env.ANTHROPIC_API_KEY =
@@ -280,15 +376,6 @@ function prepareClaudeEnvironment(args: {
if (args.group.agentConfig?.claudeEffort) {
args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort;
}
// Per-role overrides (paired room): take precedence over the flat values.
if (args.roomRole) {
const modelByRole =
args.group.agentConfig?.claudeModelByRole?.[args.roomRole];
if (modelByRole) args.env.CLAUDE_MODEL = modelByRole;
const effortByRole =
args.group.agentConfig?.claudeEffortByRole?.[args.roomRole];
if (effortByRole) args.env.CLAUDE_EFFORT = effortByRole;
}
if (args.group.agentConfig?.claudeThinking) {
args.env.CLAUDE_THINKING = args.group.agentConfig.claudeThinking;
}
@@ -308,39 +395,31 @@ function prepareCodexSessionEnvironment(args: {
sessionRootDir: string;
chatJid: string;
isMain: boolean;
roomRole?: PairedRoomRole;
isPairedRoom: boolean;
useFailoverPromptPack: boolean;
memoryBriefing?: string;
roomRole?: PairedRoomRole;
}): void {
skillOverrides?: StoredRoomSkillOverride[];
}): PreparedCodexSessionAuth | null {
// API key auth intentionally removed — Codex uses OAuth only.
// Never pass any API key to Codex child process to prevent API billing.
delete args.env.OPENAI_API_KEY;
delete args.env.CODEX_OPENAI_API_KEY;
// Per-role overrides (paired room) win over flat config which wins over env.
const codexModelByRole = args.roomRole
? args.group.agentConfig?.codexModelByRole?.[args.roomRole]
: undefined;
const codexModel =
codexModelByRole ||
args.group.agentConfig?.codexModel ||
args.envVars.CODEX_MODEL ||
process.env.CODEX_MODEL;
if (codexModel) args.env.CODEX_MODEL = codexModel;
const codexEffortByRole = args.roomRole
? args.group.agentConfig?.codexEffortByRole?.[args.roomRole]
: undefined;
const codexEffort =
codexEffortByRole ||
args.group.agentConfig?.codexEffort ||
args.envVars.CODEX_EFFORT ||
process.env.CODEX_EFFORT;
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
syncHostCodexSessionFiles(sessionCodexDir);
const codexSessionAuth = syncHostCodexSessionFiles(sessionCodexDir);
const overlayPath = path.join(args.groupDir, '.codex', 'config.toml');
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
@@ -357,24 +436,19 @@ function prepareCodexSessionEnvironment(args: {
}
}
const goalsFromConfig = readCodexFeatureFromFile(sessionConfigPath, 'goals');
const codexGoals =
args.group.agentConfig?.codexGoals ??
(goalsFromConfig ||
args.envVars.CODEX_GOALS === 'true' ||
process.env.CODEX_GOALS === 'true');
if (codexGoals) {
args.env.CODEX_GOALS = 'true';
} else {
delete args.env.CODEX_GOALS;
}
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
const codexGlobalDir = path.join(GROUPS_DIR, 'global');
const codexGlobalClaudeMdPath = path.join(codexGlobalDir, 'CLAUDE.md');
const codexGlobalMemory =
!args.isMain && fs.existsSync(codexGlobalClaudeMdPath)
? fs.readFileSync(codexGlobalClaudeMdPath, 'utf-8').trim()
: undefined;
const codexGroupClaudeMdPath = path.join(
GROUPS_DIR,
args.group.folder,
'CLAUDE.md',
);
const codexGroupMemory =
args.group.folder !== 'global' &&
args.group.folder !== 'main' &&
fs.existsSync(codexGroupClaudeMdPath)
? fs.readFileSync(codexGroupClaudeMdPath, 'utf-8').trim()
: undefined;
const sessionAgents = (
args.useFailoverPromptPack
? [
@@ -389,12 +463,9 @@ function prepareCodexSessionEnvironment(args: {
'owner-common-paired-room.md',
)
: undefined,
codexGlobalMemory,
codexGroupMemory,
args.memoryBriefing,
]
: [
readOptionalPromptFile(args.projectRoot, 'owner-common-platform.md'),
readPlatformPrompt('codex', args.projectRoot),
args.isPairedRoom
? readOptionalPromptFile(
@@ -402,8 +473,6 @@ function prepareCodexSessionEnvironment(args: {
'owner-common-paired-room.md',
)
: undefined,
codexGlobalMemory,
codexGroupMemory,
args.memoryBriefing,
]
)
@@ -418,15 +487,39 @@ function prepareCodexSessionEnvironment(args: {
// Codex reads skills from ~/.agents/skills/ (user-level) and
// {workDir}/.agents/skills/ (project-level), NOT from .codex/skills/.
// Sync to the user-level path so all Codex sessions can discover them.
const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills');
syncDirectoryEntries(
[
path.join(os.homedir(), '.claude', 'skills'),
path.join(args.projectRoot, 'runners', 'skills'),
],
codexSkillsDir,
);
if (hasDisabledSkillOverrides(args.skillOverrides, 'codex')) {
const sessionHomeDir = path.join(args.sessionRootDir, 'home');
args.env.HOME = sessionHomeDir;
syncRoomSkillDirectories({
sources: [
{
dir: path.join(os.homedir(), '.claude', 'skills'),
scope: 'codex-user',
},
{
dir: path.join(os.homedir(), '.agents', 'skills'),
scope: 'codex-user',
},
{
dir: path.join(args.projectRoot, 'runners', 'skills'),
scope: 'runner',
},
],
destination: path.join(sessionHomeDir, '.agents', 'skills'),
agentType: 'codex',
overrides: args.skillOverrides,
});
} else {
// Preserve the historical global sync path when no room override exists.
const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills');
syncDirectoryEntries(
[
path.join(os.homedir(), '.claude', 'skills'),
path.join(args.projectRoot, 'runners', 'skills'),
],
codexSkillsDir,
);
}
const mcpServerPath = path.join(
args.projectRoot,
@@ -439,14 +532,15 @@ function prepareCodexSessionEnvironment(args: {
upsertEjclawMcpServerSection({
sessionConfigPath,
mcpServerPath,
ipcDir: args.env.EJCLAW_IPC_DIR,
hostIpcDir: args.env.EJCLAW_HOST_IPC_DIR,
ipcDir: args.env[EJCLAW_ENV.ipcDir],
hostIpcDir: args.env[EJCLAW_ENV.hostIpcDir],
chatJid: args.chatJid,
groupFolder: args.group.folder,
isMain: args.isMain,
agentType: args.group.agentType || 'claude-code',
agentType: 'codex',
roomRole: args.roomRole,
workDir:
args.env.EJCLAW_WORK_DIR || args.group.workDir || args.projectRoot,
args.env[EJCLAW_ENV.workDir] || args.group.workDir || args.projectRoot,
});
}
@@ -455,12 +549,19 @@ function prepareCodexSessionEnvironment(args: {
delete args.env.ANTHROPIC_BASE_URL;
delete args.env.CLAUDE_CODE_OAUTH_TOKEN;
args.env.CODEX_HOME = sessionCodexDir;
return codexSessionAuth;
}
export interface PreparedGroupEnvironment {
env: Record<string, string>;
groupDir: string;
runnerDir: string;
codexSessionAuth?: PreparedCodexSessionAuth | null;
}
export interface PreparedReadonlySessionEnvironment {
codexHomeDir?: string;
codexSessionAuth?: PreparedCodexSessionAuth | null;
}
export function prepareGroupEnvironment(
@@ -471,12 +572,7 @@ export function prepareGroupEnvironment(
memoryBriefing?: string;
runtimeTaskId?: string;
useTaskScopedSession?: boolean;
/**
* When provided, the runner consults `agentConfig.{claudeModel,claudeEffort,
* codexModel,codexEffort}ByRole[roomRole]` and lets those entries override
* the flat per-group values. Used by paired rooms to spend the expensive
* model on a specific role (typically arbiter) only.
*/
skillOverrides?: StoredRoomSkillOverride[];
roomRole?: PairedRoomRole;
},
): PreparedGroupEnvironment {
@@ -503,12 +599,26 @@ export function prepareGroupEnvironment(
const workDirClaude = group.workDir
? path.join(group.workDir, '.claude')
: null;
const skillSources = [
path.join(os.homedir(), '.claude', 'skills'),
...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []),
path.join(projectRoot, 'runners', 'skills'),
];
syncDirectoryEntries(skillSources, path.join(groupSessionsDir, 'skills'));
syncRoomSkillDirectories({
sources: [
{
dir: path.join(os.homedir(), '.claude', 'skills'),
scope: 'claude-user',
},
...(workDirClaude
? [
{
dir: path.join(workDirClaude, 'skills'),
scope: 'workdir' as const,
},
]
: []),
{ dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
],
destination: path.join(groupSessionsDir, 'skills'),
agentType: 'claude-code',
overrides: options?.skillOverrides,
});
const groupIpcDir = runtimeTaskId
? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId)
@@ -536,20 +646,10 @@ export function prepareGroupEnvironment(
const ownerCommonPairedRoomPrompt = isPairedRoom
? readOptionalPromptFile(projectRoot, 'owner-common-paired-room.md')
: undefined;
const claudePairedRoomPrompt = isPairedRoom
? readPairedRoomPrompt('claude-code', projectRoot)
: undefined;
const globalClaudeMemory =
!isMain && fs.existsSync(globalClaudeMdPath)
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
: undefined;
const groupClaudeMdPath = path.join(GROUPS_DIR, group.folder, 'CLAUDE.md');
const groupClaudeMemory =
group.folder !== 'global' &&
group.folder !== 'main' &&
fs.existsSync(groupClaudeMdPath)
? fs.readFileSync(groupClaudeMdPath, 'utf-8').trim()
: undefined;
// Owner CLAUDE.md: platform rules + owner paired room rules.
// Reviewer paired room rules are NOT included — those belong to the
// Read-only reviewer/arbiter session only (via prepareReadonlySessionEnvironment).
@@ -558,7 +658,6 @@ export function prepareGroupEnvironment(
claudePlatformPrompt,
ownerCommonPairedRoomPrompt,
globalClaudeMemory,
groupClaudeMemory,
options?.memoryBriefing,
]
.filter((value): value is string => Boolean(value))
@@ -587,6 +686,7 @@ export function prepareGroupEnvironment(
'CLAUDE_EFFORT',
'CODEX_MODEL',
'CODEX_EFFORT',
'CODEX_GOALS',
]);
const env = buildBaseRunnerEnv({
@@ -603,8 +703,9 @@ export function prepareGroupEnvironment(
runtimeTaskId,
});
let codexSessionAuth: PreparedCodexSessionAuth | null = null;
if (agentType === 'codex') {
prepareCodexSessionEnvironment({
codexSessionAuth = prepareCodexSessionEnvironment({
env,
envVars,
projectRoot,
@@ -613,21 +714,17 @@ export function prepareGroupEnvironment(
sessionRootDir,
chatJid,
isMain,
roomRole: options?.roomRole,
isPairedRoom,
useFailoverPromptPack: useCodexReviewFailoverPromptPack,
memoryBriefing: options?.memoryBriefing,
roomRole: options?.roomRole,
skillOverrides: options?.skillOverrides,
});
} else {
prepareClaudeEnvironment({
env,
envVars,
group,
roomRole: options?.roomRole,
});
prepareClaudeEnvironment({ env, envVars, group });
}
return { env, groupDir, runnerDir };
return { env, groupDir, runnerDir, codexSessionAuth };
}
/**
@@ -650,7 +747,8 @@ export function prepareReadonlySessionEnvironment(args: {
ipcDir?: string;
hostIpcDir?: string;
workDir?: string;
}): void {
skillOverrides?: StoredRoomSkillOverride[];
}): PreparedReadonlySessionEnvironment {
const {
sessionDir,
chatJid,
@@ -662,19 +760,50 @@ export function prepareReadonlySessionEnvironment(args: {
ipcDir = '/workspace/ipc',
hostIpcDir = ipcDir,
workDir = '/workspace/project',
skillOverrides,
} = args;
const projectRoot = process.cwd();
let codexSessionAuth: PreparedCodexSessionAuth | null = null;
fs.mkdirSync(sessionDir, { recursive: true });
ensureClaudeSessionSettings(sessionDir);
ensureClaudeGlobalSettingsFile(sessionDir);
// Sync skills from host
const skillSources = [
path.join(os.homedir(), '.claude', 'skills'),
path.join(projectRoot, 'runners', 'skills'),
];
syncDirectoryEntries(skillSources, path.join(sessionDir, 'skills'));
syncRoomSkillDirectories({
sources: [
{
dir: path.join(os.homedir(), '.claude', 'skills'),
scope: agentType === 'codex' ? 'codex-user' : 'claude-user',
},
{ dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
],
destination: path.join(sessionDir, 'skills'),
agentType,
overrides: skillOverrides,
});
const codexHomeDir =
agentType === 'codex' && hasDisabledSkillOverrides(skillOverrides, 'codex')
? sessionDir
: undefined;
if (codexHomeDir) {
syncRoomSkillDirectories({
sources: [
{
dir: path.join(os.homedir(), '.claude', 'skills'),
scope: 'codex-user',
},
{
dir: path.join(os.homedir(), '.agents', 'skills'),
scope: 'codex-user',
},
{ dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' },
],
destination: path.join(codexHomeDir, '.agents', 'skills'),
agentType,
overrides: skillOverrides,
});
}
// Build CLAUDE.md with role-appropriate prompts (reviewer or arbiter)
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
@@ -689,19 +818,11 @@ export function prepareReadonlySessionEnvironment(args: {
!isMain && fs.existsSync(globalClaudeMdPath)
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
: undefined;
const groupClaudeMdPath = path.join(GROUPS_DIR, groupFolder, 'CLAUDE.md');
const groupClaudeMemory =
groupFolder !== 'global' &&
groupFolder !== 'main' &&
fs.existsSync(groupClaudeMdPath)
? fs.readFileSync(groupClaudeMdPath, 'utf-8').trim()
: undefined;
const sessionClaudeMd = [
claudePlatformPrompt,
claudePairedRoomPrompt,
globalClaudeMemory,
groupClaudeMemory,
memoryBriefing,
]
.filter((value): value is string => Boolean(value))
@@ -711,31 +832,39 @@ export function prepareReadonlySessionEnvironment(args: {
const sessionClaudeMdPath = path.join(sessionDir, 'CLAUDE.md');
if (sessionClaudeMd) {
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
const sessionCodexDir = path.join(sessionDir, '.codex');
syncHostCodexSessionFiles(sessionCodexDir);
fs.writeFileSync(
path.join(sessionCodexDir, 'AGENTS.md'),
sessionClaudeMd + '\n',
);
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
const mcpServerPath = path.join(
projectRoot,
'runners',
'agent-runner',
'dist',
'ipc-mcp-stdio.js',
);
if (fs.existsSync(mcpServerPath)) {
upsertEjclawMcpServerSection({
sessionConfigPath,
mcpServerPath,
ipcDir,
hostIpcDir,
chatJid,
groupFolder,
isMain,
agentType,
workDir,
if (agentType === 'codex') {
const sessionCodexDir = path.join(sessionDir, '.codex');
codexSessionAuth = syncHostCodexSessionFiles(sessionCodexDir);
fs.writeFileSync(
path.join(sessionCodexDir, 'AGENTS.md'),
sessionClaudeMd + '\n',
);
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
const mcpServerPath = path.join(
projectRoot,
'runners',
'agent-runner',
'dist',
'ipc-mcp-stdio.js',
);
if (fs.existsSync(mcpServerPath)) {
upsertEjclawMcpServerSection({
sessionConfigPath,
mcpServerPath,
ipcDir,
hostIpcDir,
chatJid,
groupFolder,
isMain,
agentType,
roomRole: role,
workDir,
});
}
} else {
fs.rmSync(path.join(sessionDir, '.codex'), {
recursive: true,
force: true,
});
}
logger.info(
@@ -752,4 +881,5 @@ export function prepareReadonlySessionEnvironment(args: {
} else if (fs.existsSync(sessionClaudeMdPath)) {
fs.unlinkSync(sessionClaudeMdPath);
}
return { codexHomeDir, codexSessionAuth };
}

View File

@@ -0,0 +1,99 @@
import type { ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { PassThrough } from 'stream';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js';
import { runSpawnedAgentProcess } from './agent-runner-process.js';
import type { AgentInput, AgentOutput } from './agent-runner.js';
import type { RegisteredGroup } from './types.js';
function buildProcess(): ChildProcess {
const proc = new EventEmitter() as ChildProcess & {
stdout: PassThrough;
stderr: PassThrough;
kill: ReturnType<typeof vi.fn>;
killed: boolean;
};
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
proc.killed = false;
proc.kill = vi.fn(() => {
proc.killed = true;
return true;
});
return proc;
}
function timed<T>(promise: Promise<T>): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('agent process did not resolve')), 100),
),
]);
}
const group: RegisteredGroup = {
name: 'Agent Runner Test',
folder: 'agent-runner-test',
trigger: '@Agent',
added_at: '2026-01-01T00:00:00.000Z',
agentType: 'codex',
};
const input: AgentInput = {
prompt: 'run',
groupFolder: 'agent-runner-test',
chatJid: 'dc:test',
runId: 'run-1',
isMain: false,
};
describe('runSpawnedAgentProcess', () => {
let logsDir: string | null = null;
afterEach(() => {
if (logsDir) {
fs.rmSync(logsDir, { recursive: true, force: true });
logsDir = null;
}
});
it('resolves even when streamed output delivery rejects', async () => {
logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-agent-runner-'));
const proc = buildProcess();
const onOutput = vi.fn<(_: AgentOutput) => Promise<void>>(async () => {
throw new Error('delivery failed');
});
const resultPromise = runSpawnedAgentProcess({
proc,
group,
input,
processName: 'test-agent',
logsDir,
startTime: Date.now(),
onOutput,
});
(proc.stdout as PassThrough).write(
[
OUTPUT_START_MARKER,
JSON.stringify({ status: 'success', result: 'hello' }),
OUTPUT_END_MARKER,
].join('\n'),
);
proc.emit('close', 0, null);
await expect(timed(resultPromise)).resolves.toMatchObject({
status: 'success',
result: null,
});
expect(onOutput).toHaveBeenCalledOnce();
});
});

View File

@@ -22,6 +22,11 @@ interface RunSpawnedAgentProcessArgs {
logsDir: string;
startTime: number;
onOutput?: (output: AgentOutput) => Promise<void>;
onTerminalStreamedOutputFlushed?: (output: AgentOutput) => void;
}
function isTerminalStreamedOutput(output: AgentOutput): boolean {
return (output.phase ?? 'final') !== 'progress';
}
function parseLegacyAgentOutput(stdout: string): AgentOutput {
@@ -39,6 +44,88 @@ function parseLegacyAgentOutput(stdout: string): AgentOutput {
return JSON.parse(jsonLine) as AgentOutput;
}
function logStreamedOutputDeliveryError(
err: unknown,
group: RegisteredGroup,
input: AgentInput,
): void {
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: getErrorMessage(err),
},
'Streamed agent output delivery failed',
);
}
function logStreamedAgentErrorOutput(
output: AgentOutput,
group: RegisteredGroup,
input: AgentInput,
): void {
if (output.status !== 'error') return;
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: output.error,
newSessionId: output.newSessionId,
},
'Streamed agent error output',
);
}
function chainStreamedOutputDelivery(args: {
outputChain: Promise<void>;
parsed: AgentOutput;
onOutput: (output: AgentOutput) => Promise<void>;
onTerminalStreamedOutputFlushed?: (output: AgentOutput) => void;
group: RegisteredGroup;
input: AgentInput;
}): Promise<void> {
return args.outputChain.then(async () => {
try {
await args.onOutput(args.parsed);
if (isTerminalStreamedOutput(args.parsed)) {
args.onTerminalStreamedOutputFlushed?.(args.parsed);
}
} catch (err) {
logStreamedOutputDeliveryError(err, args.group, args.input);
}
});
}
function writeTimeoutLog(args: {
logsDir: string;
input: AgentInput;
group: RegisteredGroup;
processName: string;
duration: number;
code: number | null;
signal: NodeJS.Signals | null;
hadStreamingOutput: boolean;
}): void {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.writeFileSync(
path.join(args.logsDir, `agent-${args.input.runId || 'adhoc'}-${ts}.log`),
[
`=== Agent Run Log (TIMEOUT) ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${args.group.name}`,
`ChatJid: ${args.input.chatJid}`,
`RunId: ${args.input.runId || 'n/a'}`,
`Process: ${args.processName}`,
`Duration: ${args.duration}ms`,
`Exit Code: ${args.code}`,
`Signal: ${args.signal}`,
`Had Streaming Output: ${args.hadStreamingOutput}`,
].join('\n'),
);
}
export function runSpawnedAgentProcess(
args: RunSpawnedAgentProcessArgs,
): Promise<AgentOutput> {
@@ -144,19 +231,16 @@ export function runSpawnedAgentProcess(
}
hadStreamingOutput = true;
resetTimeout();
if (parsed.status === 'error') {
logger.warn(
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: parsed.error,
newSessionId: parsed.newSessionId,
},
'Streamed agent error output',
);
}
outputChain = outputChain.then(() => onOutput(parsed));
logStreamedAgentErrorOutput(parsed, group, input);
outputChain = chainStreamedOutputDelivery({
outputChain,
parsed,
onOutput,
onTerminalStreamedOutputFlushed:
args.onTerminalStreamedOutputFlushed,
group,
input,
});
} catch (err) {
logger.warn(
{
@@ -215,22 +299,16 @@ export function runSpawnedAgentProcess(
const duration = Date.now() - startTime;
if (timedOut) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.writeFileSync(
path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`),
[
`=== Agent Run Log (TIMEOUT) ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`ChatJid: ${input.chatJid}`,
`RunId: ${input.runId || 'n/a'}`,
`Process: ${processName}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Signal: ${signal}`,
`Had Streaming Output: ${hadStreamingOutput}`,
].join('\n'),
);
writeTimeoutLog({
logsDir,
input,
group,
processName,
duration,
code,
signal,
hadStreamingOutput,
});
if (hadStreamingOutput) {
logger.info(

View File

@@ -71,6 +71,10 @@ vi.mock('./service-routing.js', () => ({
hasReviewerLease: vi.fn(() => true),
}));
vi.mock('./db.js', () => ({
getStoredRoomSkillOverrides: vi.fn(() => []),
}));
// Create a controllable fake ChildProcess
function createFakeProcess() {
const proc = new EventEmitter() as EventEmitter & {
@@ -108,6 +112,7 @@ vi.mock('child_process', async () => {
import { runAgentProcess, AgentOutput } from './agent-runner.js';
import * as agentRunnerEnvironment from './agent-runner-environment.js';
import { getStoredRoomSkillOverrides } from './db.js';
import type { RegisteredGroup } from './types.js';
const testGroup: RegisteredGroup = {
@@ -132,6 +137,72 @@ function emitOutputMarker(
proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`);
}
function mockCodexLeaseEnvironment(releaseLease: () => void) {
return vi
.spyOn(agentRunnerEnvironment, 'prepareGroupEnvironment')
.mockReturnValueOnce({
env: {
EJCLAW_IPC_DIR: '/tmp/ejclaw-test-data/ipc/test-group',
},
groupDir: '/tmp/ejclaw-test-groups/test-group',
runnerDir: '/tmp/ejclaw-test-runners/codex-runner',
codexSessionAuth: {
canonicalAuthPath: '/tmp/codex-account/auth.json',
sessionAuthPath: '/tmp/codex-session/auth.json',
accountIndex: 0,
lease: {
accountIndex: 0,
authPath: '/tmp/codex-account/auth.json',
release: releaseLease,
},
},
});
}
async function expectCodexLeaseReleasedAfterFinalOutputFlush() {
const releaseLease = vi.fn();
const prepareGroupEnvironmentSpy = mockCodexLeaseEnvironment(releaseLease);
const onOutput = vi.fn(async () => {});
const codexGroup: RegisteredGroup = {
...testGroup,
agentType: 'codex',
};
let resultPromise: Promise<AgentOutput> | undefined;
try {
resultPromise = runAgentProcess(
codexGroup,
{
...testInput,
runId: 'run-codex-lease-final',
},
() => {},
onOutput,
);
emitOutputMarker(fakeProc, {
status: 'success',
result: 'TASK_DONE\n작업 완료',
phase: 'final',
newSessionId: 'codex-session-lease-final',
});
await vi.advanceTimersByTimeAsync(10);
expect(onOutput).toHaveBeenCalledWith(
expect.objectContaining({
result: 'TASK_DONE\n작업 완료',
phase: 'final',
}),
);
expect(releaseLease).toHaveBeenCalledTimes(1);
} finally {
fakeProc.emit('close', 0);
await vi.advanceTimersByTimeAsync(10);
await resultPromise?.catch(() => undefined);
prepareGroupEnvironmentSpy.mockRestore();
}
}
describe('agent-runner timeout behavior', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -152,23 +223,18 @@ describe('agent-runner timeout behavior', () => {
onOutput,
);
// Emit output with a result
emitOutputMarker(fakeProc, {
status: 'success',
result: 'Here is my response',
newSessionId: 'session-123',
});
// Let output processing settle
await vi.advanceTimersByTimeAsync(10);
// Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
await vi.advanceTimersByTimeAsync(1830000);
// Emit close event (as if agent was stopped by the timeout)
fakeProc.emit('close', 137);
// Let the promise resolve
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
@@ -188,10 +254,8 @@ describe('agent-runner timeout behavior', () => {
onOutput,
);
// No output emitted — fire the hard timeout
await vi.advanceTimersByTimeAsync(1830000);
// Emit close event
fakeProc.emit('close', 137);
await vi.advanceTimersByTimeAsync(10);
@@ -211,7 +275,6 @@ describe('agent-runner timeout behavior', () => {
onOutput,
);
// Emit output
emitOutputMarker(fakeProc, {
status: 'success',
result: 'Done',
@@ -220,7 +283,6 @@ describe('agent-runner timeout behavior', () => {
await vi.advanceTimersByTimeAsync(10);
// Normal exit (no timeout)
fakeProc.emit('close', 0);
await vi.advanceTimersByTimeAsync(10);
@@ -230,6 +292,10 @@ describe('agent-runner timeout behavior', () => {
expect(result.newSessionId).toBe('session-456');
});
it('releases Codex auth lease after final streamed output flushes even before process close', async () => {
await expectCodexLeaseReleasedAfterFinalOutputFlush();
});
it('preserves streamed progress phase metadata', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runAgentProcess(
@@ -271,6 +337,18 @@ describe('agent-runner timeout behavior', () => {
}),
);
});
});
describe('agent-runner environment wiring', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
fakeProc = createFakeProcess();
});
afterEach(() => {
vi.useRealTimers();
});
it('passes the actual chat JID into codex runner MCP env', async () => {
vi.useRealTimers();
@@ -408,6 +486,55 @@ describe('agent-runner timeout behavior', () => {
expect(spawnEnv?.CODEX_HOME).toBe('/tmp/host-reviewer-session/.codex');
});
it('releases the initial Codex auth lease when read-only session preparation fails', async () => {
vi.useRealTimers();
fakeProc = createFakeProcess();
const releaseLease = vi.fn();
const prepareGroupEnvironmentSpy = mockCodexLeaseEnvironment(releaseLease);
const readonlyError = new Error('Codex rotation pool unavailable');
const prepareReadonlySessionEnvironmentSpy = vi
.spyOn(agentRunnerEnvironment, 'prepareReadonlySessionEnvironment')
.mockImplementationOnce(() => {
throw readonlyError;
});
const codexGroup: RegisteredGroup = {
...testGroup,
agentType: 'codex',
};
try {
await expect(
runAgentProcess(
codexGroup,
{
...testInput,
roomRoleContext: {
serviceId: 'codex-review',
role: 'reviewer',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
failoverOwner: false,
},
},
() => {},
async () => {},
{
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
CLAUDE_CONFIG_DIR: '/tmp/host-reviewer-session',
EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer',
},
),
).rejects.toThrow(readonlyError);
expect(prepareReadonlySessionEnvironmentSpy).toHaveBeenCalledTimes(1);
expect(releaseLease).toHaveBeenCalledTimes(1);
} finally {
prepareReadonlySessionEnvironmentSpy.mockRestore();
prepareGroupEnvironmentSpy.mockRestore();
}
});
it('serializes roomRoleContext into the runner stdin payload', async () => {
vi.useRealTimers();
fakeProc = createFakeProcess();
@@ -629,6 +756,18 @@ OUROBOROS_LLM_BACKEND = "codex"
expect(toml).toContain('OUROBOROS_AGENT_RUNTIME = "codex"');
expect(toml).toContain('[mcp_servers.ejclaw]');
});
});
describe('agent-runner output flushing', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
fakeProc = createFakeProcess();
});
afterEach(() => {
vi.useRealTimers();
});
it('waits for queued streamed output before resolving an error exit', async () => {
let releaseOutputs: (() => void) | undefined;
@@ -680,3 +819,110 @@ OUROBOROS_LLM_BACKEND = "codex"
);
});
});
describe('agent-runner room skill overrides', () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([]);
fakeProc = createFakeProcess();
});
afterEach(() => {
vi.useRealTimers();
});
it('passes room skill overrides into runner environment setup', async () => {
const skillOverride = {
chatJid: testInput.chatJid,
agentType: 'claude-code' as const,
skillScope: 'runner',
skillName: 'runner-off',
enabled: false,
createdAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
};
vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([skillOverride]);
const prepareGroupEnvironmentSpy = vi.spyOn(
agentRunnerEnvironment,
'prepareGroupEnvironment',
);
const resultPromise = runAgentProcess(
testGroup,
testInput,
() => {},
async () => {},
);
fakeProc.emit('close', 0);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(getStoredRoomSkillOverrides).toHaveBeenCalledWith(testInput.chatJid);
expect(prepareGroupEnvironmentSpy).toHaveBeenCalledWith(
testGroup,
testInput.isMain,
testInput.chatJid,
expect.objectContaining({
skillOverrides: [skillOverride],
}),
);
});
});
describe('agent-runner Codex goals', () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([]);
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')
);
});
});
afterEach(() => {
vi.useRealTimers();
});
it('passes Codex goal opt-in config through env and runner stdin payload', async () => {
let stdinPayload = '';
fakeProc.stdin.on('data', (chunk) => {
stdinPayload += chunk.toString();
});
const codexGroup: RegisteredGroup = {
...testGroup,
agentType: 'codex',
agentConfig: {
codexGoals: true,
},
};
const resultPromise = runAgentProcess(
codexGroup,
testInput,
() => {},
async () => {},
);
fakeProc.emit('close', 0);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(JSON.parse(stdinPayload)).toMatchObject({
prompt: 'Hello',
codexGoals: true,
});
const spawnEnv = vi.mocked(spawn).mock.calls.at(-1)?.[2]?.env as
| Record<string, string>
| undefined;
expect(spawnEnv?.CODEX_GOALS).toBe('true');
});
});

View File

@@ -2,7 +2,10 @@ import { ChildProcess, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { isUnsafeHostPairedModeEnabled } from 'ejclaw-runners-shared';
import {
EJCLAW_ENV,
isUnsafeHostPairedModeEnabled,
} from 'ejclaw-runners-shared';
/**
* Agent Process Runner for EJClaw
@@ -11,16 +14,19 @@ import { isUnsafeHostPairedModeEnabled } from 'ejclaw-runners-shared';
import {
prepareReadonlySessionEnvironment,
prepareGroupEnvironment,
type PreparedCodexSessionAuth,
} from './agent-runner-environment.js';
import { syncCodexSessionAuthBack } from './codex-token-rotation.js';
import { runSpawnedAgentProcess } from './agent-runner-process.js';
import { getEnv } from './env.js';
import { getStoredRoomSkillOverrides } from './db.js';
export {
type AvailableGroup,
writeGroupsSnapshot,
writeTasksSnapshot,
} from './agent-runner-snapshot.js';
import { logger } from './logger.js';
import { AgentType, RegisteredGroup, RoomRoleContext } from './types.js';
import { RegisteredGroup, RoomRoleContext } from './types.js';
import type { StoredRoomSkillOverride } from './db/rooms.js';
export interface AgentInput {
prompt: string;
@@ -35,6 +41,7 @@ export interface AgentInput {
useTaskScopedSession?: boolean;
assistantName?: string;
agentType?: 'claude-code' | 'codex';
codexGoals?: boolean;
roomRoleContext?: RoomRoleContext;
}
@@ -48,6 +55,68 @@ export interface AgentOutput {
agentDone?: boolean;
newSessionId?: string;
error?: string;
compaction?: {
completed: boolean;
trigger?: string | null;
};
}
function readRoomSkillOverridesForRunner(
chatJid: string,
): StoredRoomSkillOverride[] {
try {
return getStoredRoomSkillOverrides(chatJid);
} catch (err) {
logger.warn(
{
err,
chatJid,
},
'Failed to read room skill overrides; falling back to default skills',
);
return [];
}
}
function releaseCodexAuthSession(
auth: PreparedCodexSessionAuth | null | undefined,
): void {
auth?.lease?.release();
}
function finalizeCodexAuthSession(
auth: PreparedCodexSessionAuth | null | undefined,
): void {
if (!auth) return;
try {
syncCodexSessionAuthBack({
canonicalAuthPath: auth.canonicalAuthPath,
sessionAuthPath: auth.sessionAuthPath,
accountIndex: auth.accountIndex,
});
} catch (err) {
logger.warn(
{
err,
canonicalAuthPath: auth.canonicalAuthPath,
accountIndex: auth.accountIndex,
},
'Failed to sync Codex session auth back to canonical slot',
);
} finally {
auth.lease?.release();
}
}
function createCodexAuthSessionFinalizer(
getAuth: () => PreparedCodexSessionAuth | null | undefined,
): () => void {
let finalized = false;
return () => {
if (finalized) return;
finalized = true;
finalizeCodexAuthSession(getAuth());
};
}
export async function runAgentProcess(
@@ -65,17 +134,16 @@ export async function runAgentProcess(
// ── Host process mode (owner) ───────────────────────────────────
const startTime = Date.now();
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
group,
input.isMain,
input.chatJid,
{
memoryBriefing: input.memoryBriefing,
runtimeTaskId: input.runtimeTaskId,
useTaskScopedSession: input.useTaskScopedSession,
roomRole: input.roomRoleContext?.role,
},
);
const skillOverrides = readRoomSkillOverridesForRunner(input.chatJid);
const prepared = prepareGroupEnvironment(group, input.isMain, input.chatJid, {
memoryBriefing: input.memoryBriefing,
runtimeTaskId: input.runtimeTaskId,
useTaskScopedSession: input.useTaskScopedSession,
skillOverrides,
roomRole: input.roomRoleContext?.role,
});
const { env, groupDir, runnerDir } = prepared;
let codexSessionAuth = prepared.codexSessionAuth;
// Apply env overrides (caller-provided)
if (envOverrides) {
@@ -89,29 +157,48 @@ export async function runAgentProcess(
(input.roomRoleContext?.role === 'reviewer' ||
input.roomRoleContext?.role === 'arbiter')
) {
prepareReadonlySessionEnvironment({
sessionDir: envOverrides.CLAUDE_CONFIG_DIR,
chatJid: input.chatJid,
isMain: input.isMain,
groupFolder: group.folder,
agentType: group.agentType || 'claude-code',
memoryBriefing: input.memoryBriefing,
role: input.roomRoleContext.role,
ipcDir: env.EJCLAW_IPC_DIR,
hostIpcDir: env.EJCLAW_HOST_IPC_DIR,
workDir: envOverrides.EJCLAW_WORK_DIR || env.EJCLAW_WORK_DIR,
});
if ((group.agentType || 'claude-code') === 'codex') {
const isCodexGroup = (group.agentType || 'claude-code') === 'codex';
const previousCodexSessionAuth = codexSessionAuth;
let readonlySession: ReturnType<typeof prepareReadonlySessionEnvironment>;
try {
readonlySession = prepareReadonlySessionEnvironment({
sessionDir: envOverrides.CLAUDE_CONFIG_DIR,
chatJid: input.chatJid,
isMain: input.isMain,
groupFolder: group.folder,
agentType: group.agentType || 'claude-code',
memoryBriefing: input.memoryBriefing,
role: input.roomRoleContext.role,
ipcDir: env[EJCLAW_ENV.ipcDir],
hostIpcDir: env[EJCLAW_ENV.hostIpcDir],
workDir: envOverrides[EJCLAW_ENV.workDir] || env[EJCLAW_ENV.workDir],
skillOverrides,
});
} catch (err) {
if (isCodexGroup) {
releaseCodexAuthSession(previousCodexSessionAuth);
}
throw err;
}
if (isCodexGroup) {
releaseCodexAuthSession(previousCodexSessionAuth);
codexSessionAuth = readonlySession.codexSessionAuth;
env.CODEX_HOME = path.join(envOverrides.CLAUDE_CONFIG_DIR, '.codex');
if (readonlySession.codexHomeDir) {
env.HOME = readonlySession.codexHomeDir;
}
}
}
if (input.runId) {
env.EJCLAW_RUN_ID = input.runId;
env[EJCLAW_ENV.runId] = input.runId;
}
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const processSuffix = input.runId || `${Date.now()}`;
const processName = `ejclaw-${safeName}-${processSuffix}`;
const finalizeCodexAuthSessionOnce = createCodexAuthSessionFinalizer(
() => codexSessionAuth,
);
// Check if runner is built
const distEntry = path.join(runnerDir, 'dist', 'index.js');
@@ -120,6 +207,7 @@ export async function runAgentProcess(
{ runnerDir, chatJid: input.chatJid, runId: input.runId },
'Runner not built. Run: cd runners/agent-runner && bun install && bun run build',
);
releaseCodexAuthSession(codexSessionAuth);
return {
status: 'error',
result: null,
@@ -150,19 +238,40 @@ export async function runAgentProcess(
env,
});
onProcess(proc, processName, env.EJCLAW_IPC_DIR);
onProcess(proc, processName, env[EJCLAW_ENV.ipcDir]);
proc.stdin.write(JSON.stringify(input));
const runnerInput: AgentInput = {
...input,
...(group.agentConfig?.codexGoals === true ? { codexGoals: true } : {}),
};
proc.stdin.write(JSON.stringify(runnerInput));
proc.stdin.end();
runSpawnedAgentProcess({
proc,
group,
input,
input: runnerInput,
processName,
logsDir,
startTime,
onOutput,
}).then(resolve);
onTerminalStreamedOutputFlushed: finalizeCodexAuthSessionOnce,
})
.then((output) => {
finalizeCodexAuthSessionOnce();
resolve(output);
})
.catch((err: unknown) => {
finalizeCodexAuthSessionOnce();
logger.error(
{ err, processName, chatJid: input.chatJid, runId: input.runId },
'Spawned agent process runner failed',
);
resolve({
status: 'error',
result: null,
error: err instanceof Error ? err.message : String(err),
});
});
});
}

View File

@@ -0,0 +1,21 @@
import path from 'path';
import { DATA_DIR } from './config.js';
import { isValidGroupFolder } from './group-folder.js';
import type { RegisteredGroup } from './types.js';
function unique(values: Array<string | null | undefined>): string[] {
return [
...new Set(values.filter((value): value is string => Boolean(value))),
];
}
export function resolveRuntimeAttachmentBaseDirs(
group: RegisteredGroup,
): string[] | undefined {
const workspaceRoot = isValidGroupFolder(group.folder)
? path.resolve(DATA_DIR, 'workspaces', group.folder)
: null;
const dirs = unique([group.workDir, workspaceRoot]);
return dirs.length > 0 ? dirs : undefined;
}

View File

@@ -0,0 +1,155 @@
import fs from 'fs';
import path from 'path';
import { Attachment } from 'discord.js';
import { isModelDocumentPath } from 'ejclaw-runners-shared';
import { DATA_DIR } from '../config.js';
import { logger } from '../logger.js';
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
const TEXT_ATTACHMENT_NAME_PATTERN =
/\.(txt|md|json|csv|log|xml|yaml|yml|toml|ini|cfg|conf|sh|bash|zsh|py|js|ts|jsx|tsx|html|css|sql|rs|go|java|c|cpp|h|hpp|rb|php|swift|kt|scala|r|lua|pl|ex|exs|hs|ml|clj|dart|v|zig|nim|ps1|bat|cmd|mjs|cjs)$/i;
async function downloadAttachment(
att: Attachment,
defaultExt = '.bin',
): Promise<string> {
const res = await fetch(att.url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
const ext = path.extname(att.name || `file${defaultExt}`) || defaultExt;
const attachmentId = String(att.id || 'attachment').replace(
/[^a-zA-Z0-9_-]/g,
'_',
);
const filename = `${Date.now()}-${attachmentId}${ext}`;
const filePath = path.join(ATTACHMENTS_DIR, filename);
fs.writeFileSync(filePath, buffer);
logger.info({ file: filename, size: buffer.length }, 'Attachment downloaded');
return filePath;
}
function attachmentDefaultExtension(contentType: string): string {
if (contentType.startsWith('image/')) return '.png';
if (contentType.startsWith('audio/')) return '.wav';
if (contentType.startsWith('video/')) return '.mp4';
if (contentType.startsWith('text/')) return '.txt';
if (contentType === 'application/pdf') return '.pdf';
if (
contentType === 'application/zip' ||
contentType === 'application/x-zip-compressed'
) {
return '.zip';
}
return '.bin';
}
function attachmentLabel(
contentType: string,
): 'Audio' | 'File' | 'Image' | 'Video' {
if (contentType.startsWith('image/')) return 'Image';
if (contentType.startsWith('audio/')) return 'Audio';
if (contentType.startsWith('video/')) return 'Video';
return 'File';
}
function isTextAttachment(att: Attachment, contentType: string): boolean {
return (
contentType.startsWith('text/') ||
TEXT_ATTACHMENT_NAME_PATTERN.test(att.name || '')
);
}
function isStructuredDocumentAttachment(
att: Attachment,
contentType: string,
): boolean {
if (contentType === 'application/pdf') return true;
if (contentType.startsWith('text/')) return true;
return isModelDocumentPath(att.name || '');
}
function formatAttachmentReference(
label: 'Audio' | 'File' | 'Image' | 'Video',
att: Attachment,
filePath: string,
): string {
return `[${label}: ${att.name || 'file'}${filePath}]`;
}
function formatUnavailableAttachmentReference(
label: 'Audio' | 'File' | 'Image' | 'Video',
att: Attachment,
filePath: string,
contentType: string,
): string {
return `[${label} unavailable: ${att.name || 'file'}${filePath}${contentType} is not loaded as structured model input]`;
}
function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
}
function getDeclaredAttachmentSize(att: Attachment): number | null {
return typeof att.size === 'number' && Number.isFinite(att.size)
? att.size
: null;
}
function formatTooLargeAttachment(
label: 'Audio' | 'File' | 'Image' | 'Video',
att: Attachment,
size: number,
): string {
return `[${label}: ${att.name || 'file'} (too large to download: ${formatBytes(size)} > ${formatBytes(MAX_ATTACHMENT_BYTES)})]`;
}
export async function describeDownloadedAttachment(
att: Attachment,
contentType: string,
): Promise<string> {
const label = attachmentLabel(contentType);
const declaredSize = getDeclaredAttachmentSize(att);
if (declaredSize != null && declaredSize > MAX_ATTACHMENT_BYTES) {
logger.warn(
{
file: att.name,
size: declaredSize,
maxSize: MAX_ATTACHMENT_BYTES,
},
'Attachment skipped because it exceeds the download size cap',
);
return formatTooLargeAttachment(label, att, declaredSize);
}
const filePath = await downloadAttachment(
att,
attachmentDefaultExtension(contentType),
);
const reference = formatAttachmentReference(label, att, filePath);
if (label === 'File' && isStructuredDocumentAttachment(att, contentType)) {
if (!isTextAttachment(att, contentType)) return reference;
}
if (!isTextAttachment(att, contentType)) {
if (label === 'Image') return reference;
return formatUnavailableAttachmentReference(
label,
att,
filePath,
contentType,
);
}
let text = fs.readFileSync(filePath, 'utf8');
const MAX_TEXT_LENGTH = 32_000;
if (text.length > MAX_TEXT_LENGTH) {
text =
text.slice(0, MAX_TEXT_LENGTH) +
`\n...(truncated, ${text.length} chars total)`;
}
return `${reference}\n${text}`;
}

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../config.js', () => ({
STATUS_CHANNEL_ID: 'status-channel',
}));
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
},
}));
const readDashboardStatusMessageIdMock = vi.hoisted(() =>
vi.fn(() => 'dashboard-current'),
);
vi.mock('../status-dashboard.js', () => ({
readDashboardStatusMessageId: readDashboardStatusMessageIdMock,
}));
import {
deleteOwnDashboardDuplicateOnCreate,
isDashboardTrackedSend,
} from './discord-dashboard-create-cleanup.js';
function makeDashboardMessage(overrides: {
id?: string;
channelId?: string;
content?: string;
}) {
return {
id: overrides.id ?? 'dashboard-stale',
channelId: overrides.channelId ?? 'status-channel',
content: overrides.content ?? '🤖 *모델 구성*\nMemory 3.3/91.4GB',
delete: vi.fn().mockResolvedValue(undefined),
} as any;
}
describe('discord dashboard create cleanup', () => {
beforeEach(() => {
vi.clearAllMocks();
readDashboardStatusMessageIdMock.mockReturnValue('dashboard-current');
});
it('detects tracked dashboard sends', () => {
expect(
isDashboardTrackedSend(
'dc:status-channel',
'🤖 *모델 구성*\nMemory 7.5/251.7GB',
),
).toBe(true);
expect(isDashboardTrackedSend('dc:other', '🤖 *모델 구성*')).toBe(false);
expect(isDashboardTrackedSend('dc:status-channel', 'normal')).toBe(false);
});
it('deletes stale own dashboard messages on create event', async () => {
const msg = makeDashboardMessage({ id: 'dashboard-stale' });
await deleteOwnDashboardDuplicateOnCreate({
message: msg,
channelName: 'discord',
graceUntil: 0,
now: 100,
});
expect(msg.delete).toHaveBeenCalledTimes(1);
});
it('keeps the tracked dashboard message', async () => {
const msg = makeDashboardMessage({ id: 'dashboard-current' });
await deleteOwnDashboardDuplicateOnCreate({
message: msg,
channelName: 'discord',
graceUntil: 0,
now: 100,
});
expect(msg.delete).not.toHaveBeenCalled();
});
it('keeps dashboard messages during tracked send grace period', async () => {
const msg = makeDashboardMessage({ id: 'dashboard-new' });
await deleteOwnDashboardDuplicateOnCreate({
message: msg,
channelName: 'discord',
graceUntil: 200,
now: 100,
});
expect(msg.delete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,55 @@
import type { Message } from 'discord.js';
import { STATUS_CHANNEL_ID } from '../config.js';
import { DASHBOARD_STATUS_MESSAGE_MARKER } from '../dashboard-message-cleanup.js';
import { logger } from '../logger.js';
import { readDashboardStatusMessageId } from '../status-dashboard.js';
export const DASHBOARD_TRACKED_SEND_GRACE_MS = 5000;
export function isDashboardTrackedSend(jid: string, text: string): boolean {
return (
Boolean(STATUS_CHANNEL_ID) &&
jid.replace(/^dc:/, '') === STATUS_CHANNEL_ID &&
text.includes(DASHBOARD_STATUS_MESSAGE_MARKER)
);
}
export async function deleteOwnDashboardDuplicateOnCreate(args: {
message: Message;
channelName: string;
graceUntil: number;
now?: number;
}): Promise<void> {
const { message } = args;
if (!STATUS_CHANNEL_ID || message.channelId !== STATUS_CHANNEL_ID) return;
if (!message.content.includes(DASHBOARD_STATUS_MESSAGE_MARKER)) return;
const keepMessageId = readDashboardStatusMessageId(STATUS_CHANNEL_ID);
if (!keepMessageId || message.id === keepMessageId) return;
if ((args.now ?? Date.now()) < args.graceUntil) return;
try {
await message.delete();
logger.info(
{
jid: `dc:${message.channelId}`,
messageId: message.id,
keepMessageId,
channelName: args.channelName,
},
'Deleted duplicate dashboard message on Discord create event',
);
} catch (err) {
logger.debug(
{
jid: `dc:${message.channelId}`,
messageId: message.id,
keepMessageId,
channelName: args.channelName,
err,
},
'Failed to delete duplicate dashboard message on Discord create event',
);
}
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';
import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js';
class MockMessageCollection extends Map<string, any> {
filter(predicate: (message: any, id: string) => boolean) {
const filtered = new MockMessageCollection();
for (const [id, message] of this.entries()) {
if (predicate(message, id)) filtered.set(id, message);
}
return filtered;
}
}
describe('deleteRecentDiscordMessagesByContent', () => {
it('deletes duplicate own dashboard messages while keeping the tracked one', async () => {
const keepDelete = vi.fn();
const duplicateDelete = vi.fn();
const otherBotDelete = vi.fn();
const normalDelete = vi.fn();
const messages = new MockMessageCollection([
[
'tracked',
{
id: 'tracked',
author: { id: '999888777' },
content: '🤖 *모델 구성*\ntracked',
createdTimestamp: Date.now(),
delete: keepDelete,
},
],
[
'duplicate',
{
id: 'duplicate',
author: { id: '999888777' },
content: '🤖 *모델 구성*\nduplicate',
createdTimestamp: Date.now(),
delete: duplicateDelete,
},
],
[
'other-bot',
{
id: 'other-bot',
author: { id: 'other-bot' },
content: '🤖 *모델 구성*\nother bot',
createdTimestamp: Date.now(),
delete: otherBotDelete,
},
],
[
'normal',
{
id: 'normal',
author: { id: '999888777' },
content: 'normal tracked message',
createdTimestamp: Date.now(),
delete: normalDelete,
},
],
]);
const client = {
user: { id: '999888777' },
channels: {
fetch: vi.fn().mockResolvedValue({
messages: {
fetch: vi.fn().mockResolvedValue(messages),
},
}),
},
};
const deleted = await deleteRecentDiscordMessagesByContent({
client: client as any,
channelName: 'discord',
jid: 'dc:1234567890123456',
options: {
contentIncludes: '🤖 *모델 구성*',
exceptMessageId: 'tracked',
},
});
expect(deleted).toBe(1);
expect(duplicateDelete).toHaveBeenCalledTimes(1);
expect(keepDelete).not.toHaveBeenCalled();
expect(otherBotDelete).not.toHaveBeenCalled();
expect(normalDelete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
import type { Client, TextChannel } from 'discord.js';
import { logger } from '../logger.js';
import type { DeleteRecentMessagesByContentOptions } from '../types.js';
export async function deleteRecentDiscordMessagesByContent(args: {
client: Client | null;
channelName: string;
jid: string;
options: DeleteRecentMessagesByContentOptions;
}): Promise<number> {
if (!args.client) return 0;
const contentIncludes = args.options.contentIncludes.trim();
if (!contentIncludes) return 0;
let deleted = 0;
try {
const channelId = args.jid.replace(/^dc:/, '');
const channel = await args.client.channels.fetch(channelId);
if (!channel || !('messages' in channel)) return 0;
const tc = channel as TextChannel;
const messages = await tc.messages.fetch({
limit: Math.max(1, Math.min(args.options.limit ?? 100, 100)),
});
const candidates = messages.filter(
(message) =>
message.author.id === args.client?.user?.id &&
message.id !== args.options.exceptMessageId &&
message.content.includes(contentIncludes),
);
if (candidates.size === 0) return 0;
const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000;
const recent = candidates.filter(
(message) => message.createdTimestamp > twoWeeksAgo,
);
const old = candidates.filter(
(message) => message.createdTimestamp <= twoWeeksAgo,
);
if (recent.size >= 2 && 'bulkDelete' in channel) {
await tc.bulkDelete(recent);
deleted += recent.size;
} else {
for (const [, message] of recent) {
await message.delete();
deleted += 1;
}
}
for (const [, message] of old) {
await message.delete();
deleted += 1;
}
logger.info(
{
jid: args.jid,
deleted,
exceptMessageId: args.options.exceptMessageId ?? null,
channelName: args.channelName,
},
'Deleted duplicate Discord messages by content marker',
);
} catch (err) {
logger.warn(
{
jid: args.jid,
err,
deleted,
exceptMessageId: args.options.exceptMessageId ?? null,
channelName: args.channelName,
},
'Failed to delete duplicate Discord messages by content marker',
);
}
return deleted;
}

View File

@@ -0,0 +1,73 @@
import path from 'path';
import { normalizeAgentOutput } from '../agent-protocol.js';
import type { OutboundAttachment } from '../types.js';
const LOCAL_MARKDOWN_LINK_RE = /\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
export interface PreparedDiscordOutbound {
text: string;
cleanText: string;
attachments: OutboundAttachment[];
attachmentSource:
| 'structured'
| 'media-tag'
| 'md-link'
| 'image-tag'
| 'mixed'
| 'none';
silent: boolean;
}
function sanitizeLocalMarkdownLinks(text: string): string {
return text.replace(LOCAL_MARKDOWN_LINK_RE, (_full, rawPath: string) => {
const trimmed = rawPath.trim();
const basename = path.basename(trimmed.replace(/#.*$/, ''));
const lineMatch = trimmed.match(/#L(\d+)/);
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
});
}
export function prepareDiscordOutbound(
text: string,
optionAttachments: OutboundAttachment[] | undefined,
): PreparedDiscordOutbound {
const normalized = normalizeAgentOutput(text);
if (normalized.output?.visibility === 'silent') {
return {
text: '',
cleanText: '',
attachments: [],
attachmentSource: 'none',
silent: true,
};
}
const structuredOutput =
normalized.output?.visibility === 'public' ? normalized.output : null;
const outboundText = structuredOutput?.text ?? normalized.result ?? text;
const cleanText = sanitizeLocalMarkdownLinks(outboundText);
const attachments =
optionAttachments && optionAttachments.length > 0
? optionAttachments
: (structuredOutput?.attachments ?? []);
const hasAttachments = attachments.length > 0;
const attachmentSource =
optionAttachments && optionAttachments.length > 0
? 'structured'
: normalized.attachmentSource === 'legacy-ejclaw-json'
? 'structured'
: normalized.attachmentSource === 'media-tag'
? 'media-tag'
: normalized.attachmentSource === 'markdown-image'
? 'md-link'
: (normalized.attachmentSource ?? 'none');
return {
text: outboundText,
cleanText,
attachments,
attachmentSource: hasAttachments ? attachmentSource : 'none',
silent: false,
};
}

View File

@@ -0,0 +1,318 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('./registry.js', () => ({
registerChannel: vi.fn(),
}));
vi.mock('../env.js', () => ({
readEnvFile: vi.fn(() => ({})),
getEnv: vi.fn(() => undefined),
}));
vi.mock('../config.js', () => ({
ASSISTANT_NAME: 'Andy',
TRIGGER_PATTERN: /^@Andy\b/i,
DATA_DIR: '/tmp/ejclaw-test-data',
CACHE_DIR: '/tmp/ejclaw-test-cache',
}));
vi.mock('../logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../service-routing.js', () => ({
hasReviewerLease: vi.fn(() => false),
}));
type Handler = (...args: any[]) => any;
const clientRef = vi.hoisted(() => ({ current: null as any }));
vi.mock('discord.js', () => {
class MockClient {
eventHandlers = new Map<string, Handler[]>();
user: any = { id: '999888777', tag: 'Andy#1234' };
private _ready = false;
constructor(_opts: any) {
clientRef.current = this;
}
on(event: string, handler: Handler) {
this.eventHandlers.set(event, [
...(this.eventHandlers.get(event) ?? []),
handler,
]);
return this;
}
once(event: string, handler: Handler) {
return this.on(event, handler);
}
async login(_token: string) {
this._ready = true;
for (const handler of this.eventHandlers.get('ready') ?? []) {
handler({ user: this.user });
}
}
isReady() {
return this._ready;
}
channels = {
fetch: vi.fn().mockResolvedValue({
send: vi.fn().mockResolvedValue(undefined),
sendTyping: vi.fn().mockResolvedValue(undefined),
}),
};
}
return {
Client: MockClient,
Events: {
MessageCreate: 'messageCreate',
ClientReady: 'ready',
Error: 'error',
},
GatewayIntentBits: {
Guilds: 1,
GuildMessages: 2,
MessageContent: 4,
DirectMessages: 8,
},
MessageFlags: { SuppressEmbeds: 1 << 2, IsVoiceMessage: 1 << 13 },
TextChannel: class TextChannel {},
};
});
import { DiscordChannel, type DiscordChannelOpts } from './discord.js';
const ONE_PIXEL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64',
);
const MINIMAL_MP4 = Buffer.from([
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00,
0x00, 0x02, 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x69, 0x73, 0x6f, 0x32,
]);
const tempFiles: string[] = [];
afterEach(() => {
vi.clearAllMocks();
for (const file of tempFiles.splice(0)) {
fs.rmSync(file, { force: true });
}
});
function createTestOpts(): DiscordChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
roomBindings: vi.fn(() => ({})),
};
}
describe('DiscordChannel structured output', () => {
it('normalizes raw EJClaw JSON and sends direct temp images as files', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const filePath = path.join(
os.tmpdir(),
`bar-chart-label-fit-playwright-${Date.now()}.png`,
);
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
tempFiles.push(filePath);
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
const result = await channel.sendMessage(
'dc:1234567890123456',
JSON.stringify({
ejclaw: {
visibility: 'public',
text: '라벨 좌측 클리핑 회귀 수정했습니다.',
verdict: 'done',
attachments: [
{
path: filePath,
name: 'bar-chart-label-fit-playwright.png',
mime: 'image/png',
},
],
},
}),
);
expect(result).toMatchObject({
primaryMessageId: 'discord-message-1',
messageIds: ['discord-message-1'],
visible: true,
});
expect(mockChannel.send).toHaveBeenCalledWith({
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
files: [
{
attachment: fs.realpathSync(filePath),
name: 'bar-chart-label-fit-playwright.png',
},
],
flags: 1 << 2,
});
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain(
'"ejclaw"',
);
});
it('normalizes status-prefixed EJClaw JSON and keeps the status line visible', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const filePath = path.join(
os.tmpdir(),
`ejclaw-discord-image-status-${Date.now()}.png`,
);
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
tempFiles.push(filePath);
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-2' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
`TASK_DONE
\`\`\`json
${JSON.stringify({
ejclaw: {
visibility: 'public',
text: '첨부 테스트입니다.',
verdict: 'done',
attachments: [
{
path: filePath,
name: 'status.png',
mime: 'image/png',
},
],
},
})}
\`\`\``,
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: 'TASK_DONE\n\n첨부 테스트입니다.',
files: [
{
attachment: fs.realpathSync(filePath),
name: 'status.png',
},
],
flags: 1 << 2,
});
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain(
'"ejclaw"',
);
});
it('normalizes markdown images and sends them as files', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const filePath = path.join(
os.tmpdir(),
`ejclaw-discord-markdown-image-${Date.now()}.png`,
);
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
tempFiles.push(filePath);
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-3' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
`스크린샷입니다.\n![screenshot](${filePath})`,
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: '스크린샷입니다.',
files: [
{
attachment: fs.realpathSync(filePath),
name: path.basename(filePath),
},
],
flags: 1 << 2,
});
});
it('normalizes MEDIA directives and sends them as files', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const filePath = path.join(
os.tmpdir(),
`adventurer-active-sfx-preview-${Date.now()}.mp4`,
);
fs.writeFileSync(filePath, MINIMAL_MP4);
tempFiles.push(filePath);
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-media' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
`사운드 프리뷰입니다.\nMEDIA:${filePath}`,
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: '사운드 프리뷰입니다.',
files: [
{
attachment: fs.realpathSync(filePath),
name: path.basename(filePath),
},
],
flags: 1 << 2,
});
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain('MEDIA:');
});
it('keeps non-image local markdown links readable without uploading files', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-4' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
'수정 위치: [source](/tmp/project/src/index.ts#L42)',
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: '수정 위치: `index.ts:42`',
files: undefined,
flags: 1 << 2,
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -11,25 +11,26 @@ import {
TextChannel,
} from 'discord.js';
import {
ASSISTANT_NAME,
CACHE_DIR,
DATA_DIR,
TRIGGER_PATTERN,
} from '../config.js';
import { CACHE_DIR } from '../config.js';
import { getEnv } from '../env.js';
import { logger } from '../logger.js';
import { extractImageTagPaths } from '../agent-protocol.js';
import { validateOutboundAttachments } from '../outbound-attachments.js';
import { formatOutbound } from '../router.js';
import { hasReviewerLease } from '../service-routing.js';
import type {
EditMessageOptions,
OutboundAttachment,
DeleteRecentMessagesByContentOptions,
SendMessageOptions,
SendMessageResult,
} from '../types.js';
import {
DASHBOARD_TRACKED_SEND_GRACE_MS,
deleteOwnDashboardDuplicateOnCreate,
isDashboardTrackedSend,
} from './discord-dashboard-create-cleanup.js';
import { describeDownloadedAttachment } from './discord-attachments.js';
import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js';
import { prepareDiscordOutbound } from './discord-outbound.js';
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
const DISCORD_OWNER_CHANNEL = 'discord';
const DISCORD_REVIEWER_CHANNEL = 'discord-review';
@@ -37,178 +38,6 @@ const DISCORD_ARBITER_CHANNEL = 'discord-arbiter';
const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN';
const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN';
const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN';
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
// Matches a line that opens or closes a markdown fenced code block.
// Captures the fence marker (``` or ```` etc.) and the optional language tag.
const FENCE_LINE_RE = /^(`{3,})([^\s`]*)\s*$/;
// Discord rejects any message/edit body longer than 2000 characters with
// "Invalid Form Body … Must be 2000 or fewer in length".
export const DISCORD_MAX_MESSAGE_LENGTH = 2000;
/**
* Split `text` into chunks ≤ `maxLength` chars while keeping every chunk a
* valid standalone Markdown snippet for Discord. If a chunk has to end inside
* a ``` fenced code block, we append a closing fence to it and reopen the
* same fence (with the same language tag) at the start of the next chunk.
*
* Behaviour:
* - Prefers splitting at newline boundaries.
* - If a single line is itself longer than `maxLength`, falls back to a
* surrogate-pair-safe byte split for that line only.
* - Preserves the surrogate-pair safety guarantee of the previous splitter.
* - Returns `[text]` unchanged when `text.length <= maxLength`.
*/
export function chunkForDiscord(text: string, maxLength: number): string[] {
if (text.length <= maxLength) return text.length > 0 ? [text] : [];
const lines = text.split('\n');
const chunks: string[] = [];
let current = '';
let openLang = ''; // language tag of currently open fence
let openMarker: string | null = null; // ``` or ```` etc; null when not in a fence
const flush = () => {
if (current === '') return;
if (openMarker !== null) {
if (!current.endsWith('\n')) current += '\n';
current += openMarker;
chunks.push(current);
current = openMarker + openLang + '\n';
} else {
chunks.push(current);
current = '';
}
};
const hardSplitInto = (segment: string) => {
// Split `segment` across chunks at code-unit boundaries while avoiding
// splitting surrogate pairs. Used when a single source line exceeds the
// budget on its own.
let s = segment;
while (s.length > 0) {
const reserve = openMarker !== null ? openMarker.length + 1 : 0;
const room = maxLength - current.length - reserve;
if (room <= 0) {
flush();
continue;
}
let take = Math.min(s.length, room);
if (
take < s.length &&
take > 0 &&
s.charCodeAt(take - 1) >= 0xd800 &&
s.charCodeAt(take - 1) <= 0xdbff
) {
take--;
}
if (take <= 0) {
// Couldn't fit anything safely; flush and retry on an empty chunk.
flush();
continue;
}
current += s.slice(0, take);
s = s.slice(take);
if (s.length > 0) flush();
}
};
for (let li = 0; li < lines.length; li++) {
const isLast = li === lines.length - 1;
const line = lines[li];
const lineWithNL = isLast ? line : line + '\n';
const reserve = openMarker !== null ? openMarker.length + 1 : 0;
if (
current.length > 0 &&
current.length + lineWithNL.length + reserve > maxLength
) {
flush();
}
if (lineWithNL.length + reserve > maxLength) {
hardSplitInto(lineWithNL);
} else {
current += lineWithNL;
}
// Update fence state AFTER appending the line so the very-next iteration
// accounts for it correctly.
const m = line.match(FENCE_LINE_RE);
if (m) {
if (openMarker === null) {
openMarker = m[1];
openLang = m[2] || '';
} else if (m[1] === openMarker) {
openMarker = null;
openLang = '';
}
}
}
if (current.length > 0) chunks.push(current);
return chunks;
}
function extractMarkdownImageAttachments(text: string): {
cleanText: string;
attachments: OutboundAttachment[];
} {
const attachments: OutboundAttachment[] = [];
const seen = new Set<string>();
const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => {
const trimmed = rawPath.trim();
if (IMAGE_EXTS.test(trimmed)) {
if (!seen.has(trimmed)) {
attachments.push({
path: trimmed,
name: path.basename(trimmed),
});
seen.add(trimmed);
}
return '';
}
const basename = path.basename(trimmed.replace(/#.*$/, ''));
const lineMatch = trimmed.match(/#L(\d+)/);
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
});
return { cleanText, attachments };
}
function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] {
return paths
.filter((filePath) => IMAGE_EXTS.test(filePath))
.map((filePath) => ({
path: filePath,
name: path.basename(filePath),
}));
}
/**
* Download a Discord attachment to local disk.
* Returns the absolute path to the saved file.
*/
async function downloadAttachment(
att: Attachment,
defaultExt = '.bin',
): Promise<string> {
const res = await fetch(att.url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const buffer = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
const ext = path.extname(att.name || `file${defaultExt}`) || defaultExt;
const filename = `${Date.now()}-${att.id}${ext}`;
const filePath = path.join(ATTACHMENTS_DIR, filename);
fs.writeFileSync(filePath, buffer);
logger.info({ file: filename, size: buffer.length }, 'Attachment downloaded');
return filePath;
}
/**
* Wait for a pending transcription from the other service (poll cache file).
@@ -339,7 +168,6 @@ export interface DiscordChannelOpts {
export class DiscordChannel implements Channel {
name = 'discord';
readonly maxMessageLength = DISCORD_MAX_MESSAGE_LENGTH;
private client: Client | null = null;
private opts: DiscordChannelOpts;
@@ -349,6 +177,7 @@ export class DiscordChannel implements Channel {
private agentTypeFilter?: AgentType;
private receivesInbound: boolean;
private ownsDiscordJids: boolean;
private dashboardTrackedSendGraceUntil = 0;
constructor(
botToken: string,
@@ -381,163 +210,7 @@ export class DiscordChannel implements Channel {
});
this.client.on(Events.MessageCreate, async (message: Message) => {
const channelId = message.channelId;
const chatJid = `dc:${channelId}`;
if (!this.receivesInbound) return;
const isOwnBotMessage = message.author.id === this.client?.user?.id;
if (isOwnBotMessage) return;
if (message.author.bot && !hasReviewerLease(chatJid)) return;
let content = message.content;
const timestamp = message.createdAt.toISOString();
const senderName =
message.member?.displayName ||
message.author.displayName ||
message.author.username;
const sender = message.author.id;
const msgId = message.id;
// Determine chat name
let chatName: string;
if (message.guild) {
const textChannel = message.channel as TextChannel;
chatName = `${message.guild.name} #${textChannel.name}`;
} else {
chatName = senderName;
}
// Handle attachments — transcribe voice messages, download files
const isVoiceMessage = message.flags.has(MessageFlags.IsVoiceMessage);
if (message.attachments.size > 0) {
const attachmentDescriptions = await Promise.all(
[...message.attachments.values()].map(async (att) => {
const contentType = att.contentType || '';
// Voice messages → transcribe; regular audio files → download
if (
contentType.startsWith('audio/') &&
(isVoiceMessage || att.duration != null)
) {
return transcribeAudio(att);
} else if (
contentType.startsWith('audio/') ||
contentType.startsWith('image/')
) {
try {
const filePath = await downloadAttachment(
att,
contentType.startsWith('image/') ? '.png' : '.wav',
);
const label = contentType.startsWith('image/')
? 'Image'
: 'Audio';
const origName = att.name || 'file';
return `[${label}: ${origName}${filePath}]`;
} catch (err) {
logger.error(
{ err, file: att.name },
'Attachment download failed',
);
return `[File: ${att.name || 'file'} (download failed)]`;
}
} else if (contentType.startsWith('video/')) {
return `[Video: ${att.name || 'video'}]`;
} else if (
contentType.startsWith('text/') ||
/\.(txt|md|json|csv|log|xml|yaml|yml|toml|ini|cfg|conf|sh|bash|zsh|py|js|ts|jsx|tsx|html|css|sql|rs|go|java|c|cpp|h|hpp|rb|php|swift|kt|scala|r|lua|pl|ex|exs|hs|ml|clj|dart|v|zig|nim|ps1|bat|cmd|mjs|cjs)$/i.test(
att.name || '',
)
) {
// Download and inline text-based files
try {
const res = await fetch(att.url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
let text = await res.text();
// Truncate very large files
const MAX_TEXT_LENGTH = 32_000;
if (text.length > MAX_TEXT_LENGTH) {
text =
text.slice(0, MAX_TEXT_LENGTH) +
`\n...(truncated, ${text.length} chars total)`;
}
return `[File: ${att.name}]\n${text}`;
} catch (err) {
logger.error(
{ err, file: att.name },
'Text file download failed',
);
return `[File: ${att.name || 'file'} (download failed)]`;
}
} else {
return `[File: ${att.name || 'file'}]`;
}
}),
);
if (content) {
content = `${content}\n${attachmentDescriptions.join('\n')}`;
} else {
content = attachmentDescriptions.join('\n');
}
}
// Handle reply context — include who the user is replying to
if (message.reference?.messageId) {
try {
const repliedTo = await message.channel.messages.fetch(
message.reference.messageId,
);
const replyAuthor =
repliedTo.member?.displayName ||
repliedTo.author.displayName ||
repliedTo.author.username;
content = `[Reply to ${replyAuthor}] ${content}`;
} catch {
// Referenced message may have been deleted
}
}
// Store chat metadata for discovery
const isGroup = message.guild !== null;
this.opts.onChatMetadata(
chatJid,
timestamp,
chatName,
'discord',
isGroup,
);
// Only deliver full message for registered groups. Secondary role bots
// are configured as outbound-only, while the owner bot receives inbound.
const group = this.opts.roomBindings()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
'Message from unregistered Discord channel',
);
return;
}
if (
this.agentTypeFilter &&
(group.agentType || 'claude-code') !== this.agentTypeFilter
) {
return; // This JID belongs to a different agent type's bot
}
// Deliver message — startMessageLoop() will pick it up
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
is_bot_message: message.author.bot ?? false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
'Discord message stored',
);
await this.handleMessageCreate(message);
});
// Handle errors gracefully
@@ -545,7 +218,7 @@ export class DiscordChannel implements Channel {
logger.error({ err: err.message }, 'Discord client error');
});
return new Promise<void>((resolve) => {
return new Promise<void>((resolve, reject) => {
this.client!.once(Events.ClientReady, (readyClient) => {
logger.info(
{ username: readyClient.user.tag, id: readyClient.user.id },
@@ -558,18 +231,147 @@ export class DiscordChannel implements Channel {
resolve();
});
this.client!.login(this.botToken);
void this.client!.login(this.botToken).catch(reject);
});
}
private async handleMessageCreate(message: Message): Promise<void> {
const channelId = message.channelId;
const chatJid = `dc:${channelId}`;
const isOwnBotMessage = message.author.id === this.client?.user?.id;
if (isOwnBotMessage) {
await deleteOwnDashboardDuplicateOnCreate({
message,
channelName: this.name,
graceUntil: this.dashboardTrackedSendGraceUntil,
});
return;
}
if (!this.receivesInbound) return;
if (message.author.bot && !hasReviewerLease(chatJid)) return;
let content = message.content;
const timestamp = message.createdAt.toISOString();
const senderName =
message.member?.displayName ||
message.author.displayName ||
message.author.username;
const sender = message.author.id;
const msgId = message.id;
// Determine chat name
let chatName: string;
if (message.guild) {
const textChannel = message.channel as TextChannel;
chatName = `${message.guild.name} #${textChannel.name}`;
} else {
chatName = senderName;
}
// Handle attachments — transcribe voice messages, download files
const isVoiceMessage = message.flags.has(MessageFlags.IsVoiceMessage);
if (message.attachments.size > 0) {
const attachmentDescriptions = await Promise.all(
[...message.attachments.values()].map(async (att) => {
const contentType = att.contentType || '';
// Voice messages → transcribe; all attachments still get a file path.
if (
contentType.startsWith('audio/') &&
(isVoiceMessage || att.duration != null)
) {
try {
const transcript = await transcribeAudio(att);
const reference = await describeDownloadedAttachment(
att,
contentType,
);
return `${transcript}\n${reference}`;
} catch (err) {
logger.error(
{ err, file: att.name },
'Voice attachment handling failed',
);
return `[File: ${att.name || 'file'} (download failed)]`;
}
}
try {
return await describeDownloadedAttachment(att, contentType);
} catch (err) {
logger.error({ err, file: att.name }, 'Attachment download failed');
return `[File: ${att.name || 'file'} (download failed)]`;
}
}),
);
if (content) {
content = `${content}\n${attachmentDescriptions.join('\n')}`;
} else {
content = attachmentDescriptions.join('\n');
}
}
// Handle reply context — include who the user is replying to
if (message.reference?.messageId) {
try {
const repliedTo = await message.channel.messages.fetch(
message.reference.messageId,
);
const replyAuthor =
repliedTo.member?.displayName ||
repliedTo.author.displayName ||
repliedTo.author.username;
content = `[Reply to ${replyAuthor}] ${content}`;
} catch {
// Referenced message may have been deleted
}
}
// Store chat metadata for discovery
const isGroup = message.guild !== null;
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup);
// Only deliver full message for registered groups. Secondary role bots
// are configured as outbound-only, while the owner bot receives inbound.
const group = this.opts.roomBindings()[chatJid];
if (!group) {
logger.debug(
{ chatJid, chatName },
'Message from unregistered Discord channel',
);
return;
}
if (
this.agentTypeFilter &&
(group.agentType || 'claude-code') !== this.agentTypeFilter
) {
return; // This JID belongs to a different agent type's bot
}
// Deliver message — startMessageLoop() will pick it up
this.opts.onMessage(chatJid, {
id: msgId,
chat_jid: chatJid,
sender,
sender_name: senderName,
content,
timestamp,
is_from_me: false,
is_bot_message: message.author.bot ?? false,
});
logger.info(
{ chatJid, chatName, sender: senderName },
'Discord message stored',
);
}
async sendMessage(
jid: string,
text: string,
options: SendMessageOptions = {},
): Promise<void> {
): Promise<SendMessageResult> {
if (!this.client) {
logger.warn('Discord client not initialized');
return;
throw new Error('Discord client not initialized');
}
try {
@@ -577,32 +379,20 @@ export class DiscordChannel implements Channel {
const channel = await this.client.channels.fetch(channelId);
if (!channel || !('send' in channel)) {
logger.warn({ jid }, 'Discord channel not found or not text-based');
return;
throw new Error(`Discord channel not found or not text-based: ${jid}`);
}
const textChannel = channel as TextChannel;
const structuredAttachments = options.attachments ?? [];
const hasStructuredAttachments = structuredAttachments.length > 0;
const markdownExtracted = extractMarkdownImageAttachments(text);
const imageTagExtracted = extractImageTagPaths(
markdownExtracted.cleanText,
);
const legacyImageTagAttachments = imageTagPathsToAttachments(
imageTagExtracted.imagePaths,
);
const outboundAttachments = hasStructuredAttachments
? structuredAttachments
: [...markdownExtracted.attachments, ...legacyImageTagAttachments];
const attachmentSource = hasStructuredAttachments
? 'structured'
: markdownExtracted.attachments.length > 0
? 'md-link'
: legacyImageTagAttachments.length > 0
? 'image-tag'
: 'none';
const validation = validateOutboundAttachments(outboundAttachments, {
const outbound = prepareDiscordOutbound(text, options.attachments);
if (outbound.silent) {
logger.debug(
{ jid, channelName: this.name },
'Skipping silent structured Discord outbound message',
);
return { primaryMessageId: null, messageIds: [], visible: false };
}
const validation = validateOutboundAttachments(outbound.attachments, {
baseDirs: options.attachmentBaseDirs,
});
const files = validation.files;
@@ -612,29 +402,24 @@ export class DiscordChannel implements Channel {
{
jid,
channelName: this.name,
attachmentSource,
attachmentSource: outbound.attachmentSource,
rejected: validation.rejected,
},
'Rejected outbound Discord attachments',
);
}
let cleaned = imageTagExtracted.cleanText
.replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines
let cleaned = outbound.cleanText
.replace(/^[ \t]*[•*-][ \t]*$/gm, '') // remove empty bullet lines
.replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines
.trim();
// Convert @username mentions to Discord mention format
const mentionMap: Record<string, string> = {
: '216851709744513024',
};
for (const [name, id] of Object.entries(mentionMap)) {
cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`);
}
cleaned = options.rawMarkdown ? cleaned : formatOutbound(cleaned);
cleaned = cleaned.replaceAll('@눈쟁이', '<@216851709744513024>');
cleaned = formatOutbound(cleaned);
// Discord has a 2000 character limit per message and 10 attachments per message
const MAX_LENGTH = DISCORD_MAX_MESSAGE_LENGTH;
const MAX_LENGTH = 2000;
const MAX_ATTACHMENTS = 10;
const sentMessageIds: string[] = [];
let chunkCount = 0;
@@ -649,7 +434,7 @@ export class DiscordChannel implements Channel {
{ jid, channelName: this.name },
'Skipping empty Discord outbound message',
);
return;
return { primaryMessageId: null, messageIds: [], visible: false };
}
// Split files into batches of MAX_ATTACHMENTS
@@ -677,13 +462,10 @@ export class DiscordChannel implements Channel {
);
}
} else {
// Send text in chunks. The chunker preserves fenced code blocks so a
// long ``` block that straddles the 2000-char boundary doesn't get
// split mid-fence (which Discord would render as literal backticks
// and break syntax for following chunks).
const messageChunks = chunkForDiscord(cleaned, MAX_LENGTH);
// Send text in chunks, attach first batch to the first chunk
let fileBatchIndex = 0;
for (const chunk of messageChunks) {
for (let i = 0; i < cleaned.length; i += MAX_LENGTH) {
const chunk = cleaned.slice(i, i + MAX_LENGTH);
const batch = fileBatches[fileBatchIndex];
recordSentMessage(
await textChannel.send({
@@ -708,11 +490,11 @@ export class DiscordChannel implements Channel {
{
jid,
channelName: this.name,
length: text.length,
length: outbound.text.length,
deliveryMode: 'send',
chunkCount,
attachmentCount: files.length,
attachmentSource,
attachmentSource: outbound.attachmentSource,
messageId: sentMessageIds[0] ?? null,
messageIds: sentMessageIds,
botUserId: this.client.user?.id ?? null,
@@ -720,6 +502,11 @@ export class DiscordChannel implements Channel {
},
'Discord message sent',
);
return {
primaryMessageId: sentMessageIds[0] ?? null,
messageIds: sentMessageIds,
visible: chunkCount > 0,
};
} catch (err) {
logger.error(
{ jid, channelName: this.name, err },
@@ -798,18 +585,21 @@ export class DiscordChannel implements Channel {
);
}
async sendAndTrack(
jid: string,
text: string,
options: EditMessageOptions = {},
): Promise<string | null> {
if (!this.client) return null;
async sendAndTrack(jid: string, text: string): Promise<string | null> {
if (!this.client) {
throw new Error('Discord client not initialized');
}
try {
const channelId = jid.replace(/^dc:/, '');
if (isDashboardTrackedSend(jid, text)) {
this.dashboardTrackedSendGraceUntil =
Date.now() + DASHBOARD_TRACKED_SEND_GRACE_MS;
}
const channel = await this.client.channels.fetch(channelId);
if (!channel || !('send' in channel)) return null;
const cleaned = options.rawMarkdown ? text : formatOutbound(text);
const msg = await (channel as TextChannel).send(cleaned);
if (!channel || !('send' in channel)) {
throw new Error(`Discord channel not found or not text-based: ${jid}`);
}
const msg = await (channel as TextChannel).send(text);
logger.info(
{
jid,
@@ -818,7 +608,7 @@ export class DiscordChannel implements Channel {
messageId: msg.id,
botUserId: this.client.user?.id ?? null,
botUsername: this.client.user?.username ?? null,
length: cleaned.length,
length: text.length,
},
'Discord tracked message sent',
);
@@ -935,37 +725,41 @@ export class DiscordChannel implements Channel {
return deleted;
}
async deleteRecentMessagesByContent(
jid: string,
options: DeleteRecentMessagesByContentOptions,
): Promise<number> {
return deleteRecentDiscordMessagesByContent({
client: this.client,
channelName: this.name,
jid,
options,
});
}
async editMessage(
jid: string,
messageId: string,
text: string,
options: EditMessageOptions = {},
): Promise<void> {
if (!this.client) return;
if (!this.client) {
throw new Error('Discord client not initialized');
}
try {
const channelId = jid.replace(/^dc:/, '');
const channel = await this.client.channels.fetch(channelId);
if (!channel || !('messages' in channel)) return;
if (!channel || !('messages' in channel)) {
throw new Error(`Discord channel not found or not editable: ${jid}`);
}
const msg = await (channel as TextChannel).messages.fetch(messageId);
// Run the same sanitization pipeline as sendMessage so streaming
// edits do not bypass markdown neutralization (backticks, headings,
// italics, etc. that Discord would otherwise render and mangle).
// Dashboard callers opt out with rawMarkdown: true to preserve
// intentional bold/italic section headers.
const cleaned = options.rawMarkdown ? text : formatOutbound(text);
// editMessage only ever edits a single message. Callers that may produce
// text longer than DISCORD_MAX_MESSAGE_LENGTH (e.g. final result
// delivery) must route to sendMessage instead; this method must stay a
// pure single-message edit so repeated edits (progress ticker, dashboard
// refresh) never spawn extra follow-up messages.
await msg.edit(cleaned);
await msg.edit(text);
logger.info(
{
jid,
channelName: this.name,
deliveryMode: 'edit',
messageId,
length: cleaned.length,
length: text.length,
botUserId: this.client.user?.id ?? null,
botUsername: this.client.user?.username ?? null,
},

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import {
registerChannel,

View File

@@ -0,0 +1,39 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
describe('claude-session-settings', () => {
let tempDir: string;
let hostSettingsPath: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-claude-settings-'));
hostSettingsPath = path.join(tempDir, 'host-settings.json');
process.env.EJCLAW_CLAUDE_SETTINGS_PATH = hostSettingsPath;
});
afterEach(() => {
delete process.env.EJCLAW_CLAUDE_SETTINGS_PATH;
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('syncs host fastMode into the Claude session settings file', () => {
fs.writeFileSync(
hostSettingsPath,
`${JSON.stringify({ fastMode: true }, null, 2)}\n`,
);
const sessionDir = path.join(tempDir, 'session');
ensureClaudeSessionSettings(sessionDir);
const session = JSON.parse(
fs.readFileSync(path.join(sessionDir, 'settings.json'), 'utf-8'),
) as { fastMode?: boolean; env?: Record<string, string> };
expect(session.fastMode).toBe(true);
expect(session.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1');
});
});

View File

@@ -0,0 +1,68 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const DEFAULT_CLAUDE_SESSION_ENV = {
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
} as const;
function settingsHomeDir(): string {
return process.env.EJCLAW_SETTINGS_HOME || os.homedir();
}
export function claudeHostSettingsPath(): string {
const override = process.env.EJCLAW_CLAUDE_SETTINGS_PATH?.trim();
if (override) return override;
return path.join(settingsHomeDir(), '.claude', 'settings.json');
}
export function readHostClaudeFastMode(): boolean {
const file = claudeHostSettingsPath();
if (!fs.existsSync(file)) return false;
try {
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
string,
unknown
>;
return data.fastMode === true;
} catch {
return false;
}
}
export function ensureClaudeSessionSettings(groupSessionsDir: string): void {
const settingsFile = path.join(groupSessionsDir, 'settings.json');
let data: Record<string, unknown> = {
env: { ...DEFAULT_CLAUDE_SESSION_ENV },
};
if (fs.existsSync(settingsFile)) {
try {
const existing = JSON.parse(
fs.readFileSync(settingsFile, 'utf-8'),
) as Record<string, unknown>;
data = {
...existing,
env: {
...DEFAULT_CLAUDE_SESSION_ENV,
...(typeof existing.env === 'object' && existing.env !== null
? (existing.env as Record<string, unknown>)
: {}),
},
};
} catch {
data = { env: { ...DEFAULT_CLAUDE_SESSION_ENV } };
}
}
if (readHostClaudeFastMode()) {
data.fastMode = true;
} else {
delete data.fastMode;
}
fs.mkdirSync(groupSessionsDir, { recursive: true });
fs.writeFileSync(settingsFile, `${JSON.stringify(data, null, 2)}\n`);
}

View File

@@ -0,0 +1,7 @@
export const CODEX_BAD_REQUEST_DETAIL_JSON = '{"detail":"Bad Request"}';
export function isCodexBadRequestText(
text: string | null | undefined,
): boolean {
return text?.trim() === CODEX_BAD_REQUEST_DETAIL_JSON;
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import {
readCodexFeatureFromContent,
writeCodexFeatureInContent,
} from './codex-config-features.js';
describe('codex-config-features', () => {
it('reads feature flags from the [features] section', () => {
const toml = `
model = "gpt-5.5"
[features]
fast_mode = true
goals = false
`;
expect(readCodexFeatureFromContent(toml, 'fast_mode')).toBe(true);
expect(readCodexFeatureFromContent(toml, 'goals')).toBe(false);
});
it('writes missing feature flags into [features]', () => {
const updated = writeCodexFeatureInContent(
'model = "gpt-5.5"\n',
'goals',
true,
);
expect(updated).toContain('[features]');
expect(updated).toContain('goals = true');
});
});

View File

@@ -0,0 +1,83 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export type CodexConfigFeature = 'fast_mode' | 'goals';
export function codexConfigPath(): string {
const override = process.env.EJCLAW_CODEX_CONFIG_PATH?.trim();
if (override) return override;
const home = process.env.EJCLAW_SETTINGS_HOME || os.homedir();
return path.join(home, '.codex', 'config.toml');
}
export function readCodexFeatureFromContent(
content: string,
feature: CodexConfigFeature,
): boolean {
const lines = content.split('\n');
const featureLinePattern = new RegExp(`^${feature}\\s*=\\s*(true|false)$`);
let inFeatures = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '[features]') {
inFeatures = true;
continue;
}
if (inFeatures && trimmed.startsWith('[') && trimmed.endsWith(']')) {
break;
}
if (!inFeatures) continue;
const match = trimmed.match(featureLinePattern);
if (match) return match[1] === 'true';
}
return false;
}
export function readCodexFeatureFromFile(
filePath: string,
feature: CodexConfigFeature,
): boolean {
if (!fs.existsSync(filePath)) return false;
return readCodexFeatureFromContent(
fs.readFileSync(filePath, 'utf-8'),
feature,
);
}
export function writeCodexFeatureInContent(
content: string,
feature: CodexConfigFeature,
value: boolean,
): string {
const line = `${feature} = ${value}`;
const re = new RegExp(`^\\s*${feature}\\s*=\\s*(true|false)\\s*$`, 'm');
if (re.test(content)) {
return content.replace(re, line);
}
if (/^\[features\]/m.test(content)) {
return content.replace(/^\[features\]\s*$/m, `[features]\n${line}`);
}
const trimmed = content.replace(/\s*$/, '');
if (!trimmed) {
return `[features]\n${line}\n`;
}
return `${trimmed}\n\n[features]\n${line}\n`;
}
export function writeCodexFeatureToFile(
filePath: string,
feature: CodexConfigFeature,
value: boolean,
): void {
const content = fs.existsSync(filePath)
? fs.readFileSync(filePath, 'utf-8')
: '';
const updated = writeCodexFeatureInContent(content, feature, value);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.tmp`;
fs.writeFileSync(tempPath, updated, { mode: 0o600 });
fs.renameSync(tempPath, filePath);
}

245
src/codex-live-status.ts Normal file
View File

@@ -0,0 +1,245 @@
export interface CodexRateLimitWindowSummary {
limitWindowSeconds: number | null;
resetAfterSeconds: number | null;
resetAt: string | null;
usedPercent: number | null;
}
export interface CodexRateLimitSummary {
allowed: boolean | null;
limitReached: boolean | null;
primaryWindow: CodexRateLimitWindowSummary | null;
secondaryWindow: CodexRateLimitWindowSummary | null;
}
export interface CodexAdditionalRateLimitSummary {
limitName: string | null;
meteredFeature: string | null;
rateLimit: CodexRateLimitSummary | null;
}
export interface CodexCreditsSummary {
hasCredits: boolean | null;
overageLimitReached: boolean | null;
unlimited: boolean | null;
}
export interface CodexSpendControlSummary {
reached: boolean | null;
}
export interface CodexLiveStatusSummary {
checkedAt: string;
source: 'wham/usage';
planType: string | null;
email: string | null;
rateLimit: CodexRateLimitSummary | null;
rateLimitReachedType: string | null;
additionalRateLimits: CodexAdditionalRateLimitSummary[];
credits: CodexCreditsSummary | null;
spendControl: CodexSpendControlSummary | null;
}
interface CodexLiveRateLimitWindow {
limit_window_seconds?: number | null;
reset_after_seconds?: number | null;
reset_at?: number | null;
used_percent?: number | null;
}
interface CodexLiveRateLimit {
allowed?: boolean | null;
limit_reached?: boolean | null;
primary_window?: CodexLiveRateLimitWindow | null;
secondary_window?: CodexLiveRateLimitWindow | null;
}
interface CodexAdditionalLiveRateLimit {
limit_name?: string | null;
metered_feature?: string | null;
rate_limit?: CodexLiveRateLimit | null;
}
interface CodexLiveCredits {
has_credits?: boolean | null;
overage_limit_reached?: boolean | null;
unlimited?: boolean | null;
}
interface CodexLiveSpendControl {
reached?: boolean | null;
}
export interface CodexLiveStatus {
checked_at: string;
plan_type?: string | null;
email?: string | null;
rate_limit?: CodexLiveRateLimit | null;
rate_limit_reached_type?: string | null;
additional_rate_limits?: CodexAdditionalLiveRateLimit[];
credits?: CodexLiveCredits | null;
spend_control?: CodexLiveSpendControl | null;
}
function recordOrNull(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function numberOrNull(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function booleanOrNull(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null;
}
function epochSecondsToIso(value: number | null | undefined): string | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return new Date(value * 1000).toISOString();
}
function normalizeRateLimitWindow(
value: unknown,
): CodexLiveRateLimitWindow | null {
const record = recordOrNull(value);
if (!record) return null;
return {
limit_window_seconds: numberOrNull(record.limit_window_seconds),
reset_after_seconds: numberOrNull(record.reset_after_seconds),
reset_at: numberOrNull(record.reset_at),
used_percent: numberOrNull(record.used_percent),
};
}
function normalizeRateLimit(value: unknown): CodexLiveRateLimit | null {
const record = recordOrNull(value);
if (!record) return null;
return {
allowed: booleanOrNull(record.allowed),
limit_reached: booleanOrNull(record.limit_reached),
primary_window: normalizeRateLimitWindow(record.primary_window),
secondary_window: normalizeRateLimitWindow(record.secondary_window),
};
}
function normalizeAdditionalRateLimits(
value: unknown,
): CodexAdditionalLiveRateLimit[] {
if (!Array.isArray(value)) return [];
return value
.map((item): CodexAdditionalLiveRateLimit | null => {
const record = recordOrNull(item);
if (!record) return null;
return {
limit_name: stringOrNull(record.limit_name),
metered_feature: stringOrNull(record.metered_feature),
rate_limit: normalizeRateLimit(record.rate_limit),
};
})
.filter((item): item is CodexAdditionalLiveRateLimit => item !== null);
}
function normalizeCredits(value: unknown): CodexLiveCredits | null {
const record = recordOrNull(value);
if (!record) return null;
return {
has_credits: booleanOrNull(record.has_credits),
overage_limit_reached: booleanOrNull(record.overage_limit_reached),
unlimited: booleanOrNull(record.unlimited),
};
}
function normalizeSpendControl(value: unknown): CodexLiveSpendControl | null {
const record = recordOrNull(value);
if (!record) return null;
return {
reached: booleanOrNull(record.reached),
};
}
export function normalizeCodexLiveStatus(
value: unknown,
checkedAt: string,
): CodexLiveStatus | null {
const record = recordOrNull(value);
if (!record) return null;
return {
checked_at: checkedAt,
plan_type: stringOrNull(record.plan_type),
email: stringOrNull(record.email),
rate_limit: normalizeRateLimit(record.rate_limit),
rate_limit_reached_type: stringOrNull(record.rate_limit_reached_type),
additional_rate_limits: normalizeAdditionalRateLimits(
record.additional_rate_limits,
),
credits: normalizeCredits(record.credits),
spend_control: normalizeSpendControl(record.spend_control),
};
}
function toRateLimitWindowSummary(
window: CodexLiveRateLimitWindow | null | undefined,
): CodexRateLimitWindowSummary | null {
if (!window) return null;
return {
limitWindowSeconds: window.limit_window_seconds ?? null,
resetAfterSeconds: window.reset_after_seconds ?? null,
resetAt: epochSecondsToIso(window.reset_at),
usedPercent: window.used_percent ?? null,
};
}
function toRateLimitSummary(
rateLimit: CodexLiveRateLimit | null | undefined,
): CodexRateLimitSummary | null {
if (!rateLimit) return null;
return {
allowed: rateLimit.allowed ?? null,
limitReached: rateLimit.limit_reached ?? null,
primaryWindow: toRateLimitWindowSummary(rateLimit.primary_window),
secondaryWindow: toRateLimitWindowSummary(rateLimit.secondary_window),
};
}
export function toCodexLiveStatusSummary(
status: CodexLiveStatus,
): CodexLiveStatusSummary {
return {
checkedAt: status.checked_at,
source: 'wham/usage',
planType: status.plan_type ?? null,
email: status.email ?? null,
rateLimit: toRateLimitSummary(status.rate_limit),
rateLimitReachedType: status.rate_limit_reached_type ?? null,
additionalRateLimits: (status.additional_rate_limits ?? []).map(
(limit) => ({
limitName: limit.limit_name ?? null,
meteredFeature: limit.metered_feature ?? null,
rateLimit: toRateLimitSummary(limit.rate_limit),
}),
),
credits: status.credits
? {
hasCredits: status.credits.has_credits ?? null,
overageLimitReached: status.credits.overage_limit_reached ?? null,
unlimited: status.credits.unlimited ?? null,
}
: null,
spendControl: status.spend_control
? {
reached: status.spend_control.reached ?? null,
}
: null,
};
}

View File

@@ -6,6 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./agent-error-detection.js', () => ({
classifyAgentError: vi.fn(() => ({ category: 'none', reason: '' })),
classifyCodexAuthError: vi.fn(() => ({ category: 'none', reason: '' })),
isCodexPoolUnavailableError: vi.fn(
(error: string | null | undefined) =>
/all\s+codex(?:\s+rotation)?\s+accounts(?:\s+are)?\s+unavailable/i.test(
error ?? '',
) || /codex\s+rotation\s+pool\s+unavailable/i.test(error ?? ''),
),
}));
vi.mock('./config.js', () => ({
@@ -165,6 +171,236 @@ describe('codex-token-rotation d7 ≥ 100% auto-skip', () => {
expect(mod.getCodexAccountCount()).toBe(4);
expect(mod.getActiveCodexAuthPath()).not.toBe(fallbackAuthPath);
});
it('does not mutate account health for the internal all-accounts-unavailable sentinel', async () => {
const agentErrors = await import('./agent-error-detection.js');
vi.mocked(agentErrors.classifyCodexAuthError).mockReturnValueOnce({
category: 'auth-expired',
reason: 'auth-expired',
});
const mod = await import('./codex-token-rotation.js');
mod.initCodexTokenRotation();
expect(
mod.rotateCodexToken(
'auth-expired: All Codex rotation accounts unavailable; re-auth required before launching Codex',
),
).toBe(false);
const accounts = mod.getAllCodexAccounts();
expect(accounts[0]).toEqual(
expect.objectContaining({
isActive: true,
isAuthDead: false,
authStatus: 'healthy',
isRateLimited: false,
}),
);
expect(accounts[1].isActive).toBe(false);
});
it('marks refresh-token reuse as dead auth instead of a recoverable cooldown', async () => {
const agentErrors = await import('./agent-error-detection.js');
vi.mocked(agentErrors.classifyCodexAuthError).mockReturnValueOnce({
category: 'auth-expired',
reason: 'auth-expired',
});
const mod = await import('./codex-token-rotation.js');
mod.initCodexTokenRotation();
expect(mod.rotateCodexToken('refresh token was already used')).toBe(true);
const accounts = mod.getAllCodexAccounts();
expect(accounts[0]).toEqual(
expect.objectContaining({
isAuthDead: true,
authStatus: 'dead_auth',
}),
);
expect(accounts[0].isRateLimited).toBe(false);
expect(accounts[1].isActive).toBe(true);
});
});
describe('codex-token-rotation auth synchronization', () => {
let tempHome: string;
beforeEach(() => {
vi.resetModules();
tempHome = fs.mkdtempSync(path.join('/tmp', 'ejclaw-codex-rot-'));
process.env.CODEX_ROT_TEST_HOME = tempHome;
createFakeAccounts(tempHome, 4);
});
afterEach(() => {
delete process.env.CODEX_ROT_TEST_HOME;
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('recovers a dead_auth account when canonical auth.json is refreshed before lease claim', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
try {
const agentErrors = await import('./agent-error-detection.js');
vi.mocked(agentErrors.classifyCodexAuthError).mockReturnValueOnce({
category: 'auth-expired',
reason: 'auth-expired',
});
const mod = await import('./codex-token-rotation.js');
const utils = await import('./utils.js');
mod.initCodexTokenRotation();
expect(mod.rotateCodexToken('refresh token was already used')).toBe(true);
expect(mod.getAllCodexAccounts()[0].isAuthDead).toBe(true);
const refreshedAt = new Date('2026-01-01T00:00:10.000Z');
const authPath = path.join(tempHome, '.codex-accounts', '0', 'auth.json');
fs.writeFileSync(
authPath,
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { account_id: 'acct-0', access_token: 'refreshed-token' },
}),
);
fs.utimesSync(authPath, refreshedAt, refreshedAt);
mod.setCurrentCodexAccountIndex(0);
const lease = mod.claimCodexAuthLease();
try {
expect(lease).toEqual(
expect.objectContaining({ accountIndex: 0, authPath }),
);
expect(mod.getAllCodexAccounts()[0]).toEqual(
expect.objectContaining({
authStatus: 'healthy',
isAuthDead: false,
isActive: true,
}),
);
expect(vi.mocked(utils.writeJsonFile)).toHaveBeenCalled();
} finally {
lease?.release();
}
} finally {
vi.useRealTimers();
}
});
it('leases different accounts for concurrent Codex runs', async () => {
const mod = await import('./codex-token-rotation.js');
mod.initCodexTokenRotation();
const first = mod.claimCodexAuthLease();
const second = mod.claimCodexAuthLease();
try {
expect(first).toEqual(
expect.objectContaining({
accountIndex: 0,
authPath: expect.any(String),
}),
);
expect(second).toEqual(
expect.objectContaining({
accountIndex: 1,
authPath: expect.any(String),
}),
);
expect(second?.authPath).not.toBe(first?.authPath);
} finally {
second?.release();
first?.release();
}
});
it('syncs a refreshed session auth.json back to the canonical slot atomically', async () => {
const mod = await import('./codex-token-rotation.js');
mod.initCodexTokenRotation();
const canonicalAuthPath = path.join(
tempHome,
'.codex-accounts',
'0',
'auth.json',
);
const sessionAuthPath = path.join(
tempHome,
'session',
'.codex',
'auth.json',
);
fs.mkdirSync(path.dirname(sessionAuthPath), { recursive: true });
fs.writeFileSync(
sessionAuthPath,
JSON.stringify({
auth_mode: 'chatgpt',
tokens: {
account_id: 'acct-0',
access_token: 'new-access',
refresh_token: 'new-refresh',
},
}),
);
const synced = mod.syncCodexSessionAuthBack({
canonicalAuthPath,
sessionAuthPath,
accountIndex: 0,
});
expect(synced).toBe(true);
const canonical = JSON.parse(fs.readFileSync(canonicalAuthPath, 'utf-8'));
expect(canonical.tokens).toEqual(
expect.objectContaining({
account_id: 'acct-0',
access_token: 'new-access',
refresh_token: 'new-refresh',
}),
);
});
it('refuses to sync a session auth.json that belongs to another Codex account', async () => {
const mod = await import('./codex-token-rotation.js');
mod.initCodexTokenRotation();
const canonicalAuthPath = path.join(
tempHome,
'.codex-accounts',
'0',
'auth.json',
);
const before = fs.readFileSync(canonicalAuthPath, 'utf-8');
const sessionAuthPath = path.join(
tempHome,
'wrong-account',
'.codex',
'auth.json',
);
fs.mkdirSync(path.dirname(sessionAuthPath), { recursive: true });
fs.writeFileSync(
sessionAuthPath,
JSON.stringify({
auth_mode: 'chatgpt',
tokens: {
account_id: 'acct-other',
access_token: 'other-access',
refresh_token: 'other-refresh',
},
}),
);
expect(
mod.syncCodexSessionAuthBack({
canonicalAuthPath,
sessionAuthPath,
accountIndex: 0,
}),
).toBe(false);
expect(fs.readFileSync(canonicalAuthPath, 'utf-8')).toBe(before);
});
});
describe('codex-token-rotation single-account fallback', () => {

View File

@@ -17,13 +17,13 @@ import path from 'path';
import {
classifyAgentError,
classifyCodexAuthError,
isCodexPoolUnavailableError,
type CodexRotationReason,
} from './agent-error-detection.js';
import { DATA_DIR } from './config.js';
import { logger } from './logger.js';
import {
computeCooldownUntil,
findNextAvailable,
parseRetryAfterFromError,
} from './token-rotation-base.js';
import { readJsonFile, writeJsonFile } from './utils.js';
@@ -34,15 +34,27 @@ interface CodexAccount {
index: number;
authPath: string;
accountId: string;
authFileMtimeMs: number;
planType: string;
subscriptionUntil: string | null;
rateLimitedUntil: number | null;
authStatus: 'healthy' | 'dead_auth';
authDeadAt: number | null;
authDeadReason: string | null;
leasedUntil: number | null;
leaseId: string | null;
lastUsagePct?: number;
lastUsageD7Pct?: number;
resetAt?: string;
resetD7At?: string;
}
export interface CodexAuthLease {
accountIndex: number;
authPath: string;
release: () => void;
}
export type CodexRotationTriggerResult =
| {
shouldRotate: false;
@@ -77,10 +89,20 @@ const accounts: CodexAccount[] = [];
let currentIndex = 0;
let initialized = false;
const CODEX_AUTH_LEASE_MS = 2 * 60 * 60_000;
const ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
const DEFAULT_CODEX_DIR = path.join(os.homedir(), '.codex');
const DEFAULT_AUTH_PATH = path.join(DEFAULT_CODEX_DIR, 'auth.json');
function readAuthFileMtimeMs(authPath: string): number {
try {
return fs.statSync(authPath).mtimeMs;
} catch {
return 0;
}
}
function loadCodexAccount(
authPath: string,
fallbackAccountId: string,
@@ -99,9 +121,15 @@ function loadCodexAccount(
index: accounts.length,
authPath,
accountId,
authFileMtimeMs: readAuthFileMtimeMs(authPath),
planType: jwt.planType,
subscriptionUntil: jwt.expiresAt,
rateLimitedUntil: null,
authStatus: 'healthy',
authDeadAt: null,
authDeadReason: null,
leasedUntil: null,
leaseId: null,
});
return true;
}
@@ -149,6 +177,8 @@ function saveCodexState(): void {
const state = {
currentIndex,
rateLimits: accounts.map((a) => a.rateLimitedUntil),
authDeadAts: accounts.map((a) => a.authDeadAt),
authDeadReasons: accounts.map((a) => a.authDeadReason),
usagePcts: accounts.map((a) => a.lastUsagePct ?? null),
usageD7Pcts: accounts.map((a) => a.lastUsageD7Pct ?? null),
resetAts: accounts.map((a) => a.resetAt ?? null),
@@ -167,6 +197,8 @@ function loadCodexState(quiet = false): void {
const state = readJsonFile<{
currentIndex?: number;
rateLimits?: (number | null)[];
authDeadAts?: (number | null)[];
authDeadReasons?: (string | null)[];
usagePcts?: (number | null)[];
usageD7Pcts?: (number | null)[];
resetAts?: (string | null)[];
@@ -175,6 +207,7 @@ function loadCodexState(quiet = false): void {
if (!state) return;
const now = Date.now();
let restoredDeadAuth = false;
if (
typeof state.currentIndex === 'number' &&
state.currentIndex < accounts.length
@@ -195,6 +228,39 @@ function loadCodexState(quiet = false): void {
}
}
}
if (Array.isArray(state.authDeadAts)) {
for (
let i = 0;
i < Math.min(state.authDeadAts.length, accounts.length);
i++
) {
const deadAt = state.authDeadAts[i];
const acct = accounts[i];
if (typeof deadAt === 'number' && deadAt > 0) {
const currentMtime = readAuthFileMtimeMs(acct.authPath);
acct.authFileMtimeMs = currentMtime;
if (currentMtime > deadAt) {
acct.authStatus = 'dead_auth';
acct.authDeadAt = deadAt;
acct.authDeadReason = state.authDeadReasons?.[i] ?? 'auth-expired';
restoredDeadAuth =
restoreDeadAuthIfAuthFileChanged(acct) || restoredDeadAuth;
} else {
acct.authStatus = 'dead_auth';
acct.authDeadAt = deadAt;
acct.authDeadReason = state.authDeadReasons?.[i] ?? 'auth-expired';
acct.rateLimitedUntil = null;
}
}
}
}
if (
currentIndex < accounts.length &&
accounts[currentIndex]?.authStatus === 'dead_auth'
) {
const nextIdx = findNextCodexAvailable(currentIndex);
if (nextIdx !== null) currentIndex = nextIdx;
}
if (Array.isArray(state.usagePcts)) {
for (
let i = 0;
@@ -239,6 +305,7 @@ function loadCodexState(quiet = false): void {
'Codex rotation state restored',
);
}
if (restoredDeadAuth) saveCodexState();
}
/**
@@ -256,6 +323,52 @@ export function getActiveCodexAuthPath(): string | null {
return getCodexAuthPath();
}
/** Currently active codex account index (after rotation). */
export function getCurrentCodexAccountIndex(): number {
return accounts.length === 0 ? 0 : currentIndex;
}
/** Find the rotation-array index that owns the given auth.json path. */
export function findCodexAccountIndexByAuthPath(
authPath: string,
): number | null {
for (let i = 0; i < accounts.length; i += 1) {
if (accounts[i]?.authPath === authPath) return i;
}
return null;
}
/** Manually switch the active codex account. Clears its rate-limit cooldown. */
export function setCurrentCodexAccountIndex(targetIndex: number): void {
if (accounts.length === 0) {
throw new Error('codex token rotation: no accounts loaded');
}
if (
!Number.isInteger(targetIndex) ||
targetIndex < 0 ||
targetIndex >= accounts.length
) {
throw new Error(
`codex switch: index ${targetIndex} out of range [0..${accounts.length - 1}]`,
);
}
if (targetIndex === currentIndex) return;
const previous = currentIndex;
currentIndex = targetIndex;
// Clear rate-limit on the chosen account so rotation logic doesn't bounce it.
accounts[targetIndex].rateLimitedUntil = null;
saveCodexState();
logger.info(
{
transition: 'rotation:manual-switch',
fromIndex: previous,
toIndex: currentIndex,
totalAccounts: accounts.length,
},
`Codex switched manually to account #${currentIndex + 1}/${accounts.length}`,
);
}
export function getCodexAuthPath(
accountIndex: number = currentIndex,
): string | null {
@@ -263,10 +376,272 @@ export function getCodexAuthPath(
return accounts[accountIndex]?.authPath ?? null;
}
function codexLockPath(authPath: string): string {
return path.join(path.dirname(authPath), '.ejclaw-auth.lock');
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function readExistingLease(lockPath: string): {
leaseId?: string;
pid?: number;
expiresAt?: number;
} | null {
const data = readJsonFile<{
leaseId?: string;
pid?: number;
expiresAt?: number;
}>(lockPath);
return data ?? null;
}
function tryAcquireDiskLease(
acct: CodexAccount,
leaseId: string,
now: number,
): boolean {
const lockPath = codexLockPath(acct.authPath);
const payload = JSON.stringify(
{
leaseId,
pid: process.pid,
accountIndex: acct.index,
createdAt: now,
expiresAt: now + CODEX_AUTH_LEASE_MS,
},
null,
2,
);
try {
fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return false;
}
const existing = readExistingLease(lockPath);
const expired = Boolean(existing?.expiresAt && existing.expiresAt <= now);
const deadPid = Boolean(existing?.pid && !isPidAlive(existing.pid));
if (!expired && !deadPid) return false;
try {
fs.unlinkSync(lockPath);
} catch {
return false;
}
try {
fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
return true;
} catch {
return false;
}
}
function releaseDiskLease(authPath: string, leaseId: string): void {
const lockPath = codexLockPath(authPath);
const existing = readExistingLease(lockPath);
if (existing?.leaseId && existing.leaseId !== leaseId) return;
try {
fs.unlinkSync(lockPath);
} catch {
// Best effort only. Expired/stale locks are cleared by the next claimant.
}
}
function isCodexAccountUsable(
acct: CodexAccount,
now = Date.now(),
opts?: { ignoreRateLimits?: boolean; ignoreD7?: boolean },
): boolean {
if (restoreDeadAuthIfAuthFileChanged(acct)) saveCodexState();
if (acct.authStatus === 'dead_auth') return false;
if (
!opts?.ignoreRateLimits &&
acct.rateLimitedUntil &&
acct.rateLimitedUntil > now
) {
return false;
}
if (
!opts?.ignoreD7 &&
acct.lastUsageD7Pct != null &&
acct.lastUsageD7Pct >= 100
) {
return false;
}
if (acct.leasedUntil && acct.leasedUntil > now) return false;
const existing = readExistingLease(codexLockPath(acct.authPath));
if (!existing) return true;
if (existing.expiresAt && existing.expiresAt <= now) return true;
if (existing.pid && !isPidAlive(existing.pid)) return true;
return false;
}
function restoreDeadAuthIfAuthFileChanged(acct: CodexAccount): boolean {
if (acct.authStatus !== 'dead_auth' || !acct.authDeadAt) return false;
const currentMtime = readAuthFileMtimeMs(acct.authPath);
acct.authFileMtimeMs = currentMtime;
if (currentMtime <= acct.authDeadAt) return false;
const previousDeadAt = acct.authDeadAt;
acct.authStatus = 'healthy';
acct.authDeadAt = null;
acct.authDeadReason = null;
acct.rateLimitedUntil = null;
logger.info(
{
transition: 'rotation:auth-file-refreshed',
accountIndex: acct.index,
previousDeadAt: new Date(previousDeadAt).toISOString(),
authFileMtime: new Date(currentMtime).toISOString(),
},
`Codex account #${acct.index + 1}/${accounts.length} marked healthy after auth.json refresh`,
);
return true;
}
function markCodexAccountDeadAuth(acct: CodexAccount, reason?: string): void {
acct.authStatus = 'dead_auth';
acct.authDeadAt = Date.now();
acct.authDeadReason = reason || 'auth-expired';
acct.rateLimitedUntil = null;
acct.leasedUntil = null;
acct.leaseId = null;
logger.warn(
{
transition: 'rotation:dead-auth',
accountIndex: acct.index,
accountId: acct.accountId,
reason: acct.authDeadReason,
},
`Codex account #${acct.index + 1}/${accounts.length} marked dead; re-auth required`,
);
}
function updateAccountMetadataFromAuthFile(acct: CodexAccount): void {
const data = readJsonFile<{
tokens?: { account_id?: string; id_token?: string };
}>(acct.authPath);
if (data?.tokens?.account_id) acct.accountId = data.tokens.account_id;
const jwt = parseJwtAuth(data?.tokens?.id_token || '');
acct.planType = jwt.planType;
acct.subscriptionUntil = jwt.expiresAt;
acct.authFileMtimeMs = readAuthFileMtimeMs(acct.authPath);
}
export function claimCodexAuthLease(): CodexAuthLease | null {
if (accounts.length === 0) return null;
const now = Date.now();
const attempts = accounts.length;
for (let offset = 0; offset < attempts; offset += 1) {
const idx = (currentIndex + offset) % accounts.length;
const acct = accounts[idx];
if (!isCodexAccountUsable(acct, now)) continue;
const leaseId = `${process.pid}-${now}-${idx}-${Math.random()
.toString(36)
.slice(2)}`;
if (!tryAcquireDiskLease(acct, leaseId, now)) continue;
currentIndex = idx;
acct.leasedUntil = now + CODEX_AUTH_LEASE_MS;
acct.leaseId = leaseId;
return {
accountIndex: idx,
authPath: acct.authPath,
release: () => {
if (acct.leaseId === leaseId) {
acct.leasedUntil = null;
acct.leaseId = null;
}
releaseDiskLease(acct.authPath, leaseId);
},
};
}
return null;
}
export function syncCodexSessionAuthBack(args: {
canonicalAuthPath: string;
sessionAuthPath: string;
accountIndex?: number | null;
}): boolean {
if (!fs.existsSync(args.sessionAuthPath)) return false;
if (!fs.existsSync(args.canonicalAuthPath)) return false;
const canonical = readJsonFile<{
auth_mode?: string;
tokens?: { account_id?: string };
}>(args.canonicalAuthPath);
const session = readJsonFile<{
auth_mode?: string;
tokens?: { account_id?: string };
}>(args.sessionAuthPath);
if (!canonical || !session?.tokens) return false;
const canonicalAccount = canonical.tokens?.account_id;
const sessionAccount = session.tokens.account_id;
if (
canonicalAccount &&
sessionAccount &&
canonicalAccount !== sessionAccount
) {
logger.warn(
{
canonicalAuthPath: args.canonicalAuthPath,
sessionAuthPath: args.sessionAuthPath,
accountIndex: args.accountIndex ?? null,
},
'Refusing to sync Codex session auth back: account mismatch',
);
return false;
}
const sessionRaw = fs.readFileSync(args.sessionAuthPath, 'utf-8');
const canonicalRaw = fs.readFileSync(args.canonicalAuthPath, 'utf-8');
if (sessionRaw === canonicalRaw) return false;
const tmpPath = `${args.canonicalAuthPath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmpPath, sessionRaw, { mode: 0o600 });
fs.renameSync(tmpPath, args.canonicalAuthPath);
const idx =
args.accountIndex ??
findCodexAccountIndexByAuthPath(args.canonicalAuthPath);
if (idx != null && accounts[idx]) {
const acct = accounts[idx];
acct.authStatus = 'healthy';
acct.authDeadAt = null;
acct.authDeadReason = null;
updateAccountMetadataFromAuthFile(acct);
saveCodexState();
}
logger.info(
{
accountIndex: idx,
canonicalAuthPath: args.canonicalAuthPath,
},
'Synced refreshed Codex session auth back to canonical account slot',
);
return true;
}
export function detectCodexRotationTrigger(
error?: string | null,
): CodexRotationTriggerResult {
if (!error) return { shouldRotate: false, reason: '' };
if (isCodexPoolUnavailableError(error)) {
return { shouldRotate: false, reason: '' };
}
// Common patterns (429, 503, network) — delegated to SSOT
const common = classifyAgentError(error);
@@ -293,18 +668,38 @@ export function rotateCodexToken(
): boolean {
if (accounts.length <= 1) return false;
const previousIndex = currentIndex;
const acct = accounts[currentIndex];
const cooldownUntil = computeCooldownUntil(errorMessage);
acct.rateLimitedUntil = cooldownUntil;
acct.lastUsagePct = 100;
// Extract reset time string from error for display
const retryAt = parseRetryAfterFromError(errorMessage);
if (retryAt) {
acct.resetAt = new Date(retryAt).toISOString();
if (isCodexPoolUnavailableError(errorMessage)) {
logger.warn(
{
transition: 'rotation:skip-pool-unavailable-sentinel',
currentIndex,
totalAccounts: accounts.length,
reason: errorMessage ?? null,
},
'Refusing to mark Codex accounts unhealthy from internal pool-unavailable sentinel',
);
return false;
}
const nextIdx = findNextAvailable(accounts, currentIndex, opts);
const previousIndex = currentIndex;
const acct = accounts[currentIndex];
const authFailure = classifyCodexAuthError(errorMessage);
let cooldownUntil: number | null = null;
if (authFailure.category === 'auth-expired') {
markCodexAccountDeadAuth(acct, errorMessage || authFailure.reason);
} else {
cooldownUntil = computeCooldownUntil(errorMessage);
acct.rateLimitedUntil = cooldownUntil;
acct.lastUsagePct = 100;
// Extract reset time string from error for display
const retryAt = parseRetryAfterFromError(errorMessage);
if (retryAt) {
acct.resetAt = new Date(retryAt).toISOString();
}
}
const nextIdx = findNextCodexAvailable(currentIndex, opts);
if (nextIdx !== null) {
accounts[nextIdx].rateLimitedUntil = null;
currentIndex = nextIdx;
@@ -318,6 +713,7 @@ export function rotateCodexToken(
ignoreRL: opts?.ignoreRateLimits ?? false,
cooldownUntil:
cooldownUntil != null ? new Date(cooldownUntil).toISOString() : null,
authDead: authFailure.category === 'auth-expired',
reason: errorMessage ?? null,
},
`Codex rotated to account #${currentIndex + 1}/${accounts.length}`,
@@ -334,28 +730,40 @@ export function rotateCodexToken(
ignoreRL: opts?.ignoreRateLimits ?? false,
cooldownUntil:
cooldownUntil != null ? new Date(cooldownUntil).toISOString() : null,
authDead: authFailure.category === 'auth-expired',
reason: errorMessage ?? null,
},
'All Codex accounts are rate-limited',
authFailure.category === 'auth-expired'
? 'All Codex accounts unavailable after auth failure; re-auth required'
: 'All Codex accounts are rate-limited',
);
saveCodexState();
return false;
}
/**
* Find the next Codex account that is neither rate-limited nor 7d-exhausted.
*/
function findNextCodexAvailable(fromIndex?: number): number | null {
function findNextCodexAvailable(
fromIndex?: number,
opts?: { ignoreRateLimits?: boolean },
): number | null {
const now = Date.now();
const start = fromIndex ?? currentIndex;
for (let i = 1; i < accounts.length; i++) {
const idx = (start + i) % accounts.length;
const acct = accounts[idx];
const rlOk = !acct.rateLimitedUntil || acct.rateLimitedUntil <= now;
const usageOk = acct.lastUsageD7Pct == null || acct.lastUsageD7Pct < 100;
if (rlOk && usageOk) return idx;
if (isCodexAccountUsable(acct, now, opts)) return idx;
}
// All exhausted — fall back to rate-limit-only check
return findNextAvailable(accounts, start);
// All d7-exhausted — fall back to rate-limit/dead/lease checks only.
for (let i = 1; i < accounts.length; i++) {
const idx = (start + i) % accounts.length;
const acct = accounts[idx];
if (isCodexAccountUsable(acct, now, { ...opts, ignoreD7: true })) {
return idx;
}
}
return null;
}
/**
@@ -423,9 +831,17 @@ export function updateCodexAccountUsage(
export function markCodexTokenHealthy(): void {
if (accounts.length === 0) return;
const acct = accounts[currentIndex];
let changed = false;
if (acct?.authStatus === 'dead_auth') {
acct.authStatus = 'healthy';
acct.authDeadAt = null;
acct.authDeadReason = null;
changed = true;
}
if (acct?.rateLimitedUntil) {
const previousCooldownUntil = acct.rateLimitedUntil;
acct.rateLimitedUntil = null;
changed = true;
logger.info(
{
transition: 'rotation:clear-rate-limit',
@@ -435,8 +851,8 @@ export function markCodexTokenHealthy(): void {
},
'Cleared Codex account rate-limit state after successful response',
);
saveCodexState();
}
if (changed) saveCodexState();
}
export function getCodexAccountCount(): number {
@@ -449,6 +865,10 @@ export function getAllCodexAccounts(): {
planType: string;
isActive: boolean;
isRateLimited: boolean;
isAuthDead?: boolean;
authStatus?: 'healthy' | 'dead_auth';
authDeadAt?: string;
isLeased?: boolean;
cachedUsagePct?: number;
cachedUsageD7Pct?: number;
resetAt?: string;
@@ -461,6 +881,10 @@ export function getAllCodexAccounts(): {
planType: a.planType,
isActive: i === currentIndex,
isRateLimited: Boolean(a.rateLimitedUntil && a.rateLimitedUntil > now),
isAuthDead: a.authStatus === 'dead_auth',
authStatus: a.authStatus,
authDeadAt: a.authDeadAt ? new Date(a.authDeadAt).toISOString() : undefined,
isLeased: Boolean(a.leasedUntil && a.leasedUntil > now),
cachedUsagePct: a.lastUsagePct,
cachedUsageD7Pct: a.lastUsageD7Pct,
resetAt: a.resetAt,

View File

@@ -300,13 +300,9 @@ export function applyCodexUsageToAccount(
// Select the effective bucket
const primaryBucket = usage.find((l) => l.limitId === 'codex');
let effective: CodexRateLimit | null = null;
const effective = primaryBucket ?? (usage.length === 1 ? usage[0] : null);
if (primaryBucket) {
effective = primaryBucket;
} else if (usage.length === 1) {
effective = usage[0];
} else {
if (!effective) {
// Multiple unknown buckets — cannot determine which is authoritative
logger.warn(
{ account: accountIndex + 1 },

104
src/compact-refresh.test.ts Normal file
View File

@@ -0,0 +1,104 @@
import fs from 'fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/ejclaw-compact-refresh-test',
}));
import {
clearCompactRefreshIfUnchanged,
markCompactRefreshNeeded,
maybeApplyCompactRefresh,
readCompactRefreshFlag,
} from './compact-refresh.js';
const refreshDir = '/tmp/ejclaw-compact-refresh-test/compact-refresh';
describe('compact-refresh', () => {
beforeEach(() => {
fs.rmSync(refreshDir, { recursive: true, force: true });
});
it('marks and reads compact refresh flags by session folder', () => {
const flag = markCompactRefreshNeeded({
sessionFolder: 'room:reviewer',
sessionId: 'session-1',
trigger: 'auto',
});
expect(readCompactRefreshFlag('room:reviewer')).toMatchObject({
sessionFolder: 'room:reviewer',
sessionId: 'session-1',
trigger: 'auto',
compactedAt: flag.compactedAt,
});
});
it('applies a one-shot compact refresh only for owner and reviewer roles', () => {
const flag = markCompactRefreshNeeded({
sessionFolder: 'room',
sessionId: 'session-1',
});
const applied = maybeApplyCompactRefresh({
sessionFolder: 'room',
sessionId: 'session-1',
role: 'owner',
prompt: 'continue task',
});
expect(applied?.flag).toEqual(flag);
expect(applied?.prompt).toContain('EJClaw compact refresh');
expect(applied?.prompt).toContain('continue task');
clearCompactRefreshIfUnchanged({
sessionFolder: 'room',
flag,
});
expect(readCompactRefreshFlag('room')).toBeNull();
});
it('does not apply to arbiter, compact commands, fresh sessions, or stale sessions', () => {
markCompactRefreshNeeded({
sessionFolder: 'room',
sessionId: 'session-1',
});
expect(
maybeApplyCompactRefresh({
sessionFolder: 'room',
sessionId: 'session-1',
role: 'arbiter',
prompt: 'judge',
}),
).toBeNull();
expect(
maybeApplyCompactRefresh({
sessionFolder: 'room',
sessionId: 'session-1',
role: 'owner',
prompt: '/compact',
}),
).toBeNull();
expect(
maybeApplyCompactRefresh({
sessionFolder: 'room',
role: 'owner',
prompt: 'fresh',
}),
).toBeNull();
expect(
maybeApplyCompactRefresh({
sessionFolder: 'room',
sessionId: 'different-session',
role: 'owner',
prompt: 'stale',
}),
).toBeNull();
expect(readCompactRefreshFlag('room')).toBeNull();
});
});

128
src/compact-refresh.ts Normal file
View File

@@ -0,0 +1,128 @@
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import type { PairedRoomRole } from './types.js';
export interface CompactRefreshFlag {
sessionFolder: string;
sessionId: string;
compactedAt: string;
trigger?: string | null;
}
export interface AppliedCompactRefresh {
flag: CompactRefreshFlag;
prompt: string;
}
const COMPACT_REFRESH_DIR = path.join(DATA_DIR, 'compact-refresh');
const COMPACT_REFRESH_PROMPT = `[EJClaw compact refresh]
The previous run compacted this same SDK session. Continue following the current AGENTS.md/CLAUDE.md role rules exactly: required first-line status, paired-room role boundaries, output-only task context, concise Korean responses, and verification before completion. This is not new user scope.
[/EJClaw compact refresh]`;
function getFlagPath(sessionFolder: string): string {
return path.join(
COMPACT_REFRESH_DIR,
`${encodeURIComponent(sessionFolder)}.json`,
);
}
function isRefreshableRole(role: PairedRoomRole): boolean {
return role === 'owner' || role === 'reviewer';
}
function isSessionCommand(prompt: string): boolean {
return prompt.trim() === '/compact';
}
export function readCompactRefreshFlag(
sessionFolder: string,
): CompactRefreshFlag | null {
const flagPath = getFlagPath(sessionFolder);
if (!fs.existsSync(flagPath)) return null;
try {
const parsed = JSON.parse(
fs.readFileSync(flagPath, 'utf-8'),
) as Partial<CompactRefreshFlag>;
if (
parsed.sessionFolder !== sessionFolder ||
typeof parsed.sessionId !== 'string' ||
parsed.sessionId.length === 0 ||
typeof parsed.compactedAt !== 'string'
) {
fs.rmSync(flagPath, { force: true });
return null;
}
return {
sessionFolder,
sessionId: parsed.sessionId,
compactedAt: parsed.compactedAt,
trigger:
typeof parsed.trigger === 'string' && parsed.trigger.length > 0
? parsed.trigger
: null,
};
} catch {
fs.rmSync(flagPath, { force: true });
return null;
}
}
export function markCompactRefreshNeeded(args: {
sessionFolder: string;
sessionId: string;
trigger?: string | null;
}): CompactRefreshFlag {
const flag: CompactRefreshFlag = {
sessionFolder: args.sessionFolder,
sessionId: args.sessionId,
compactedAt: new Date().toISOString(),
trigger: args.trigger ?? null,
};
fs.mkdirSync(COMPACT_REFRESH_DIR, { recursive: true });
fs.writeFileSync(
getFlagPath(args.sessionFolder),
`${JSON.stringify(flag)}\n`,
);
return flag;
}
export function clearCompactRefreshIfUnchanged(args: {
sessionFolder: string;
flag: CompactRefreshFlag;
}): void {
const current = readCompactRefreshFlag(args.sessionFolder);
if (
current &&
current.sessionId === args.flag.sessionId &&
current.compactedAt === args.flag.compactedAt
) {
fs.rmSync(getFlagPath(args.sessionFolder), { force: true });
}
}
export function maybeApplyCompactRefresh(args: {
sessionFolder: string;
sessionId?: string;
role: PairedRoomRole;
prompt: string;
}): AppliedCompactRefresh | null {
if (!args.sessionId) return null;
if (!isRefreshableRole(args.role)) return null;
if (isSessionCommand(args.prompt)) return null;
const flag = readCompactRefreshFlag(args.sessionFolder);
if (!flag) return null;
if (flag.sessionId !== args.sessionId) {
fs.rmSync(getFlagPath(args.sessionFolder), { force: true });
return null;
}
return {
flag,
prompt: `${COMPACT_REFRESH_PROMPT}\n\n${args.prompt}`,
};
}

View File

@@ -46,6 +46,7 @@ describe('config/env loading', () => {
delete process.env.WEB_DASHBOARD_HOST;
delete process.env.WEB_DASHBOARD_PORT;
delete process.env.WEB_DASHBOARD_STATIC_DIR;
delete process.env.WEB_DASHBOARD_TOKEN;
delete process.env.SESSION_COMMAND_USER_IDS;
vi.resetModules();
});
@@ -175,12 +176,14 @@ describe('config/env loading', () => {
expect(config.WEB_DASHBOARD.staticDir).toBe(
path.resolve(tempRoot, 'apps', 'dashboard', 'dist'),
);
expect(config.WEB_DASHBOARD.token).toBe('');
vi.resetModules();
process.env.WEB_DASHBOARD_ENABLED = 'true';
process.env.WEB_DASHBOARD_HOST = '0.0.0.0';
process.env.WEB_DASHBOARD_PORT = '9001';
process.env.WEB_DASHBOARD_STATIC_DIR = './custom-dashboard-dist';
process.env.WEB_DASHBOARD_TOKEN = 'mobile-secret';
config = await import('./config.js');
expect(config.WEB_DASHBOARD).toEqual({
@@ -188,6 +191,7 @@ describe('config/env loading', () => {
host: '0.0.0.0',
port: 9001,
staticDir: path.resolve(tempRoot, 'custom-dashboard-dist'),
token: 'mobile-secret',
});
});

View File

@@ -135,6 +135,34 @@ function assertNoLegacyEnvAliasesConfigured(): void {
);
}
function buildPathsConfig(
projectRoot: string,
homeDir: string,
): AppConfig['paths'] {
return {
projectRoot,
homeDir,
senderAllowlistPath: path.join(
homeDir,
'.config',
'ejclaw',
'sender-allowlist.json',
),
storeDir: path.resolve(
readNonEmptyText('EJCLAW_STORE_DIR') ?? path.join(projectRoot, 'store'),
),
groupsDir: path.resolve(
readNonEmptyText('EJCLAW_GROUPS_DIR') ?? path.join(projectRoot, 'groups'),
),
dataDir: path.resolve(
readNonEmptyText('EJCLAW_DATA_DIR') ?? path.join(projectRoot, 'data'),
),
cacheDir: path.resolve(
readNonEmptyText('EJCLAW_CACHE_DIR') ?? path.join(projectRoot, 'cache'),
),
};
}
export function loadConfig(): AppConfig {
assertNoLegacyEnvAliasesConfigured();
@@ -176,29 +204,7 @@ export function loadConfig(): AppConfig {
codexMainId: codexMainServiceId,
codexReviewId: codexReviewServiceId,
},
paths: {
projectRoot,
homeDir,
senderAllowlistPath: path.join(
homeDir,
'.config',
'ejclaw',
'sender-allowlist.json',
),
storeDir: path.resolve(
readNonEmptyText('EJCLAW_STORE_DIR') ?? path.join(projectRoot, 'store'),
),
groupsDir: path.resolve(
readNonEmptyText('EJCLAW_GROUPS_DIR') ??
path.join(projectRoot, 'groups'),
),
dataDir: path.resolve(
readNonEmptyText('EJCLAW_DATA_DIR') ?? path.join(projectRoot, 'data'),
),
cacheDir: path.resolve(
readNonEmptyText('EJCLAW_CACHE_DIR') ?? path.join(projectRoot, 'cache'),
),
},
paths: buildPathsConfig(projectRoot, homeDir),
runtime: {
pollInterval: 2000,
schedulerPollInterval: 60000,
@@ -268,6 +274,7 @@ export function loadConfig(): AppConfig {
readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ??
path.join(projectRoot, 'apps', 'dashboard', 'dist'),
),
token: readNonEmptyText('WEB_DASHBOARD_TOKEN') ?? '',
},
codexWarmup: {
enabled: readBoolean('CODEX_WARMUP_ENABLED', false),

View File

@@ -83,6 +83,7 @@ export interface AppConfig {
host: string;
port: number;
staticDir: string;
token: string;
};
codexWarmup: {
enabled: boolean;

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import {
cleanupDashboardDuplicateMessages,
purgeDashboardMessages,
} from './dashboard-message-cleanup.js';
function makeChannel(deleteRecentMessagesByContent: any) {
return {
name: 'discord',
connect: vi.fn(),
sendMessage: vi.fn(),
isConnected: () => true,
ownsJid: () => true,
disconnect: vi.fn(),
deleteRecentMessagesByContent,
};
}
describe('cleanupDashboardDuplicateMessages', () => {
it('deletes recent dashboard messages except the tracked status message', async () => {
const deleteRecentMessagesByContent = vi.fn(async () => 3);
const deleted = await cleanupDashboardDuplicateMessages(
{
statusChannelId: 'status-channel',
channels: [makeChannel(deleteRecentMessagesByContent)],
},
'tracked-message',
);
expect(deleted).toBe(3);
expect(deleteRecentMessagesByContent).toHaveBeenCalledWith(
'dc:status-channel',
{
contentIncludes: '🤖 *모델 구성*',
exceptMessageId: 'tracked-message',
limit: 100,
},
);
});
it('skips cleanup when there is no tracked status message yet', async () => {
const deleteRecentMessagesByContent = vi.fn(async () => 3);
const deleted = await cleanupDashboardDuplicateMessages(
{
statusChannelId: 'status-channel',
channels: [makeChannel(deleteRecentMessagesByContent)],
},
null,
);
expect(deleted).toBe(0);
expect(deleteRecentMessagesByContent).not.toHaveBeenCalled();
});
});
describe('purgeDashboardMessages', () => {
it('deletes dashboard marker messages without purging the whole channel', async () => {
const deleteRecentMessagesByContent = vi.fn(async () => 2);
const deleted = await purgeDashboardMessages({
statusChannelId: 'status-channel',
channels: [makeChannel(deleteRecentMessagesByContent)],
});
expect(deleted).toBe(2);
expect(deleteRecentMessagesByContent).toHaveBeenCalledWith(
'dc:status-channel',
{
contentIncludes: '🤖 *모델 구성*',
limit: 100,
},
);
});
it('continues deleting dashboard batches until the last batch is partial', async () => {
const deleteRecentMessagesByContent = vi
.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(7);
const deleted = await purgeDashboardMessages({
statusChannelId: 'status-channel',
channels: [makeChannel(deleteRecentMessagesByContent)],
});
expect(deleted).toBe(107);
expect(deleteRecentMessagesByContent).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,67 @@
import { logger } from './logger.js';
import type { Channel } from './types.js';
export const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*';
const DASHBOARD_DUPLICATE_CLEANUP_LIMIT = 100;
function findDashboardCleanupChannel(opts: {
channels: Channel[];
statusChannelId: string;
}): Channel | undefined {
if (!opts.statusChannelId) return undefined;
return opts.channels.find(
(item) =>
item.name.startsWith('discord') &&
item.isConnected() &&
item.deleteRecentMessagesByContent,
);
}
export async function purgeDashboardMessages(opts: {
channels: Channel[];
statusChannelId: string;
}): Promise<number> {
const channel = findDashboardCleanupChannel(opts);
if (!channel?.deleteRecentMessagesByContent) return 0;
let total = 0;
for (let i = 0; i < 10; i += 1) {
const deleted = await channel.deleteRecentMessagesByContent(
`dc:${opts.statusChannelId}`,
{
contentIncludes: DASHBOARD_STATUS_MESSAGE_MARKER,
limit: DASHBOARD_DUPLICATE_CLEANUP_LIMIT,
},
);
total += deleted;
if (deleted < DASHBOARD_DUPLICATE_CLEANUP_LIMIT) break;
}
return total;
}
export async function cleanupDashboardDuplicateMessages(
opts: { channels: Channel[]; statusChannelId: string },
keepMessageId: string | null,
): Promise<number> {
if (!opts.statusChannelId || !keepMessageId) return 0;
const channel = findDashboardCleanupChannel(opts);
if (!channel?.deleteRecentMessagesByContent) return 0;
try {
return await channel.deleteRecentMessagesByContent(
`dc:${opts.statusChannelId}`,
{
contentIncludes: DASHBOARD_STATUS_MESSAGE_MARKER,
exceptMessageId: keepMessageId,
limit: DASHBOARD_DUPLICATE_CLEANUP_LIMIT,
},
);
} catch (err) {
logger.warn(
{ err, keepMessageId },
'Dashboard duplicate message cleanup failed',
);
return 0;
}
}

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./logger.js', () => ({
logger: {
warn: vi.fn(),
},
}));
import { logger } from './logger.js';
import { parseAttachmentPayload } from './db/work-items.js';
describe('attachment payload parsing', () => {
beforeEach(() => {
vi.mocked(logger.warn).mockReset();
});
it('logs malformed JSON without throwing or breaking old rows', () => {
const attachments = parseAttachmentPayload('{"path":', {
table: 'work_items',
rowId: 42,
});
expect(attachments).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
table: 'work_items',
rowId: 42,
reason: 'invalid_json',
payloadLength: 8,
payloadPreview: '{"path":',
err: expect.any(SyntaxError),
}),
'Ignored malformed attachment payload',
);
});
it('logs non-array attachment JSON without throwing', () => {
const payload = '{"path":"/tmp/image.png"}';
const attachments = parseAttachmentPayload(payload, {
table: 'paired_turn_outputs',
rowId: 7,
});
expect(attachments).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
table: 'paired_turn_outputs',
rowId: 7,
reason: 'not_array',
payloadLength: payload.length,
payloadPreview: payload,
}),
'Ignored malformed attachment payload',
);
});
it('keeps valid attachments while logging malformed entries', () => {
const payload = JSON.stringify([
{ path: '/tmp/image.png', name: 'image.png', mime: 'image/png' },
{ name: 'missing-path.png' },
null,
]);
const attachments = parseAttachmentPayload(payload, {
table: 'work_items',
rowId: 43,
});
expect(attachments).toEqual([
{ path: '/tmp/image.png', name: 'image.png', mime: 'image/png' },
]);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
table: 'work_items',
rowId: 43,
invalidEntryCount: 2,
validEntryCount: 1,
payloadLength: payload.length,
payloadPreview: payload,
}),
'Ignored invalid attachment payload entries',
);
});
});

177
src/db-evidence.test.ts Normal file
View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from 'vitest';
import { _initTestDatabase, insertPairedTurnOutput } from './db.js';
import { requireDatabase } from './db/runtime-database.js';
import {
normalizeDbEvidenceLimit,
normalizeDbEvidenceMinutes,
normalizeDbEvidenceTaskId,
runDbEvidenceRequest,
} from './db-evidence.js';
describe('DB evidence presets', () => {
function seedPairedTask(): void {
_initTestDatabase();
requireDatabase()
.prepare(
`
INSERT INTO paired_tasks (
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
owner_agent_type, reviewer_agent_type, arbiter_agent_type,
status, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
'task-1',
'room-1',
'room-folder',
'codex-main',
'claude',
'codex',
'claude-code',
'codex',
'active',
'2026-05-26T00:00:00.000Z',
'2026-05-26T00:10:00.000Z',
);
requireDatabase()
.prepare(
`
INSERT INTO paired_turns (
turn_id, task_id, task_updated_at, role, intent_kind, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
'turn-1',
'task-1',
'2026-05-26T00:10:00.000Z',
'owner',
'owner-turn',
'2026-05-26T00:00:01.000Z',
'2026-05-26T00:00:02.000Z',
);
requireDatabase()
.prepare(
`
INSERT INTO paired_turn_attempts (
attempt_id, turn_id, attempt_no, task_id, task_updated_at, role, intent_kind,
state, executor_service_id, executor_agent_type, active_run_id,
created_at, updated_at, completed_at, last_error
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
'turn-1:attempt:1',
'turn-1',
1,
'task-1',
'2026-05-26T00:10:00.000Z',
'owner',
'owner-turn',
'failed',
'codex-main',
'codex',
null,
'2026-05-26T00:00:01.000Z',
'2026-05-26T00:00:03.000Z',
'2026-05-26T00:00:04.000Z',
'failure with sk-12345678901234567890',
);
insertPairedTurnOutput(
'task-1',
1,
'owner',
'SECRET USER TEXT SHOULD NOT BE RETURNED',
'2026-05-26T00:00:05.000Z',
);
requireDatabase()
.prepare(
`
INSERT INTO work_items (
group_folder, chat_jid, agent_type, service_id, delivery_role,
status, start_seq, end_seq, result_payload, delivery_attempts,
created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
'room-folder',
'room-1',
'codex',
'codex-main',
'owner',
'produced',
null,
null,
'raw delivery body should not be returned',
0,
'2026-05-26T00:00:06.000Z',
'2026-05-26T00:00:06.000Z',
);
}
it('normalizes request bounds and validates task ids', () => {
expect(normalizeDbEvidenceMinutes()).toBe(60);
expect(normalizeDbEvidenceMinutes(0)).toBe(1);
expect(normalizeDbEvidenceMinutes(99999)).toBe(1440);
expect(normalizeDbEvidenceLimit()).toBe(20);
expect(normalizeDbEvidenceLimit(999)).toBe(100);
expect(normalizeDbEvidenceTaskId(' task-1 ')).toBe('task-1');
expect(() => normalizeDbEvidenceTaskId('../bad task')).toThrow(
'Unsupported paired task id',
);
});
it('returns task status and flow metadata without raw bodies', () => {
seedPairedTask();
const status = JSON.parse(
runDbEvidenceRequest(
requireDatabase(),
{ action: 'db_paired_task_status', taskId: 'task-1' },
{ sourceGroup: 'room-folder', isMain: false },
),
) as { task: { id: string; group_folder: string } };
expect(status.task.id).toBe('task-1');
expect(status.task.group_folder).toBe('room-folder');
const flowText = runDbEvidenceRequest(
requireDatabase(),
{ action: 'db_paired_task_flow', taskId: 'task-1' },
{ sourceGroup: 'room-folder', isMain: false },
);
expect(flowText).toContain('"output_chars"');
expect(flowText).toContain('"last_error_chars"');
expect(flowText).not.toContain('SECRET USER TEXT');
expect(flowText).not.toContain('raw delivery body');
expect(flowText).not.toContain('sk-12345678901234567890');
});
it('scopes non-main DB evidence to the source group', () => {
seedPairedTask();
const blocked = JSON.parse(
runDbEvidenceRequest(
requireDatabase(),
{ action: 'db_paired_task_status', taskId: 'task-1' },
{ sourceGroup: 'other-folder', isMain: false },
),
) as { task: unknown };
expect(blocked.task).toBeNull();
const main = JSON.parse(
runDbEvidenceRequest(
requireDatabase(),
{ action: 'db_paired_task_status', taskId: 'task-1' },
{ sourceGroup: 'other-folder', isMain: true },
),
) as { task: { id: string } };
expect(main.task.id).toBe('task-1');
});
});

495
src/db-evidence.ts Normal file
View File

@@ -0,0 +1,495 @@
import type { Database } from 'bun:sqlite';
import {
DB_EVIDENCE_ACTIONS,
isDbEvidenceAction,
type DbEvidenceAction,
} from 'ejclaw-runners-shared';
import { parseGitHubCiMetadata } from './github-ci.js';
import { extractWatchCiTarget } from './task-watch-status.js';
type SqlBinding = string | number | bigint | boolean | null | Uint8Array;
export { DB_EVIDENCE_ACTIONS, isDbEvidenceAction, type DbEvidenceAction };
export interface DbEvidenceRequest {
action: DbEvidenceAction;
taskId?: string;
minutes?: number;
limit?: number;
}
export interface DbEvidenceScope {
sourceGroup: string;
isMain: boolean;
}
const DEFAULT_RECENT_MINUTES = 60;
const MAX_RECENT_MINUTES = 24 * 60;
const DEFAULT_ROW_LIMIT = 20;
const MAX_ROW_LIMIT = 100;
const TASK_ID_PATTERN = /^[A-Za-z0-9._:@/-]{1,200}$/;
export function normalizeDbEvidenceMinutes(value?: number): number {
if (!Number.isFinite(value)) {
return DEFAULT_RECENT_MINUTES;
}
const normalized = Math.trunc(value as number);
return Math.min(Math.max(normalized, 1), MAX_RECENT_MINUTES);
}
export function normalizeDbEvidenceLimit(value?: number): number {
if (!Number.isFinite(value)) {
return DEFAULT_ROW_LIMIT;
}
const normalized = Math.trunc(value as number);
return Math.min(Math.max(normalized, 1), MAX_ROW_LIMIT);
}
export function normalizeDbEvidenceTaskId(value?: string): string {
const taskId = value?.trim();
if (!taskId || !TASK_ID_PATTERN.test(taskId)) {
throw new Error(`Unsupported paired task id for DB evidence: ${value}`);
}
return taskId;
}
function stringifyEvidence(value: unknown): string {
return JSON.stringify(value, null, 2);
}
function groupScopeClause(scope: DbEvidenceScope): {
clause: string;
params: SqlBinding[];
} {
return scope.isMain
? { clause: '', params: [] }
: { clause: ' AND group_folder = ?', params: [scope.sourceGroup] };
}
function getScopedTask(
database: Database,
taskId: string,
scope: DbEvidenceScope,
): Record<string, unknown> | null {
const groupScope = groupScopeClause(scope);
const row = database
.prepare(
`
SELECT id,
group_folder,
chat_jid,
status,
round_trip_count,
owner_failure_count,
owner_step_done_streak,
finalize_step_done_count,
task_done_then_user_reopen_count,
empty_step_done_streak,
arbiter_verdict,
arbiter_requested_at,
completion_reason,
owner_agent_type,
reviewer_agent_type,
arbiter_agent_type,
created_at,
updated_at
FROM paired_tasks
WHERE id = ?
${groupScope.clause}
`,
)
.get(taskId, ...groupScope.params) as Record<string, unknown> | undefined;
return row ?? null;
}
function runPairedTaskStatus(
database: Database,
request: DbEvidenceRequest,
scope: DbEvidenceScope,
): string {
const taskId = normalizeDbEvidenceTaskId(request.taskId);
const task = getScopedTask(database, taskId, scope);
return stringifyEvidence({
action: request.action,
task,
});
}
function runPairedTaskFlow(
database: Database,
request: DbEvidenceRequest,
scope: DbEvidenceScope,
): string {
const taskId = normalizeDbEvidenceTaskId(request.taskId);
const limit = normalizeDbEvidenceLimit(request.limit);
const task = getScopedTask(database, taskId, scope);
if (!task) {
return stringifyEvidence({
action: request.action,
task: null,
turns: [],
attempts: [],
outputs: [],
deliveries: [],
});
}
const turns = database
.prepare(
`
SELECT turn_id,
role,
intent_kind,
created_at,
updated_at
FROM paired_turns
WHERE task_id = ?
ORDER BY updated_at, created_at, turn_id
LIMIT ?
`,
)
.all(taskId, limit);
const attempts = database
.prepare(
`
SELECT attempt_id,
parent_attempt_id,
turn_id,
attempt_no,
role,
intent_kind,
state,
executor_agent_type,
active_run_id,
created_at,
updated_at,
completed_at,
length(last_error) AS last_error_chars
FROM paired_turn_attempts
WHERE task_id = ?
ORDER BY updated_at, attempt_no, attempt_id
LIMIT ?
`,
)
.all(taskId, limit);
const outputs = database
.prepare(
`
SELECT turn_number,
role,
verdict,
created_at,
length(output_text) AS output_chars
FROM paired_turn_outputs
WHERE task_id = ?
ORDER BY turn_number, created_at, role
LIMIT ?
`,
)
.all(taskId, limit);
const deliveries = database
.prepare(
`
SELECT id,
agent_type,
delivery_role,
status,
delivery_attempts,
created_at,
updated_at,
delivered_at,
length(result_payload) AS result_payload_chars,
length(last_error) AS last_error_chars
FROM work_items
WHERE chat_jid = ?
AND created_at >= ?
AND created_at <= datetime(?, '+1 minute')
ORDER BY created_at, id
LIMIT ?
`,
)
.all(
String(task.chat_jid),
String(task.created_at),
String(task.updated_at),
limit,
);
return stringifyEvidence({
action: request.action,
task,
turns,
attempts,
outputs,
deliveries,
});
}
function runRecentPairedFailures(
database: Database,
request: DbEvidenceRequest,
scope: DbEvidenceScope,
): string {
const minutes = normalizeDbEvidenceMinutes(request.minutes);
const limit = normalizeDbEvidenceLimit(request.limit);
const cutoff = new Date(Date.now() - minutes * 60_000).toISOString();
const groupScope = groupScopeClause(scope);
const tasks = database
.prepare(
`
SELECT id,
group_folder,
chat_jid,
status,
owner_failure_count,
arbiter_verdict,
completion_reason,
arbiter_requested_at,
updated_at
FROM paired_tasks
WHERE updated_at >= ?
AND (
owner_failure_count > 0
OR status = 'arbiter_requested'
OR arbiter_verdict IS NOT NULL
OR completion_reason IS NOT NULL
)
${groupScope.clause}
ORDER BY updated_at DESC
LIMIT ?
`,
)
.all(cutoff, ...groupScope.params, limit);
const attempts = database
.prepare(
`
SELECT attempts.task_id,
attempts.turn_id,
attempts.attempt_no,
attempts.role,
attempts.intent_kind,
attempts.state,
attempts.executor_agent_type,
attempts.active_run_id,
attempts.updated_at,
attempts.completed_at,
length(attempts.last_error) AS last_error_chars
FROM paired_turn_attempts attempts
JOIN paired_tasks tasks
ON tasks.id = attempts.task_id
WHERE attempts.updated_at >= ?
AND (
attempts.state = 'failed'
OR attempts.last_error IS NOT NULL
)
${scope.isMain ? '' : 'AND tasks.group_folder = ?'}
ORDER BY attempts.updated_at DESC
LIMIT ?
`,
)
.all(cutoff, ...(scope.isMain ? [] : [scope.sourceGroup]), limit);
const deliveryRetries = database
.prepare(
`
SELECT id,
chat_jid,
agent_type,
delivery_role,
status,
delivery_attempts,
updated_at,
length(last_error) AS last_error_chars
FROM work_items
WHERE updated_at >= ?
AND status = 'delivery_retry'
${scope.isMain ? '' : 'AND group_folder = ?'}
ORDER BY updated_at DESC
LIMIT ?
`,
)
.all(cutoff, ...(scope.isMain ? [] : [scope.sourceGroup]), limit);
return stringifyEvidence({
action: request.action,
window_minutes: minutes,
cutoff,
tasks,
attempts,
delivery_retries: deliveryRetries,
});
}
function normalizeScheduledTaskEvidenceRow(
row: Record<string, unknown>,
): Record<string, unknown> {
const prompt = typeof row.prompt === 'string' ? row.prompt : '';
const metadata = parseGitHubCiMetadata(
typeof row.ci_metadata === 'string' ? row.ci_metadata : null,
);
return {
id: row.id,
group_folder: row.group_folder,
chat_jid: row.chat_jid,
agent_type: row.agent_type,
room_role: row.room_role,
ci_provider: row.ci_provider,
ci_repo: metadata?.repo ?? null,
ci_run_id: metadata?.run_id ?? null,
ci_poll_count: metadata?.poll_count ?? null,
ci_consecutive_errors: metadata?.consecutive_errors ?? null,
ci_last_checked_at: metadata?.last_checked_at ?? null,
ci_target: extractWatchCiTarget(prompt),
max_duration_ms: row.max_duration_ms,
status_message_id: row.status_message_id,
status_started_at: row.status_started_at,
schedule_type: row.schedule_type,
schedule_value: row.schedule_value,
next_run: row.next_run,
last_run: row.last_run,
last_result_chars: row.last_result_chars,
status: row.status,
created_at: row.created_at,
prompt_chars: row.prompt_chars,
};
}
function runRecentScheduledTasks(
database: Database,
request: DbEvidenceRequest,
scope: DbEvidenceScope,
): string {
const minutes = normalizeDbEvidenceMinutes(request.minutes);
const limit = normalizeDbEvidenceLimit(request.limit);
const cutoff = new Date(Date.now() - minutes * 60_000).toISOString();
const groupScope = groupScopeClause(scope);
const rows = database
.prepare(
`
SELECT id,
group_folder,
chat_jid,
agent_type,
room_role,
ci_provider,
ci_metadata,
max_duration_ms,
status_message_id,
status_started_at,
prompt,
length(prompt) AS prompt_chars,
schedule_type,
schedule_value,
next_run,
last_run,
length(last_result) AS last_result_chars,
status,
created_at
FROM scheduled_tasks
WHERE (
created_at >= ?
OR last_run >= ?
OR next_run >= ?
OR status_started_at >= ?
OR status IN ('active', 'paused')
)
${groupScope.clause}
ORDER BY COALESCE(last_run, status_started_at, next_run, created_at) DESC
LIMIT ?
`,
)
.all(cutoff, cutoff, cutoff, cutoff, ...groupScope.params, limit) as Array<
Record<string, unknown>
>;
return stringifyEvidence({
action: request.action,
window_minutes: minutes,
cutoff,
tasks: rows.map(normalizeScheduledTaskEvidenceRow),
});
}
function runScheduledTaskRuns(
database: Database,
request: DbEvidenceRequest,
scope: DbEvidenceScope,
): string {
const minutes = normalizeDbEvidenceMinutes(request.minutes);
const limit = normalizeDbEvidenceLimit(request.limit);
const cutoff = new Date(Date.now() - minutes * 60_000).toISOString();
const taskId = request.taskId
? normalizeDbEvidenceTaskId(request.taskId)
: null;
const params: SqlBinding[] = [cutoff];
const clauses = ['logs.run_at >= ?'];
if (taskId) {
clauses.push('logs.task_id = ?');
params.push(taskId);
}
if (!scope.isMain) {
clauses.push('tasks.group_folder = ?');
params.push(scope.sourceGroup);
}
params.push(limit);
const rows = database
.prepare(
`
SELECT logs.task_id,
tasks.group_folder,
tasks.chat_jid,
tasks.agent_type,
tasks.room_role,
tasks.ci_provider,
logs.run_at,
logs.duration_ms,
logs.status,
length(logs.result) AS result_chars,
length(logs.error) AS error_chars
FROM task_run_logs logs
LEFT JOIN scheduled_tasks tasks
ON tasks.id = logs.task_id
WHERE ${clauses.join(' AND ')}
ORDER BY logs.run_at DESC, logs.id DESC
LIMIT ?
`,
)
.all(...params);
return stringifyEvidence({
action: request.action,
window_minutes: minutes,
cutoff,
task_id: taskId,
runs: rows,
});
}
export function runDbEvidenceRequest(
database: Database,
request: DbEvidenceRequest,
scope: DbEvidenceScope,
): string {
switch (request.action) {
case 'db_paired_task_status':
return runPairedTaskStatus(database, request, scope);
case 'db_paired_task_flow':
return runPairedTaskFlow(database, request, scope);
case 'db_recent_paired_failures':
return runRecentPairedFailures(database, request, scope);
case 'db_recent_scheduled_tasks':
return runRecentScheduledTasks(database, request, scope);
case 'db_scheduled_task_runs':
return runScheduledTaskRuns(database, request, scope);
}
}

View File

@@ -0,0 +1,258 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_initTestDatabase,
createPairedTask,
getPairedTurnAttempts,
markPairedTurnRunning,
recoverInterruptedPairedTurnAttemptsForService,
} from './db.js';
import { CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID } from './config.js';
import { requireDatabase } from './db/runtime-database.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import type { PairedTask } from './types.js';
function makeTask(overrides: Partial<PairedTask> = {}): PairedTask {
return {
id: 'restart-running-attempt-task',
chat_jid: 'dc:restart-room',
group_folder: 'restart-room',
owner_service_id: CODEX_MAIN_SERVICE_ID,
reviewer_service_id: 'claude',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: null,
round_trip_count: 1,
owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: 0,
task_done_then_user_reopen_count: 0,
empty_step_done_streak: 0,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-05-27T00:00:00.000Z',
updated_at: '2026-05-27T00:10:00.000Z',
...overrides,
};
}
describe('paired turn attempt restart recovery', () => {
beforeEach(() => {
_initTestDatabase();
});
it('fails local-service running attempts so restart recovery can retry the turn', () => {
const task = makeTask();
createPairedTask(task);
const turnIdentity = buildPairedTurnIdentity({
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'owner-turn',
role: 'owner',
});
markPairedTurnRunning({
turnIdentity,
executorServiceId: CODEX_MAIN_SERVICE_ID,
executorAgentType: 'codex',
runId: 'run-before-restart',
});
const recovered = recoverInterruptedPairedTurnAttemptsForService({
serviceIds: [CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID],
now: '2026-05-27T00:11:00.000Z',
});
expect(recovered).toEqual([
expect.objectContaining({
chat_jid: task.chat_jid,
group_folder: task.group_folder,
task_id: task.id,
task_status: 'active',
turn_id: turnIdentity.turnId,
attempt_no: 1,
role: 'owner',
intent_kind: 'owner-turn',
}),
]);
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
expect.objectContaining({
attempt_no: 1,
state: 'failed',
active_run_id: null,
completed_at: '2026-05-27T00:11:00.000Z',
last_error: 'Interrupted by service restart before completion.',
}),
]);
markPairedTurnRunning({
turnIdentity,
executorServiceId: CODEX_MAIN_SERVICE_ID,
executorAgentType: 'codex',
runId: 'run-after-restart',
});
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
expect.objectContaining({
attempt_no: 1,
state: 'failed',
}),
expect.objectContaining({
attempt_no: 2,
parent_attempt_id: `${turnIdentity.turnId}:attempt:1`,
state: 'running',
active_run_id: 'run-after-restart',
}),
]);
});
it('does not touch running attempts owned by another service', () => {
const task = makeTask({ id: 'other-service-task' });
createPairedTask(task);
const turnIdentity = buildPairedTurnIdentity({
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'reviewer-turn',
role: 'reviewer',
});
markPairedTurnRunning({
turnIdentity,
executorServiceId: 'claude',
executorAgentType: 'claude-code',
runId: 'reviewer-run',
});
expect(
recoverInterruptedPairedTurnAttemptsForService({
serviceIds: [CODEX_MAIN_SERVICE_ID],
now: '2026-05-27T00:11:00.000Z',
}),
).toEqual([]);
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
expect.objectContaining({
state: 'running',
active_run_id: 'reviewer-run',
}),
]);
});
it('clears stale running attempts for already completed tasks without replaying them', () => {
const task = makeTask({
id: 'completed-stale-running-task',
status: 'completed',
completion_reason: 'done',
});
createPairedTask(task);
const turnIdentity = buildPairedTurnIdentity({
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'owner-turn',
role: 'owner',
});
markPairedTurnRunning({
turnIdentity,
executorServiceId: CODEX_MAIN_SERVICE_ID,
executorAgentType: 'codex',
runId: 'completed-run-before-restart',
});
expect(
recoverInterruptedPairedTurnAttemptsForService({
serviceIds: [CODEX_MAIN_SERVICE_ID],
now: '2026-05-27T00:11:00.000Z',
}),
).toEqual([]);
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
expect.objectContaining({
state: 'failed',
active_run_id: null,
completed_at: '2026-05-27T00:11:00.000Z',
last_error: 'Interrupted by service restart before completion.',
}),
]);
});
it('recovers codex executor attempts even when the orchestration lease used the claude service id', () => {
const task = makeTask({ id: 'cross-service-lease-task' });
createPairedTask(task);
const turnIdentity = buildPairedTurnIdentity({
taskId: task.id,
taskUpdatedAt: task.updated_at,
intentKind: 'owner-turn',
role: 'owner',
});
markPairedTurnRunning({
turnIdentity,
executorServiceId: CODEX_MAIN_SERVICE_ID,
executorAgentType: 'codex',
runId: 'codex-run-before-restart',
});
requireDatabase()
.prepare(
`
INSERT INTO paired_task_execution_leases (
task_id,
chat_jid,
role,
turn_id,
turn_attempt_id,
turn_attempt_no,
intent_kind,
claimed_run_id,
claimed_service_id,
task_status,
task_updated_at,
claimed_at,
updated_at,
expires_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
task.id,
task.chat_jid,
'owner',
turnIdentity.turnId,
`${turnIdentity.turnId}:attempt:1`,
1,
'owner-turn',
'orchestrator-run-before-restart',
CLAUDE_SERVICE_ID,
task.status,
task.updated_at,
'2026-05-27T00:10:00.000Z',
'2026-05-27T00:10:00.000Z',
'2026-05-27T00:20:00.000Z',
);
expect(
recoverInterruptedPairedTurnAttemptsForService({
serviceIds: [CLAUDE_SERVICE_ID, CODEX_MAIN_SERVICE_ID],
now: '2026-05-27T00:11:00.000Z',
}),
).toEqual([
expect.objectContaining({
task_id: task.id,
turn_id: turnIdentity.turnId,
role: 'owner',
intent_kind: 'owner-turn',
}),
]);
expect(getPairedTurnAttempts(turnIdentity.turnId)).toEqual([
expect.objectContaining({
state: 'failed',
active_run_id: null,
}),
]);
});
});

View File

@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_initTestDatabase,
createPairedTask,
failPairedTurn,
getOwnerCodexBadRequestFailureSummaryForTask,
markPairedTurnRunning,
} from './db.js';
import { CODEX_MAIN_SERVICE_ID } from './config.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import type { PairedTask } from './types.js';
const BAD_REQUEST_ERROR = '{"detail":"Bad Request"}';
beforeEach(() => {
_initTestDatabase();
});
function makeTask(overrides: Partial<PairedTask> = {}): PairedTask {
return {
id: 'task-bad-request',
chat_jid: 'dc:room',
group_folder: 'eyejokerdb-9',
owner_service_id: CODEX_MAIN_SERVICE_ID,
reviewer_service_id: 'claude',
title: null,
source_ref: null,
plan_notes: null,
review_requested_at: null,
round_trip_count: 1,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-29T08:00:00.000Z',
updated_at: '2026-04-29T08:00:00.000Z',
...overrides,
};
}
function recordOwnerCodexFailure(args: {
taskId: string;
taskUpdatedAt: string;
runId: string;
error: string;
}): void {
const turnIdentity = buildPairedTurnIdentity({
taskId: args.taskId,
taskUpdatedAt: args.taskUpdatedAt,
intentKind: 'owner-follow-up',
role: 'owner',
});
markPairedTurnRunning({
turnIdentity,
executorServiceId: CODEX_MAIN_SERVICE_ID,
executorAgentType: 'codex',
runId: args.runId,
});
failPairedTurn({ turnIdentity, error: args.error });
}
describe('owner Codex Bad Request attempt summaries', () => {
it('summarizes consecutive owner Codex Bad Request failures for a task', () => {
const task = makeTask();
createPairedTask(task);
for (const runId of ['run-1', 'run-2', 'run-3']) {
recordOwnerCodexFailure({
taskId: task.id,
taskUpdatedAt: task.updated_at,
runId,
error: BAD_REQUEST_ERROR,
});
}
expect(
getOwnerCodexBadRequestFailureSummaryForTask({
taskId: task.id,
threshold: 3,
}),
).toMatchObject({
taskId: task.id,
failures: 3,
});
});
it('does not summarize when the latest owner Codex failures are not all the narrow Bad Request signal', () => {
const task = makeTask();
createPairedTask(task);
recordOwnerCodexFailure({
taskId: task.id,
taskUpdatedAt: task.updated_at,
runId: 'run-1',
error: BAD_REQUEST_ERROR,
});
recordOwnerCodexFailure({
taskId: task.id,
taskUpdatedAt: task.updated_at,
runId: 'run-2',
error: BAD_REQUEST_ERROR,
});
recordOwnerCodexFailure({
taskId: task.id,
taskUpdatedAt: task.updated_at,
runId: 'run-3',
error: 'HTTP 400 Bad Request',
});
expect(
getOwnerCodexBadRequestFailureSummaryForTask({
taskId: task.id,
threshold: 3,
}),
).toBeNull();
});
it('counts only the latest consecutive Bad Request failures', () => {
const task = makeTask();
createPairedTask(task);
recordOwnerCodexFailure({
taskId: task.id,
taskUpdatedAt: task.updated_at,
runId: 'run-1',
error: BAD_REQUEST_ERROR,
});
recordOwnerCodexFailure({
taskId: task.id,
taskUpdatedAt: task.updated_at,
runId: 'run-2',
error: 'HTTP 400 Bad Request',
});
for (const runId of ['run-3', 'run-4', 'run-5']) {
recordOwnerCodexFailure({
taskId: task.id,
taskUpdatedAt: task.updated_at,
runId,
error: BAD_REQUEST_ERROR,
});
}
expect(
getOwnerCodexBadRequestFailureSummaryForTask({
taskId: task.id,
threshold: 3,
}),
).toMatchObject({
taskId: task.id,
failures: 3,
});
});
});

View File

@@ -0,0 +1,76 @@
import { Database } from 'bun:sqlite';
import { describe, expect, it } from 'vitest';
import { applyBaseSchema } from './db/base-schema.js';
import {
getPairedTurnOutputsFromDatabase,
insertPairedTurnOutputInDatabase,
} from './db/paired-turn-outputs.js';
describe('paired turn output attachments', () => {
it('marks truncated output text so reviewers know evidence was clipped', () => {
const database = new Database(':memory:');
try {
applyBaseSchema(database);
insertPairedTurnOutputInDatabase(
database,
'paired-task-turn-output-truncated',
1,
'owner',
'x'.repeat(60_000),
);
const [output] = getPairedTurnOutputsFromDatabase(
database,
'paired-task-turn-output-truncated',
);
expect(output.output_text).toHaveLength(50_000);
expect(output.output_text).toMatch(
/\[Output truncated: 60000 > 50000 chars\]/,
);
} finally {
database.close();
}
});
it('preserves attachments for reviewer evidence prompts', () => {
const database = new Database(':memory:');
try {
applyBaseSchema(database);
insertPairedTurnOutputInDatabase(
database,
'paired-task-turn-output-attachments',
1,
'owner',
'TASK_DONE\n새 스크린샷 첨부',
{
attachments: [
{
path: '/tmp/settings-v0.1.92-deployed-390.png',
name: 'settings-v0.1.92-deployed-390.png',
mime: 'image/png',
},
],
},
);
const outputs = getPairedTurnOutputsFromDatabase(
database,
'paired-task-turn-output-attachments',
);
expect(outputs[0].attachments).toEqual([
{
path: '/tmp/settings-v0.1.92-deployed-390.png',
name: 'settings-v0.1.92-deployed-390.png',
mime: 'image/png',
},
]);
} finally {
database.close();
}
});
});

View File

@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_initTestDatabase,
getLatestPairedTurnForTask,
markPairedTurnRunning,
reservePairedTurnReservation,
updatePairedTurnProgressText,
} from './db.js';
import { CODEX_MAIN_SERVICE_ID } from './config.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
beforeEach(() => {
_initTestDatabase();
});
describe('paired turn progress activity', () => {
it('selects the latest paired turn by progress activity timestamp', async () => {
const taskId = 'progress-latest-task';
const activeTurn = buildPairedTurnIdentity({
taskId,
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
intentKind: 'owner-follow-up',
role: 'owner',
});
const queuedTurn = buildPairedTurnIdentity({
taskId,
taskUpdatedAt: '2026-04-10T00:01:00.000Z',
intentKind: 'owner-follow-up',
role: 'owner',
});
markPairedTurnRunning({
turnIdentity: activeTurn,
executorServiceId: CODEX_MAIN_SERVICE_ID,
executorAgentType: 'codex',
runId: 'run-progress-active',
});
expect(
reservePairedTurnReservation({
chatJid: 'dc:ops',
taskId,
taskStatus: 'active',
roundTripCount: 1,
taskUpdatedAt: queuedTurn.taskUpdatedAt,
intentKind: queuedTurn.intentKind,
runId: 'run-queued-empty',
}),
).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 5));
updatePairedTurnProgressText(
activeTurn.turnId,
'checking dashboard parity',
);
expect(getLatestPairedTurnForTask(taskId)).toMatchObject({
turn_id: activeTurn.turnId,
progress_text: 'checking dashboard parity',
});
});
});

View File

@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { _initTestDatabase, assignRoom, getStoredRoomSettings } from './db.js';
describe('room assignment metadata', () => {
beforeEach(() => {
_initTestDatabase();
});
it('preserves explicit trigger metadata when assigning a room', () => {
const group = assignRoom('dc:triggered-room', {
name: 'Triggered Room',
roomMode: 'single',
ownerAgentType: 'codex',
folder: 'triggered-room',
trigger: '@Repro',
requiresTrigger: true,
});
expect(group).toMatchObject({
trigger: '@Repro',
requiresTrigger: true,
});
expect(getStoredRoomSettings('dc:triggered-room')).toMatchObject({
trigger: '@Repro',
requiresTrigger: true,
});
});
});

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { _initTestDatabase } from './db.js';
import { requireDatabase } from './db/runtime-database.js';
import { runDbEvidenceRequest } from './db-evidence.js';
function seedScheduledTaskEvidence(): void {
_initTestDatabase();
const now = new Date().toISOString();
requireDatabase()
.prepare(
`
INSERT INTO scheduled_tasks (
id, group_folder, chat_jid, agent_type, room_role, ci_provider,
ci_metadata, max_duration_ms, status_message_id, status_started_at,
prompt, schedule_type, schedule_value, context_mode, next_run,
last_run, last_result, status, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
'watch-1',
'room-folder',
'room-1',
'codex',
'owner',
'github',
JSON.stringify({
repo: 'phj1081/EJClaw',
run_id: 123,
poll_count: 2,
consecutive_errors: 1,
last_checked_at: now,
}),
300_000,
'status-msg-1',
now,
'[BACKGROUND CI WATCH]\ntarget=PR #180 checks\nSECRET PROMPT BODY',
'interval',
'15000',
'isolated',
now,
now,
'raw CI result body',
'active',
now,
);
requireDatabase()
.prepare(
`
INSERT INTO task_run_logs (
task_id, run_at, duration_ms, status, result, error
)
VALUES (?, ?, ?, ?, ?, ?)
`,
)
.run(
'watch-1',
now,
42,
'success',
'raw scheduler result',
'raw scheduler error',
);
}
describe('scheduled task DB evidence presets', () => {
it('returns scheduled task metadata without raw prompt or result bodies', () => {
seedScheduledTaskEvidence();
const tasksText = runDbEvidenceRequest(
requireDatabase(),
{ action: 'db_recent_scheduled_tasks', minutes: 1440 },
{ sourceGroup: 'room-folder', isMain: false },
);
expect(tasksText).toContain('"ci_repo": "phj1081/EJClaw"');
expect(tasksText).toContain('"ci_run_id": 123');
expect(tasksText).toContain('"status_message_id": "status-msg-1"');
expect(tasksText).toContain('"prompt_chars"');
expect(tasksText).toContain('"last_result_chars"');
expect(tasksText).not.toContain('SECRET PROMPT BODY');
expect(tasksText).not.toContain('raw CI result body');
});
it('returns scheduled run metadata without raw result or error bodies', () => {
seedScheduledTaskEvidence();
const runsText = runDbEvidenceRequest(
requireDatabase(),
{
action: 'db_scheduled_task_runs',
taskId: 'watch-1',
minutes: 1440,
},
{ sourceGroup: 'room-folder', isMain: false },
);
expect(runsText).toContain('"duration_ms": 42');
expect(runsText).toContain('"result_chars"');
expect(runsText).toContain('"error_chars"');
expect(runsText).not.toContain('raw scheduler result');
expect(runsText).not.toContain('raw scheduler error');
});
it('scopes scheduled task evidence for non-main rooms', () => {
seedScheduledTaskEvidence();
const scopedOut = JSON.parse(
runDbEvidenceRequest(
requireDatabase(),
{ action: 'db_recent_scheduled_tasks', minutes: 1440 },
{ sourceGroup: 'other-folder', isMain: false },
),
) as { tasks: unknown[] };
expect(scopedOut.tasks).toEqual([]);
});
});

28
src/db-sessions.test.ts Normal file
View File

@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_initTestDatabase,
deleteAllSessionsForGroup,
getSession,
setSession,
} from './db.js';
describe('session accessors', () => {
beforeEach(() => {
_initTestDatabase();
});
it('deletes owner, reviewer, and arbiter role-scoped sessions for a group', () => {
setSession('group-a', 'owner-session');
setSession('group-a:reviewer', 'reviewer-session');
setSession('group-a:arbiter', 'arbiter-session');
setSession('group-b', 'other-session');
deleteAllSessionsForGroup('group-a');
expect(getSession('group-a')).toBeUndefined();
expect(getSession('group-a:reviewer')).toBeUndefined();
expect(getSession('group-a:arbiter')).toBeUndefined();
expect(getSession('group-b')).toBe('other-session');
});
});

51
src/db-work-items.test.ts Normal file
View File

@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
_initTestDatabase,
createProducedWorkItem,
getOpenWorkItem,
getRecentDeliveredWorkItemsForChat,
markWorkItemDelivered,
} from './db.js';
beforeEach(() => {
_initTestDatabase();
});
describe('work item canonical output queue', () => {
it('supersedes stale open work items before creating a newer canonical output', () => {
const stale = createProducedWorkItem({
group_folder: 'discord_test',
chat_jid: 'dc:supersede',
agent_type: 'codex',
service_id: 'codex-main',
delivery_role: 'owner',
start_seq: 1,
end_seq: 2,
result_payload: 'stale owner output',
});
const fresh = createProducedWorkItem({
group_folder: 'discord_test',
chat_jid: 'dc:supersede',
agent_type: 'codex',
service_id: 'codex-main',
delivery_role: 'owner',
start_seq: 3,
end_seq: 4,
result_payload: 'fresh owner output',
});
expect(fresh.id).not.toBe(stale.id);
expect(getOpenWorkItem('dc:supersede', 'codex', 'codex-main')?.id).toBe(
fresh.id,
);
markWorkItemDelivered(fresh.id, 'discord-message-id');
expect(getRecentDeliveredWorkItemsForChat('dc:supersede', 10)).toEqual([
expect.objectContaining({
id: fresh.id,
result_payload: 'fresh owner output',
}),
]);
});
});

View File

@@ -31,7 +31,6 @@ import {
getEffectiveRuntimeRoomMode,
getExplicitRoomMode,
getLatestMessageSeqAtOrBefore,
getLatestPairedTaskForChat,
getLatestTurnNumber,
getLastRespondingAgentType,
getRegisteredGroup,
@@ -199,7 +198,7 @@ function getStoredRoleOverridesForLegacyMigration(
agent_config_json: string | null;
created_at: string;
updated_at: string;
}> = [];
}>;
try {
rows = database
.prepare(

View File

@@ -11,7 +11,6 @@ export {
enforceMemoryBounds,
expireStaleMemories,
getAllChats,
getEarliestUnansweredHumanSeq,
getLastHumanMessageContent,
getLastHumanMessageSender,
getLastHumanMessageTimestamp,
@@ -22,8 +21,10 @@ export {
getNewMessagesBySeq,
getOpenWorkItem,
getOpenWorkItemForChat,
getRecentDeliveredWorkItemsForChat,
getRecentChatMessages,
getRecentDeliveredOwnerWorkItemsForChat,
getRecentChatMessagesBatch,
hasMessage,
hasRecentRestartAnnouncement,
markWorkItemDelivered,
markWorkItemDeliveryRetry,
@@ -42,6 +43,7 @@ export {
createTask,
deleteAllSessionsForGroup,
deleteSession,
deleteStoredRoomSkillOverride,
deleteTask,
findDuplicateCiWatcher,
getAllRoomBindings,
@@ -61,6 +63,7 @@ export {
getSessionForAgentType,
getStoredRoomRoleAgentPlan,
getStoredRoomSettings,
getStoredRoomSkillOverrides,
getTaskById,
getTasksForGroup,
hasActiveCiWatcherForChat,
@@ -70,6 +73,7 @@ export {
setRouterStateForService,
setSession,
setSessionForAgentType,
upsertStoredRoomSkillOverride,
updateRegisteredGroupName,
updateTask,
updateTaskAfterRun,
@@ -88,6 +92,7 @@ export {
createServiceHandoff,
failPairedTurn,
failServiceHandoff,
getAllOpenPairedTasks,
getAllChannelOwnerLeases,
getAllPendingServiceHandoffs,
getChannelOwnerLease,
@@ -96,18 +101,23 @@ export {
getLatestPairedTaskForChat,
getLatestPreviousPairedTaskForChat,
getLatestTurnNumber,
getOwnerCodexBadRequestFailureSummaryForTask,
getPairedProject,
getPairedTaskById,
getPairedTurnAttempts,
getPairedTurnById,
getPairedTurnOutputs,
getRecentPairedTurnOutputsForChat,
getPairedTurnsForTask,
getLatestPairedTurnForTask,
updatePairedTurnProgressText,
getPairedWorkspace,
getPendingServiceHandoffs,
insertPairedTurnOutput,
listPairedWorkspacesForTask,
markPairedTurnRunning,
refreshPairedTaskExecutionLease,
recoverInterruptedPairedTurnAttemptsForService,
releasePairedTaskExecutionLease,
reservePairedTurnReservation,
setChannelOwnerLease,
@@ -124,7 +134,10 @@ export type {
MemorySourceKind,
RecallMemoryQuery,
} from './db/memories.js';
export type { PairedTurnAttemptRecord } from './db/paired-turn-attempts.js';
export type {
InterruptedPairedTurnAttemptRecoveryCandidate,
PairedTurnAttemptRecord,
} from './db/paired-turn-attempts.js';
export type { PairedTurnRecord } from './db/paired-turns.js';
export type { ChatInfo } from './db/messages.js';
export type { WorkItem } from './db/work-items.js';

View File

@@ -1,5 +1,25 @@
import { Database } from 'bun:sqlite';
const ROOM_SKILL_OVERRIDES_SCHEMA = `
CREATE TABLE IF NOT EXISTS room_skill_overrides (
chat_jid TEXT NOT NULL,
agent_type TEXT NOT NULL,
skill_scope TEXT NOT NULL,
skill_name TEXT NOT NULL,
enabled INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, agent_type, skill_scope, skill_name),
FOREIGN KEY (chat_jid) REFERENCES room_settings(chat_jid) ON DELETE CASCADE,
CHECK (agent_type IN ('claude-code', 'codex')),
CHECK (enabled IN (0, 1)),
CHECK (length(skill_scope) > 0),
CHECK (length(skill_name) > 0)
);
CREATE INDEX IF NOT EXISTS idx_room_skill_overrides_room
ON room_skill_overrides(chat_jid, agent_type);
`;
export function applyBaseSchema(database: Database): void {
database.exec(`
CREATE TABLE IF NOT EXISTS chats (
@@ -24,6 +44,7 @@ export function applyBaseSchema(database: Database): void {
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_chat_timestamp ON messages(chat_jid, timestamp DESC);
CREATE TABLE IF NOT EXISTS message_sequence (
id INTEGER PRIMARY KEY AUTOINCREMENT
);
@@ -160,7 +181,7 @@ export function applyBaseSchema(database: Database): void {
task_id TEXT NOT NULL,
turn_number INTEGER NOT NULL,
role TEXT NOT NULL,
output_text TEXT NOT NULL,
output_text TEXT NOT NULL, attachment_payload TEXT,
verdict TEXT,
created_at TEXT NOT NULL,
UNIQUE(task_id, turn_number, role)
@@ -410,8 +431,7 @@ export function applyBaseSchema(database: Database): void {
last_used_at TEXT,
archived_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_memories_scope
ON memories(scope_kind, scope_key);
CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope_kind, scope_key);
CREATE INDEX IF NOT EXISTS idx_memories_active
ON memories(scope_kind, scope_key, archived_at, created_at);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
@@ -435,4 +455,5 @@ export function applyBaseSchema(database: Database): void {
VALUES (new.id, new.content, new.keywords_json);
END;
`);
database.exec(ROOM_SKILL_OVERRIDES_SCHEMA);
}

View File

@@ -39,7 +39,10 @@ function getExpectedSchemaMigrations(): Array<{
{ version: 12, name: 'paired_verdict_and_step_telemetry' },
{ version: 13, name: 'message_source_kind' },
{ version: 14, name: 'work_item_attachments' },
{ version: 15, name: 'reviewer_failure_count' },
{ version: 15, name: 'turn_progress_text' },
{ version: 16, name: 'room_skill_overrides' },
{ version: 17, name: 'scheduled_task_room_role' },
{ version: 18, name: 'paired_turn_output_attachments' },
];
}

View File

@@ -9,7 +9,11 @@ import {
inferRoleFromServiceShadow,
resolveRoleServiceShadow,
} from '../role-service-shadow.js';
import type { AgentType, PairedRoomRole } from '../types.js';
import {
normalizePairedRoomRoleOrNull,
type AgentType,
type PairedRoomRole,
} from '../types.js';
import { normalizeStoredAgentType } from './room-registration.js';
interface RequiredRoleMetadataInput {
@@ -20,7 +24,7 @@ interface RequiredRoleMetadataInput {
fallbackAgentType?: AgentType | null;
}
interface OptionalRoleMetadataInput extends RequiredRoleMetadataInput {}
type OptionalRoleMetadataInput = RequiredRoleMetadataInput;
function resolveFilledRequiredAgentType(
input: RequiredRoleMetadataInput,
@@ -336,14 +340,6 @@ export function readCanonicalChannelOwnerLeaseMetadata(input: {
};
}
function normalizeHandoffRole(
role: string | null | undefined,
): PairedRoomRole | null {
return role === 'owner' || role === 'reviewer' || role === 'arbiter'
? role
: null;
}
function assertStoredRoleMatchesServiceShadow(args: {
context: string;
storedRole: PairedRoomRole | null;
@@ -389,11 +385,11 @@ export function fillCanonicalServiceHandoffMetadata(input: {
}): CanonicalServiceHandoffMetadata {
const context = `service_handoffs(${input.id})`;
const sourceRole =
normalizeHandoffRole(input.source_role) ??
normalizeHandoffRole(input.intended_role);
normalizePairedRoomRoleOrNull(input.source_role) ??
normalizePairedRoomRoleOrNull(input.intended_role);
const targetRole =
normalizeHandoffRole(input.target_role) ??
normalizeHandoffRole(input.intended_role);
normalizePairedRoomRoleOrNull(input.target_role) ??
normalizePairedRoomRoleOrNull(input.intended_role);
const storedSourceAgentType = normalizeStoredAgentType(
input.source_agent_type,
);
@@ -541,11 +537,11 @@ export function readCanonicalServiceHandoffMetadata(input: {
}): CanonicalServiceHandoffMetadata {
const context = `service_handoffs(${input.id})`;
const sourceRole =
normalizeHandoffRole(input.source_role) ??
normalizeHandoffRole(input.intended_role);
normalizePairedRoomRoleOrNull(input.source_role) ??
normalizePairedRoomRoleOrNull(input.intended_role);
const targetRole =
normalizeHandoffRole(input.target_role) ??
normalizeHandoffRole(input.intended_role);
normalizePairedRoomRoleOrNull(input.target_role) ??
normalizePairedRoomRoleOrNull(input.intended_role);
const sourceServiceId = readStoredHandoffServiceId({
context,
field: 'source_service_id',

View File

@@ -95,6 +95,17 @@ export function getAllChatsFromDatabase(database: Database): ChatInfo[] {
.all() as ChatInfo[];
}
export function hasMessageInDatabase(
database: Database,
chatJid: string,
id: string,
): boolean {
const row = database
.prepare('SELECT 1 FROM messages WHERE chat_jid = ? AND id = ? LIMIT 1')
.get(chatJid, id);
return !!row;
}
export function storeMessageInDatabase(
database: Database,
msg: NewMessage,
@@ -308,22 +319,31 @@ export function getMessagesSinceSeqFromDatabase(
return rows.map(normalizeMessageRow);
}
const recentChatMessagesStmtCache = new WeakMap<
Database,
ReturnType<Database['prepare']>
>();
export function getRecentChatMessagesFromDatabase(
database: Database,
chatJid: string,
limit: number = 20,
): NewMessage[] {
const sql = `
SELECT * FROM (
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind
FROM messages
WHERE chat_jid = ?
AND content != '' AND content IS NOT NULL
ORDER BY timestamp DESC
LIMIT ?
) ORDER BY timestamp
`;
const rows = database.prepare(sql).all(chatJid, limit) as Array<
let stmt = recentChatMessagesStmtCache.get(database);
if (!stmt) {
stmt = database.prepare(`
SELECT * FROM (
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, message_source_kind
FROM messages
WHERE chat_jid = ?
AND content != '' AND content IS NOT NULL
ORDER BY timestamp DESC
LIMIT ?
) ORDER BY timestamp
`);
recentChatMessagesStmtCache.set(database, stmt);
}
const rows = stmt.all(chatJid, limit) as Array<
NewMessage & {
is_from_me?: boolean | number;
is_bot_message?: boolean | number;
@@ -332,36 +352,42 @@ export function getRecentChatMessagesFromDatabase(
return rows.map(normalizeMessageRow);
}
/**
* Returns the seq of the earliest unanswered human message in this chat —
* i.e. the first user message that came after the most recent bot reply.
* Returns null if no such message exists (the user is "caught up").
*
* Used by restart-recovery to rewind the agent cursor before re-running an
* interrupted turn from the message that initiated it.
*/
export function getEarliestUnansweredHumanSeqFromDatabase(
export function getRecentChatMessagesBatchFromDatabase(
database: Database,
chatJid: string,
): number | null {
const lastBot = database
.prepare(
`SELECT MAX(seq) AS seq FROM messages
WHERE chat_jid = ? AND is_bot_message = 1`,
chatJids: string[],
limit: number = 8,
): Map<string, NewMessage[]> {
const out = new Map<string, NewMessage[]>();
if (chatJids.length === 0) return out;
const placeholders = chatJids.map(() => '?').join(',');
const sql = `
WITH ranked AS (
SELECT id, chat_jid, sender, sender_name, content, timestamp,
is_from_me, is_bot_message, message_source_kind,
ROW_NUMBER() OVER (PARTITION BY chat_jid ORDER BY timestamp DESC) AS rn
FROM messages
WHERE chat_jid IN (${placeholders})
AND content != '' AND content IS NOT NULL
)
.get(chatJid) as { seq: number | null } | undefined;
const lastBotSeq = lastBot?.seq ?? 0;
const row = database
.prepare(
`SELECT MIN(seq) AS seq FROM messages
WHERE chat_jid = ?
AND seq > ?
AND is_bot_message = 0
AND is_from_me = 0
AND content != '' AND content IS NOT NULL`,
)
.get(chatJid, lastBotSeq) as { seq: number | null } | undefined;
return row?.seq ?? null;
SELECT id, chat_jid, sender, sender_name, content, timestamp,
is_from_me, is_bot_message, message_source_kind
FROM ranked
WHERE rn <= ?
ORDER BY chat_jid, timestamp ASC
`;
const rows = database.prepare(sql).all(...chatJids, limit) as Array<
NewMessage & {
is_from_me?: boolean | number;
is_bot_message?: boolean | number;
}
>;
for (const row of rows) {
const normalized = normalizeMessageRow(row);
const existing = out.get(normalized.chat_jid);
if (existing) existing.push(normalized);
else out.set(normalized.chat_jid, [normalized]);
}
return out;
}
export function getLastHumanMessageTimestampFromDatabase(

View File

@@ -1,23 +0,0 @@
import type { Database } from 'bun:sqlite';
import { tableHasColumn } from './helpers.js';
import type { SchemaMigrationDefinition } from './types.js';
export const REVIEWER_FAILURE_COUNT_MIGRATION: SchemaMigrationDefinition = {
version: 15,
name: 'reviewer_failure_count',
apply(database: Database) {
if (!tableHasColumn(database, 'paired_tasks', 'reviewer_failure_count')) {
database.exec(`
ALTER TABLE paired_tasks
ADD COLUMN reviewer_failure_count INTEGER NOT NULL DEFAULT 0
`);
}
database.exec(`
UPDATE paired_tasks
SET reviewer_failure_count = 0
WHERE reviewer_failure_count IS NULL
`);
},
};

View File

@@ -0,0 +1,19 @@
import type { Database } from 'bun:sqlite';
import { tableHasColumn } from './helpers.js';
import type { SchemaMigrationDefinition } from './types.js';
export const TURN_PROGRESS_TEXT_MIGRATION: SchemaMigrationDefinition = {
version: 15,
name: 'turn_progress_text',
apply(database: Database) {
if (!tableHasColumn(database, 'paired_turns', 'progress_text')) {
database.exec(`ALTER TABLE paired_turns ADD COLUMN progress_text TEXT`);
}
if (!tableHasColumn(database, 'paired_turns', 'progress_updated_at')) {
database.exec(
`ALTER TABLE paired_turns ADD COLUMN progress_updated_at TEXT`,
);
}
},
};

View File

@@ -0,0 +1,29 @@
import type { Database } from 'bun:sqlite';
import type { SchemaMigrationDefinition } from './types.js';
export const ROOM_SKILL_OVERRIDES_MIGRATION: SchemaMigrationDefinition = {
version: 16,
name: 'room_skill_overrides',
apply(database: Database) {
database.exec(`
CREATE TABLE IF NOT EXISTS room_skill_overrides (
chat_jid TEXT NOT NULL,
agent_type TEXT NOT NULL,
skill_scope TEXT NOT NULL,
skill_name TEXT NOT NULL,
enabled INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, agent_type, skill_scope, skill_name),
FOREIGN KEY (chat_jid) REFERENCES room_settings(chat_jid) ON DELETE CASCADE,
CHECK (agent_type IN ('claude-code', 'codex')),
CHECK (enabled IN (0, 1)),
CHECK (length(skill_scope) > 0),
CHECK (length(skill_name) > 0)
);
CREATE INDEX IF NOT EXISTS idx_room_skill_overrides_room
ON room_skill_overrides(chat_jid, agent_type);
`);
},
};

View File

@@ -0,0 +1,13 @@
import type { SchemaMigrationDefinition } from './types.js';
import { tryExecMigration } from './helpers.js';
export const SCHEDULED_TASK_ROOM_ROLE_MIGRATION = {
version: 17,
name: 'scheduled_task_room_role',
apply(database) {
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN room_role TEXT`,
);
},
} satisfies SchemaMigrationDefinition;

View File

@@ -0,0 +1,20 @@
import type { Database } from 'bun:sqlite';
import { tableHasColumn } from './helpers.js';
import type { SchemaMigrationDefinition } from './types.js';
export const PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION: SchemaMigrationDefinition =
{
version: 18,
name: 'paired_turn_output_attachments',
apply(database: Database) {
if (
!tableHasColumn(database, 'paired_turn_outputs', 'attachment_payload')
) {
database.exec(`
ALTER TABLE paired_turn_outputs
ADD COLUMN attachment_payload TEXT
`);
}
},
};

View File

@@ -14,7 +14,10 @@ import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js';
import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js';
import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
import { REVIEWER_FAILURE_COUNT_MIGRATION } from './015_reviewer-failure-count.js';
import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js';
import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.js';
import { SCHEDULED_TASK_ROOM_ROLE_MIGRATION } from './017_scheduled-task-room-role.js';
import { PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION } from './018_paired-turn-output-attachments.js';
import type {
SchemaMigrationArgs,
SchemaMigrationDefinition,
@@ -37,7 +40,10 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION,
MESSAGE_SOURCE_KIND_MIGRATION,
WORK_ITEM_ATTACHMENTS_MIGRATION,
REVIEWER_FAILURE_COUNT_MIGRATION,
TURN_PROGRESS_TEXT_MIGRATION,
ROOM_SKILL_OVERRIDES_MIGRATION,
SCHEDULED_TASK_ROOM_ROLE_MIGRATION,
PAIRED_TURN_OUTPUT_ATTACHMENTS_MIGRATION,
];
function ensureSchemaMigrationsTable(database: Database): void {

View File

@@ -20,7 +20,6 @@ import {
import {
AgentType,
PairedProject,
PairedRoomRole,
PairedTask,
PairedTaskStatus,
PairedTurnReservationIntentKind,
@@ -51,7 +50,6 @@ export type PairedTaskUpdates = Partial<
| 'review_requested_at'
| 'round_trip_count'
| 'owner_failure_count'
| 'reviewer_failure_count'
| 'owner_step_done_streak'
| 'finalize_step_done_count'
| 'task_done_then_user_reopen_count'
@@ -178,7 +176,6 @@ export function createPairedTaskInDatabase(
review_requested_at,
round_trip_count,
owner_failure_count,
reviewer_failure_count,
owner_step_done_streak,
finalize_step_done_count,
task_done_then_user_reopen_count,
@@ -190,7 +187,7 @@ export function createPairedTaskInDatabase(
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
@@ -208,7 +205,6 @@ export function createPairedTaskInDatabase(
task.review_requested_at,
task.round_trip_count,
task.owner_failure_count ?? 0,
task.reviewer_failure_count ?? 0,
task.owner_step_done_streak ?? 0,
task.finalize_step_done_count ?? 0,
task.task_done_then_user_reopen_count ?? 0,
@@ -232,21 +228,27 @@ export function getPairedTaskByIdFromDatabase(
return row ? hydratePairedTaskRow(database, row) : undefined;
}
const latestPairedTaskStmtCache = new WeakMap<
Database,
ReturnType<Database['prepare']>
>();
export function getLatestPairedTaskForChatFromDatabase(
database: Database,
chatJid: string,
): PairedTask | undefined {
const row = database
.prepare(
`
SELECT *
FROM paired_tasks
WHERE chat_jid = ?
ORDER BY updated_at DESC
LIMIT 1
`,
)
.get(chatJid) as StoredPairedTaskRow | undefined;
let stmt = latestPairedTaskStmtCache.get(database);
if (!stmt) {
stmt = database.prepare(`
SELECT *
FROM paired_tasks
WHERE chat_jid = ?
ORDER BY updated_at DESC
LIMIT 1
`);
latestPairedTaskStmtCache.set(database, stmt);
}
const row = stmt.get(chatJid) as StoredPairedTaskRow | undefined;
return row ? hydratePairedTaskRow(database, row) : undefined;
}
@@ -269,6 +271,22 @@ export function getLatestOpenPairedTaskForChatFromDatabase(
return row ? hydratePairedTaskRow(database, row) : undefined;
}
export function getAllOpenPairedTasksFromDatabase(
database: Database,
): PairedTask[] {
const rows = database
.prepare(
`
SELECT *
FROM paired_tasks
WHERE status NOT IN ('completed')
ORDER BY updated_at DESC, created_at DESC
`,
)
.all() as StoredPairedTaskRow[];
return rows.map((row) => hydratePairedTaskRow(database, row));
}
export function getLatestPreviousPairedTaskForChatFromDatabase(
database: Database,
chatJid: string,
@@ -321,10 +339,6 @@ export function updatePairedTaskInDatabase(
fields.push('owner_failure_count = ?');
values.push(updates.owner_failure_count);
}
if (updates.reviewer_failure_count !== undefined) {
fields.push('reviewer_failure_count = ?');
values.push(updates.reviewer_failure_count);
}
if (updates.owner_step_done_streak !== undefined) {
fields.push('owner_step_done_streak = ?');
values.push(updates.owner_step_done_streak);
@@ -404,10 +418,6 @@ export function updatePairedTaskIfUnchangedInDatabase(
fields.push('owner_failure_count = ?');
values.push(updates.owner_failure_count);
}
if (updates.reviewer_failure_count !== undefined) {
fields.push('reviewer_failure_count = ?');
values.push(updates.reviewer_failure_count);
}
if (updates.owner_step_done_streak !== undefined) {
fields.push('owner_step_done_streak = ?');
values.push(updates.owner_step_done_streak);

View File

@@ -1,6 +1,7 @@
import { Database } from 'bun:sqlite';
import { normalizeServiceId } from '../config.js';
import { CODEX_BAD_REQUEST_DETAIL_JSON } from '../codex-bad-request-signal.js';
import type { PairedTurnIdentity } from '../paired-turn-identity.js';
import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js';
import type { AgentType, PairedRoomRole } from '../types.js';
@@ -33,6 +34,25 @@ export interface PairedTurnAttemptRecord {
last_error: string | null;
}
export interface InterruptedPairedTurnAttemptRecoveryCandidate {
chat_jid: string;
group_folder: string;
task_id: string;
task_status: string;
turn_id: string;
attempt_id: string;
attempt_no: number;
role: PairedRoomRole;
intent_kind: PairedTurnIdentity['intentKind'];
}
export interface OwnerCodexBadRequestFailureSummary {
taskId: string;
failures: number;
firstFailureAt: string;
latestFailureAt: string;
}
function resolveExecutorMetadata(args: {
executorServiceId?: string | null;
executorAgentType?: AgentType | null;
@@ -423,6 +443,154 @@ export function getPairedTurnAttemptIdFromDatabase(
return row?.attempt_id ?? null;
}
export function recoverInterruptedPairedTurnAttemptsForServiceInDatabase(
database: Database,
args: {
serviceIds: string[];
now?: string;
error?: string;
},
): InterruptedPairedTurnAttemptRecoveryCandidate[] {
const serviceIds = Array.from(
new Set(args.serviceIds.map((serviceId) => normalizeServiceId(serviceId))),
).filter((serviceId) => serviceId.length > 0);
if (serviceIds.length === 0) {
return [];
}
const servicePlaceholders = serviceIds.map(() => '?').join(', ');
const now = args.now ?? new Date().toISOString();
const error =
args.error ?? 'Interrupted by service restart before completion.';
return database.transaction(() => {
database
.prepare(
`
UPDATE paired_turn_attempts
SET state = 'failed',
active_run_id = NULL,
updated_at = ?,
completed_at = ?,
last_error = COALESCE(last_error, ?)
WHERE state = 'running'
AND executor_service_id IN (${servicePlaceholders})
AND task_id IN (
SELECT id FROM paired_tasks WHERE status = 'completed'
)
`,
)
.run(now, now, error, ...serviceIds);
const rows = database
.prepare(
`
SELECT
tasks.chat_jid,
tasks.group_folder,
attempts.task_id,
tasks.status AS task_status,
attempts.turn_id,
attempts.attempt_id,
attempts.attempt_no,
attempts.role,
attempts.intent_kind
FROM paired_turn_attempts attempts
JOIN paired_tasks tasks
ON tasks.id = attempts.task_id
WHERE attempts.state = 'running'
AND attempts.executor_service_id IN (${servicePlaceholders})
AND tasks.status != 'completed'
AND NOT EXISTS (
SELECT 1
FROM paired_task_execution_leases leases
WHERE leases.task_id = attempts.task_id
AND leases.turn_id = attempts.turn_id
AND leases.claimed_service_id NOT IN (${servicePlaceholders})
)
ORDER BY attempts.updated_at ASC, attempts.attempt_no ASC
`,
)
.all(
...serviceIds,
...serviceIds,
) as InterruptedPairedTurnAttemptRecoveryCandidate[];
if (rows.length === 0) {
return rows;
}
const update = database.prepare(
`
UPDATE paired_turn_attempts
SET state = 'failed',
active_run_id = NULL,
updated_at = ?,
completed_at = ?,
last_error = ?
WHERE attempt_id = ?
AND state = 'running'
AND executor_service_id IN (${servicePlaceholders})
`,
);
for (const row of rows) {
update.run(now, now, error, row.attempt_id, ...serviceIds);
}
return rows;
})();
}
export function getOwnerCodexBadRequestFailureSummaryForTaskFromDatabase(
database: Database,
args: {
taskId: string;
threshold: number;
},
): OwnerCodexBadRequestFailureSummary | null {
const threshold = Math.max(1, Math.floor(args.threshold));
const attempts = database
.prepare(
`
SELECT state, last_error, created_at
FROM paired_turn_attempts
WHERE task_id = ?
AND role = 'owner'
AND executor_agent_type = 'codex'
ORDER BY created_at DESC, attempt_no DESC
`,
)
.all(args.taskId) as Array<{
state: PairedTurnAttemptState;
last_error: string | null;
created_at: string;
}>;
const consecutiveFailures = [];
for (const attempt of attempts) {
if (
attempt.state === 'failed' &&
attempt.last_error?.trim() === CODEX_BAD_REQUEST_DETAIL_JSON
) {
consecutiveFailures.push(attempt);
continue;
}
break;
}
if (consecutiveFailures.length < threshold) {
return null;
}
const firstFailureAt =
consecutiveFailures[consecutiveFailures.length - 1].created_at;
const latestFailureAt = consecutiveFailures[0].created_at;
return {
taskId: args.taskId,
failures: consecutiveFailures.length,
firstFailureAt,
latestFailureAt,
};
}
export function setPairedTurnAttemptContinuationHandoffIdInDatabase(
database: Database,
args: {

View File

@@ -1,21 +1,38 @@
import { Database } from 'bun:sqlite';
import { logger } from '../logger.js';
import { parseVisibleVerdict } from '../paired-verdict.js';
import {
parseReviewerVisibleVerdict,
parseVisibleVerdict,
} from '../paired-verdict.js';
import { PairedRoomRole, PairedTurnOutput } from '../types.js';
OutboundAttachment,
PairedRoomRole,
PairedTurnOutput,
} from '../types.js';
import {
parseAttachmentPayload,
serializeAttachmentPayload,
} from './work-items.js';
const MAX_TURN_OUTPUT_CHARS = 50_000;
function storedOutputText(outputText: string): string {
if (outputText.length <= MAX_TURN_OUTPUT_CHARS) {
return outputText;
}
const notice = `\n\n[Output truncated: ${outputText.length} > ${MAX_TURN_OUTPUT_CHARS} chars]`;
return `${outputText.slice(0, MAX_TURN_OUTPUT_CHARS - notice.length)}${notice}`;
}
export function insertPairedTurnOutputInDatabase(
database: Database,
taskId: string,
turnNumber: number,
role: PairedRoomRole,
outputText: string,
createdAt?: string,
options: {
createdAt?: string;
attachments?: OutboundAttachment[];
} = {},
): void {
if (outputText.length > MAX_TURN_OUTPUT_CHARS) {
logger.warn(
@@ -33,21 +50,36 @@ export function insertPairedTurnOutputInDatabase(
database
.prepare(
`INSERT OR REPLACE INTO paired_turn_outputs
(task_id, turn_number, role, output_text, verdict, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
(task_id, turn_number, role, output_text, attachment_payload, verdict, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(
taskId,
turnNumber,
role,
outputText.slice(0, MAX_TURN_OUTPUT_CHARS),
role === 'reviewer'
? parseReviewerVisibleVerdict(outputText)
: parseVisibleVerdict(outputText),
createdAt ?? new Date().toISOString(),
storedOutputText(outputText),
serializeAttachmentPayload(options.attachments),
parseVisibleVerdict(outputText),
options.createdAt ?? new Date().toISOString(),
);
}
type StoredPairedTurnOutputRow = PairedTurnOutput & {
attachment_payload?: string | null;
};
function hydratePairedTurnOutputRow(
row: StoredPairedTurnOutputRow,
): PairedTurnOutput {
return {
...row,
attachments: parseAttachmentPayload(row.attachment_payload, {
table: 'paired_turn_outputs',
rowId: row.id,
}),
};
}
export function getPairedTurnOutputsFromDatabase(
database: Database,
taskId: string,
@@ -58,7 +90,34 @@ export function getPairedTurnOutputsFromDatabase(
WHERE task_id = ?
ORDER BY turn_number ASC`,
)
.all(taskId) as PairedTurnOutput[];
.all(taskId)
.map((row) => hydratePairedTurnOutputRow(row as StoredPairedTurnOutputRow));
}
const recentOutputsForChatStmtCache = new WeakMap<
Database,
ReturnType<Database['prepare']>
>();
export function getRecentPairedTurnOutputsForChatFromDatabase(
database: Database,
chatJid: string,
limit: number = 8,
): PairedTurnOutput[] {
let stmt = recentOutputsForChatStmtCache.get(database);
if (!stmt) {
stmt = database.prepare(`
SELECT o.*
FROM paired_turn_outputs o
INNER JOIN paired_tasks t ON o.task_id = t.id
WHERE t.chat_jid = ?
ORDER BY o.created_at DESC
LIMIT ?
`);
recentOutputsForChatStmtCache.set(database, stmt);
}
const rows = stmt.all(chatJid, limit) as StoredPairedTurnOutputRow[];
return rows.reverse().map(hydratePairedTurnOutputRow);
}
export function getLatestTurnNumberFromDatabase(

View File

@@ -1,7 +1,7 @@
import { Database } from 'bun:sqlite';
import { buildPairedTurnAttemptId } from './paired-turn-attempts.js';
import { tableHasColumn, tryExecMigration } from './migrations/helpers.js';
import { tableHasColumn } from './migrations/helpers.js';
// Paired-turn provenance rebuild helpers extracted from the legacy schema
// bundle. These remain runtime helpers because v10 replays them during

View File

@@ -33,6 +33,8 @@ export interface PairedTurnRecord {
updated_at: string;
completed_at: string | null;
last_error: string | null;
progress_text?: string | null;
progress_updated_at?: string | null;
}
interface StoredPairedTurnRow {
@@ -43,6 +45,8 @@ interface StoredPairedTurnRow {
intent_kind: PairedTurnIdentity['intentKind'];
created_at: string;
updated_at: string;
progress_text?: string | null;
progress_updated_at?: string | null;
}
function hydratePairedTurnRecord(
@@ -493,6 +497,67 @@ export function getPairedTurnByIdFromDatabase(
);
}
const updateProgressTextStmtCache = new WeakMap<
Database,
ReturnType<Database['prepare']>
>();
export function updatePairedTurnProgressTextFromDatabase(
database: Database,
turnId: string,
progressText: string | null,
): void {
const now = new Date().toISOString();
let stmt = updateProgressTextStmtCache.get(database);
if (!stmt) {
stmt = database.prepare(`
UPDATE paired_turns
SET progress_text = ?,
progress_updated_at = ?,
updated_at = ?
WHERE turn_id = ?
`);
updateProgressTextStmtCache.set(database, stmt);
}
stmt.run(progressText, now, now, turnId);
}
const latestPairedTurnStmtCache = new WeakMap<
Database,
ReturnType<Database['prepare']>
>();
export function getLatestPairedTurnForTaskFromDatabase(
database: Database,
taskId: string,
): PairedTurnRecord | null {
let stmt = latestPairedTurnStmtCache.get(database);
if (!stmt) {
stmt = database.prepare(`
SELECT *
FROM paired_turns
WHERE task_id = ?
ORDER BY CASE
WHEN progress_text IS NOT NULL
AND trim(progress_text) <> ''
AND progress_updated_at IS NOT NULL
AND progress_updated_at > updated_at
THEN progress_updated_at
ELSE updated_at
END DESC,
turn_id DESC
LIMIT 1
`);
latestPairedTurnStmtCache.set(database, stmt);
}
const row = stmt.get(taskId) as StoredPairedTurnRow | undefined;
if (!row) return null;
return hydratePairedTurnRecord(
row,
getCurrentPairedTurnAttemptForTurnFromDatabase(database, row.turn_id),
);
}
export function getPairedTurnsForTaskFromDatabase(
database: Database,
taskId: string,

View File

@@ -115,7 +115,7 @@ function getStoredRoomRoleOverrideRows(
agent_config_json: string | null;
created_at: string;
updated_at: string;
}> = [];
}>;
try {
rows = database
.prepare(

View File

@@ -28,6 +28,24 @@ interface StoredRoomModeRow {
source: RoomModeSource;
}
export interface StoredRoomSkillOverride {
chatJid: string;
agentType: AgentType;
skillScope: string;
skillName: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface StoredRoomSkillOverrideInput {
chatJid: string;
agentType: AgentType;
skillScope: string;
skillName: string;
enabled: boolean;
}
export interface AssignRoomInput {
name: string;
roomMode?: RoomMode;
@@ -35,6 +53,8 @@ export interface AssignRoomInput {
reviewerAgentType?: AgentType;
arbiterAgentType?: AgentType | null;
folder?: string;
trigger?: string;
requiresTrigger?: boolean;
isMain?: boolean;
workDir?: string;
addedAt?: string;
@@ -186,8 +206,9 @@ export function assignRoomInDatabase(
const snapshot: RoomRegistrationSnapshot = {
name: input.name,
folder,
triggerPattern: existing?.trigger ?? '',
requiresTrigger: existing?.requiresTrigger ?? false,
triggerPattern: input.trigger ?? existing?.trigger ?? '',
requiresTrigger:
input.requiresTrigger ?? existing?.requiresTrigger ?? false,
isMain: input.isMain ?? existing?.isMain ?? false,
ownerAgentType,
workDir: input.workDir ?? existing?.workDir ?? null,
@@ -307,6 +328,112 @@ export function getStoredRoomRoleAgentPlanFromDatabase(
return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined;
}
export function getStoredRoomSkillOverridesFromDatabase(
database: Database,
chatJid?: string,
): StoredRoomSkillOverride[] {
const params: string[] = [];
const where = chatJid ? 'WHERE chat_jid = ?' : '';
if (chatJid) params.push(chatJid);
let rows: Array<{
chat_jid: string;
agent_type: string;
skill_scope: string;
skill_name: string;
enabled: number;
created_at: string;
updated_at: string;
}>;
try {
rows = database
.prepare(
`SELECT chat_jid, agent_type, skill_scope, skill_name, enabled,
created_at, updated_at
FROM room_skill_overrides
${where}
ORDER BY chat_jid, agent_type, skill_scope, skill_name`,
)
.all(...params) as Array<{
chat_jid: string;
agent_type: string;
skill_scope: string;
skill_name: string;
enabled: number;
created_at: string;
updated_at: string;
}>;
} catch {
return [];
}
return rows
.map((row) => {
const agentType = normalizeStoredAgentType(row.agent_type);
if (!agentType) return null;
return {
chatJid: row.chat_jid,
agentType,
skillScope: row.skill_scope,
skillName: row.skill_name,
enabled: row.enabled === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
})
.filter((row): row is StoredRoomSkillOverride => Boolean(row));
}
export function upsertStoredRoomSkillOverrideInDatabase(
database: Database,
input: StoredRoomSkillOverrideInput,
): void {
const agentType = normalizeStoredAgentType(input.agentType);
if (!agentType) {
throw new Error(`Unsupported agent type: ${input.agentType}`);
}
const now = new Date().toISOString();
database
.prepare(
`INSERT INTO room_skill_overrides (
chat_jid, agent_type, skill_scope, skill_name, enabled,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_jid, agent_type, skill_scope, skill_name)
DO UPDATE SET
enabled = excluded.enabled,
updated_at = excluded.updated_at`,
)
.run(
input.chatJid,
agentType,
input.skillScope,
input.skillName,
input.enabled ? 1 : 0,
now,
now,
);
}
export function deleteStoredRoomSkillOverrideFromDatabase(
database: Database,
input: Omit<StoredRoomSkillOverrideInput, 'enabled'>,
): void {
const agentType = normalizeStoredAgentType(input.agentType);
if (!agentType) {
throw new Error(`Unsupported agent type: ${input.agentType}`);
}
database
.prepare(
`DELETE FROM room_skill_overrides
WHERE chat_jid = ?
AND agent_type = ?
AND skill_scope = ?
AND skill_name = ?`,
)
.run(input.chatJid, agentType, input.skillScope, input.skillName);
}
function getStoredRoomModeRowFromDatabase(
database: Database,
chatJid: string,

View File

@@ -17,7 +17,6 @@ import {
import {
type ChatInfo,
getAllChatsFromDatabase,
getEarliestUnansweredHumanSeqFromDatabase,
getLastHumanMessageContentFromDatabase,
getLastHumanMessageSenderFromDatabase,
getLastHumanMessageTimestampFromDatabase,
@@ -27,6 +26,8 @@ import {
getNewMessagesBySeqFromDatabase,
getNewMessagesFromDatabase,
getRecentChatMessagesFromDatabase,
getRecentChatMessagesBatchFromDatabase,
hasMessageInDatabase,
hasRecentRestartAnnouncementInDatabase,
storeChatMetadataInDatabase,
storeMessageInDatabase,
@@ -37,7 +38,7 @@ import {
createProducedWorkItemInDatabase,
getOpenWorkItemForChatFromDatabase,
getOpenWorkItemFromDatabase,
getRecentDeliveredOwnerWorkItemsForChatFromDatabase,
getRecentDeliveredWorkItemsForChatFromDatabase,
markWorkItemDeliveredInDatabase,
markWorkItemDeliveryRetryInDatabase,
} from './work-items.js';
@@ -143,6 +144,10 @@ export function storeMessage(msg: NewMessage): void {
storeMessageInDatabase(requireDatabase(), msg);
}
export function hasMessage(chatJid: string, id: string): boolean {
return hasMessageInDatabase(requireDatabase(), chatJid, id);
}
export function getNewMessages(
jids: string[],
lastTimestamp: string,
@@ -221,12 +226,19 @@ export function getRecentChatMessages(
return getRecentChatMessagesFromDatabase(requireDatabase(), chatJid, limit);
}
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
return getLastHumanMessageTimestampFromDatabase(requireDatabase(), chatJid);
export function getRecentChatMessagesBatch(
chatJids: string[],
limit: number = 8,
): Map<string, NewMessage[]> {
return getRecentChatMessagesBatchFromDatabase(
requireDatabase(),
chatJids,
limit,
);
}
export function getEarliestUnansweredHumanSeq(chatJid: string): number | null {
return getEarliestUnansweredHumanSeqFromDatabase(requireDatabase(), chatJid);
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
return getLastHumanMessageTimestampFromDatabase(requireDatabase(), chatJid);
}
export function getLastHumanMessageSender(chatJid: string): string | null {
@@ -272,11 +284,11 @@ export function getOpenWorkItemForChat(
);
}
export function getRecentDeliveredOwnerWorkItemsForChat(
export function getRecentDeliveredWorkItemsForChat(
chatJid: string,
limit: number,
limit: number = 8,
): WorkItem[] {
return getRecentDeliveredOwnerWorkItemsForChatFromDatabase(
return getRecentDeliveredWorkItemsForChatFromDatabase(
requireDatabase(),
chatJid,
limit,

View File

@@ -24,6 +24,7 @@ import {
clearPairedTurnReservationsInDatabase,
type PairedTaskUpdates,
createPairedTaskInDatabase,
getAllOpenPairedTasksFromDatabase,
getLastBotFinalMessageFromDatabase,
getLatestOpenPairedTaskForChatFromDatabase,
getLatestPreviousPairedTaskForChatFromDatabase,
@@ -42,12 +43,17 @@ import {
} from './paired-state.js';
import {
clearPairedTurnAttemptsInDatabase,
getOwnerCodexBadRequestFailureSummaryForTaskFromDatabase,
getPairedTurnAttemptsForTurnFromDatabase,
recoverInterruptedPairedTurnAttemptsForServiceInDatabase,
type InterruptedPairedTurnAttemptRecoveryCandidate,
type OwnerCodexBadRequestFailureSummary,
type PairedTurnAttemptRecord,
} from './paired-turn-attempts.js';
import {
getLatestTurnNumberFromDatabase,
getPairedTurnOutputsFromDatabase,
getRecentPairedTurnOutputsForChatFromDatabase,
insertPairedTurnOutputInDatabase,
} from './paired-turn-outputs.js';
import {
@@ -57,6 +63,8 @@ import {
failPairedTurnInDatabase,
getPairedTurnByIdFromDatabase,
getPairedTurnsForTaskFromDatabase,
getLatestPairedTurnForTaskFromDatabase,
updatePairedTurnProgressTextFromDatabase,
markPairedTurnRunningInDatabase,
type PairedTurnRecord,
} from './paired-turns.js';
@@ -113,6 +121,10 @@ export function getLatestPreviousPairedTaskForChat(
);
}
export function getAllOpenPairedTasks(): PairedTask[] {
return getAllOpenPairedTasksFromDatabase(requireDatabase());
}
export function updatePairedTask(id: string, updates: PairedTaskUpdates): void {
updatePairedTaskInDatabase(requireDatabase(), id, updates);
}
@@ -206,12 +218,50 @@ export function getPairedTurnsForTask(taskId: string): PairedTurnRecord[] {
return getPairedTurnsForTaskFromDatabase(requireDatabase(), taskId);
}
export function getLatestPairedTurnForTask(
taskId: string,
): PairedTurnRecord | null {
return getLatestPairedTurnForTaskFromDatabase(requireDatabase(), taskId);
}
export function updatePairedTurnProgressText(
turnId: string,
progressText: string | null,
): void {
updatePairedTurnProgressTextFromDatabase(
requireDatabase(),
turnId,
progressText,
);
}
export function getPairedTurnAttempts(
turnId: string,
): PairedTurnAttemptRecord[] {
return getPairedTurnAttemptsForTurnFromDatabase(requireDatabase(), turnId);
}
export function recoverInterruptedPairedTurnAttemptsForService(args: {
serviceIds: string[];
now?: string;
error?: string;
}): InterruptedPairedTurnAttemptRecoveryCandidate[] {
return recoverInterruptedPairedTurnAttemptsForServiceInDatabase(
requireDatabase(),
args,
);
}
export function getOwnerCodexBadRequestFailureSummaryForTask(args: {
taskId: string;
threshold: number;
}): OwnerCodexBadRequestFailureSummary | null {
return getOwnerCodexBadRequestFailureSummaryForTaskFromDatabase(
requireDatabase(),
args,
);
}
export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
upsertPairedWorkspaceInDatabase(requireDatabase(), workspace);
}
@@ -312,15 +362,24 @@ export function insertPairedTurnOutput(
turnNumber: number,
role: PairedRoomRole,
outputText: string,
createdAt?: string,
createdAtOrOptions?:
| string
| {
createdAt?: string;
attachments?: import('../types.js').OutboundAttachment[];
},
): void {
const options =
typeof createdAtOrOptions === 'string'
? { createdAt: createdAtOrOptions }
: (createdAtOrOptions ?? {});
insertPairedTurnOutputInDatabase(
requireDatabase(),
taskId,
turnNumber,
role,
outputText,
createdAt,
options,
);
}
@@ -328,6 +387,17 @@ export function getPairedTurnOutputs(taskId: string): PairedTurnOutput[] {
return getPairedTurnOutputsFromDatabase(requireDatabase(), taskId);
}
export function getRecentPairedTurnOutputsForChat(
chatJid: string,
limit: number = 8,
): PairedTurnOutput[] {
return getRecentPairedTurnOutputsForChatFromDatabase(
requireDatabase(),
chatJid,
limit,
);
}
export function getLatestTurnNumber(taskId: string): number {
return getLatestTurnNumberFromDatabase(requireDatabase(), taskId);
}

View File

@@ -28,6 +28,7 @@ import {
assignRoomInDatabase,
clearExplicitRoomModeInDatabase,
deleteStoredRoomSettingsForTestsInDatabase,
deleteStoredRoomSkillOverrideFromDatabase,
getAllRoomBindingsFromDatabase,
getEffectiveRoomModeFromDatabase,
getEffectiveRuntimeRoomModeFromDatabase,
@@ -36,10 +37,14 @@ import {
getRegisteredGroupFromDatabase,
getStoredRoomRoleAgentPlanFromDatabase,
getStoredRoomSettingsFromDatabase,
getStoredRoomSkillOverridesFromDatabase,
setExplicitRoomModeInDatabase,
setRegisteredGroupForTestsInDatabase,
setStoredRoomOwnerAgentTypeForTestsInDatabase,
type StoredRoomSkillOverride,
type StoredRoomSkillOverrideInput,
updateRegisteredGroupNameInDatabase,
upsertStoredRoomSkillOverrideInDatabase,
} from './rooms.js';
import { type StoredRoomSettings } from './room-registration.js';
import {
@@ -363,6 +368,26 @@ export function getStoredRoomRoleAgentPlan(
return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid);
}
export function getStoredRoomSkillOverrides(
chatJid?: string,
): StoredRoomSkillOverride[] {
const db = getDatabaseIfInitialized();
if (!db) return [];
return getStoredRoomSkillOverridesFromDatabase(db, chatJid);
}
export function upsertStoredRoomSkillOverride(
input: StoredRoomSkillOverrideInput,
): void {
upsertStoredRoomSkillOverrideInDatabase(requireDatabase(), input);
}
export function deleteStoredRoomSkillOverride(
input: Omit<StoredRoomSkillOverrideInput, 'enabled'>,
): void {
deleteStoredRoomSkillOverrideFromDatabase(requireDatabase(), input);
}
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid);
}

View File

@@ -211,6 +211,7 @@ function parseLastAgentSeqState(
`Invalid last_agent_seq JSON for ${serviceId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err },
);
}

View File

@@ -141,8 +141,11 @@ export function deleteAllSessionsForGroupFromDatabase(
groupFolder: string,
): void {
database
.prepare('DELETE FROM sessions WHERE group_folder = ?')
.run(groupFolder);
.prepare(
`DELETE FROM sessions
WHERE group_folder IN (?, ?, ?)`,
)
.run(groupFolder, `${groupFolder}:reviewer`, `${groupFolder}:arbiter`);
}
export function getAllSessionsForAgentTypeFromDatabase(

View File

@@ -1,12 +1,22 @@
import { Database } from 'bun:sqlite';
import {
DEFAULT_TASK_CONTEXT_MODE,
WATCH_CI_PROMPT_PREFIX,
} from 'ejclaw-runners-shared';
import { AgentType, ScheduledTask, TaskRunLog } from '../types.js';
import {
AgentType,
PairedRoomRole,
ScheduledTask,
TaskRunLog,
} from '../types.js';
export type CreateScheduledTaskInput = Omit<
ScheduledTask,
| 'last_run'
| 'last_result'
| 'agent_type'
| 'room_role'
| 'ci_provider'
| 'ci_metadata'
| 'max_duration_ms'
@@ -14,6 +24,7 @@ export type CreateScheduledTaskInput = Omit<
| 'status_started_at'
> & {
agent_type?: AgentType | null;
room_role?: PairedRoomRole | null;
ci_provider?: ScheduledTask['ci_provider'];
ci_metadata?: string | null;
max_duration_ms?: number | null;
@@ -45,8 +56,8 @@ export function createTaskInDatabase(
database
.prepare(
`
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, ci_provider, ci_metadata, max_duration_ms, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, room_role, ci_provider, ci_metadata, max_duration_ms, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
@@ -54,6 +65,7 @@ export function createTaskInDatabase(
task.group_folder,
task.chat_jid,
task.agent_type || 'claude-code',
task.room_role ?? null,
task.ci_provider ?? null,
task.ci_metadata ?? null,
task.max_duration_ms ?? null,
@@ -62,7 +74,7 @@ export function createTaskInDatabase(
task.prompt,
task.schedule_type,
task.schedule_value,
task.context_mode || 'isolated',
task.context_mode || DEFAULT_TASK_CONTEXT_MODE,
task.next_run,
task.status,
task.created_at,
@@ -73,9 +85,10 @@ export function getTaskByIdFromDatabase(
database: Database,
id: string,
): ScheduledTask | undefined {
return database
const row = database
.prepare('SELECT * FROM scheduled_tasks WHERE id = ?')
.get(id) as ScheduledTask | undefined;
.get(id) as ScheduledTask | null | undefined;
return row ?? undefined;
}
export function findDuplicateCiWatcherInDatabase(
@@ -208,10 +221,10 @@ export function hasActiveCiWatcherForChatInDatabase(
const row = database
.prepare(
`SELECT 1 FROM scheduled_tasks
WHERE chat_jid = ? AND status = 'active' AND prompt LIKE '[BACKGROUND CI WATCH]%'
WHERE chat_jid = ? AND status = 'active' AND prompt LIKE ?
LIMIT 1`,
)
.get(chatJid);
.get(chatJid, `${WATCH_CI_PROMPT_PREFIX}%`);
return !!row;
}

View File

@@ -1,6 +1,7 @@
import { Database } from 'bun:sqlite';
import { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../config.js';
import { logger } from '../logger.js';
import {
inferAgentTypeFromServiceShadow,
inferRoleFromServiceShadow,
@@ -8,6 +9,8 @@ import {
} from '../role-service-shadow.js';
import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js';
const SUPERSEDED_WORK_ITEM_ERROR = 'superseded_by_newer_canonical_output';
export interface WorkItem {
id: number;
group_folder: string;
@@ -99,36 +102,107 @@ function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem {
...row,
agent_type: agentType,
service_id: readStoredWorkItemServiceId(row),
attachments: parseAttachmentPayload(row.attachment_payload),
attachments: parseAttachmentPayload(row.attachment_payload, {
table: 'work_items',
rowId: row.id,
}),
};
}
function parseAttachmentPayload(
export interface AttachmentPayloadContext {
table?: string;
rowId?: string | number;
}
function payloadPreview(payload: string): string {
return payload.length > 200 ? `${payload.slice(0, 200)}...` : payload;
}
function isAttachmentPayloadEntry(
item: unknown,
): item is { path: string; name?: unknown; mime?: unknown } {
return (
item !== null &&
typeof item === 'object' &&
!Array.isArray(item) &&
typeof (item as { path?: unknown }).path === 'string'
);
}
function logMalformedAttachmentPayload(args: {
payload: string;
context?: AttachmentPayloadContext;
reason: string;
err?: unknown;
}): void {
logger.warn(
{
...args.context,
reason: args.reason,
payloadLength: args.payload.length,
payloadPreview: payloadPreview(args.payload),
...(args.err ? { err: args.err } : {}),
},
'Ignored malformed attachment payload',
);
}
export function parseAttachmentPayload(
payload: string | null | undefined,
context?: AttachmentPayloadContext,
): OutboundAttachment[] {
if (!payload) return [];
try {
const parsed = JSON.parse(payload) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(item): item is OutboundAttachment =>
item !== null &&
typeof item === 'object' &&
!Array.isArray(item) &&
typeof (item as { path?: unknown }).path === 'string',
)
.map((item) => ({
path: item.path,
...(typeof item.name === 'string' ? { name: item.name } : {}),
...(typeof item.mime === 'string' ? { mime: item.mime } : {}),
}));
} catch {
if (!Array.isArray(parsed)) {
logMalformedAttachmentPayload({
payload,
context,
reason: 'not_array',
});
return [];
}
const attachments: OutboundAttachment[] = [];
let invalidEntryCount = 0;
for (const item of parsed) {
if (isAttachmentPayloadEntry(item)) {
attachments.push({
path: item.path,
...(typeof item.name === 'string' ? { name: item.name } : {}),
...(typeof item.mime === 'string' ? { mime: item.mime } : {}),
});
} else {
invalidEntryCount += 1;
}
}
if (invalidEntryCount > 0) {
logger.warn(
{
...context,
invalidEntryCount,
validEntryCount: attachments.length,
payloadLength: payload.length,
payloadPreview: payloadPreview(payload),
},
'Ignored invalid attachment payload entries',
);
}
return attachments;
} catch (err) {
logMalformedAttachmentPayload({
payload,
context,
reason: 'invalid_json',
err,
});
return [];
}
}
function serializeAttachmentPayload(
export function serializeAttachmentPayload(
attachments: OutboundAttachment[] | undefined,
): string | null {
if (!attachments?.length) return null;
@@ -267,6 +341,57 @@ export function getOpenWorkItemForChatFromDatabase(
return row ? hydrateWorkItemRow(row) : undefined;
}
export function getRecentDeliveredWorkItemsForChatFromDatabase(
database: Database,
chatJid: string,
limit: number = 8,
): WorkItem[] {
const rows = database
.prepare(
`SELECT *
FROM work_items
WHERE chat_jid = ?
AND status = 'delivered'
AND (last_error IS NULL OR last_error <> ?)
ORDER BY COALESCE(delivered_at, updated_at, created_at) DESC, id DESC
LIMIT ?`,
)
.all(chatJid, SUPERSEDED_WORK_ITEM_ERROR, limit) as StoredWorkItemRow[];
return rows.reverse().map(hydrateWorkItemRow);
}
function supersedeOpenWorkItemsForCanonicalKey(
database: Database,
input: CreateProducedWorkItemInput,
agentType: AgentType,
serviceId: string,
now: string,
): void {
database
.prepare(
`UPDATE work_items
SET status = 'delivered',
delivered_at = ?,
delivery_message_id = NULL,
last_error = ?,
updated_at = ?
WHERE chat_jid = ?
AND agent_type = ?
AND IFNULL(service_id, '') = IFNULL(?, '')
AND IFNULL(delivery_role, '') = IFNULL(?, '')
AND status IN ('produced', 'delivery_retry')`,
)
.run(
now,
SUPERSEDED_WORK_ITEM_ERROR,
now,
input.chat_jid,
agentType,
serviceId,
input.delivery_role ?? null,
);
}
export function createProducedWorkItemInDatabase(
database: Database,
input: CreateProducedWorkItemInput,
@@ -278,46 +403,57 @@ export function createProducedWorkItemInDatabase(
deliveryRole: input.delivery_role,
serviceId: input.service_id,
});
database
.prepare(
`INSERT INTO work_items (
group_folder,
chat_jid,
agent_type,
service_id,
delivery_role,
status,
start_seq,
end_seq,
result_payload,
attachment_payload,
delivery_attempts,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`,
)
.run(
input.group_folder,
input.chat_jid,
return database.transaction(() => {
supersedeOpenWorkItemsForCanonicalKey(
database,
input,
agentType,
serviceId,
input.delivery_role ?? null,
input.start_seq,
input.end_seq,
input.result_payload,
serializeAttachmentPayload(input.attachments),
now,
now,
);
const lastId = (
database.prepare('SELECT last_insert_rowid() as id').get() as { id: number }
).id;
return hydrateWorkItemRow(
database
.prepare('SELECT * FROM work_items WHERE id = ?')
.get(lastId) as StoredWorkItemRow,
);
.prepare(
`INSERT INTO work_items (
group_folder,
chat_jid,
agent_type,
service_id,
delivery_role,
status,
start_seq,
end_seq,
result_payload,
attachment_payload,
delivery_attempts,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`,
)
.run(
input.group_folder,
input.chat_jid,
agentType,
serviceId,
input.delivery_role ?? null,
input.start_seq,
input.end_seq,
input.result_payload,
serializeAttachmentPayload(input.attachments),
now,
now,
);
const lastId = (
database.prepare('SELECT last_insert_rowid() as id').get() as {
id: number;
}
).id;
return hydrateWorkItemRow(
database
.prepare('SELECT * FROM work_items WHERE id = ?')
.get(lastId) as StoredWorkItemRow,
);
})();
}
export function markWorkItemDeliveredInDatabase(

View File

@@ -0,0 +1,45 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it } from 'vitest';
import {
collectArtifactMetadata,
normalizeArtifactEvidenceKind,
} from './deploy-evidence.js';
describe('deploy evidence helpers', () => {
it('normalizes fixed artifact kinds', () => {
expect(normalizeArtifactEvidenceKind()).toBe('build_outputs');
expect(normalizeArtifactEvidenceKind('dashboard_dist')).toBe(
'dashboard_dist',
);
expect(() => normalizeArtifactEvidenceKind('/etc/passwd')).toThrow(
'Unsupported artifact evidence kind',
);
});
it('returns metadata only for build artifacts', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-deploy-'));
const dashboardDir = path.join(root, 'apps', 'dashboard', 'dist');
fs.mkdirSync(path.join(dashboardDir, 'assets'), { recursive: true });
fs.writeFileSync(path.join(dashboardDir, 'index.html'), '<html></html>');
fs.writeFileSync(path.join(dashboardDir, 'assets', 'index.js'), 'secret');
const text = collectArtifactMetadata(
{
projectRoot: root,
dataDir: path.join(root, 'data'),
dashboardStaticDir: dashboardDir,
},
{ action: 'ejclaw_artifact_metadata', artifactKind: 'dashboard_dist' },
);
expect(text).toContain('"file_count"');
expect(text).toContain('"total_bytes"');
expect(text).toContain('index.js');
expect(text).not.toContain('"secret"');
fs.rmSync(root, { recursive: true, force: true });
});
});

262
src/deploy-evidence.ts Normal file
View File

@@ -0,0 +1,262 @@
import { createHash } from 'crypto';
import { execFile } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
ARTIFACT_EVIDENCE_KINDS,
DEPLOY_EVIDENCE_ACTIONS,
isArtifactEvidenceKind,
isDeployEvidenceAction,
type ArtifactEvidenceKind,
type DeployEvidenceAction,
} from 'ejclaw-runners-shared';
export {
ARTIFACT_EVIDENCE_KINDS,
DEPLOY_EVIDENCE_ACTIONS,
isDeployEvidenceAction,
type ArtifactEvidenceKind,
type DeployEvidenceAction,
};
export interface DeployEvidenceRequest {
action: DeployEvidenceAction;
artifactKind?: string;
}
export interface DeployEvidencePaths {
projectRoot: string;
dataDir: string;
dashboardStaticDir: string;
}
const COMMAND_TIMEOUT_MS = 5_000;
const COMMAND_MAX_BUFFER = 1024 * 1024;
const MAX_DIR_ENTRIES = 5_000;
const MAX_LATEST_FILES = 12;
export function normalizeArtifactEvidenceKind(
value?: string,
): ArtifactEvidenceKind {
if (!value) return 'build_outputs';
if (isArtifactEvidenceKind(value)) {
return value;
}
throw new Error(`Unsupported artifact evidence kind: ${value}`);
}
function execFileText(file: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
file,
args,
{
encoding: 'utf8',
timeout: COMMAND_TIMEOUT_MS,
maxBuffer: COMMAND_MAX_BUFFER,
},
(error, stdout, stderr) => {
if (error) {
const details = stderr?.trim() || stdout?.trim() || error.message;
reject(new Error(`${file} ${args.join(' ')} failed: ${details}`));
return;
}
resolve(stdout.trim());
},
);
});
}
function sha256File(filePath: string): string {
const hash = createHash('sha256');
hash.update(fs.readFileSync(filePath));
return hash.digest('hex');
}
function fileMetadata(filePath: string): Record<string, unknown> {
if (!fs.existsSync(filePath)) {
return {
path: filePath,
exists: false,
};
}
const stat = fs.statSync(filePath);
const base = {
path: filePath,
exists: true,
type: stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other',
size_bytes: stat.size,
mtime: stat.mtime.toISOString(),
};
if (!stat.isFile()) {
return base;
}
return {
...base,
sha256: sha256File(filePath),
};
}
function walkDirectory(root: string): Array<{ path: string; stat: fs.Stats }> {
if (!fs.existsSync(root)) return [];
const pending = [root];
const files: Array<{ path: string; stat: fs.Stats }> = [];
while (pending.length > 0 && files.length < MAX_DIR_ENTRIES) {
const current = pending.pop()!;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const entryPath = path.join(current, entry.name);
if (entry.isDirectory()) {
pending.push(entryPath);
continue;
}
if (!entry.isFile()) continue;
files.push({ path: entryPath, stat: fs.statSync(entryPath) });
if (files.length >= MAX_DIR_ENTRIES) break;
}
}
return files;
}
function directoryMetadata(root: string): Record<string, unknown> {
if (!fs.existsSync(root)) {
return {
path: root,
exists: false,
};
}
const files = walkDirectory(root);
const totalBytes = files.reduce((sum, file) => sum + file.stat.size, 0);
const latestFiles = [...files]
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
.slice(0, MAX_LATEST_FILES)
.map((file) => ({
relative_path: path.relative(root, file.path),
size_bytes: file.stat.size,
mtime: file.stat.mtime.toISOString(),
}));
return {
path: root,
exists: true,
file_count: files.length,
total_bytes: totalBytes,
truncated: files.length >= MAX_DIR_ENTRIES,
latest_files: latestFiles,
};
}
function buildOutputArtifacts(
paths: DeployEvidencePaths,
): Record<string, unknown> {
return {
root_dist: fileMetadata(path.join(paths.projectRoot, 'dist', 'index.js')),
dashboard_index: fileMetadata(
path.join(paths.dashboardStaticDir, 'index.html'),
),
dashboard_assets: directoryMetadata(
path.join(paths.dashboardStaticDir, 'assets'),
),
agent_runner: fileMetadata(
path.join(
paths.projectRoot,
'runners',
'agent-runner',
'dist',
'index.js',
),
),
codex_runner: fileMetadata(
path.join(
paths.projectRoot,
'runners',
'codex-runner',
'dist',
'index.js',
),
),
};
}
export async function collectDeployState(
paths: DeployEvidencePaths,
): Promise<string> {
const [head, logLine, status] = await Promise.all([
execFileText('git', ['-C', paths.projectRoot, 'rev-parse', 'HEAD']),
execFileText('git', [
'-C',
paths.projectRoot,
'log',
'-1',
'--oneline',
'--decorate',
]),
execFileText('git', ['-C', paths.projectRoot, 'status', '--short']),
]);
return JSON.stringify(
{
action: 'ejclaw_deploy_state',
project_root: paths.projectRoot,
git: {
head,
log: logLine,
dirty: status.length > 0,
status_short: status,
},
artifacts: buildOutputArtifacts(paths),
},
null,
2,
);
}
export function collectArtifactMetadata(
paths: DeployEvidencePaths,
request: DeployEvidenceRequest,
): string {
const kind = normalizeArtifactEvidenceKind(request.artifactKind);
const payload =
kind === 'build_outputs'
? buildOutputArtifacts(paths)
: kind === 'dashboard_dist'
? directoryMetadata(paths.dashboardStaticDir)
: kind === 'runner_dist'
? {
agent_runner: directoryMetadata(
path.join(paths.projectRoot, 'runners', 'agent-runner', 'dist'),
),
codex_runner: directoryMetadata(
path.join(paths.projectRoot, 'runners', 'codex-runner', 'dist'),
),
}
: kind === 'android_debug_apk'
? fileMetadata(
path.join(
paths.projectRoot,
'apps',
'android',
'app',
'build',
'outputs',
'apk',
'debug',
'app-debug.apk',
),
)
: directoryMetadata(path.join(paths.dataDir, 'attachments'));
return JSON.stringify(
{
action: 'ejclaw_artifact_metadata',
artifact_kind: kind,
payload,
},
null,
2,
);
}

60
src/deploy-script.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
type PackageJson = {
dependencies?: Record<string, string>;
scripts?: Record<string, string>;
workspaces?: string[];
};
function readPackageJson(relativePath: string): PackageJson {
return JSON.parse(
fs.readFileSync(path.resolve(import.meta.dirname, relativePath), 'utf8'),
) as PackageJson;
}
describe('deploy script', () => {
it('refreshes workspace dependencies after pulling before building', () => {
const packageJson = readPackageJson('../package.json');
const deploy = packageJson.scripts?.deploy ?? '';
const pullIndex = deploy.indexOf('git pull --ff-only');
const rootInstallIndex = deploy.indexOf('bun install --frozen-lockfile');
const buildIndex = deploy.indexOf('bun run build:all');
expect(pullIndex).toBeGreaterThanOrEqual(0);
expect(rootInstallIndex).toBeGreaterThan(pullIndex);
expect(rootInstallIndex).toBeLessThan(buildIndex);
expect(
deploy.indexOf('bun install --frozen-lockfile', rootInstallIndex + 1),
).toBe(-1);
});
it('uses workspace links for shared runner code instead of copied file dependencies', () => {
const rootPackageJson = readPackageJson('../package.json');
const agentRunnerPackageJson = readPackageJson(
'../runners/agent-runner/package.json',
);
const codexRunnerPackageJson = readPackageJson(
'../runners/codex-runner/package.json',
);
expect(rootPackageJson.workspaces).toEqual(
expect.arrayContaining([
'runners/shared',
'runners/agent-runner',
'runners/codex-runner',
]),
);
expect(rootPackageJson.dependencies?.['ejclaw-runners-shared']).toBe(
'workspace:*',
);
expect(agentRunnerPackageJson.dependencies?.['ejclaw-runners-shared']).toBe(
'workspace:*',
);
expect(codexRunnerPackageJson.dependencies?.['ejclaw-runners-shared']).toBe(
'workspace:*',
);
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import {
_setRegisteredGroupForTests,

View File

@@ -190,7 +190,7 @@ export async function checkGitHubActionsRun(
};
}
let failedJobs: string[] = [];
let failedJobs: string[];
try {
failedJobs = await fetchFailedJobs(metadata);
} catch {

115
src/github-evidence.test.ts Normal file
View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import {
buildGitHubEvidenceCommand,
decodeGitHubContentBase64,
normalizeGitHubRef,
normalizeGitHubRepo,
normalizeGitHubWorkflowPath,
normalizePositiveInteger,
} from './github-evidence.js';
describe('GitHub evidence helpers', () => {
it('validates repo and numeric identifiers', () => {
expect(normalizeGitHubRepo('owner/repo')).toBe('owner/repo');
expect(() => normalizeGitHubRepo('../repo')).toThrow(
'Unsupported GitHub repo',
);
expect(normalizePositiveInteger(12, 'pr_number')).toBe(12);
expect(() => normalizePositiveInteger(0, 'pr_number')).toThrow(
'Missing or invalid pr_number',
);
});
it('validates workflow evidence paths and refs', () => {
expect(
normalizeGitHubWorkflowPath('.github/workflows/display-analyzer.yml'),
).toBe('.github/workflows/display-analyzer.yml');
expect(normalizeGitHubWorkflowPath('.github/workflows/ci.yaml')).toBe(
'.github/workflows/ci.yaml',
);
expect(() => normalizeGitHubWorkflowPath('README.md')).toThrow(
'Unsupported GitHub workflow path',
);
expect(() =>
normalizeGitHubWorkflowPath('.github/workflows/../ci.yml'),
).toThrow('Unsupported GitHub workflow path');
expect(normalizeGitHubRef('prod')).toBe('prod');
expect(normalizeGitHubRef('feature/display-analyzer')).toBe(
'feature/display-analyzer',
);
expect(normalizeGitHubRef('00b971972e4b2e7ecf0c6d789f405fef7edeb258')).toBe(
'00b971972e4b2e7ecf0c6d789f405fef7edeb258',
);
expect(() => normalizeGitHubRef('../prod')).toThrow(
'Missing or invalid ref',
);
expect(() => normalizeGitHubRef('feature branch')).toThrow(
'Missing or invalid ref',
);
});
it('builds fixed gh commands', () => {
expect(
buildGitHubEvidenceCommand({
action: 'github_pr_status',
repo: 'owner/repo',
prNumber: 164,
}).args,
).toEqual([
'pr',
'view',
'164',
'--repo',
'owner/repo',
'--json',
'number,title,state,mergeStateStatus,headRefName,baseRefName,headRefOid,url,statusCheckRollup',
]);
expect(
buildGitHubEvidenceCommand({
action: 'github_run_status',
repo: 'owner/repo',
runId: 123,
}).args,
).toContain('123');
expect(
buildGitHubEvidenceCommand({
action: 'github_run_jobs',
repo: 'owner/repo',
runId: 123,
}).args,
).toEqual([
'run',
'view',
'123',
'--repo',
'owner/repo',
'--json',
'databaseId,name,status,conclusion,url,headBranch,headSha,jobs',
]);
expect(
buildGitHubEvidenceCommand({
action: 'github_workflow_file',
repo: 'owner/repo',
workflowPath: '.github/workflows/display-analyzer-check.yml',
ref: 'feature/display-analyzer',
}),
).toMatchObject({
args: [
'api',
'repos/owner/repo/contents/.github/workflows/display-analyzer-check.yml?ref=feature%2Fdisplay-analyzer',
'--jq',
'.content',
],
decodeBase64Stdout: true,
});
});
it('decodes GitHub contents API workflow bodies', () => {
expect(decodeGitHubContentBase64('bmFtZTogQ0kK')).toBe('name: CI\n');
});
});

210
src/github-evidence.ts Normal file
View File

@@ -0,0 +1,210 @@
import { execFile } from 'child_process';
import {
GITHUB_EVIDENCE_ACTIONS,
isGitHubEvidenceAction,
type GitHubEvidenceAction,
} from 'ejclaw-runners-shared';
export {
GITHUB_EVIDENCE_ACTIONS,
isGitHubEvidenceAction,
type GitHubEvidenceAction,
};
export interface GitHubEvidenceRequest {
action: GitHubEvidenceAction;
repo?: string;
prNumber?: number;
runId?: number;
workflowPath?: string;
ref?: string;
}
interface GitHubCommandSpec {
file: string;
args: string[];
commandText: string;
decodeBase64Stdout?: boolean;
}
const REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/;
const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
const COMMAND_TIMEOUT_MS = 10_000;
const COMMAND_MAX_BUFFER = 2 * 1024 * 1024;
const MAX_OUTPUT_CHARS = 24_000;
export function normalizeGitHubRepo(value?: string): string {
const repo = value?.trim();
if (!repo || repo.includes('..') || !REPO_PATTERN.test(repo)) {
throw new Error(`Unsupported GitHub repo for evidence: ${value}`);
}
return repo;
}
export function normalizePositiveInteger(
value: number | undefined,
label: string,
): number {
if (value == null || !Number.isInteger(value) || value <= 0) {
throw new Error(`Missing or invalid ${label} for GitHub evidence`);
}
return value;
}
export function normalizeGitHubWorkflowPath(value?: string): string {
const workflowPath = value?.trim();
if (!workflowPath || !WORKFLOW_PATH_PATTERN.test(workflowPath)) {
throw new Error(`Unsupported GitHub workflow path for evidence: ${value}`);
}
return workflowPath;
}
export function normalizeGitHubRef(value?: string): string {
const ref = value?.trim();
if (
!ref ||
ref.includes('..') ||
ref.includes('//') ||
ref.endsWith('/') ||
!REF_PATTERN.test(ref)
) {
throw new Error(`Missing or invalid ref for GitHub evidence`);
}
return ref;
}
export function decodeGitHubContentBase64(value: string): string {
return Buffer.from(value.replace(/\s+/g, ''), 'base64').toString('utf8');
}
export function buildGitHubEvidenceCommand(
request: GitHubEvidenceRequest,
): GitHubCommandSpec {
const repo = normalizeGitHubRepo(request.repo);
switch (request.action) {
case 'github_pr_status': {
const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number');
const args = [
'pr',
'view',
String(prNumber),
'--repo',
repo,
'--json',
'number,title,state,mergeStateStatus,headRefName,baseRefName,headRefOid,url,statusCheckRollup',
];
return {
file: 'gh',
args,
commandText: `gh ${args.join(' ')}`,
};
}
case 'github_pr_diff_stat': {
const prNumber = normalizePositiveInteger(request.prNumber, 'pr_number');
const args = ['pr', 'diff', String(prNumber), '--repo', repo, '--stat'];
return {
file: 'gh',
args,
commandText: `gh ${args.join(' ')}`,
};
}
case 'github_run_status': {
const runId = normalizePositiveInteger(request.runId, 'run_id');
const args = [
'run',
'view',
String(runId),
'--repo',
repo,
'--json',
'status,conclusion,name,displayTitle,url,headBranch,headSha,event,createdAt,updatedAt',
];
return {
file: 'gh',
args,
commandText: `gh ${args.join(' ')}`,
};
}
case 'github_run_jobs': {
const runId = normalizePositiveInteger(request.runId, 'run_id');
const args = [
'run',
'view',
String(runId),
'--repo',
repo,
'--json',
'databaseId,name,status,conclusion,url,headBranch,headSha,jobs',
];
return {
file: 'gh',
args,
commandText: `gh ${args.join(' ')}`,
};
}
case 'github_workflow_file': {
const workflowPath = normalizeGitHubWorkflowPath(request.workflowPath);
const ref = normalizeGitHubRef(request.ref);
const endpoint = `repos/${repo}/contents/${workflowPath}?ref=${encodeURIComponent(ref)}`;
const args = ['api', endpoint, '--jq', '.content'];
return {
file: 'gh',
args,
commandText: `gh ${args.join(' ')}`,
decodeBase64Stdout: true,
};
}
}
}
function truncateGitHubEvidenceText(value: string | undefined): string {
if (!value) return '';
if (value.length <= MAX_OUTPUT_CHARS) {
return value;
}
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
}
export function runGitHubEvidenceCommand(
request: GitHubEvidenceRequest,
): Promise<{
command: string;
stdout: string;
stderr: string;
}> {
const command = buildGitHubEvidenceCommand(request);
return new Promise((resolve, reject) => {
execFile(
command.file,
command.args,
{
encoding: 'utf8',
timeout: COMMAND_TIMEOUT_MS,
maxBuffer: COMMAND_MAX_BUFFER,
env: process.env,
},
(error, stdout, stderr) => {
if (error) {
reject(
Object.assign(error, {
command: command.commandText,
stdout: truncateGitHubEvidenceText(stdout),
stderr: truncateGitHubEvidenceText(stderr),
}),
);
return;
}
const normalizedStdout = command.decodeBase64Stdout
? decodeGitHubContentBase64(stdout)
: stdout;
resolve({
command: command.commandText,
stdout: truncateGitHubEvidenceText(normalizedStdout),
stderr: truncateGitHubEvidenceText(stderr),
});
},
);
});
}

View File

@@ -30,6 +30,7 @@ export interface GroupState {
runPhase: RunPhase;
runningTaskId: string | null;
currentRunId: string | null;
lastCloseRequest: { runId: string | null; reason: string | null } | null;
directTerminalDeliveries: Map<string, string>;
recentDirectTerminalDeliveries: Map<string, Map<string, string>>;
pendingMessages: boolean;
@@ -52,6 +53,7 @@ export function createGroupState(): GroupState {
runPhase: 'idle',
runningTaskId: null,
currentRunId: null,
lastCloseRequest: null,
directTerminalDeliveries: new Map(),
recentDirectTerminalDeliveries: new Map(),
pendingMessages: false,
@@ -139,6 +141,7 @@ export function transitionRunPhase(
export function resetRunState(state: GroupState, groupJid: string): void {
state.currentRunId = null;
state.runningTaskId = null;
state.lastCloseRequest = null;
state.startedAt = null;
state.process = null;
state.processName = null;

View File

@@ -46,7 +46,7 @@ describe('GroupQueue', () => {
let concurrentCount = 0;
let maxConcurrent = 0;
const processMessages = vi.fn(async (groupJid: string) => {
const processMessages = vi.fn(async (_groupJid: string) => {
concurrentCount++;
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
// Simulate async work
@@ -282,7 +282,7 @@ describe('GroupQueue', () => {
let maxActive = 0;
const completionCallbacks: Array<() => void> = [];
const processMessages = vi.fn(async (groupJid: string) => {
const processMessages = vi.fn(async (_groupJid: string) => {
activeCount++;
maxActive = Math.max(maxActive, activeCount);
await new Promise<void>((resolve) => completionCallbacks.push(resolve));
@@ -360,7 +360,7 @@ describe('GroupQueue', () => {
const executionOrder: string[] = [];
let resolveFirst: () => void;
const processMessages = vi.fn(async (groupJid: string) => {
const processMessages = vi.fn(async (_groupJid: string) => {
if (executionOrder.length === 0) {
// First call: block until we release it
await new Promise<void>((resolve) => {
@@ -747,3 +747,47 @@ describe('GroupQueue', () => {
await vi.advanceTimersByTimeAsync(10);
});
});
describe('GroupQueue close reason tracking', () => {
let queue: GroupQueue;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
queue = new GroupQueue();
});
afterEach(() => {
vi.useRealTimers();
});
it('records the close reason for the active message run until it finishes', async () => {
const ipcDir = '/tmp/ejclaw-test-data/ipc/group-folder';
let releaseRun!: (value: boolean) => void;
let activeRunId = '';
const blocker = new Promise<boolean>((resolve) => {
releaseRun = resolve;
});
queue.setProcessMessagesFn(
vi.fn(async (_groupJid: string, context: GroupRunContext) => {
activeRunId = context.runId;
return await blocker;
}),
);
queue.enqueueMessageCheck('group1@g.us', ipcDir);
await vi.advanceTimersByTimeAsync(10);
queue.closeStdin('group1@g.us', { reason: 'human-message-detected' });
expect(queue.getCloseReasonForRun('group1@g.us', activeRunId)).toBe(
'human-message-detected',
);
expect(queue.getCloseReasonForRun('group1@g.us', 'other-run')).toBeNull();
releaseRun(true);
await vi.advanceTimersByTimeAsync(10);
expect(queue.getCloseReasonForRun('group1@g.us', activeRunId)).toBeNull();
});
});

View File

@@ -322,7 +322,10 @@ export class GroupQueue {
}
return state.directTerminalDeliveries.get(senderRole) ?? null;
}
getCloseReasonForRun(groupJid: string, runId: string): string | null {
const closeRequest = this.getGroup(groupJid).lastCloseRequest;
return closeRequest?.runId === runId ? closeRequest.reason : null;
}
hasRecordedDirectTerminalDeliveryForRun(
groupJid: string,
runId: string,
@@ -342,7 +345,6 @@ export class GroupQueue {
state.recentDirectTerminalDeliveries.get(runId)?.has(senderRole) ?? false
);
}
private clearPostCloseTimers(state: GroupState): void {
if (state.postCloseTermTimer) {
clearTimeout(state.postCloseTermTimer);
@@ -428,29 +430,27 @@ export class GroupQueue {
}, POST_CLOSE_SIGKILL_DELAY_MS);
}
/**
* Signal the active agent process to wind down by writing a close sentinel.
*/
closeStdin(
groupJid: string,
metadata?: { runId?: string; reason?: string },
): void {
const state = this.getGroup(groupJid);
if (state.runPhase === 'idle' || !state.ipcDir) return;
const runId = metadata?.runId ?? state.currentRunId;
state.lastCloseRequest = { runId, reason: metadata?.reason ?? null };
if (state.runPhase === 'running_messages') {
transitionRunPhase(state, groupJid, 'closing_messages', {
reason: metadata?.reason,
runId: metadata?.runId ?? state.currentRunId,
runId,
});
assertRunPhaseInvariants(state, groupJid);
}
try {
writeCloseSentinel(state.ipcDir);
logger.info(
{
groupJid,
runId: metadata?.runId ?? state.currentRunId,
runId,
ipcDir: state.ipcDir,
reason: metadata?.reason ?? 'unspecified',
},
@@ -460,7 +460,7 @@ export class GroupQueue {
logger.warn(
{
groupJid,
runId: metadata?.runId ?? state.currentRunId,
runId,
ipcDir: state.ipcDir,
reason: metadata?.reason ?? 'unspecified',
err,

View File

@@ -89,6 +89,17 @@ describe('host evidence IPC', () => {
requestId: 'req-1',
action: 'ejclaw_service_status',
tailLines: undefined,
taskId: undefined,
minutes: undefined,
limit: undefined,
repo: undefined,
prNumber: undefined,
runId: undefined,
workflowPath: undefined,
ref: undefined,
artifactKind: undefined,
sourceGroup: 'other-group',
isMain: false,
});
expect(response.requestId).toBe('req-1');
expect(response.ok).toBe(true);

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
buildRoleRuntimeConfigEvidence,
buildHostEvidenceCommand,
clampHostEvidenceTailLines,
isHostEvidenceAction,
@@ -10,6 +11,14 @@ describe('host evidence helpers', () => {
it('recognizes only allowlisted actions', () => {
expect(isHostEvidenceAction('ejclaw_service_status')).toBe(true);
expect(isHostEvidenceAction('ejclaw_service_logs')).toBe(true);
expect(isHostEvidenceAction('ejclaw_role_runtime_config')).toBe(true);
expect(isHostEvidenceAction('db_paired_task_status')).toBe(true);
expect(isHostEvidenceAction('db_recent_scheduled_tasks')).toBe(true);
expect(isHostEvidenceAction('db_scheduled_task_runs')).toBe(true);
expect(isHostEvidenceAction('ejclaw_deploy_state')).toBe(true);
expect(isHostEvidenceAction('github_pr_status')).toBe(true);
expect(isHostEvidenceAction('github_run_jobs')).toBe(true);
expect(isHostEvidenceAction('github_workflow_file')).toBe(true);
expect(isHostEvidenceAction('rm -rf /')).toBe(false);
});
@@ -39,4 +48,22 @@ describe('host evidence helpers', () => {
expect.arrayContaining(['--user', '-u', 'ejclaw', '-n', '42']),
);
});
it('returns role runtime config without secret-shaped fields', () => {
const text = buildRoleRuntimeConfigEvidence();
const parsed = JSON.parse(text) as {
roles: {
owner: { agent_type: string; effective_model: string };
reviewer: { agent_type: string; effective_model: string };
arbiter: { agent_type: string | null; effective_model: string | null };
};
};
expect(parsed.roles.owner.agent_type).toMatch(/^(codex|claude-code)$/);
expect(parsed.roles.reviewer.agent_type).toMatch(/^(codex|claude-code)$/);
expect(text).not.toMatch(/api[_-]?key/i);
expect(text).not.toMatch(/token/i);
expect(text).not.toMatch(/secret/i);
expect(text).not.toMatch(/password/i);
});
});

View File

@@ -2,19 +2,61 @@ import { execFile } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
ARBITER_AGENT_TYPE,
ARBITER_MODEL_CONFIG,
ARBITER_SERVICE_ID,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
CURRENT_RUNTIME_AGENT_TYPE,
DEFAULT_CLAUDE_MODEL,
DEFAULT_CODEX_MODEL,
OWNER_AGENT_TYPE,
OWNER_MODEL_CONFIG,
REVIEWER_AGENT_TYPE,
REVIEWER_MODEL_CONFIG,
REVIEWER_SERVICE_ID_FOR_TYPE,
SERVICE_ID,
SERVICE_SESSION_SCOPE,
DATA_DIR,
WEB_DASHBOARD,
} from './config.js';
import type { AgentType } from './types.js';
import {
HOST_EVIDENCE_ACTIONS,
isHostEvidenceAction,
type HostEvidenceAction,
} from 'ejclaw-runners-shared';
import {
collectArtifactMetadata,
collectDeployState,
} from './deploy-evidence.js';
import { isDbEvidenceAction, runDbEvidenceRequest } from './db-evidence.js';
import { requireDatabase } from './db/runtime-database.js';
import {
isGitHubEvidenceAction,
runGitHubEvidenceCommand,
} from './github-evidence.js';
import { resolveGroupIpcPath } from './group-folder.js';
export const HOST_EVIDENCE_ACTIONS = [
'ejclaw_service_status',
'ejclaw_service_logs',
] as const;
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
export { HOST_EVIDENCE_ACTIONS, isHostEvidenceAction, type HostEvidenceAction };
export interface HostEvidenceRequest {
requestId: string;
action: HostEvidenceAction;
tailLines?: number;
taskId?: string;
minutes?: number;
limit?: number;
repo?: string;
prNumber?: number;
runId?: number;
workflowPath?: string;
ref?: string;
artifactKind?: string;
sourceGroup?: string;
isMain?: boolean;
}
export interface HostEvidenceResult {
@@ -42,15 +84,7 @@ const MAX_LOG_TAIL_LINES = 200;
const MAX_OUTPUT_CHARS = 16_000;
const COMMAND_TIMEOUT_MS = 5_000;
const COMMAND_MAX_BUFFER = 1024 * 1024;
export function isHostEvidenceAction(
value: unknown,
): value is HostEvidenceAction {
return (
typeof value === 'string' &&
HOST_EVIDENCE_ACTIONS.includes(value as HostEvidenceAction)
);
}
const PROJECT_ROOT = process.cwd();
export function clampHostEvidenceTailLines(value?: number): number {
if (!Number.isFinite(value)) {
@@ -108,6 +142,10 @@ export function buildHostEvidenceCommand(
commandText: `journalctl ${args.join(' ')}`,
};
}
default:
throw new Error(
`Host evidence action has no shell command: ${request.action}`,
);
}
}
@@ -119,6 +157,95 @@ export function truncateHostEvidenceText(value: string | undefined): string {
return `${value.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated]`;
}
function effectiveModelForAgentType(
agentType: AgentType | null | undefined,
configuredModel: string | undefined,
): string | null {
if (!agentType) {
return null;
}
if (configuredModel) {
return configuredModel;
}
return agentType === 'claude-code'
? DEFAULT_CLAUDE_MODEL
: DEFAULT_CODEX_MODEL;
}
function ownerServiceIdForAgentType(agentType: AgentType): string {
return agentType === 'claude-code'
? CLAUDE_SERVICE_ID
: CODEX_MAIN_SERVICE_ID;
}
function buildRoleRuntimeConfigRole(
role: 'owner' | 'reviewer' | 'arbiter',
agentType: AgentType | null | undefined,
modelConfig: {
model?: string;
effort?: string;
fallbackEnabled: boolean;
},
serviceId: string | null,
): Record<string, unknown> {
return {
role,
enabled: role !== 'arbiter' || Boolean(agentType),
agent_type: agentType ?? null,
service_id: serviceId,
configured_model: modelConfig.model ?? null,
effective_model: effectiveModelForAgentType(agentType, modelConfig.model),
effort: modelConfig.effort ?? null,
fallback_enabled: modelConfig.fallbackEnabled,
};
}
export function buildRoleRuntimeConfigEvidence(): string {
return JSON.stringify(
{
action: 'ejclaw_role_runtime_config',
current_service: {
service_id: SERVICE_ID,
session_scope: SERVICE_SESSION_SCOPE,
runtime_agent_type: CURRENT_RUNTIME_AGENT_TYPE,
},
defaults: {
claude_model: DEFAULT_CLAUDE_MODEL,
codex_model: DEFAULT_CODEX_MODEL,
},
services: {
claude: CLAUDE_SERVICE_ID,
codex_main: CODEX_MAIN_SERVICE_ID,
codex_review: CODEX_REVIEW_SERVICE_ID,
reviewer_for_type: REVIEWER_SERVICE_ID_FOR_TYPE,
arbiter: ARBITER_SERVICE_ID,
},
roles: {
owner: buildRoleRuntimeConfigRole(
'owner',
OWNER_AGENT_TYPE,
OWNER_MODEL_CONFIG,
ownerServiceIdForAgentType(OWNER_AGENT_TYPE),
),
reviewer: buildRoleRuntimeConfigRole(
'reviewer',
REVIEWER_AGENT_TYPE,
REVIEWER_MODEL_CONFIG,
REVIEWER_SERVICE_ID_FOR_TYPE,
),
arbiter: buildRoleRuntimeConfigRole(
'arbiter',
ARBITER_AGENT_TYPE,
ARBITER_MODEL_CONFIG,
ARBITER_SERVICE_ID,
),
},
},
null,
2,
);
}
function execFileCapture(
file: string,
args: string[],
@@ -164,9 +291,110 @@ function extractExitCode(error: unknown): number {
export async function runHostEvidenceRequest(
request: HostEvidenceRequest,
): Promise<HostEvidenceResult> {
const command = buildHostEvidenceCommand(request);
let commandText = '';
try {
if (request.action === 'ejclaw_role_runtime_config') {
commandText = `internal:${request.action}`;
return {
ok: true,
action: request.action,
command: commandText,
stdout: truncateHostEvidenceText(buildRoleRuntimeConfigEvidence()),
stderr: '',
exitCode: 0,
};
}
if (isDbEvidenceAction(request.action)) {
commandText = `internal:${request.action}`;
return {
ok: true,
action: request.action,
command: commandText,
stdout: truncateHostEvidenceText(
runDbEvidenceRequest(
requireDatabase(),
{
action: request.action,
taskId: request.taskId,
minutes: request.minutes,
limit: request.limit,
},
{
sourceGroup: request.sourceGroup ?? '',
isMain: request.isMain === true,
},
),
),
stderr: '',
exitCode: 0,
};
}
if (request.action === 'ejclaw_deploy_state') {
commandText = `internal:${request.action}`;
return {
ok: true,
action: request.action,
command: commandText,
stdout: truncateHostEvidenceText(
await collectDeployState({
projectRoot: PROJECT_ROOT,
dataDir: DATA_DIR,
dashboardStaticDir: WEB_DASHBOARD.staticDir,
}),
),
stderr: '',
exitCode: 0,
};
}
if (request.action === 'ejclaw_artifact_metadata') {
commandText = `internal:${request.action}`;
return {
ok: true,
action: request.action,
command: commandText,
stdout: truncateHostEvidenceText(
collectArtifactMetadata(
{
projectRoot: PROJECT_ROOT,
dataDir: DATA_DIR,
dashboardStaticDir: WEB_DASHBOARD.staticDir,
},
{
action: request.action,
artifactKind: request.artifactKind,
},
),
),
stderr: '',
exitCode: 0,
};
}
if (isGitHubEvidenceAction(request.action)) {
const githubResult = await runGitHubEvidenceCommand({
action: request.action,
repo: request.repo,
prNumber: request.prNumber,
runId: request.runId,
workflowPath: request.workflowPath,
ref: request.ref,
});
commandText = githubResult.command;
return {
ok: true,
action: request.action,
command: commandText,
stdout: truncateHostEvidenceText(githubResult.stdout),
stderr: truncateHostEvidenceText(githubResult.stderr),
exitCode: 0,
};
}
const command = buildHostEvidenceCommand(request);
commandText = command.commandText;
const { stdout, stderr } = await execFileCapture(
command.file,
command.args,
@@ -174,7 +402,7 @@ export async function runHostEvidenceRequest(
return {
ok: true,
action: request.action,
command: command.commandText,
command: commandText,
stdout: truncateHostEvidenceText(stdout),
stderr: truncateHostEvidenceText(stderr),
exitCode: 0,
@@ -192,7 +420,10 @@ export async function runHostEvidenceRequest(
return {
ok: false,
action: request.action,
command: command.commandText,
command:
typeof error === 'object' && error !== null && 'command' in error
? String((error as { command?: unknown }).command ?? commandText)
: commandText,
stdout: truncateHostEvidenceText(stdout),
stderr: truncateHostEvidenceText(stderr),
exitCode: extractExitCode(error),

View File

@@ -3,7 +3,6 @@ import { describe, expect, it, vi } from 'vitest';
import {
editFormattedTrackedChannelMessage,
isTerminalStatusMessage,
sendFormattedChannelMessage,
sendFormattedTrackedChannelMessage,
} from './index.js';
import { composeDashboardContent } from './dashboard-render.js';
@@ -81,12 +80,6 @@ describe('index scheduler messaging helpers', () => {
'msg-123',
'<internal>only hidden</internal>',
);
await sendFormattedChannelMessage(
[channel],
'dc:test',
'<internal>only hidden</internal>',
);
expect(trackedResult).toBeNull();
expect(sendAndTrack).not.toHaveBeenCalled();
expect(editMessage).not.toHaveBeenCalled();

View File

@@ -1,10 +1,12 @@
import {
ASSISTANT_NAME,
DATA_DIR,
ARBITER_SERVICE_ID,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
IDLE_TIMEOUT,
POLL_INTERVAL,
SERVICE_ID,
isSessionCommandSenderAllowed,
STATUS_CHANNEL_ID,
STATUS_UPDATE_INTERVAL,
TIMEZONE,
@@ -19,29 +21,25 @@ import {
} from './channels/registry.js';
import { writeGroupsSnapshot } from './agent-runner.js';
import {
type AssignRoomInput,
getAllTasks,
getEarliestUnansweredHumanSeq,
hasRecentRestartAnnouncement,
initDatabase,
recoverInterruptedPairedTurnAttemptsForService,
storeChatMetadata,
storeMessage,
} from './db.js';
import { composeDashboardContent } from './dashboard-render.js';
import { GroupQueue } from './group-queue.js';
import { resolveGroupIpcPath } from './group-folder.js';
import { startIpcWatcher } from './ipc.js';
import {
findChannel,
findChannelByName,
sanitizeForOutbound,
normalizeMessageForDedupe,
resolveChannelForDeliveryRole,
} from './router.js';
deliverCanonicalOutboundMessage,
deliverIpcOutboundMessage,
} from './ipc-outbound-delivery.js';
import { findChannel, formatOutbound } from './router.js';
import {
buildRestartAnnouncement,
buildInterruptedRestartAnnouncement,
consumeRestartContext,
getRecoverableInterruptedGroups,
getInterruptedRecoveryCandidates,
inferRecentRestartContext,
type RestartContext,
@@ -49,7 +47,6 @@ import {
} from './restart-context.js';
import {
isSenderAllowed,
isTriggerAllowed,
loadSenderAllowlist,
shouldDropMessage,
} from './sender-allowlist.js';
@@ -61,6 +58,10 @@ import { startWebDashboardServer } from './web-dashboard-server.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
import { initCodexTokenRotation } from './codex-token-rotation.js';
import {
startCodexAccountRefreshLoop,
stopCodexAccountRefreshLoop,
} from './settings-store.js';
import {
hasAvailableClaudeToken,
initTokenRotation,
@@ -69,7 +70,7 @@ import {
isBotMessageSourceKind,
resolveInjectedMessageSourceKind,
} from './message-source.js';
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
import { parseVisibleVerdict } from './paired-verdict.js';
export function isTerminalStatusMessage(text: string): boolean {
return parseVisibleVerdict(text) !== 'continue';
@@ -88,20 +89,6 @@ import { FAILOVER_MIN_DURATION_MS } from './config.js';
// Token rotation is initialized lazily on first use or at startup below
export async function sendFormattedChannelMessage(
channels: Channel[],
jid: string,
rawText: string,
): Promise<void> {
const channel = findChannel(channels, jid);
if (!channel) {
logger.warn({ jid }, 'No channel owns JID, cannot send message');
return;
}
const text = sanitizeForOutbound(rawText);
if (text) await channel.sendMessage(jid, text);
}
export async function sendFormattedTrackedChannelMessage(
channels: Channel[],
jid: string,
@@ -112,7 +99,7 @@ export async function sendFormattedTrackedChannelMessage(
logger.warn({ jid }, 'No channel owns JID, cannot send tracked message');
return null;
}
const text = sanitizeForOutbound(rawText);
const text = formatOutbound(rawText);
if (!text || !channel.sendAndTrack) return null;
return channel.sendAndTrack(jid, text);
}
@@ -128,7 +115,7 @@ export async function editFormattedTrackedChannelMessage(
logger.warn({ jid }, 'No channel owns JID, cannot edit tracked message');
return;
}
const text = sanitizeForOutbound(rawText);
const text = formatOutbound(rawText);
if (!text || !channel.editMessage) return;
await channel.editMessage(jid, messageId, text);
}
@@ -154,6 +141,23 @@ const runtime = createMessageRuntime({
clearSession: runtimeState.clearSession,
});
async function deliverFormattedCanonicalMessage(
jid: string,
rawText: string,
deliveryRole?: 'owner' | 'reviewer' | 'arbiter',
): Promise<void> {
const text = formatOutbound(rawText);
if (!text) return;
await deliverCanonicalOutboundMessage(
{ jid, text, deliveryRole },
{
channels,
roomBindings: runtimeState.getRoomBindings,
log: logger,
},
);
}
/**
* Get available groups list for the agent.
* Returns groups ordered by most recent activity.
@@ -176,6 +180,22 @@ export function _setRoomBindings(
runtimeState.setRoomBindings(groups);
}
async function tryDeliverRestartAnnouncement(
chatJid: string,
rawText: string,
): Promise<boolean> {
try {
await deliverFormattedCanonicalMessage(chatJid, rawText);
return true;
} catch (err) {
logger.warn(
{ err, chatJid },
'Skipped restart recovery announcement because no channel is connected',
);
return false;
}
}
async function announceRestartRecovery(
processStartedAtMs: number,
): Promise<RestartContext | null> {
@@ -190,23 +210,26 @@ async function announceRestartRecovery(
return explicitContext;
}
await sendFormattedChannelMessage(
channels,
explicitContext.chatJid,
buildRestartAnnouncement(explicitContext),
);
logger.info(
{ chatJid: explicitContext.chatJid },
'Sent explicit restart recovery announcement',
);
if (
await tryDeliverRestartAnnouncement(
explicitContext.chatJid,
buildRestartAnnouncement(explicitContext),
)
) {
logger.info(
{ chatJid: explicitContext.chatJid },
'Sent explicit restart recovery announcement',
);
}
for (const interrupted of explicitContext.interruptedGroups ?? []) {
for (const interrupted of getRecoverableInterruptedGroups(
explicitContext,
)) {
if (interrupted.chatJid === explicitContext.chatJid) continue;
if (hasRecentRestartAnnouncement(interrupted.chatJid, dedupeSince)) {
continue;
}
await sendFormattedChannelMessage(
channels,
await tryDeliverRestartAnnouncement(
interrupted.chatJid,
buildInterruptedRestartAnnouncement(interrupted),
);
@@ -228,18 +251,65 @@ async function announceRestartRecovery(
return null;
}
await sendFormattedChannelMessage(
channels,
inferred.chatJid,
inferred.lines.join('\n'),
);
logger.info(
{ chatJid: inferred.chatJid },
'Sent inferred restart recovery announcement',
);
if (
await tryDeliverRestartAnnouncement(
inferred.chatJid,
inferred.lines.join('\n'),
)
) {
logger.info(
{ chatJid: inferred.chatJid },
'Sent inferred restart recovery announcement',
);
}
return null;
}
function queueInterruptedPairedTurnAttemptRecovery(): void {
for (const candidate of recoverInterruptedPairedTurnAttemptsForService({
serviceIds: [
SERVICE_ID,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
ARBITER_SERVICE_ID,
].filter((serviceId): serviceId is string => Boolean(serviceId)),
})) {
const binding = runtimeState.getRoomBindings()[candidate.chat_jid];
if (!binding) {
logger.warn(
{
chatJid: candidate.chat_jid,
groupFolder: candidate.group_folder,
taskId: candidate.task_id,
turnId: candidate.turn_id,
attemptId: candidate.attempt_id,
},
'Skipped interrupted paired turn recovery because room binding is missing',
);
continue;
}
queue.enqueueMessageCheck(
candidate.chat_jid,
resolveGroupIpcPath(binding.folder),
);
logger.info(
{
chatJid: candidate.chat_jid,
groupFolder: binding.folder,
taskId: candidate.task_id,
taskStatus: candidate.task_status,
turnId: candidate.turn_id,
attemptId: candidate.attempt_id,
attemptNo: candidate.attempt_no,
role: candidate.role,
intentKind: candidate.intent_kind,
},
'Queued interrupted paired turn attempt for restart recovery',
);
}
}
async function main(): Promise<void> {
const processStartedAtMs = Date.now();
initDatabase();
@@ -247,6 +317,7 @@ async function main(): Promise<void> {
initTokenRotation();
initCodexTokenRotation();
startTokenRefreshLoop();
startCodexAccountRefreshLoop();
runtimeState.loadState();
@@ -256,6 +327,7 @@ async function main(): Promise<void> {
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
stopTokenRefreshLoop();
stopCodexAccountRefreshLoop();
if (leaseRecoveryTimer) {
clearInterval(leaseRecoveryTimer);
leaseRecoveryTimer = null;
@@ -269,32 +341,17 @@ async function main(): Promise<void> {
(
status,
): status is typeof status & {
status: 'processing' | 'waiting';
} => status.status !== 'inactive',
status: 'processing';
} => status.status === 'processing',
)
.map((status) => {
// Capture the seq of the earliest unanswered human message for this
// chat so the next startup can rewind the agent cursor and re-run
// the interrupted turn automatically.
let rewindToSeq: number | null = null;
try {
rewindToSeq = getEarliestUnansweredHumanSeq(status.jid);
} catch (err) {
logger.warn(
{ err, chatJid: status.jid },
'Failed to capture rewind seq for shutdown snapshot',
);
}
return {
chatJid: status.jid,
groupName: roomBindings[status.jid]?.name || status.jid,
status: status.status,
elapsedMs: status.elapsedMs,
pendingMessages: status.pendingMessages,
pendingTasks: status.pendingTasks,
rewindToSeq,
};
});
.map((status) => ({
chatJid: status.jid,
groupName: roomBindings[status.jid]?.name || status.jid,
status: status.status,
elapsedMs: status.elapsedMs,
pendingMessages: status.pendingMessages,
pendingTasks: status.pendingTasks,
}));
const writtenPaths = writeShutdownRestartContext(
roomBindings,
interruptedGroups,
@@ -362,22 +419,28 @@ async function main(): Promise<void> {
);
continue;
}
channels.push(channel);
await channel.connect();
try {
await channel.connect();
channels.push(channel);
} catch (err) {
logger.error(
{ channel: channelName, err },
'Channel connect failed — skipping',
);
}
}
if (channels.length === 0) {
logger.fatal('No channels connected');
process.exit(1);
if (WEB_DASHBOARD.enabled) {
logger.warn(
'No channels connected; continuing in web-dashboard-only mode',
);
} else {
logger.fatal('No channels connected');
process.exit(1);
}
}
// Start subsystems (independently of connection handler)
// Paired-room scheduler output goes through the reviewer bot slot.
const reviewerChannelName = 'discord-review';
const reviewerChannelForCron = findChannelByName(
channels,
reviewerChannelName,
);
startSchedulerLoop({
roomBindings: runtimeState.getRoomBindings,
getSessions: runtimeState.getSessions,
@@ -385,58 +448,25 @@ async function main(): Promise<void> {
onProcess: (groupJid, proc, processName, ipcDir) =>
queue.registerProcess(groupJid, proc, processName, ipcDir),
sendMessage: (jid, rawText) =>
sendFormattedChannelMessage(channels, jid, rawText),
sendMessageViaReviewerBot: reviewerChannelForCron
? async (jid, rawText) => {
const text = sanitizeForOutbound(rawText);
if (text) await reviewerChannelForCron.sendMessage(jid, text);
}
: undefined,
deliverFormattedCanonicalMessage(jid, rawText),
sendMessageViaReviewerBot: (jid, rawText) =>
deliverFormattedCanonicalMessage(jid, rawText, 'reviewer'),
sendTrackedMessage: (jid, rawText) =>
sendFormattedTrackedChannelMessage(channels, jid, rawText),
editTrackedMessage: (jid, messageId, rawText) =>
editFormattedTrackedChannelMessage(channels, jid, messageId, rawText),
});
startIpcWatcher({
sendMessage: async (jid, text, senderRole, runId) => {
if (
runId &&
(senderRole === 'reviewer' || senderRole === 'arbiter') &&
queue.hasRecordedDirectTerminalDeliveryForRun(jid, runId, senderRole)
) {
logger.info(
{
transition: 'ipc:skip-post-terminal',
chatJid: jid,
senderRole,
runId,
},
'Skipped IPC relay message because the run already emitted a direct terminal verdict',
);
return;
}
const route = resolveChannelForDeliveryRole(channels, jid, senderRole);
if (!route.channel) throw new Error(`No channel for JID: ${jid}`);
logger.info(
sendMessage: async (jid, text, senderRole, runId, attachments) => {
await deliverIpcOutboundMessage(
{ jid, text, senderRole, runId, attachments },
{
transition: 'ipc:route',
chatJid: jid,
runId: runId ?? null,
senderRole: senderRole ?? null,
requestedRoleChannel: route.requestedRoleChannelName,
selectedChannel: route.selectedChannelName,
usedRoleChannel: route.usedRoleChannel,
fallbackUsed: route.fallbackUsed,
channels,
roomBindings: runtimeState.getRoomBindings,
queue,
log: logger,
},
'IPC relay routed message to channel',
);
await route.channel.sendMessage(jid, text);
if (
(senderRole === 'reviewer' || senderRole === 'arbiter') &&
isTerminalStatusMessage(text)
) {
queue.noteDirectTerminalDelivery(jid, senderRole, text);
}
},
injectInboundMessage: async (payload) => {
const jid = payload.chatJid;
@@ -498,6 +528,7 @@ async function main(): Promise<void> {
});
queue.setProcessMessagesFn(runtime.processGroupMessages);
queue.enterRecoveryMode();
queueInterruptedPairedTurnAttemptRecovery();
runtime.recoverPendingMessages();
const restartContext = await announceRestartRecovery(processStartedAtMs);
const roomBindings = runtimeState.getRoomBindings();
@@ -505,37 +536,6 @@ async function main(): Promise<void> {
restartContext,
roomBindings,
)) {
// Auto-resume: rewind the agent cursor to just before the earliest
// unanswered human message so the missed-message processor picks it up
// again. Claude/Codex sessions are persisted by group folder, so the
// agent will resume mid-turn rather than restarting from scratch.
if (candidate.rewindToSeq != null && candidate.rewindToSeq > 0) {
const cursors = runtimeState.getLastAgentTimestamps();
const previousCursor = cursors[candidate.chatJid];
const targetCursor = String(candidate.rewindToSeq - 1);
// Only rewind if the current cursor is past the target — never advance.
const previousNum = previousCursor
? Number.parseInt(previousCursor, 10)
: 0;
if (
Number.isFinite(previousNum) &&
previousNum >= candidate.rewindToSeq
) {
cursors[candidate.chatJid] = targetCursor;
runtimeState.saveState();
logger.info(
{
chatJid: candidate.chatJid,
groupFolder: candidate.groupFolder,
previousCursor,
rewoundTo: targetCursor,
rewindToSeq: candidate.rewindToSeq,
},
'Rewound agent cursor for interrupted-turn auto-resume',
);
}
}
queue.enqueueMessageCheck(
candidate.chatJid,
resolveGroupIpcPath(candidate.groupFolder),
@@ -547,7 +547,6 @@ async function main(): Promise<void> {
status: candidate.status,
pendingMessages: candidate.pendingMessages,
pendingTasks: candidate.pendingTasks,
rewindToSeq: candidate.rewindToSeq,
},
'Queued interrupted group for restart recovery',
);
@@ -570,7 +569,13 @@ async function main(): Promise<void> {
},
purgeOnStart: true,
});
webDashboardServer = startWebDashboardServer(WEB_DASHBOARD);
webDashboardServer = startWebDashboardServer({
...WEB_DASHBOARD,
getRoomBindings: runtimeState.getRoomBindings,
enqueueMessageCheck: (chatJid, groupFolder) =>
queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)),
nudgeScheduler: nudgeSchedulerLoop,
});
startUsagePrimer();
leaseRecoveryTimer = setInterval(() => {

View File

@@ -139,6 +139,34 @@ describe('schedule_task authorization', () => {
expect(allTasks[0].agent_type).toBe('codex');
});
it('stores the scheduling caller agent type when provided', async () => {
groups['other@g.us'] = {
...OTHER_GROUP,
agentType: 'claude-code',
};
_setRegisteredGroupForTests('other@g.us', groups['other@g.us']);
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'caller owned task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
agent_type: 'codex',
room_role: 'owner',
},
'whatsapp_main',
true,
deps,
);
const allTasks = getAllTasks();
expect(allTasks).toHaveLength(1);
expect(allTasks[0].agent_type).toBe('codex');
expect(allTasks[0].room_role).toBe('owner');
});
it('non-main group cannot schedule for another group', async () => {
await processTaskIpc(
{
@@ -356,6 +384,42 @@ describe('resume_task authorization', () => {
});
});
// --- update_task behavior ---
describe('update_task behavior', () => {
it('updates once tasks even when next_run is not recomputed', async () => {
createTask({
id: 'task-once-update',
group_folder: 'other-group',
chat_jid: 'other@g.us',
prompt: 'original once task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
await processTaskIpc(
{
type: 'update_task',
taskId: 'task-once-update',
prompt: 'updated once task',
schedule_value: '2025-07-01T00:00:00',
},
'other-group',
false,
deps,
);
const task = getTaskById('task-once-update')!;
expect(task.prompt).toBe('updated once task');
expect(task.schedule_value).toBe('2025-07-01T00:00:00');
expect(task.next_run).toBe('2025-06-01T00:00:00.000Z');
});
});
// --- cancel_task authorization ---
describe('cancel_task authorization', () => {
@@ -556,6 +620,7 @@ describe('IPC message authorization', () => {
'review text',
'reviewer',
'run-reviewer-ipc',
undefined,
);
expect(result).toEqual(
expect.objectContaining({
@@ -565,6 +630,83 @@ describe('IPC message authorization', () => {
);
});
it('forwards structured attachments through authorized IPC messages', async () => {
const sendMessage = vi.fn(async () => {});
const attachments = [
{
path: '/tmp/ejclaw-room-mobile-list-390.png',
name: 'room.png',
mime: 'image/png',
},
];
const result = await forwardAuthorizedIpcMessage(
{
type: 'message',
chatJid: 'other@g.us',
text: '스크린샷입니다.',
attachments,
},
'other-group',
false,
groups,
sendMessage,
);
expect(sendMessage).toHaveBeenCalledWith(
'other@g.us',
'스크린샷입니다.',
undefined,
undefined,
attachments,
);
expect(result.outcome).toBe('sent');
});
it('normalizes raw EJClaw envelopes in authorized IPC messages', async () => {
const sendMessage = vi.fn(async () => {});
const result = await forwardAuthorizedIpcMessage(
{
type: 'message',
chatJid: 'other@g.us',
text: JSON.stringify({
ejclaw: {
visibility: 'public',
text: '첨부를 렌더링했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/bar-chart-label-fit-playwright.png',
name: 'bar-chart-label-fit-playwright.png',
mime: 'image/png',
},
],
},
}),
},
'other-group',
false,
groups,
sendMessage,
);
expect(sendMessage).toHaveBeenCalledWith(
'other@g.us',
'첨부를 렌더링했습니다.',
undefined,
undefined,
[
{
path: '/tmp/bar-chart-label-fit-playwright.png',
name: 'bar-chart-label-fit-playwright.png',
mime: 'image/png',
},
],
);
expect(result.outcome).toBe('sent');
});
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
const sendMessage = vi.fn(async () => {});
const injectInboundMessage = vi.fn(async () => {});
@@ -964,6 +1106,31 @@ describe('assign_room success', () => {
expect(group!.folder).toBe('new-group');
});
it('assign_room persists trigger metadata through IPC', async () => {
await processTaskIpc(
{
type: 'assign_room',
jid: 'triggered@g.us',
name: 'Triggered Group',
folder: 'triggered-group',
trigger: '@Repro',
requiresTrigger: true,
},
'whatsapp_main',
true,
deps,
);
expect(getStoredRoomSettings('triggered@g.us')).toMatchObject({
trigger: '@Repro',
requiresTrigger: true,
});
expect(getRegisteredGroup('triggered@g.us')).toMatchObject({
trigger: '@Repro',
requiresTrigger: true,
});
});
it('assign_room auto-fills missing folder for single rooms without exposing trigger metadata', async () => {
await processTaskIpc(
{

View File

@@ -2,6 +2,7 @@ import type {
IpcMessageForwardResult,
IpcMessagePayload,
} from './ipc-types.js';
import { normalizeAgentOutput } from './agent-protocol.js';
import type { RegisteredGroup } from './types.js';
export async function forwardAuthorizedIpcMessage(
@@ -14,6 +15,7 @@ export async function forwardAuthorizedIpcMessage(
text: string,
senderRole?: string,
runId?: string,
attachments?: import('./types.js').OutboundAttachment[],
) => Promise<void>,
injectInboundMessage?: (payload: {
chatJid: string;
@@ -79,7 +81,25 @@ export async function forwardAuthorizedIpcMessage(
};
}
await sendMessage(msg.chatJid, msg.text, msg.senderRole, msg.runId);
const normalized = normalizeAgentOutput(msg.text);
if (normalized.output?.visibility === 'silent') {
return {
outcome: 'sent',
chatJid: msg.chatJid,
targetGroup: targetGroup?.folder ?? null,
isMainOverride,
senderRole: msg.senderRole ?? null,
};
}
const structured =
normalized.output?.visibility === 'public' ? normalized.output : null;
const text = structured?.text ?? normalized.result ?? msg.text;
const attachments =
msg.attachments && msg.attachments.length > 0
? msg.attachments
: (structured?.attachments ?? undefined);
await sendMessage(msg.chatJid, text, msg.senderRole, msg.runId, attachments);
return {
outcome: 'sent',
chatJid: msg.chatJid,

View File

@@ -0,0 +1,240 @@
import { describe, expect, it, vi } from 'vitest';
import {
deliverCanonicalOutboundMessage,
deliverIpcOutboundMessage,
} from './ipc-outbound-delivery.js';
import type { WorkItem } from './db/work-items.js';
import type { Channel, RegisteredGroup } from './types.js';
function makeChannel(name = 'discord', owns = true): Channel {
return {
name,
connect: async () => {},
sendMessage: vi.fn(async () => {}),
isConnected: () => true,
ownsJid: () => owns,
disconnect: async () => {},
};
}
function makeGroup(overrides: Partial<RegisteredGroup> = {}): RegisteredGroup {
return {
name: 'Room',
folder: 'room-folder',
added_at: '2026-04-28T00:00:00.000Z',
agentType: 'codex',
workDir: '/repo',
...overrides,
};
}
function makeWorkItem(input: {
chat_jid: string;
group_folder: string;
agent_type?: WorkItem['agent_type'];
delivery_role?: WorkItem['delivery_role'];
result_payload: string;
attachments?: WorkItem['attachments'];
}): WorkItem {
return {
id: 123,
group_folder: input.group_folder,
chat_jid: input.chat_jid,
agent_type: input.agent_type ?? 'codex',
service_id: 'codex-main',
delivery_role: input.delivery_role ?? null,
status: 'produced',
start_seq: null,
end_seq: null,
result_payload: input.result_payload,
attachments: input.attachments ?? [],
delivery_attempts: 0,
delivery_message_id: null,
last_error: null,
created_at: '2026-04-28T00:00:00.000Z',
updated_at: '2026-04-28T00:00:00.000Z',
delivered_at: null,
};
}
describe('deliverIpcOutboundMessage', () => {
it('funnels ordinary outbound through canonical work item delivery', async () => {
const channel = makeChannel();
const createdItem = makeWorkItem({
chat_jid: 'dc:room',
group_folder: 'room-folder',
result_payload: '일반 안내',
});
const createWorkItem = vi.fn(() => createdItem);
const deliverWorkItem = vi.fn(async () => true);
const result = await deliverCanonicalOutboundMessage(
{ jid: 'dc:room', text: '일반 안내' },
{
channels: [channel],
roomBindings: () => ({ 'dc:room': makeGroup() }),
createWorkItem,
deliverWorkItem,
},
);
expect(result).toBe('delivered');
expect(createWorkItem).toHaveBeenCalledWith({
group_folder: 'room-folder',
chat_jid: 'dc:room',
agent_type: 'codex',
delivery_role: null,
start_seq: null,
end_seq: null,
result_payload: '일반 안내',
attachments: undefined,
});
expect(deliverWorkItem).toHaveBeenCalledWith(
expect.objectContaining({
channel,
item: createdItem,
attachmentBaseDirs: expect.arrayContaining([
'/repo',
expect.stringMatching(/data\/workspaces\/room-folder$/),
]),
}),
);
expect(channel.sendMessage).not.toHaveBeenCalled();
});
it('funnels IPC outbound through canonical work item delivery', async () => {
const ownerChannel = makeChannel();
const reviewerChannel = makeChannel('discord-review', false);
const createdItem = makeWorkItem({
chat_jid: 'dc:room',
group_folder: 'room-folder',
delivery_role: 'reviewer',
result_payload: 'TASK_DONE\n검증 완료',
});
const createWorkItem = vi.fn(() => createdItem);
const deliverWorkItem = vi.fn(async () => true);
const noteDirectTerminalDelivery = vi.fn();
const result = await deliverIpcOutboundMessage(
{
jid: 'dc:room',
text: 'TASK_DONE\n검증 완료',
senderRole: 'reviewer',
runId: 'run-1',
},
{
channels: [ownerChannel, reviewerChannel],
roomBindings: () => ({ 'dc:room': makeGroup() }),
queue: { noteDirectTerminalDelivery },
createWorkItem,
deliverWorkItem,
},
);
expect(result).toBe('delivered');
expect(createWorkItem).toHaveBeenCalledWith({
group_folder: 'room-folder',
chat_jid: 'dc:room',
agent_type: 'codex',
delivery_role: 'reviewer',
start_seq: null,
end_seq: null,
result_payload: 'TASK_DONE\n검증 완료',
attachments: undefined,
});
expect(deliverWorkItem).toHaveBeenCalledWith(
expect.objectContaining({
channel: reviewerChannel,
item: createdItem,
attachmentBaseDirs: expect.arrayContaining([
'/repo',
expect.stringMatching(/data\/workspaces\/room-folder$/),
]),
}),
);
expect(noteDirectTerminalDelivery).toHaveBeenCalledWith(
'dc:room',
'reviewer',
'TASK_DONE\n검증 완료',
);
expect(ownerChannel.sendMessage).not.toHaveBeenCalled();
expect(reviewerChannel.sendMessage).not.toHaveBeenCalled();
});
it('keeps failed IPC outbound in the delivery retry queue', async () => {
const channel = makeChannel();
const createWorkItem = vi.fn((input) => makeWorkItem(input as any));
const deliverWorkItem = vi.fn(async () => false);
const noteDirectTerminalDelivery = vi.fn();
const result = await deliverIpcOutboundMessage(
{
jid: 'dc:room',
text: 'TASK_DONE\n검증 완료',
senderRole: 'reviewer',
runId: 'run-1',
},
{
channels: [channel],
roomBindings: () => ({ 'dc:room': makeGroup() }),
queue: { noteDirectTerminalDelivery },
createWorkItem,
deliverWorkItem,
},
);
expect(result).toBe('queued_retry');
expect(createWorkItem).toHaveBeenCalledTimes(1);
expect(deliverWorkItem).toHaveBeenCalledTimes(1);
expect(noteDirectTerminalDelivery).not.toHaveBeenCalled();
});
it('does not create non-canonical outbound for unregistered rooms', async () => {
const createWorkItem = vi.fn((input) => makeWorkItem(input as any));
const deliverWorkItem = vi.fn(async () => true);
await expect(
deliverIpcOutboundMessage(
{ jid: 'dc:missing', text: 'hello' },
{
channels: [makeChannel()],
roomBindings: () => ({}),
queue: {},
createWorkItem,
deliverWorkItem,
},
),
).rejects.toThrow('No registered room binding for outbound JID');
expect(createWorkItem).not.toHaveBeenCalled();
expect(deliverWorkItem).not.toHaveBeenCalled();
});
it('skips duplicate terminal IPC messages already recorded for the run', async () => {
const createWorkItem = vi.fn((input) => makeWorkItem(input as any));
const deliverWorkItem = vi.fn(async () => true);
const result = await deliverIpcOutboundMessage(
{
jid: 'dc:room',
text: 'TASK_DONE\n검증 완료',
senderRole: 'reviewer',
runId: 'run-1',
},
{
channels: [makeChannel()],
roomBindings: () => ({ 'dc:room': makeGroup() }),
queue: {
hasRecordedDirectTerminalDeliveryForRun: () => true,
},
createWorkItem,
deliverWorkItem,
},
);
expect(result).toBe('skipped_recorded_terminal');
expect(createWorkItem).not.toHaveBeenCalled();
expect(deliverWorkItem).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,193 @@
import { createProducedWorkItem } from './db.js';
import type { WorkItem } from './db/work-items.js';
import { resolveRuntimeAttachmentBaseDirs } from './attachment-base-dirs.js';
import { deliverOpenWorkItem } from './message-runtime-delivery.js';
import { parseVisibleVerdict } from './paired-verdict.js';
import { resolveChannelForDeliveryRole } from './router.js';
import { normalizePairedRoomRole } from './types.js';
import type {
Channel,
OutboundAttachment,
PairedRoomRole,
RegisteredGroup,
} from './types.js';
import { logger } from './logger.js';
type IpcDeliveryLog = Pick<typeof logger, 'info' | 'warn' | 'error'>;
interface IpcOutboundQueue {
hasRecordedDirectTerminalDeliveryForRun?(
groupJid: string,
runId: string,
senderRole?: string | null,
): boolean;
noteDirectTerminalDelivery?(
groupJid: string,
senderRole?: string | null,
text?: string | null,
): void;
}
export interface IpcOutboundDeliveryArgs {
jid: string;
text: string;
senderRole?: string;
runId?: string;
attachments?: OutboundAttachment[];
}
export interface IpcOutboundDeliveryDeps {
channels: Channel[];
roomBindings: () => Record<string, RegisteredGroup>;
queue: IpcOutboundQueue;
log?: IpcDeliveryLog;
createWorkItem?: typeof createProducedWorkItem;
deliverWorkItem?: typeof deliverOpenWorkItem;
}
export interface CanonicalOutboundDeliveryArgs {
jid: string;
text: string;
deliveryRole?: PairedRoomRole;
attachments?: OutboundAttachment[];
}
export interface CanonicalOutboundDeliveryDeps {
channels: Channel[];
roomBindings: () => Record<string, RegisteredGroup>;
log?: IpcDeliveryLog;
createWorkItem?: typeof createProducedWorkItem;
deliverWorkItem?: typeof deliverOpenWorkItem;
}
export type CanonicalOutboundDeliveryResult = 'delivered' | 'queued_retry';
export type IpcOutboundDeliveryResult =
| 'delivered'
| 'queued_retry'
| 'skipped_recorded_terminal';
function isTerminalStatusMessage(text: string): boolean {
return parseVisibleVerdict(text) !== 'continue';
}
export async function deliverCanonicalOutboundMessage(
args: CanonicalOutboundDeliveryArgs,
deps: CanonicalOutboundDeliveryDeps,
): Promise<CanonicalOutboundDeliveryResult> {
const log = deps.log ?? logger;
const group = deps.roomBindings()[args.jid];
if (!group) {
throw new Error(`No registered room binding for outbound JID: ${args.jid}`);
}
const route = resolveChannelForDeliveryRole(
deps.channels,
args.jid,
args.deliveryRole,
);
if (!route.channel) throw new Error(`No channel for JID: ${args.jid}`);
log.info(
{
transition: 'outbound:route',
chatJid: args.jid,
deliveryRole: args.deliveryRole ?? null,
requestedRoleChannel: route.requestedRoleChannelName,
selectedChannel: route.selectedChannelName,
usedRoleChannel: route.usedRoleChannel,
fallbackUsed: route.fallbackUsed,
},
'Routed outbound message to canonical work item delivery',
);
const createWorkItem = deps.createWorkItem ?? createProducedWorkItem;
const workItem = createWorkItem({
group_folder: group.folder,
chat_jid: args.jid,
agent_type: group.agentType ?? 'claude-code',
delivery_role: args.deliveryRole ?? null,
start_seq: null,
end_seq: null,
result_payload: args.text,
attachments: args.attachments,
});
const deliverWorkItem = deps.deliverWorkItem ?? deliverOpenWorkItem;
const delivered = await deliverWorkItem({
channel: route.channel,
item: workItem as WorkItem,
log,
attachmentBaseDirs: resolveRuntimeAttachmentBaseDirs(group),
isDuplicateOfLastBotFinal: () => false,
openContinuation: () => {},
});
return delivered ? 'delivered' : 'queued_retry';
}
export async function deliverIpcOutboundMessage(
args: IpcOutboundDeliveryArgs,
deps: IpcOutboundDeliveryDeps,
): Promise<IpcOutboundDeliveryResult> {
const log = deps.log ?? logger;
const deliveryRole = normalizePairedRoomRole(args.senderRole);
if (
args.runId &&
(deliveryRole === 'reviewer' || deliveryRole === 'arbiter') &&
deps.queue.hasRecordedDirectTerminalDeliveryForRun?.(
args.jid,
args.runId,
deliveryRole,
)
) {
log.info(
{
transition: 'ipc:skip-post-terminal',
chatJid: args.jid,
senderRole: deliveryRole,
runId: args.runId,
},
'Skipped IPC relay message because the run already emitted a direct terminal verdict',
);
return 'skipped_recorded_terminal';
}
log.info(
{
transition: 'ipc:route',
chatJid: args.jid,
runId: args.runId ?? null,
senderRole: deliveryRole ?? null,
},
'IPC relay routed message to canonical work item delivery',
);
const result = await deliverCanonicalOutboundMessage(
{
jid: args.jid,
text: args.text,
deliveryRole,
attachments: args.attachments,
},
{
channels: deps.channels,
roomBindings: deps.roomBindings,
log,
createWorkItem: deps.createWorkItem,
deliverWorkItem: deps.deliverWorkItem,
},
);
if (result !== 'delivered') {
return result;
}
if (
(deliveryRole === 'reviewer' || deliveryRole === 'arbiter') &&
isTerminalStatusMessage(args.text)
) {
deps.queue.noteDirectTerminalDelivery?.(args.jid, deliveryRole, args.text);
}
return 'delivered';
}

View File

@@ -1,25 +1,16 @@
import { CronExpressionParser } from 'cron-parser';
import { TIMEZONE } from './config.js';
import {
isHostEvidenceAction,
runHostEvidenceRequest,
writeHostEvidenceResponse,
} from './host-evidence.js';
import {
createTask,
deleteTask,
findDuplicateCiWatcher,
getTaskById,
rememberMemory,
updateTask,
} from './db.js';
import { isValidGroupFolder } from './group-folder.js';
import { deleteTask, updateTask } from './db.js';
import { logger } from './logger.js';
import { handleHostEvidenceRequest } from './ipc-task-processor/host-evidence.js';
import { handlePersistMemory } from './ipc-task-processor/memory.js';
import {
DEFAULT_WATCH_CI_MAX_DURATION_MS,
isWatchCiTask,
} from './task-watch-status.js';
handleAssignRoom,
handleRefreshGroups,
} from './ipc-task-processor/rooms.js';
import { handleScheduleTask } from './ipc-task-processor/schedule.js';
import {
handleTaskStateMutation,
handleUpdateTask,
} from './ipc-task-processor/tasks.js';
import type { IpcDeps, TaskIpcPayload } from './ipc-types.js';
export async function processTaskIpc(
@@ -28,561 +19,54 @@ export async function processTaskIpc(
isMain: boolean,
deps: IpcDeps,
): Promise<void> {
const roomBindings = deps.roomBindings();
switch (data.type) {
case 'schedule_task':
if (
data.prompt &&
data.schedule_type &&
data.schedule_value &&
data.targetJid
) {
const targetJid = data.targetJid;
const targetGroupEntry =
roomBindings[targetJid] ||
Object.values(roomBindings).find(
(group) => group.folder === targetJid,
);
if (!targetGroupEntry) {
logger.warn(
{ targetJid },
'Cannot schedule task: target group not registered',
);
break;
}
const targetFolder = targetGroupEntry.folder;
const resolvedTargetJid =
roomBindings[targetJid] !== undefined
? targetJid
: Object.entries(roomBindings).find(
([, group]) => group.folder === targetFolder,
)?.[0];
if (!resolvedTargetJid) {
logger.warn(
{ targetJid, targetFolder },
'Cannot resolve scheduled task target JID from folder',
);
break;
}
if (!isMain && targetFolder !== sourceGroup) {
logger.warn(
{ sourceGroup, targetFolder },
'Unauthorized schedule_task attempt blocked',
);
break;
}
const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once';
let nextRun: string | null = null;
if (scheduleType === 'cron') {
try {
const interval = CronExpressionParser.parse(data.schedule_value, {
tz: TIMEZONE,
});
nextRun = interval.next().toISOString();
} catch {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid cron expression',
);
break;
}
} else if (scheduleType === 'interval') {
const ms = parseInt(data.schedule_value, 10);
if (isNaN(ms) || ms <= 0) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid interval',
);
break;
}
nextRun = isWatchCiTask({ prompt: data.prompt })
? new Date().toISOString()
: new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') {
const date = new Date(data.schedule_value);
if (isNaN(date.getTime())) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid timestamp',
);
break;
}
nextRun = date.toISOString();
}
if (data.ci_provider && data.ci_metadata) {
const existing = findDuplicateCiWatcher(
resolvedTargetJid,
data.ci_provider,
data.ci_metadata,
);
if (existing) {
logger.info(
{
existingTaskId: existing.id,
existingAgentType: existing.agent_type,
ciProvider: data.ci_provider,
sourceGroup,
},
'Duplicate CI watcher skipped — another agent already watches this run',
);
break;
}
}
const taskId =
data.taskId ||
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contextMode =
data.context_mode === 'group' || data.context_mode === 'isolated'
? data.context_mode
: 'isolated';
createTask({
id: taskId,
group_folder: targetFolder,
chat_jid: resolvedTargetJid,
agent_type: targetGroupEntry.agentType || 'claude-code',
ci_provider: data.ci_provider ?? null,
ci_metadata: data.ci_metadata ?? null,
max_duration_ms: isWatchCiTask({ prompt: data.prompt })
? DEFAULT_WATCH_CI_MAX_DURATION_MS
: null,
prompt: data.prompt,
schedule_type: scheduleType,
schedule_value: data.schedule_value,
context_mode: contextMode,
next_run: nextRun,
status: 'active',
created_at: new Date().toISOString(),
});
logger.info(
{
taskId,
sourceGroup,
targetFolder,
contextMode,
agentType: targetGroupEntry.agentType || 'claude-code',
},
'Task created via IPC',
);
if (nextRun && new Date(nextRun).getTime() <= Date.now()) {
deps.nudgeScheduler?.();
}
}
handleScheduleTask(data, sourceGroup, isMain, deps);
break;
case 'pause_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'paused' });
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task paused via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task pause attempt',
);
}
}
handleTaskStateMutation(data, sourceGroup, isMain, {
action: () => updateTask(data.taskId!, { status: 'paused' }),
successMessage: 'Task paused via IPC',
unauthorizedMessage: 'Unauthorized task pause attempt',
});
break;
case 'resume_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'active' });
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task resumed via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task resume attempt',
);
}
}
handleTaskStateMutation(data, sourceGroup, isMain, {
action: () => updateTask(data.taskId!, { status: 'active' }),
successMessage: 'Task resumed via IPC',
unauthorizedMessage: 'Unauthorized task resume attempt',
});
break;
case 'cancel_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
deleteTask(data.taskId);
logger.info(
{ taskId: data.taskId, sourceGroup },
'Task cancelled via IPC',
);
} else {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task cancel attempt',
);
}
}
handleTaskStateMutation(data, sourceGroup, isMain, {
action: () => deleteTask(data.taskId!),
successMessage: 'Task cancelled via IPC',
unauthorizedMessage: 'Unauthorized task cancel attempt',
});
break;
case 'host_evidence_request':
if (!data.requestId) {
logger.warn(
{ sourceGroup },
'Ignoring host_evidence_request without requestId',
);
break;
}
if (data.action === 'ejclaw_room_runtime') {
if (!data.chatJid) {
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
ok: false,
action: 'ejclaw_room_runtime',
command: 'internal:ejclaw_room_runtime',
stdout: '',
stderr: '',
exitCode: 1,
error: 'Missing chatJid for ejclaw_room_runtime request',
});
logger.warn(
{ sourceGroup, requestId: data.requestId },
'Rejected ejclaw_room_runtime request without chatJid',
);
break;
}
if (!deps.getRoomRuntimeReport) {
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
ok: false,
action: 'ejclaw_room_runtime',
command: 'internal:ejclaw_room_runtime',
stdout: '',
stderr: '',
exitCode: 1,
error: 'Room runtime reporting is not configured',
});
logger.warn(
{ sourceGroup, requestId: data.requestId, chatJid: data.chatJid },
'Rejected ejclaw_room_runtime request because runtime reporter is unavailable',
);
break;
}
const report = deps.getRoomRuntimeReport({
chatJid: data.chatJid,
sourceGroup,
isMain,
});
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
ok: true,
action: 'ejclaw_room_runtime',
command: 'internal:ejclaw_room_runtime',
stdout: JSON.stringify(report, null, 2),
stderr: '',
exitCode: 0,
});
logger.info(
{
sourceGroup,
requestId: data.requestId,
action: data.action,
chatJid: data.chatJid,
},
'Processed room runtime report request via IPC',
);
break;
}
if (!isHostEvidenceAction(data.action)) {
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
ok: false,
action: 'ejclaw_service_status',
command: '',
stdout: '',
stderr: '',
exitCode: 1,
error: `Unsupported host evidence action: ${String(data.action)}`,
});
logger.warn(
{ sourceGroup, requestId: data.requestId, action: data.action },
'Rejected unsupported host evidence action',
);
break;
}
{
const result = await runHostEvidenceRequest({
requestId: data.requestId,
action: data.action,
tailLines:
typeof data.tail_lines === 'number' ? data.tail_lines : undefined,
});
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
...result,
});
logger.info(
{
sourceGroup,
requestId: data.requestId,
action: data.action,
ok: result.ok,
exitCode: result.exitCode,
},
'Processed host evidence request via IPC',
);
}
await handleHostEvidenceRequest(data, sourceGroup, isMain, deps);
break;
case 'update_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (!task) {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Task not found for update',
);
break;
}
if (!isMain && task.group_folder !== sourceGroup) {
logger.warn(
{ taskId: data.taskId, sourceGroup },
'Unauthorized task update attempt',
);
break;
}
const updates: Parameters<typeof updateTask>[1] = {};
if (data.prompt !== undefined) updates.prompt = data.prompt;
if (data.schedule_type !== undefined) {
updates.schedule_type = data.schedule_type as
| 'cron'
| 'interval'
| 'once';
}
if (data.schedule_value !== undefined) {
updates.schedule_value = data.schedule_value;
}
if (data.schedule_type || data.schedule_value) {
const updatedTask = {
...task,
...updates,
};
if (updatedTask.schedule_type === 'cron') {
try {
const interval = CronExpressionParser.parse(
updatedTask.schedule_value,
{ tz: TIMEZONE },
);
updates.next_run = interval.next().toISOString();
} catch {
logger.warn(
{ taskId: data.taskId, value: updatedTask.schedule_value },
'Invalid cron in task update',
);
break;
}
} else if (updatedTask.schedule_type === 'interval') {
const ms = parseInt(updatedTask.schedule_value, 10);
if (!isNaN(ms) && ms > 0) {
updates.next_run = new Date(Date.now() + ms).toISOString();
}
}
}
updateTask(data.taskId, updates);
logger.info(
{ taskId: data.taskId, sourceGroup, updates },
'Task updated via IPC',
);
}
handleUpdateTask(data, sourceGroup, isMain);
break;
case 'refresh_groups':
if (isMain) {
logger.info(
{ sourceGroup },
'Group metadata refresh requested via IPC',
);
await deps.syncGroups(true);
const availableGroups = deps.getAvailableGroups();
deps.writeGroupsSnapshot(sourceGroup, true, availableGroups);
} else {
logger.warn(
{ sourceGroup },
'Unauthorized refresh_groups attempt blocked',
);
}
await handleRefreshGroups(sourceGroup, isMain, deps);
break;
case 'assign_room':
if (!isMain) {
logger.warn(
{ sourceGroup, type: data.type },
`Unauthorized ${data.type} attempt blocked`,
);
break;
}
if (data.jid && data.name) {
if (data.folder && !isValidGroupFolder(data.folder)) {
logger.warn(
{ sourceGroup, folder: data.folder },
`Invalid ${data.type} request - unsafe folder name`,
);
break;
}
if (
data.room_mode !== undefined &&
data.room_mode !== 'single' &&
data.room_mode !== 'tribunal'
) {
logger.warn(
{ sourceGroup, roomMode: data.room_mode },
'Invalid assign_room request - unknown room_mode',
);
break;
}
if (
data.owner_agent_type !== undefined &&
data.owner_agent_type !== 'claude-code' &&
data.owner_agent_type !== 'codex'
) {
logger.warn(
{ sourceGroup, ownerAgentType: data.owner_agent_type },
'Invalid assign_room request - unknown owner_agent_type',
);
break;
}
if (
data.reviewer_agent_type !== undefined &&
data.reviewer_agent_type !== 'claude-code' &&
data.reviewer_agent_type !== 'codex'
) {
logger.warn(
{ sourceGroup, reviewerAgentType: data.reviewer_agent_type },
'Invalid assign_room request - unknown reviewer_agent_type',
);
break;
}
if (
data.arbiter_agent_type !== undefined &&
data.arbiter_agent_type !== null &&
data.arbiter_agent_type !== 'claude-code' &&
data.arbiter_agent_type !== 'codex'
) {
logger.warn(
{ sourceGroup, arbiterAgentType: data.arbiter_agent_type },
'Invalid assign_room request - unknown arbiter_agent_type',
);
break;
}
deps.assignRoom(data.jid, {
name: data.name,
roomMode: data.room_mode,
ownerAgentType: data.owner_agent_type,
reviewerAgentType: data.reviewer_agent_type,
arbiterAgentType: data.arbiter_agent_type,
folder: data.folder,
isMain: data.isMain,
workDir: data.workDir,
});
} else {
logger.warn(
{ data },
`Invalid ${data.type} request - missing required fields`,
);
}
handleAssignRoom(data, sourceGroup, isMain, deps);
break;
case 'persist_memory': {
if (
data.scopeKind !== 'room' ||
typeof data.scopeKey !== 'string' ||
typeof data.content !== 'string'
) {
logger.warn(
{ sourceGroup, data },
'Invalid persist_memory request - missing required fields',
);
break;
}
const expectedScopeKey = `room:${sourceGroup}`;
if (data.scopeKey !== expectedScopeKey) {
logger.warn(
{ sourceGroup, scopeKey: data.scopeKey, expectedScopeKey },
'Unauthorized persist_memory attempt blocked',
);
break;
}
if (
data.source_kind !== undefined &&
data.source_kind !== 'compact' &&
data.source_kind !== 'explicit' &&
data.source_kind !== 'import' &&
data.source_kind !== 'system'
) {
logger.warn(
{ sourceGroup, sourceKind: data.source_kind },
'Invalid persist_memory request - unknown source_kind',
);
break;
}
if (
Array.isArray(data.keywords) &&
!data.keywords.every((value) => typeof value === 'string')
) {
logger.warn(
{ sourceGroup, keywords: data.keywords },
'Invalid persist_memory request - keywords must be strings',
);
break;
}
rememberMemory({
scopeKind: 'room',
scopeKey: data.scopeKey,
content: data.content,
keywords: data.keywords,
memoryKind:
typeof data.memory_kind === 'string' ? data.memory_kind : null,
sourceKind:
(data.source_kind as
| 'compact'
| 'explicit'
| 'import'
| 'system'
| undefined) ?? 'compact',
sourceRef: typeof data.source_ref === 'string' ? data.source_ref : null,
});
logger.info(
{
sourceGroup,
scopeKey: data.scopeKey,
sourceKind: data.source_kind ?? 'compact',
},
'Memory persisted via IPC',
);
case 'persist_memory':
handlePersistMemory(data, sourceGroup);
break;
}
default:
logger.warn({ type: data.type }, 'Unknown IPC task type');

View File

@@ -0,0 +1,148 @@
import {
isHostEvidenceAction,
runHostEvidenceRequest,
writeHostEvidenceResponse,
} from '../host-evidence.js';
import { logger } from '../logger.js';
import type { IpcDeps, TaskIpcPayload } from '../ipc-types.js';
export async function handleHostEvidenceRequest(
data: TaskIpcPayload,
sourceGroup: string,
isMain: boolean,
deps: IpcDeps,
): Promise<void> {
if (!data.requestId) {
logger.warn(
{ sourceGroup },
'Ignoring host_evidence_request without requestId',
);
return;
}
if (data.action === 'ejclaw_room_runtime') {
handleRoomRuntimeReportRequest(data, sourceGroup, isMain, deps);
return;
}
if (!isHostEvidenceAction(data.action)) {
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
ok: false,
action: 'ejclaw_service_status',
command: '',
stdout: '',
stderr: '',
exitCode: 1,
error: `Unsupported host evidence action: ${String(data.action)}`,
});
logger.warn(
{ sourceGroup, requestId: data.requestId, action: data.action },
'Rejected unsupported host evidence action',
);
return;
}
const result = await runHostEvidenceRequest({
requestId: data.requestId,
action: data.action,
tailLines:
typeof data.tail_lines === 'number' ? data.tail_lines : undefined,
taskId: typeof data.task_id === 'string' ? data.task_id : undefined,
minutes: typeof data.minutes === 'number' ? data.minutes : undefined,
limit: typeof data.limit === 'number' ? data.limit : undefined,
repo: typeof data.repo === 'string' ? data.repo : undefined,
prNumber: typeof data.pr_number === 'number' ? data.pr_number : undefined,
runId: typeof data.run_id === 'number' ? data.run_id : undefined,
workflowPath:
typeof data.workflow_path === 'string' ? data.workflow_path : undefined,
ref: typeof data.ref === 'string' ? data.ref : undefined,
artifactKind:
typeof data.artifact_kind === 'string' ? data.artifact_kind : undefined,
sourceGroup,
isMain,
});
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId,
...result,
});
logger.info(
{
sourceGroup,
requestId: data.requestId,
action: data.action,
ok: result.ok,
exitCode: result.exitCode,
},
'Processed host evidence request via IPC',
);
}
function handleRoomRuntimeReportRequest(
data: TaskIpcPayload,
sourceGroup: string,
isMain: boolean,
deps: IpcDeps,
): void {
if (!data.chatJid) {
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId!,
ok: false,
action: 'ejclaw_room_runtime',
command: 'internal:ejclaw_room_runtime',
stdout: '',
stderr: '',
exitCode: 1,
error: 'Missing chatJid for ejclaw_room_runtime request',
});
logger.warn(
{ sourceGroup, requestId: data.requestId },
'Rejected ejclaw_room_runtime request without chatJid',
);
return;
}
if (!deps.getRoomRuntimeReport) {
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId!,
ok: false,
action: 'ejclaw_room_runtime',
command: 'internal:ejclaw_room_runtime',
stdout: '',
stderr: '',
exitCode: 1,
error: 'Room runtime reporting is not configured',
});
logger.warn(
{ sourceGroup, requestId: data.requestId, chatJid: data.chatJid },
'Rejected ejclaw_room_runtime request because runtime reporter is unavailable',
);
return;
}
const report = deps.getRoomRuntimeReport({
chatJid: data.chatJid,
sourceGroup,
isMain,
});
writeHostEvidenceResponse(sourceGroup, {
requestId: data.requestId!,
ok: true,
action: 'ejclaw_room_runtime',
command: 'internal:ejclaw_room_runtime',
stdout: JSON.stringify(report, null, 2),
stderr: '',
exitCode: 0,
});
logger.info(
{
sourceGroup,
requestId: data.requestId,
action: data.action,
chatJid: data.chatJid,
},
'Processed room runtime report request via IPC',
);
}

View File

@@ -0,0 +1,96 @@
import { rememberMemory } from '../db.js';
import { logger } from '../logger.js';
import type { TaskIpcPayload } from '../ipc-types.js';
type MemorySourceKind = Parameters<typeof rememberMemory>[0]['sourceKind'];
export function handlePersistMemory(
data: TaskIpcPayload,
sourceGroup: string,
): void {
if (!hasPersistMemoryFields(data)) {
logger.warn(
{ sourceGroup, data },
'Invalid persist_memory request - missing required fields',
);
return;
}
const expectedScopeKey = `room:${sourceGroup}`;
if (data.scopeKey !== expectedScopeKey) {
logger.warn(
{ sourceGroup, scopeKey: data.scopeKey, expectedScopeKey },
'Unauthorized persist_memory attempt blocked',
);
return;
}
if (!isMemorySourceKind(data.source_kind)) {
logger.warn(
{ sourceGroup, sourceKind: data.source_kind },
'Invalid persist_memory request - unknown source_kind',
);
return;
}
if (!hasValidKeywords(data.keywords)) {
logger.warn(
{ sourceGroup, keywords: data.keywords },
'Invalid persist_memory request - keywords must be strings',
);
return;
}
rememberMemory({
scopeKind: 'room',
scopeKey: data.scopeKey,
content: data.content,
keywords: data.keywords,
memoryKind: typeof data.memory_kind === 'string' ? data.memory_kind : null,
sourceKind: data.source_kind ?? 'compact',
sourceRef: typeof data.source_ref === 'string' ? data.source_ref : null,
});
logger.info(
{
sourceGroup,
scopeKey: data.scopeKey,
sourceKind: data.source_kind ?? 'compact',
},
'Memory persisted via IPC',
);
}
function hasPersistMemoryFields(
data: TaskIpcPayload,
): data is TaskIpcPayload & {
scopeKind: 'room';
scopeKey: string;
content: string;
} {
return (
data.scopeKind === 'room' &&
typeof data.scopeKey === 'string' &&
typeof data.content === 'string'
);
}
function isMemorySourceKind(
value: string | undefined,
): value is MemorySourceKind | undefined {
return (
value === undefined ||
value === 'compact' ||
value === 'explicit' ||
value === 'import' ||
value === 'system'
);
}
function hasValidKeywords(
value: TaskIpcPayload['keywords'],
): value is string[] | undefined {
return (
value === undefined ||
(Array.isArray(value) && value.every((item) => typeof item === 'string'))
);
}

View File

@@ -0,0 +1,135 @@
import { isValidGroupFolder } from '../group-folder.js';
import { logger } from '../logger.js';
import type { IpcDeps, TaskIpcPayload } from '../ipc-types.js';
import type { AgentType, RoomMode } from '../types.js';
export async function handleRefreshGroups(
sourceGroup: string,
isMain: boolean,
deps: IpcDeps,
): Promise<void> {
if (!isMain) {
logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked');
return;
}
logger.info({ sourceGroup }, 'Group metadata refresh requested via IPC');
await deps.syncGroups(true);
const availableGroups = deps.getAvailableGroups();
deps.writeGroupsSnapshot(sourceGroup, true, availableGroups);
}
export function handleAssignRoom(
data: TaskIpcPayload,
sourceGroup: string,
isMain: boolean,
deps: IpcDeps,
): void {
const validation = validateAssignRoomRequest(data, sourceGroup, isMain);
if (!validation.ok) return;
deps.assignRoom(data.jid!, {
name: data.name!,
roomMode: data.room_mode,
ownerAgentType: data.owner_agent_type,
reviewerAgentType: data.reviewer_agent_type,
arbiterAgentType: data.arbiter_agent_type,
folder: data.folder,
trigger: data.trigger,
requiresTrigger: data.requiresTrigger,
isMain: data.isMain,
workDir: data.workDir,
});
}
function validateAssignRoomRequest(
data: TaskIpcPayload,
sourceGroup: string,
isMain: boolean,
): { ok: true } | { ok: false } {
if (!isMain) {
logger.warn(
{ sourceGroup, type: data.type },
`Unauthorized ${data.type} attempt blocked`,
);
return { ok: false };
}
if (!data.jid || !data.name) {
logger.warn(
{ data },
`Invalid ${data.type} request - missing required fields`,
);
return { ok: false };
}
if (data.folder && !isValidGroupFolder(data.folder)) {
logger.warn(
{ sourceGroup, folder: data.folder },
`Invalid ${data.type} request - unsafe folder name`,
);
return { ok: false };
}
if (data.trigger !== undefined && typeof data.trigger !== 'string') {
logger.warn(
{ sourceGroup, trigger: data.trigger },
'Invalid assign_room request - trigger must be a string',
);
return { ok: false };
}
if (
data.requiresTrigger !== undefined &&
typeof data.requiresTrigger !== 'boolean'
) {
logger.warn(
{ sourceGroup, requiresTrigger: data.requiresTrigger },
'Invalid assign_room request - requiresTrigger must be a boolean',
);
return { ok: false };
}
if (data.room_mode !== undefined && !isRoomMode(data.room_mode)) {
logger.warn(
{ sourceGroup, roomMode: data.room_mode },
'Invalid assign_room request - unknown room_mode',
);
return { ok: false };
}
if (
data.owner_agent_type !== undefined &&
!isAssignableAgentType(data.owner_agent_type)
) {
logger.warn(
{ sourceGroup, ownerAgentType: data.owner_agent_type },
'Invalid assign_room request - unknown owner_agent_type',
);
return { ok: false };
}
if (
data.reviewer_agent_type !== undefined &&
!isAssignableAgentType(data.reviewer_agent_type)
) {
logger.warn(
{ sourceGroup, reviewerAgentType: data.reviewer_agent_type },
'Invalid assign_room request - unknown reviewer_agent_type',
);
return { ok: false };
}
if (
data.arbiter_agent_type !== undefined &&
data.arbiter_agent_type !== null &&
!isAssignableAgentType(data.arbiter_agent_type)
) {
logger.warn(
{ sourceGroup, arbiterAgentType: data.arbiter_agent_type },
'Invalid assign_room request - unknown arbiter_agent_type',
);
return { ok: false };
}
return { ok: true };
}
function isRoomMode(value: string): value is RoomMode {
return value === 'single' || value === 'tribunal';
}
function isAssignableAgentType(value: string): value is AgentType {
return value === 'claude-code' || value === 'codex';
}

View File

@@ -0,0 +1,218 @@
import { CronExpressionParser } from 'cron-parser';
import { normalizeTaskContextMode } from 'ejclaw-runners-shared';
import { createTask, findDuplicateCiWatcher } from '../db.js';
import { normalizeStoredAgentType } from '../db/room-registration.js';
import { TIMEZONE } from '../config.js';
import { logger } from '../logger.js';
import {
DEFAULT_WATCH_CI_MAX_DURATION_MS,
isWatchCiTask,
} from '../task-watch-status.js';
import type { IpcDeps, TaskIpcPayload } from '../ipc-types.js';
import { normalizePairedRoomRole, type RegisteredGroup } from '../types.js';
type RoomBindings = ReturnType<IpcDeps['roomBindings']>;
type ScheduleType = 'cron' | 'interval' | 'once';
interface ResolvedScheduleTarget {
folder: string;
jid: string;
room: RegisteredGroup;
}
export function handleScheduleTask(
data: TaskIpcPayload,
sourceGroup: string,
isMain: boolean,
deps: IpcDeps,
): void {
if (!hasRequiredScheduleFields(data)) return;
const roomBindings = deps.roomBindings();
const target = resolveScheduleTarget(data.targetJid, roomBindings);
if (!target) return;
if (!isMain && target.folder !== sourceGroup) {
logger.warn(
{ sourceGroup, targetFolder: target.folder },
'Unauthorized schedule_task attempt blocked',
);
return;
}
const scheduleType = data.schedule_type as ScheduleType;
const nextRunResult = resolveNextRun(data, scheduleType);
if (!nextRunResult.ok) return;
if (isDuplicateCiWatcher(data, target.jid, sourceGroup)) return;
const taskId =
data.taskId ||
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contextMode = normalizeTaskContextMode(data.context_mode);
const scheduledAgentType =
normalizeStoredAgentType(data.agent_type) ??
target.room.agentType ??
'claude-code';
const scheduledRoomRole = normalizePairedRoomRole(data.room_role);
createTask({
id: taskId,
group_folder: target.folder,
chat_jid: target.jid,
agent_type: scheduledAgentType,
room_role: scheduledRoomRole ?? null,
ci_provider: data.ci_provider ?? null,
ci_metadata: data.ci_metadata ?? null,
max_duration_ms: isWatchCiTask({ prompt: data.prompt })
? DEFAULT_WATCH_CI_MAX_DURATION_MS
: null,
prompt: data.prompt,
schedule_type: scheduleType,
schedule_value: data.schedule_value,
context_mode: contextMode,
next_run: nextRunResult.nextRun,
status: 'active',
created_at: new Date().toISOString(),
});
logger.info(
{
taskId,
sourceGroup,
targetFolder: target.folder,
contextMode,
agentType: scheduledAgentType,
roomRole: scheduledRoomRole ?? null,
},
'Task created via IPC',
);
if (
nextRunResult.nextRun &&
new Date(nextRunResult.nextRun).getTime() <= Date.now()
) {
deps.nudgeScheduler?.();
}
}
function hasRequiredScheduleFields(
data: TaskIpcPayload,
): data is TaskIpcPayload & {
prompt: string;
schedule_type: string;
schedule_value: string;
targetJid: string;
} {
return Boolean(
data.prompt && data.schedule_type && data.schedule_value && data.targetJid,
);
}
function resolveScheduleTarget(
targetJid: string,
roomBindings: RoomBindings,
): ResolvedScheduleTarget | null {
const targetRoom =
roomBindings[targetJid] ||
Object.values(roomBindings).find((group) => group.folder === targetJid);
if (!targetRoom) {
logger.warn(
{ targetJid },
'Cannot schedule task: target group not registered',
);
return null;
}
const resolvedTargetJid =
roomBindings[targetJid] !== undefined
? targetJid
: Object.entries(roomBindings).find(
([, group]) => group.folder === targetRoom.folder,
)?.[0];
if (!resolvedTargetJid) {
logger.warn(
{ targetJid, targetFolder: targetRoom.folder },
'Cannot resolve scheduled task target JID from folder',
);
return null;
}
return {
folder: targetRoom.folder,
jid: resolvedTargetJid,
room: targetRoom,
};
}
function isDuplicateCiWatcher(
data: TaskIpcPayload,
targetJid: string,
sourceGroup: string,
): boolean {
if (!data.ci_provider || !data.ci_metadata) return false;
const existing = findDuplicateCiWatcher(
targetJid,
data.ci_provider,
data.ci_metadata,
);
if (!existing) return false;
logger.info(
{
existingTaskId: existing.id,
existingAgentType: existing.agent_type,
ciProvider: data.ci_provider,
sourceGroup,
},
'Duplicate CI watcher skipped — another agent already watches this run',
);
return true;
}
function resolveNextRun(
data: TaskIpcPayload & { prompt?: string; schedule_value?: string },
scheduleType: ScheduleType,
): { ok: true; nextRun: string | null } | { ok: false } {
if (scheduleType === 'cron') {
try {
const interval = CronExpressionParser.parse(data.schedule_value!, {
tz: TIMEZONE,
});
return { ok: true, nextRun: interval.next().toISOString() };
} catch {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid cron expression',
);
return { ok: false };
}
}
if (scheduleType === 'interval') {
const ms = parseInt(data.schedule_value!, 10);
if (isNaN(ms) || ms <= 0) {
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval');
return { ok: false };
}
return {
ok: true,
nextRun: isWatchCiTask({ prompt: data.prompt! })
? new Date().toISOString()
: new Date(Date.now() + ms).toISOString(),
};
}
if (scheduleType === 'once') {
const date = new Date(data.schedule_value!);
if (isNaN(date.getTime())) {
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp');
return { ok: false };
}
return { ok: true, nextRun: date.toISOString() };
}
return { ok: true, nextRun: null };
}

Some files were not shown because too many files have changed in this diff Show More