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:
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
48
src/db.ts
48
src/db.ts
@@ -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);
|
||||||
|
|||||||
35
src/index.ts
35
src/index.ts
@@ -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;
|
||||||
|
|||||||
@@ -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' },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user