feat: unify 3 bot services into single process

- Add UNIFIED_MODE flag (default on, disable with UNIFIED_MODE=0)
- Register all 3 Discord bots in one process (claude, codex, review)
- Load all registered groups regardless of agent_type (codex/owner priority)
- Start credential proxy and container cleanup at unified startup
- shouldServiceProcessChat returns true in unified mode
- Add findChannelByName for role-based response routing
- Backward compatible: UNIFIED_MODE=0 restores per-service behavior
This commit is contained in:
Eyejoker
2026-03-29 18:22:57 +09:00
parent fc9f2867b9
commit d01f98bd61
20 changed files with 118 additions and 759 deletions

View File

@@ -726,12 +726,18 @@ registerChannel('discord', (opts: ChannelOpts) => {
); );
}); });
// Only register the secondary Codex bot channel when running as the primary (claude-code) service. // Register the secondary Codex bot channel.
// The codex service uses its own DISCORD_BOT_TOKEN via systemd EnvironmentFile override. // In unified mode all bots register unconditionally; in legacy per-service mode
if ((process.env.ASSISTANT_NAME || 'claude') !== 'codex') { // the codex service uses its own DISCORD_BOT_TOKEN via systemd EnvironmentFile override.
registerChannel('discord-codex', (opts: ChannelOpts) => { registerChannel('discord-codex', (opts: ChannelOpts) => {
const token = getEnv('DISCORD_CODEX_BOT_TOKEN') || ''; const token = getEnv('DISCORD_CODEX_BOT_TOKEN') || '';
if (!token) return null; // Codex Discord bot is optional if (!token) return null; // Codex Discord bot is optional
return new DiscordChannel(token, opts, 'codex'); return new DiscordChannel(token, opts, 'codex');
}); });
}
// Register the review bot channel (codex agent type, separate token).
registerChannel('discord-review', (opts: ChannelOpts) => {
const token = getEnv('DISCORD_REVIEW_BOT_TOKEN') || '';
if (!token) return null; // Review Discord bot is optional
return new DiscordChannel(token, opts, 'codex');
});

View File

@@ -22,6 +22,9 @@ export const SERVICE_AGENT_TYPE: AgentType =
? 'codex' ? 'codex'
: 'claude-code'; : 'claude-code';
/** Unified mode: single process manages all bots. Disable with UNIFIED_MODE=0. */
export const UNIFIED_MODE = getEnv('UNIFIED_MODE') !== '0';
export function normalizeServiceId(serviceId: string): string { export function normalizeServiceId(serviceId: string): string {
if (serviceId === 'codex') { if (serviceId === 'codex') {
return CODEX_MAIN_SERVICE_ID; return CODEX_MAIN_SERVICE_ID;

View File

@@ -215,11 +215,7 @@ export async function runReviewerContainer(args: {
const containerName = `ejclaw-reviewer-${group.folder}-${Date.now()}`; const containerName = `ejclaw-reviewer-${group.folder}-${Date.now()}`;
const mounts = buildReviewerMounts(group, ownerWorkspaceDir); const mounts = buildReviewerMounts(group, ownerWorkspaceDir);
const containerArgs = buildContainerArgs( const containerArgs = buildContainerArgs(mounts, containerName, envOverrides);
mounts,
containerName,
envOverrides,
);
logger.info( logger.info(
{ {
@@ -275,9 +271,7 @@ export async function runReviewerContainer(args: {
// Parse streamed output markers // Parse streamed output markers
let startIdx: number; let startIdx: number;
while ( while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
(startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1
) {
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
if (endIdx === -1) break; if (endIdx === -1) break;

View File

@@ -52,14 +52,12 @@ export function startCredentialProxy(
} }
// Default: Anthropic/Claude API // Default: Anthropic/Claude API
const headers: Record< const headers: Record<string, string | number | string[] | undefined> =
string, {
string | number | string[] | undefined ...(req.headers as Record<string, string>),
> = { host: anthropicUrl.host,
...(req.headers as Record<string, string>), 'content-length': body.length,
host: anthropicUrl.host, };
'content-length': body.length,
};
delete headers['connection']; delete headers['connection'];
delete headers['keep-alive']; delete headers['keep-alive'];

View File

@@ -607,13 +607,6 @@ describe('paired task state', () => {
updated_at: '2026-03-28T00:10:00.000Z', updated_at: '2026-03-28T00:10:00.000Z',
}); });
expect(getPairedTaskById('paired-task-2')?.gate_turn_kind).toBe(
'implementation_start',
);
expect(getPairedTaskById('paired-task-2')?.reviewer_verdict).toBe(
'done_with_concerns',
);
upsertPairedWorkspace({ upsertPairedWorkspace({
id: 'paired-task-2:reviewer', id: 'paired-task-2:reviewer',
task_id: 'paired-task-2', task_id: 'paired-task-2',

View File

@@ -1752,7 +1752,7 @@ export function getAllRegisteredGroups(
); );
continue; continue;
} }
result[row.jid] = { const group: RegisteredGroup = {
name: row.name, name: row.name,
folder: row.folder, folder: row.folder,
trigger: row.trigger_pattern, trigger: row.trigger_pattern,
@@ -1764,6 +1764,17 @@ export function getAllRegisteredGroups(
agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined, agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined,
workDir: row.work_dir || undefined, workDir: row.work_dir || undefined,
}; };
// In unified mode (no agentTypeFilter), when the same JID has both
// claude-code and codex rows, prefer the codex entry (owner).
const existing = result[row.jid];
if (existing && !agentTypeFilter) {
if (group.agentType === 'codex') {
result[row.jid] = group;
}
// else keep existing (already codex or first-seen)
} else {
result[row.jid] = group;
}
} }
return result; return result;
} }
@@ -1836,11 +1847,6 @@ export function createPairedTask(task: PairedTask): void {
source_ref, source_ref,
plan_notes, plan_notes,
review_requested_at, review_requested_at,
last_finalized_checkpoint,
gate_turn_kind,
reviewer_verdict,
reviewer_verdict_at,
reviewer_verdict_note,
status, status,
created_at, created_at,
updated_at updated_at
@@ -1857,11 +1863,6 @@ export function createPairedTask(task: PairedTask): void {
task.source_ref, task.source_ref,
task.plan_notes, task.plan_notes,
task.review_requested_at, task.review_requested_at,
task.last_finalized_checkpoint ?? null,
task.gate_turn_kind ?? null,
task.reviewer_verdict ?? null,
task.reviewer_verdict_at ?? null,
task.reviewer_verdict_note ?? null,
task.status, task.status,
task.created_at, task.created_at,
task.updated_at, task.updated_at,
@@ -1916,11 +1917,6 @@ export function updatePairedTask(
| 'source_ref' | 'source_ref'
| 'plan_notes' | 'plan_notes'
| 'review_requested_at' | 'review_requested_at'
| 'last_finalized_checkpoint'
| 'gate_turn_kind'
| 'reviewer_verdict'
| 'reviewer_verdict_at'
| 'reviewer_verdict_note'
| 'status' | 'status'
| 'updated_at' | 'updated_at'
> >
@@ -1945,26 +1941,6 @@ export function updatePairedTask(
fields.push('review_requested_at = ?'); fields.push('review_requested_at = ?');
values.push(updates.review_requested_at); values.push(updates.review_requested_at);
} }
if (updates.last_finalized_checkpoint !== undefined) {
fields.push('last_finalized_checkpoint = ?');
values.push(updates.last_finalized_checkpoint);
}
if (updates.gate_turn_kind !== undefined) {
fields.push('gate_turn_kind = ?');
values.push(updates.gate_turn_kind);
}
if (updates.reviewer_verdict !== undefined) {
fields.push('reviewer_verdict = ?');
values.push(updates.reviewer_verdict);
}
if (updates.reviewer_verdict_at !== undefined) {
fields.push('reviewer_verdict_at = ?');
values.push(updates.reviewer_verdict_at);
}
if (updates.reviewer_verdict_note !== undefined) {
fields.push('reviewer_verdict_note = ?');
values.push(updates.reviewer_verdict_note);
}
if (updates.status !== undefined) { if (updates.status !== undefined) {
fields.push('status = ?'); fields.push('status = ?');
values.push(updates.status); values.push(updates.status);

View File

@@ -3,11 +3,13 @@ import path from 'path';
import { import {
ASSISTANT_NAME, ASSISTANT_NAME,
CREDENTIAL_PROXY_PORT,
DATA_DIR, DATA_DIR,
IDLE_TIMEOUT, IDLE_TIMEOUT,
POLL_INTERVAL, POLL_INTERVAL,
SERVICE_ID, SERVICE_ID,
SERVICE_AGENT_TYPE, SERVICE_AGENT_TYPE,
UNIFIED_MODE,
isClaudeService, isClaudeService,
isReviewService, isReviewService,
isSessionCommandSenderAllowed, isSessionCommandSenderAllowed,
@@ -85,6 +87,8 @@ import {
restoreDefaultChannelLease, restoreDefaultChannelLease,
} from './service-routing.js'; } from './service-routing.js';
import { FAILOVER_MIN_DURATION_MS } from './config.js'; import { FAILOVER_MIN_DURATION_MS } from './config.js';
import { startCredentialProxy } from './credential-proxy.js';
import { cleanupOrphans } from './container-runtime.js';
// Token rotation is initialized lazily on first use or at startup below // Token rotation is initialized lazily on first use or at startup below
@@ -184,13 +188,16 @@ function loadState(): void {
lastAgentTimestamp = {}; lastAgentTimestamp = {};
} }
sessions = getAllSessions(); sessions = getAllSessions();
// Load only this service's registrations. The DB can hold both // In unified mode, load ALL groups (duplicates resolved by preferring codex).
// claude-code and codex rows for the same Discord JID. // In legacy per-service mode, load only this service's registrations.
registeredGroups = getAllRegisteredGroups(SERVICE_AGENT_TYPE); registeredGroups = UNIFIED_MODE
? getAllRegisteredGroups()
: getAllRegisteredGroups(SERVICE_AGENT_TYPE);
logger.info( logger.info(
{ {
groupCount: Object.keys(registeredGroups).length, groupCount: Object.keys(registeredGroups).length,
agentType: SERVICE_AGENT_TYPE, agentType: SERVICE_AGENT_TYPE,
unifiedMode: UNIFIED_MODE,
}, },
'State loaded', 'State loaded',
); );
@@ -316,9 +323,20 @@ async function main(): Promise<void> {
initTokenRotation(); initTokenRotation();
initCodexTokenRotation(); initCodexTokenRotation();
// Only the Claude service owns Claude OAuth refresh to avoid // Unified mode: start credential proxy for container isolation and clean up orphaned containers
// cross-service refresh-token races on shared credentials. if (UNIFIED_MODE) {
if (shouldStartTokenRefreshLoop(SERVICE_AGENT_TYPE)) { startCredentialProxy(CREDENTIAL_PROXY_PORT).catch((err) =>
logger.warn(
{ err },
'Failed to start credential proxy (may already be running)',
),
);
cleanupOrphans();
}
// In unified mode, always start the Claude OAuth refresh loop.
// In per-service mode, only the Claude service owns refresh to avoid races.
if (UNIFIED_MODE || shouldStartTokenRefreshLoop(SERVICE_AGENT_TYPE)) {
startTokenRefreshLoop(); startTokenRefreshLoop();
} else { } else {
logger.info( logger.info(
@@ -430,7 +448,8 @@ async function main(): Promise<void> {
} }
// Start subsystems (independently of connection handler) // Start subsystems (independently of connection handler)
if (!isReviewService()) { // In unified mode always run the scheduler. In per-service mode, skip for review-only service.
if (UNIFIED_MODE || !isReviewService()) {
startSchedulerLoop({ startSchedulerLoop({
registeredGroups: () => registeredGroups, registeredGroups: () => registeredGroups,
getSessions: () => sessions, getSessions: () => sessions,
@@ -511,7 +530,7 @@ async function main(): Promise<void> {
purgeOnStart: true, purgeOnStart: true,
}); });
if (isClaudeService()) { if (UNIFIED_MODE || isClaudeService()) {
leaseRecoveryTimer = setInterval(() => { leaseRecoveryTimer = setInterval(() => {
if (!hasAvailableClaudeToken()) { if (!hasAvailableClaudeToken()) {
return; return;

View File

@@ -304,7 +304,7 @@ describe('runAgentForGroup room memory', () => {
); );
}); });
it('requires a visible structured verdict in reviewer gate turns', async () => { it('allows silent reviewer outputs', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test', chat_jid: 'group@test',
@@ -325,112 +325,21 @@ describe('runAgentForGroup room memory', () => {
reviewer_service_id: 'claude', reviewer_service_id: 'claude',
title: null, title: null,
source_ref: 'HEAD', source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null, review_requested_at: null,
gate_turn_kind: 'implementation_start', plan_notes: null,
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active', status: 'active',
created_at: '2026-03-29T00:00:00.000Z', created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z', updated_at: '2026-03-29T00:00:00.000Z',
}, },
execution: {
id: 'run-gate:claude',
task_id: 'paired-task-gate',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-29T00:00:00.000Z',
started_at: '2026-03-29T00:00:00.000Z',
completed_at: null,
},
workspace: null, workspace: null,
envOverrides: {}, envOverrides: {},
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
});
await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-review-gate-prompt',
});
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
group,
expect.objectContaining({
prompt: expect.stringContaining(
'This turn is a paired-room gate turn for implementation_start. Silent output is forbidden.',
),
}),
expect.any(Function),
undefined,
{},
);
});
it('blocks silent reviewer output on gate turns and records a silent verdict', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-gate',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: 'implementation_start',
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
execution: {
id: 'run-gate:claude',
task_id: 'paired-task-gate',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-29T00:00:00.000Z',
started_at: '2026-03-29T00:00:00.000Z',
completed_at: null,
},
workspace: null,
envOverrides: {},
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
}); });
vi.mocked(agentRunner.runAgentProcess).mockImplementation( vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => { async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({ await onOutput?.({
status: 'success', status: 'success',
result: null, result: null,
output: { visibility: 'silent', verdict: 'silent' }, output: { visibility: 'silent' },
phase: 'final', phase: 'final',
}); });
return { return {
@@ -445,190 +354,7 @@ describe('runAgentForGroup room memory', () => {
group, group,
prompt: 'hello', prompt: 'hello',
chatJid: 'group@test', chatJid: 'group@test',
runId: 'run-review-gate-silent', runId: 'run-review-silent',
onOutput: async (output) => {
outputs.push(output);
},
});
expect(result).toBe('error');
expect(outputs.at(-1)?.output).toEqual({
visibility: 'public',
text: expect.stringContaining(
'Reviewer gate turn requires a visible structured verdict.',
),
});
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith(
expect.objectContaining({
executionId: 'run-gate:claude',
status: 'failed',
reviewerVerdict: 'silent',
}),
);
});
it('allows structured reviewer verdicts on gate turns', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-gate',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: 'implementation_start',
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
execution: {
id: 'run-gate:claude',
task_id: 'paired-task-gate',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-29T00:00:00.000Z',
started_at: '2026-03-29T00:00:00.000Z',
completed_at: null,
},
workspace: null,
envOverrides: {},
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result: null,
output: {
visibility: 'public',
verdict: 'done_with_concerns',
text: '**DONE_WITH_CONCERNS**\n문제는 없지만 확인 포인트는 있습니다.',
},
phase: 'final',
});
return {
status: 'success',
result: null,
};
},
);
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-review-gate-done',
onOutput: async () => {},
});
expect(result).toBe('success');
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith(
expect.objectContaining({
executionId: 'run-gate:claude',
status: 'succeeded',
reviewerVerdict: 'done_with_concerns',
}),
);
});
it('still allows silent reviewer outputs on non-gate turns', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-gate',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: null,
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
execution: {
id: 'run-gate:claude',
task_id: 'paired-task-gate',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-29T00:00:00.000Z',
started_at: '2026-03-29T00:00:00.000Z',
completed_at: null,
},
workspace: null,
envOverrides: {},
gateTurnKind: null,
requiresVisibleVerdict: false,
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result: null,
output: { visibility: 'silent', verdict: 'silent' },
phase: 'final',
});
return {
status: 'success',
result: null,
};
},
);
const outputs: AgentOutput[] = [];
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-review-non-gate-silent',
onOutput: async (output) => { onOutput: async (output) => {
outputs.push(output); outputs.push(output);
}, },
@@ -637,7 +363,7 @@ describe('runAgentForGroup room memory', () => {
expect(result).toBe('success'); expect(result).toBe('success');
expect(outputs).toEqual([ expect(outputs).toEqual([
expect.objectContaining({ expect.objectContaining({
output: { visibility: 'silent', verdict: 'silent' }, output: { visibility: 'silent' },
}), }),
]); ]);
}); });

View File

@@ -51,7 +51,7 @@ import {
} from './codex-token-rotation.js'; } from './codex-token-rotation.js';
import type { CodexRotationReason } from './agent-error-detection.js'; import type { CodexRotationReason } from './agent-error-detection.js';
import { getTokenCount } from './token-rotation.js'; import { getTokenCount } from './token-rotation.js';
import type { PairedReviewerVerdict, RegisteredGroup } from './types.js'; import type { RegisteredGroup } from './types.js';
// ── Main executor ───────────────────────────────────────────────── // ── Main executor ─────────────────────────────────────────────────
@@ -143,21 +143,6 @@ export async function runAgentForGroup(
}); });
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed'; let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
let pairedExecutionSummary: string | null = null; let pairedExecutionSummary: string | null = null;
let pairedReviewerVerdict: PairedReviewerVerdict | null = null;
let pairedReviewerVerdictNote: string | null = null;
let reviewerGateValidationFailed = false;
const formatReviewerGateFailureMessage = (): string => {
const taskId = pairedExecutionContext?.task.id ?? 'unknown-task';
const gateTurnKind =
pairedExecutionContext?.gateTurnKind ?? 'implementation_start';
return [
'Reviewer gate turn requires a visible structured verdict.',
`- Task: ${taskId}`,
`- Gate: ${gateTurnKind}`,
'- Allowed verdicts: done, done_with_concerns, blocked',
].join('\n');
};
const shouldHandoffToCodex = ( const shouldHandoffToCodex = (
reason: AgentTriggerReason, reason: AgentTriggerReason,
@@ -386,41 +371,6 @@ export async function runAgentForGroup(
sawVisibleOutput: true, sawVisibleOutput: true,
}; };
} }
if (
pairedExecutionContext?.requiresVisibleVerdict &&
(output.phase === undefined || output.phase === 'final')
) {
const structuredOutput = getStructuredAgentOutput(output);
const reviewerVerdict = structuredOutput?.verdict ?? null;
if (
structuredOutput?.visibility === 'silent' ||
reviewerVerdict === null
) {
reviewerGateValidationFailed = true;
pairedReviewerVerdict =
structuredOutput?.visibility === 'silent' ? 'silent' : null;
pairedReviewerVerdictNote = null;
const failureMessage = formatReviewerGateFailureMessage();
pairedExecutionSummary = failureMessage.slice(0, 500);
await onOutput({
status: 'success',
result: null,
output: {
visibility: 'public',
text: failureMessage,
},
phase: 'final',
});
return;
}
if (structuredOutput) {
pairedReviewerVerdict = reviewerVerdict;
pairedReviewerVerdictNote =
structuredOutput.visibility === 'public'
? structuredOutput.text
: null;
}
}
await onOutput(output); await onOutput(output);
} }
: undefined; : undefined;
@@ -915,26 +865,6 @@ export async function runAgentForGroup(
return 'error'; return 'error';
} }
if (
pairedExecutionContext?.requiresVisibleVerdict &&
(reviewerGateValidationFailed || pairedReviewerVerdict === null)
) {
if (!reviewerGateValidationFailed && onOutput) {
const failureMessage = formatReviewerGateFailureMessage();
pairedExecutionSummary = failureMessage.slice(0, 500);
await onOutput({
status: 'success',
result: null,
output: {
visibility: 'public',
text: failureMessage,
},
phase: 'final',
});
}
return 'error';
}
pairedExecutionStatus = 'succeeded'; pairedExecutionStatus = 'succeeded';
return 'success'; return 'success';
} finally { } finally {
@@ -943,8 +873,6 @@ export async function runAgentForGroup(
taskId: pairedExecutionContext.task.id, taskId: pairedExecutionContext.task.id,
status: pairedExecutionStatus, status: pairedExecutionStatus,
summary: pairedExecutionSummary, summary: pairedExecutionSummary,
reviewerVerdict: pairedReviewerVerdict,
reviewerVerdictNote: pairedReviewerVerdictNote,
}); });
} }
} }

View File

@@ -217,9 +217,6 @@ describe('createMessageRuntime', () => {
vi.mocked(db.isPairedRoomJid).mockReturnValue(false); vi.mocked(db.isPairedRoomJid).mockReturnValue(false);
vi.mocked(config.isClaudeService).mockReturnValue(true); vi.mocked(config.isClaudeService).mockReturnValue(true);
vi.mocked(config.isReviewService).mockReturnValue(false); vi.mocked(config.isReviewService).mockReturnValue(false);
vi.mocked(pairedExecutionContext.planPairedExecutionRecovery).mockReturnValue(
null,
);
}); });
it('surfaces the pending review message through the message-runtime /review path', async () => { it('surfaces the pending review message through the message-runtime /review path', async () => {
@@ -2793,31 +2790,6 @@ describe('createMessageRuntime', () => {
const enqueueMessageCheck = vi.fn(); const enqueueMessageCheck = vi.fn();
const enqueueTask = vi.fn(); const enqueueTask = vi.fn();
vi.mocked(pairedExecutionContext.planPairedExecutionRecovery).mockReturnValue(
{
task: {
id: 'task-1',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: '2026-03-29T00:00:00.000Z',
status: 'review_pending',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
role: 'reviewer',
checkpointFingerprint: 'fingerprint-1',
recoveryKey: 'paired-recovery:task-1:reviewer:fingerprint-1',
prompt: 'resume reviewer',
},
);
vi.mocked(db.getOpenWorkItem).mockReturnValue({ vi.mocked(db.getOpenWorkItem).mockReturnValue({
id: 99, id: 99,
group_folder: group.folder, group_folder: group.folder,
@@ -2866,158 +2838,8 @@ describe('createMessageRuntime', () => {
chatJid, chatJid,
resolveGroupIpcPath(group.folder), resolveGroupIpcPath(group.folder),
); );
expect(pairedExecutionContext.planPairedExecutionRecovery).not.toHaveBeenCalled();
expect(enqueueTask).not.toHaveBeenCalled(); expect(enqueueTask).not.toHaveBeenCalled();
expect(db.getMessagesSinceSeq).not.toHaveBeenCalled(); expect(db.getMessagesSinceSeq).not.toHaveBeenCalled();
}); });
it('recovery queues paired execution resume when no messages are pending', () => {
const chatJid = 'group@test';
const group = {
...makeGroup('codex'),
workDir: '/repo/canonical',
};
const enqueueMessageCheck = vi.fn();
const enqueueTask = vi.fn();
vi.mocked(pairedExecutionContext.planPairedExecutionRecovery).mockReturnValue(
{
task: {
id: 'task-1',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: '2026-03-29T00:00:00.000Z',
status: 'review_pending',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
role: 'reviewer',
checkpointFingerprint: 'fingerprint-1',
recoveryKey: 'paired-recovery:task-1:reviewer:fingerprint-1',
prompt: 'resume reviewer',
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [makeChannel(chatJid)],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
enqueueMessageCheck,
enqueueTask,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
runtime.recoverPendingMessages();
expect(pairedExecutionContext.planPairedExecutionRecovery).toHaveBeenCalled();
expect(enqueueMessageCheck).not.toHaveBeenCalled();
expect(enqueueTask).toHaveBeenCalledWith(
chatJid,
'paired-recovery:task-1:reviewer:fingerprint-1',
expect.any(Function),
);
});
it('recovery does not queue paired execution resume when messages are already pending', () => {
const chatJid = 'group@test';
const group = {
...makeGroup('codex'),
workDir: '/repo/canonical',
};
const enqueueMessageCheck = vi.fn();
const enqueueTask = vi.fn();
vi.mocked(pairedExecutionContext.planPairedExecutionRecovery).mockReturnValue(
{
task: {
id: 'task-1',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: '2026-03-29T00:00:00.000Z',
status: 'review_pending',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
role: 'reviewer',
checkpointFingerprint: 'fingerprint-1',
recoveryKey: 'paired-recovery:task-1:reviewer:fingerprint-1',
prompt: 'resume reviewer',
},
);
vi.mocked(db.getMessagesSinceSeq).mockReturnValue([
{
id: 'msg-1',
chat_jid: chatJid,
sender: 'user',
sender_name: 'User',
content: 'pending',
timestamp: '2026-03-29T00:00:00.000Z',
seq: 1,
is_bot_message: false,
is_from_me: false,
},
] as any);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [makeChannel(chatJid)],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
enqueueMessageCheck,
enqueueTask,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
runtime.recoverPendingMessages();
expect(enqueueMessageCheck).toHaveBeenCalledWith(
chatJid,
resolveGroupIpcPath(group.folder),
);
expect(pairedExecutionContext.planPairedExecutionRecovery).not.toHaveBeenCalled();
expect(enqueueTask).not.toHaveBeenCalled();
});
}); });

View File

@@ -918,57 +918,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
} }
} }
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(lease, SERVICE_ID);
const recoveryPlan = planPairedExecutionRecovery({
group,
chatJid,
roomRoleContext,
});
if (!recoveryPlan) {
continue;
}
deps.queue.enqueueTask(chatJid, recoveryPlan.recoveryKey, async () => {
const recoveryChannel = findChannel(deps.channels, chatJid);
if (!recoveryChannel) {
logger.warn(
{
chatJid,
taskId: recoveryPlan.task.id,
role: recoveryPlan.role,
recoveryKey: recoveryPlan.recoveryKey,
},
'Skipping paired execution recovery because no channel owns the chat',
);
return;
}
const runId = `recovery-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 8)}`;
logger.info(
{
chatJid,
group: group.name,
taskId: recoveryPlan.task.id,
role: recoveryPlan.role,
checkpointFingerprint: recoveryPlan.checkpointFingerprint,
recoveryKey: recoveryPlan.recoveryKey,
runId,
},
'Recovery: resuming paired execution after restart',
);
await executeTurn({
group,
prompt: recoveryPlan.prompt,
chatJid,
runId,
channel: recoveryChannel,
startSeq: null,
endSeq: null,
});
});
} }
}; };

View File

@@ -5,7 +5,7 @@ import {
CODEX_REVIEW_SERVICE_ID, CODEX_REVIEW_SERVICE_ID,
normalizeServiceId, normalizeServiceId,
} from './config.js'; } from './config.js';
import type { PairedGateTurnKind, StructuredAgentOutput } from './types.js'; import type { StructuredAgentOutput } from './types.js';
const ANY_SUPPRESS_TOKEN_PATTERN = /__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?/g; const ANY_SUPPRESS_TOKEN_PATTERN = /__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?/g;
const EXACT_ANY_SUPPRESS_TOKEN_PATTERN = /^__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?$/; const EXACT_ANY_SUPPRESS_TOKEN_PATTERN = /^__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?$/;
@@ -64,46 +64,21 @@ export function parseStructuredOutputEnvelope(
): StructuredAgentOutput | null { ): StructuredAgentOutput | null {
try { try {
const parsed = JSON.parse(rawText) as { const parsed = JSON.parse(rawText) as {
ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown }; ejclaw?: { visibility?: unknown; text?: unknown };
}; };
const envelope = parsed?.ejclaw; const envelope = parsed?.ejclaw;
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
return null; return null;
} }
if (envelope.visibility === 'silent') { if (envelope.visibility === 'silent') {
if ( return { visibility: 'silent' };
envelope.verdict !== undefined &&
!STRUCTURED_SILENT_VERDICTS.has(String(envelope.verdict))
) {
return null;
}
return {
visibility: 'silent',
verdict:
envelope.verdict === 'silent'
? ('silent' as const)
: undefined,
};
} }
if ( if (
envelope.visibility === 'public' && envelope.visibility === 'public' &&
typeof envelope.text === 'string' && typeof envelope.text === 'string' &&
envelope.text.length > 0 envelope.text.length > 0
) { ) {
if ( return { visibility: 'public', text: envelope.text };
envelope.verdict !== undefined &&
!STRUCTURED_PUBLIC_VERDICTS.has(String(envelope.verdict))
) {
return null;
}
return {
visibility: 'public',
text: envelope.text,
verdict:
typeof envelope.verdict === 'string'
? (envelope.verdict as 'done' | 'done_with_concerns' | 'blocked')
: undefined,
};
} }
} catch { } catch {
return null; return null;
@@ -116,7 +91,7 @@ export function buildStructuredOutputPrompt(
prompt: string, prompt: string,
options?: { options?: {
reviewerMode?: boolean; reviewerMode?: boolean;
gateTurnKind?: PairedGateTurnKind | null; gateTurnKind?: string | null;
requiresVisibleVerdict?: boolean; requiresVisibleVerdict?: boolean;
}, },
): string { ): string {

View File

@@ -33,10 +33,8 @@ vi.mock('./logger.js', () => ({
import * as db from './db.js'; import * as db from './db.js';
import { import {
completePairedExecutionContext, completePairedExecutionContext,
finalizeRoomDeployment,
formatRoomReviewReadyMessage, formatRoomReviewReadyMessage,
markRoomReviewReady, markRoomReviewReady,
planPairedExecutionRecovery,
preparePairedExecutionContext, preparePairedExecutionContext,
} from './paired-execution-context.js'; } from './paired-execution-context.js';
import * as pairedWorkspaceManager from './paired-workspace-manager.js'; import * as pairedWorkspaceManager from './paired-workspace-manager.js';
@@ -130,7 +128,10 @@ async function importExecutionContextForService(serviceId: string) {
); );
vi.doMock('./db.js', () => dbModule); vi.doMock('./db.js', () => dbModule);
vi.doMock('./paired-workspace-manager.js', () => pairedWorkspaceManagerModule); vi.doMock(
'./paired-workspace-manager.js',
() => pairedWorkspaceManagerModule,
);
vi.doMock('./logger.js', () => ({ vi.doMock('./logger.js', () => ({
logger: { logger: {
debug: vi.fn(), debug: vi.fn(),
@@ -473,7 +474,9 @@ describe('paired execution context', () => {
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });
pairedWorkspaceManagerModule.markPairedTaskReviewReady.mockReturnValue(null); pairedWorkspaceManagerModule.markPairedTaskReviewReady.mockReturnValue(
null,
);
const result = isolatedMarkRoomReviewReady({ const result = isolatedMarkRoomReviewReady({
group, group,

View File

@@ -103,11 +103,6 @@ function ensureActiveTask(
source_ref: resolveCanonicalSourceRef(canonicalWorkDir), source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
plan_notes: null, plan_notes: null,
review_requested_at: null, review_requested_at: null,
last_finalized_checkpoint: null,
gate_turn_kind: null,
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active', status: 'active',
created_at: now, created_at: now,
updated_at: now, updated_at: now,
@@ -133,7 +128,7 @@ export interface PreparedPairedExecutionContext {
task: PairedTask; task: PairedTask;
workspace: PairedWorkspace | null; workspace: PairedWorkspace | null;
envOverrides: Record<string, string>; envOverrides: Record<string, string>;
gateTurnKind?: PairedGateTurnKind | null; gateTurnKind?: string | null;
requiresVisibleVerdict?: boolean; requiresVisibleVerdict?: boolean;
blockMessage?: string; blockMessage?: string;
} }
@@ -163,22 +158,11 @@ export function preparePairedExecutionContext(args: {
} }
const latestTask = getPairedTaskById(task.id) ?? task; const latestTask = getPairedTaskById(task.id) ?? task;
const gateTurnKind = getGateTurnKind(latestTask);
const requiresVisibleVerdict =
roomRoleContext.role === 'reviewer' &&
!!gateTurnKind &&
!hasVisibleReviewerGateVerdict(latestTask.reviewer_verdict);
let workspace: PairedWorkspace | null = null; let workspace: PairedWorkspace | null = null;
let blockMessage: string | undefined; let blockMessage: string | undefined;
const now = new Date().toISOString(); const now = new Date().toISOString();
if ( if (roomRoleContext.role === 'owner') {
roomRoleContext.role === 'owner' &&
gateTurnKind &&
!allowsOwnerToProceedForGate(latestTask.reviewer_verdict)
) {
blockMessage = formatOwnerGateBlockedMessage(latestTask);
} else if (roomRoleContext.role === 'owner') {
workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id); workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id);
} else { } else {
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask); const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
@@ -203,17 +187,12 @@ export function preparePairedExecutionContext(args: {
} }
if (roomRoleContext.role === 'reviewer') { if (roomRoleContext.role === 'reviewer') {
envOverrides.EJCLAW_REVIEWER_RUNTIME = '1'; envOverrides.EJCLAW_REVIEWER_RUNTIME = '1';
if (requiresVisibleVerdict && gateTurnKind) {
envOverrides.EJCLAW_PAIRED_GATE_TURN_KIND = gateTurnKind;
}
} }
return { return {
task: getPairedTaskById(task.id) ?? task, task: getPairedTaskById(task.id) ?? task,
workspace, workspace,
envOverrides, envOverrides,
gateTurnKind,
requiresVisibleVerdict,
blockMessage, blockMessage,
}; };
} }

View File

@@ -63,7 +63,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -129,8 +129,7 @@ describe('paired workspace manager', () => {
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at, db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
).toBeTruthy(); ).toBeTruthy();
expect( expect(
db.getPairedWorkspace('paired-task-1', 'reviewer') db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_ref,
?.snapshot_ref,
).toBeTruthy(); ).toBeTruthy();
}); });
@@ -152,7 +151,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -204,7 +203,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -279,7 +278,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -353,7 +352,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -493,7 +492,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -554,7 +553,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -609,7 +608,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -670,7 +669,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -733,7 +732,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({
@@ -821,7 +820,7 @@ describe('paired workspace manager', () => {
chat_jid: 'dc:test', chat_jid: 'dc:test',
group_folder: 'paired-room', group_folder: 'paired-room',
canonical_work_dir: canonicalDir, canonical_work_dir: canonicalDir,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
db.createPairedTask({ db.createPairedTask({

View File

@@ -198,8 +198,13 @@ function listReviewableTrackedDiffFiles(
sourceDir: string, sourceDir: string,
sourceRef: string, sourceRef: string,
): string[] { ): string[] {
return listGitPaths(sourceDir, ['diff', '--name-only', '-z', sourceRef, '--']) return listGitPaths(sourceDir, [
.filter((relativePath) => !isReviewerSnapshotDeniedPath(relativePath)); 'diff',
'--name-only',
'-z',
sourceRef,
'--',
]).filter((relativePath) => !isReviewerSnapshotDeniedPath(relativePath));
} }
function copySnapshotPaths( function copySnapshotPaths(
@@ -323,9 +328,7 @@ function makeWorkspaceRecord(args: {
snapshot_source_dir: snapshot_source_dir:
args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null, args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null,
snapshot_ref: snapshot_ref:
args.snapshotSourceFingerprint ?? args.snapshotSourceFingerprint ?? existing?.snapshot_ref ?? null,
existing?.snapshot_ref ??
null,
status: args.status ?? 'ready', status: args.status ?? 'ready',
snapshot_refreshed_at: snapshot_refreshed_at:
args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null, args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null,
@@ -334,7 +337,9 @@ function makeWorkspaceRecord(args: {
}; };
} }
export function resolvePairedTaskSourceFingerprint(taskId: string): string | null { export function resolvePairedTaskSourceFingerprint(
taskId: string,
): string | null {
const { task } = getTaskAndProject(taskId); const { task } = getTaskAndProject(taskId);
const ownerWorkspace = getPairedWorkspace(taskId, 'owner'); const ownerWorkspace = getPairedWorkspace(taskId, 'owner');
if (!ownerWorkspace) { if (!ownerWorkspace) {
@@ -574,8 +579,7 @@ export function prepareReviewerWorkspaceForExecution(
role: 'reviewer', role: 'reviewer',
workspaceDir: reviewerWorkspace.workspace_dir, workspaceDir: reviewerWorkspace.workspace_dir,
snapshotSourceDir: reviewerWorkspace.snapshot_source_dir, snapshotSourceDir: reviewerWorkspace.snapshot_source_dir,
snapshotSourceFingerprint: snapshotSourceFingerprint: reviewerWorkspace.snapshot_ref,
reviewerWorkspace.snapshot_ref,
snapshotRefreshedAt: reviewerWorkspace.snapshot_refreshed_at, snapshotRefreshedAt: reviewerWorkspace.snapshot_refreshed_at,
status: 'stale', status: 'stale',
createdAt: reviewerWorkspace.created_at, createdAt: reviewerWorkspace.created_at,

View File

@@ -93,6 +93,13 @@ export function findChannel(
return channels.find((c) => c.ownsJid(jid)); return channels.find((c) => c.ownsJid(jid));
} }
export function findChannelByName(
channels: Channel[],
name: string,
): Channel | undefined {
return channels.find((c) => c.name === name);
}
/** /**
* Normalize message text for deduplication comparison. * Normalize message text for deduplication comparison.
* - Trim leading/trailing whitespace * - Trim leading/trailing whitespace

View File

@@ -4,6 +4,7 @@ import {
CODEX_REVIEW_SERVICE_ID, CODEX_REVIEW_SERVICE_ID,
SERVICE_AGENT_TYPE, SERVICE_AGENT_TYPE,
SERVICE_ID, SERVICE_ID,
UNIFIED_MODE,
normalizeServiceId, normalizeServiceId,
} from './config.js'; } from './config.js';
import { import {
@@ -142,6 +143,7 @@ export function shouldServiceProcessChat(
chatJid: string, chatJid: string,
serviceId: string = SERVICE_ID, serviceId: string = SERVICE_ID,
): boolean { ): boolean {
if (UNIFIED_MODE) return true;
const normalizedServiceId = normalizeServiceId(serviceId); const normalizedServiceId = normalizeServiceId(serviceId);
const lease = getEffectiveChannelLease(chatJid); const lease = getEffectiveChannelLease(chatJid);
return ( return (

View File

@@ -234,23 +234,6 @@ describe('handleSessionCommand', () => {
); );
}); });
it('handles authorized /deploy-complete without invoking the agent', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({
missedMessages: [makeMsg('/deploy-complete')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.finalizeDeployment).toHaveBeenCalledTimes(1);
expect(deps.runAgent).not.toHaveBeenCalled();
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
expect(deps.sendMessage).toHaveBeenCalledWith('Deployment finalized.');
});
it('handles authorized /review without invoking the agent', async () => { it('handles authorized /review without invoking the agent', async () => {
const deps = makeDeps({ const deps = makeDeps({
markReviewReady: vi markReviewReady: vi

View File

@@ -62,11 +62,6 @@ export interface PairedTask {
source_ref: string | null; source_ref: string | null;
plan_notes: string | null; plan_notes: string | null;
review_requested_at: string | null; review_requested_at: string | null;
last_finalized_checkpoint?: string | null;
gate_turn_kind?: PairedGateTurnKind | null;
reviewer_verdict?: PairedReviewerVerdict | null;
reviewer_verdict_at?: string | null;
reviewer_verdict_note?: string | null;
status: PairedTaskStatus; status: PairedTaskStatus;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -91,11 +86,9 @@ export type StructuredAgentOutput =
| { | {
visibility: 'public'; visibility: 'public';
text: string; text: string;
verdict?: Exclude<PairedReviewerVerdict, 'silent'>;
} }
| { | {
visibility: 'silent'; visibility: 'silent';
verdict?: 'silent';
}; };
export function normalizeAgentOutputPhase( export function normalizeAgentOutputPhase(