fix: route scheduled watcher output by room role
This commit is contained in:
@@ -41,9 +41,22 @@ const chatJid = process.env.EJCLAW_CHAT_JID!;
|
||||
const groupFolder = process.env.EJCLAW_GROUP_FOLDER!;
|
||||
const isMain = process.env.EJCLAW_IS_MAIN === '1';
|
||||
const agentType = process.env.EJCLAW_AGENT_TYPE || 'claude-code';
|
||||
const roomRole = process.env.EJCLAW_ROOM_ROLE;
|
||||
const runtimeTaskId = process.env.EJCLAW_RUNTIME_TASK_ID;
|
||||
const allowGenericScheduling = agentType !== 'codex';
|
||||
|
||||
function currentAgentType(): 'claude-code' | 'codex' {
|
||||
return agentType === 'codex' ? 'codex' : 'claude-code';
|
||||
}
|
||||
|
||||
function currentRoomRole(): 'owner' | 'reviewer' | 'arbiter' | undefined {
|
||||
return roomRole === 'owner' ||
|
||||
roomRole === 'reviewer' ||
|
||||
roomRole === 'arbiter'
|
||||
? roomRole
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function writeIpcFile(dir: string, data: object): string {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -214,6 +227,8 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
||||
prompt: args.prompt,
|
||||
schedule_type: args.schedule_type,
|
||||
schedule_value: args.schedule_value,
|
||||
agent_type: currentAgentType(),
|
||||
room_role: currentRoomRole(),
|
||||
context_mode: args.context_mode || 'group',
|
||||
targetJid,
|
||||
createdBy: groupFolder,
|
||||
@@ -301,6 +316,18 @@ server.tool(
|
||||
),
|
||||
},
|
||||
async (args) => {
|
||||
if (roomRole && roomRole !== 'owner') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'watch_ci is owner-only in paired rooms. Ask the owner to schedule the watcher.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const isGitHubWatcher = args.ci_provider === 'github';
|
||||
const target =
|
||||
args.target ||
|
||||
@@ -383,6 +410,8 @@ server.tool(
|
||||
prompt,
|
||||
schedule_type: 'interval' as const,
|
||||
schedule_value: String(pollSeconds * 1000),
|
||||
agent_type: currentAgentType(),
|
||||
room_role: currentRoomRole(),
|
||||
context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||
ci_provider: args.ci_provider,
|
||||
ci_metadata: isGitHubWatcher
|
||||
|
||||
@@ -41,6 +41,7 @@ function getExpectedSchemaMigrations(): Array<{
|
||||
{ version: 14, name: 'work_item_attachments' },
|
||||
{ version: 15, name: 'turn_progress_text' },
|
||||
{ version: 16, name: 'room_skill_overrides' },
|
||||
{ version: 17, name: 'scheduled_task_room_role' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
13
src/db/migrations/017_scheduled-task-room-role.ts
Normal file
13
src/db/migrations/017_scheduled-task-room-role.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { SchemaMigrationDefinition } from './types.js';
|
||||
import { tryExecMigration } from './helpers.js';
|
||||
|
||||
export const SCHEDULED_TASK_ROOM_ROLE_MIGRATION = {
|
||||
version: 17,
|
||||
name: 'scheduled_task_room_role',
|
||||
apply(database) {
|
||||
tryExecMigration(
|
||||
database,
|
||||
`ALTER TABLE scheduled_tasks ADD COLUMN room_role TEXT`,
|
||||
);
|
||||
},
|
||||
} satisfies SchemaMigrationDefinition;
|
||||
@@ -16,6 +16,7 @@ import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
|
||||
import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
|
||||
import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js';
|
||||
import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.js';
|
||||
import { SCHEDULED_TASK_ROOM_ROLE_MIGRATION } from './017_scheduled-task-room-role.js';
|
||||
import type {
|
||||
SchemaMigrationArgs,
|
||||
SchemaMigrationDefinition,
|
||||
@@ -40,6 +41,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
|
||||
WORK_ITEM_ATTACHMENTS_MIGRATION,
|
||||
TURN_PROGRESS_TEXT_MIGRATION,
|
||||
ROOM_SKILL_OVERRIDES_MIGRATION,
|
||||
SCHEDULED_TASK_ROOM_ROLE_MIGRATION,
|
||||
];
|
||||
|
||||
function ensureSchemaMigrationsTable(database: Database): void {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { AgentType, ScheduledTask, TaskRunLog } from '../types.js';
|
||||
import {
|
||||
AgentType,
|
||||
PairedRoomRole,
|
||||
ScheduledTask,
|
||||
TaskRunLog,
|
||||
} from '../types.js';
|
||||
|
||||
export type CreateScheduledTaskInput = Omit<
|
||||
ScheduledTask,
|
||||
| 'last_run'
|
||||
| 'last_result'
|
||||
| 'agent_type'
|
||||
| 'room_role'
|
||||
| 'ci_provider'
|
||||
| 'ci_metadata'
|
||||
| 'max_duration_ms'
|
||||
@@ -14,6 +20,7 @@ export type CreateScheduledTaskInput = Omit<
|
||||
| 'status_started_at'
|
||||
> & {
|
||||
agent_type?: AgentType | null;
|
||||
room_role?: PairedRoomRole | null;
|
||||
ci_provider?: ScheduledTask['ci_provider'];
|
||||
ci_metadata?: string | null;
|
||||
max_duration_ms?: number | null;
|
||||
@@ -45,8 +52,8 @@ export function createTaskInDatabase(
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, ci_provider, ci_metadata, max_duration_ms, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, agent_type, room_role, ci_provider, ci_metadata, max_duration_ms, status_message_id, status_started_at, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
@@ -54,6 +61,7 @@ export function createTaskInDatabase(
|
||||
task.group_folder,
|
||||
task.chat_jid,
|
||||
task.agent_type || 'claude-code',
|
||||
task.room_role ?? null,
|
||||
task.ci_provider ?? null,
|
||||
task.ci_metadata ?? null,
|
||||
task.max_duration_ms ?? null,
|
||||
|
||||
@@ -139,6 +139,34 @@ describe('schedule_task authorization', () => {
|
||||
expect(allTasks[0].agent_type).toBe('codex');
|
||||
});
|
||||
|
||||
it('stores the scheduling caller agent type when provided', async () => {
|
||||
groups['other@g.us'] = {
|
||||
...OTHER_GROUP,
|
||||
agentType: 'claude-code',
|
||||
};
|
||||
_setRegisteredGroupForTests('other@g.us', groups['other@g.us']);
|
||||
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'schedule_task',
|
||||
prompt: 'caller owned task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2025-06-01T00:00:00',
|
||||
targetJid: 'other@g.us',
|
||||
agent_type: 'codex',
|
||||
room_role: 'owner',
|
||||
},
|
||||
'whatsapp_main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
const allTasks = getAllTasks();
|
||||
expect(allTasks).toHaveLength(1);
|
||||
expect(allTasks[0].agent_type).toBe('codex');
|
||||
expect(allTasks[0].room_role).toBe('owner');
|
||||
});
|
||||
|
||||
it('non-main group cannot schedule for another group', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
|
||||
@@ -16,17 +16,27 @@ import {
|
||||
} from './db.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { normalizeStoredAgentType } from './db/room-registration.js';
|
||||
import {
|
||||
DEFAULT_WATCH_CI_MAX_DURATION_MS,
|
||||
isWatchCiTask,
|
||||
} from './task-watch-status.js';
|
||||
import type { IpcDeps, TaskIpcPayload } from './ipc-types.js';
|
||||
import type { PairedRoomRole } from './types.js';
|
||||
|
||||
type RoomBindings = ReturnType<IpcDeps['roomBindings']>;
|
||||
type ScheduleType = 'cron' | 'interval' | 'once';
|
||||
type TaskUpdates = Parameters<typeof updateTask>[1];
|
||||
type MemorySourceKind = Parameters<typeof rememberMemory>[0]['sourceKind'];
|
||||
|
||||
function normalizePairedRoomRole(
|
||||
role: string | null | undefined,
|
||||
): PairedRoomRole | undefined {
|
||||
return role === 'owner' || role === 'reviewer' || role === 'arbiter'
|
||||
? role
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function processTaskIpc(
|
||||
data: TaskIpcPayload,
|
||||
sourceGroup: string,
|
||||
@@ -173,11 +183,17 @@ function handleScheduleTask(
|
||||
data.context_mode === 'group' || data.context_mode === 'isolated'
|
||||
? data.context_mode
|
||||
: 'isolated';
|
||||
const scheduledAgentType =
|
||||
normalizeStoredAgentType(data.agent_type) ??
|
||||
targetGroupEntry.agentType ??
|
||||
'claude-code';
|
||||
const scheduledRoomRole = normalizePairedRoomRole(data.room_role);
|
||||
createTask({
|
||||
id: taskId,
|
||||
group_folder: targetFolder,
|
||||
chat_jid: resolvedTargetJid,
|
||||
agent_type: targetGroupEntry.agentType || 'claude-code',
|
||||
agent_type: scheduledAgentType,
|
||||
room_role: scheduledRoomRole ?? null,
|
||||
ci_provider: data.ci_provider ?? null,
|
||||
ci_metadata: data.ci_metadata ?? null,
|
||||
max_duration_ms: isWatchCiTask({ prompt: data.prompt })
|
||||
@@ -197,7 +213,8 @@ function handleScheduleTask(
|
||||
sourceGroup,
|
||||
targetFolder,
|
||||
contextMode,
|
||||
agentType: targetGroupEntry.agentType || 'claude-code',
|
||||
agentType: scheduledAgentType,
|
||||
roomRole: scheduledRoomRole ?? null,
|
||||
},
|
||||
'Task created via IPC',
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AgentType,
|
||||
MessageSourceKind,
|
||||
OutboundAttachment,
|
||||
PairedRoomRole,
|
||||
PairedTask,
|
||||
RegisteredGroup,
|
||||
RoomMode,
|
||||
@@ -120,6 +121,8 @@ export interface TaskIpcPayload {
|
||||
schedule_type?: string;
|
||||
schedule_value?: string;
|
||||
context_mode?: string;
|
||||
agent_type?: AgentType;
|
||||
room_role?: PairedRoomRole;
|
||||
ci_provider?: 'github';
|
||||
ci_metadata?: string;
|
||||
groupFolder?: string;
|
||||
|
||||
79
src/task-scheduler-github-delivery.test.ts
Normal file
79
src/task-scheduler-github-delivery.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { checkGitHubActionsRunMock, sendScheduledMessageMock } = vi.hoisted(
|
||||
() => ({
|
||||
checkGitHubActionsRunMock: vi.fn(),
|
||||
sendScheduledMessageMock: vi.fn(async () => {}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock('./github-ci.js', () => ({
|
||||
checkGitHubActionsRun: checkGitHubActionsRunMock,
|
||||
computeGitHubWatcherDelayMs: vi.fn(() => 15_000),
|
||||
MAX_GITHUB_CONSECUTIVE_ERRORS: 5,
|
||||
parseGitHubCiMetadata: vi.fn((raw: string | null | undefined) =>
|
||||
raw ? JSON.parse(raw) : null,
|
||||
),
|
||||
serializeGitHubCiMetadata: vi.fn((metadata: unknown) =>
|
||||
JSON.stringify(metadata),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./task-scheduler-runtime.js', () => ({
|
||||
sendScheduledMessage: sendScheduledMessageMock,
|
||||
}));
|
||||
|
||||
vi.mock('./task-status-tracker.js', () => ({
|
||||
createTaskStatusTracker: () => ({
|
||||
update: vi.fn(async () => {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
||||
import { runGithubCiTask } from './task-scheduler-github.js';
|
||||
|
||||
describe('GitHub watcher delivery identity', () => {
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
checkGitHubActionsRunMock.mockReset();
|
||||
sendScheduledMessageMock.mockClear();
|
||||
});
|
||||
|
||||
it('passes the scheduling agent type to completion delivery', async () => {
|
||||
checkGitHubActionsRunMock.mockResolvedValueOnce({
|
||||
terminal: true,
|
||||
resultSummary: '성공: owner/repo run 765432',
|
||||
completionMessage: 'CI 완료: GitHub Actions run 765432\n판정: 성공',
|
||||
});
|
||||
createTask({
|
||||
id: 'task-github-codex-paired-complete',
|
||||
group_folder: 'paired-group',
|
||||
chat_jid: 'paired@g.us',
|
||||
agent_type: 'codex',
|
||||
room_role: 'owner',
|
||||
ci_provider: 'github',
|
||||
ci_metadata: JSON.stringify({ repo: 'owner/repo', run_id: 765432 }),
|
||||
prompt: '[BACKGROUND CI WATCH]\nGitHub Actions run 765432',
|
||||
schedule_type: 'interval',
|
||||
schedule_value: '15000',
|
||||
context_mode: 'isolated',
|
||||
next_run: new Date().toISOString(),
|
||||
status: 'active',
|
||||
created_at: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const task = getTaskById('task-github-codex-paired-complete');
|
||||
expect(task).toBeDefined();
|
||||
const deps = { sendMessage: vi.fn(async () => {}) } as any;
|
||||
|
||||
await runGithubCiTask(task!, deps);
|
||||
|
||||
expect(sendScheduledMessageMock).toHaveBeenCalledWith(
|
||||
deps,
|
||||
'paired@g.us',
|
||||
'CI 완료: GitHub Actions run 765432\n판정: 성공',
|
||||
'owner',
|
||||
);
|
||||
expect(getTaskById('task-github-codex-paired-complete')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,7 @@ export async function runGithubCiTask(
|
||||
deps,
|
||||
task.chat_jid,
|
||||
check.completionMessage,
|
||||
task.room_role,
|
||||
);
|
||||
}
|
||||
deleteTask(task.id);
|
||||
|
||||
67
src/task-scheduler-runtime.test.ts
Normal file
67
src/task-scheduler-runtime.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./service-routing.js', () => ({
|
||||
hasReviewerLease: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import * as serviceRouting from './service-routing.js';
|
||||
import { sendScheduledMessage } from './task-scheduler-runtime.js';
|
||||
|
||||
describe('scheduled message delivery identity', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('uses the default owner identity for owner output in paired rooms', async () => {
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
const sendMessageViaReviewerBot = vi.fn(async () => {});
|
||||
|
||||
await sendScheduledMessage(
|
||||
{ sendMessage, sendMessageViaReviewerBot } as any,
|
||||
'paired@g.us',
|
||||
'owner watcher done',
|
||||
'owner',
|
||||
);
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
'paired@g.us',
|
||||
'owner watcher done',
|
||||
);
|
||||
expect(sendMessageViaReviewerBot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the reviewer identity for claude output in paired rooms', async () => {
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
const sendMessageViaReviewerBot = vi.fn(async () => {});
|
||||
|
||||
await sendScheduledMessage(
|
||||
{ sendMessage, sendMessageViaReviewerBot } as any,
|
||||
'paired@g.us',
|
||||
'reviewer watcher done',
|
||||
'reviewer',
|
||||
);
|
||||
|
||||
expect(sendMessageViaReviewerBot).toHaveBeenCalledWith(
|
||||
'paired@g.us',
|
||||
'reviewer watcher done',
|
||||
);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed for reviewer output when the reviewer bot is missing', async () => {
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
|
||||
await expect(
|
||||
sendScheduledMessage(
|
||||
{ sendMessage } as any,
|
||||
'paired@g.us',
|
||||
'reviewer watcher done',
|
||||
'reviewer',
|
||||
),
|
||||
).rejects.toThrow(/reviewer Discord bot/);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getTaskRuntimeTaskId,
|
||||
shouldUseTaskScopedSession,
|
||||
} from './task-watch-status.js';
|
||||
import type { AgentType, ScheduledTask } from './types.js';
|
||||
import type { AgentType, PairedRoomRole, ScheduledTask } from './types.js';
|
||||
import type {
|
||||
SchedulerDependencies,
|
||||
TaskExecutionContext,
|
||||
@@ -49,8 +49,9 @@ export async function sendScheduledMessage(
|
||||
deps: SchedulerDependencies,
|
||||
chatJid: string,
|
||||
text: string,
|
||||
senderRoomRole?: PairedRoomRole | null,
|
||||
): Promise<void> {
|
||||
if (!hasReviewerLease(chatJid)) {
|
||||
if (!hasReviewerLease(chatJid) || senderRoomRole !== 'reviewer') {
|
||||
await deps.sendMessage(chatJid, text);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -448,146 +448,6 @@ describe('task scheduler', () => {
|
||||
expect(task?.next_run).not.toBeNull();
|
||||
});
|
||||
|
||||
it('uses the reviewer bot identity for paired-room scheduled output', async () => {
|
||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||
createTask({
|
||||
id: 'task-paired-reviewer-output',
|
||||
group_folder: 'paired-group',
|
||||
chat_jid: 'paired@g.us',
|
||||
agent_type: 'codex',
|
||||
prompt: 'paired task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: dueAt,
|
||||
context_mode: 'isolated',
|
||||
next_run: dueAt,
|
||||
status: 'active',
|
||||
created_at: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockImplementation(
|
||||
(jid) => jid === 'paired@g.us',
|
||||
);
|
||||
(runAgentProcessMock as any).mockImplementationOnce(
|
||||
async (
|
||||
_group: unknown,
|
||||
_input: unknown,
|
||||
_onProcess: unknown,
|
||||
onOutput?: (output: Record<string, unknown>) => Promise<void>,
|
||||
) => {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
phase: 'final',
|
||||
result: 'paired reviewer output',
|
||||
});
|
||||
return {
|
||||
status: 'success',
|
||||
result: 'paired reviewer output',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const enqueueTask = vi.fn(
|
||||
async (_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
||||
await fn();
|
||||
},
|
||||
);
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
const sendMessageViaReviewerBot = vi.fn(async () => {});
|
||||
|
||||
await runSchedulerTickOnce({
|
||||
serviceAgentType: 'codex',
|
||||
roomBindings: () => ({
|
||||
'paired@g.us': {
|
||||
name: 'Paired',
|
||||
folder: 'paired-group',
|
||||
trigger: '@Codex',
|
||||
added_at: '2026-02-22T00:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
},
|
||||
}),
|
||||
getSessions: () => ({}),
|
||||
queue: { enqueueTask } as any,
|
||||
onProcess: () => {},
|
||||
sendMessage,
|
||||
sendMessageViaReviewerBot,
|
||||
});
|
||||
|
||||
expect(runAgentProcessMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageViaReviewerBot).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageViaReviewerBot).toHaveBeenCalledWith(
|
||||
'paired@g.us',
|
||||
'paired reviewer output',
|
||||
);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed for paired-room scheduled output when the reviewer bot is missing', async () => {
|
||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||
createTask({
|
||||
id: 'task-paired-reviewer-missing',
|
||||
group_folder: 'paired-group',
|
||||
chat_jid: 'paired@g.us',
|
||||
agent_type: 'codex',
|
||||
prompt: 'paired task',
|
||||
schedule_type: 'once',
|
||||
schedule_value: dueAt,
|
||||
context_mode: 'isolated',
|
||||
next_run: dueAt,
|
||||
status: 'active',
|
||||
created_at: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockImplementation(
|
||||
(jid) => jid === 'paired@g.us',
|
||||
);
|
||||
(runAgentProcessMock as any).mockImplementationOnce(
|
||||
async (
|
||||
_group: unknown,
|
||||
_input: unknown,
|
||||
_onProcess: unknown,
|
||||
onOutput?: (output: Record<string, unknown>) => Promise<void>,
|
||||
) => {
|
||||
await onOutput?.({
|
||||
status: 'success',
|
||||
phase: 'final',
|
||||
result: 'paired reviewer output',
|
||||
});
|
||||
return {
|
||||
status: 'success',
|
||||
result: 'paired reviewer output',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const enqueueTask = vi.fn(
|
||||
async (_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
||||
await fn();
|
||||
},
|
||||
);
|
||||
const sendMessage = vi.fn(async () => {});
|
||||
|
||||
await runSchedulerTickOnce({
|
||||
serviceAgentType: 'codex',
|
||||
roomBindings: () => ({
|
||||
'paired@g.us': {
|
||||
name: 'Paired',
|
||||
folder: 'paired-group',
|
||||
trigger: '@Codex',
|
||||
added_at: '2026-02-22T00:00:00.000Z',
|
||||
agentType: 'codex',
|
||||
},
|
||||
}),
|
||||
getSessions: () => ({}),
|
||||
queue: { enqueueTask } as any,
|
||||
onProcess: () => {},
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
expect(runAgentProcessMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(getTaskById('task-paired-reviewer-missing')).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps group-context tasks on the chat queue key', async () => {
|
||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||
createTask({
|
||||
|
||||
@@ -107,6 +107,28 @@ export function computeNextRun(task: ScheduledTask): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function forwardScheduledOutput(
|
||||
deps: SchedulerDependencies,
|
||||
task: ScheduledTask,
|
||||
outputText: string,
|
||||
taskRoomRole: ScheduledTask['room_role'],
|
||||
): Promise<void> {
|
||||
await sendScheduledMessage(deps, task.chat_jid, outputText, taskRoomRole);
|
||||
}
|
||||
|
||||
function writeCurrentTaskSnapshot(
|
||||
context: TaskExecutionContext,
|
||||
task: ScheduledTask,
|
||||
taskAgentType: TaskExecutionContext['taskAgentType'],
|
||||
): void {
|
||||
writeTaskSnapshotForGroup(
|
||||
taskAgentType,
|
||||
task.group_folder,
|
||||
context.isMain,
|
||||
context.runtimeTaskId,
|
||||
);
|
||||
}
|
||||
|
||||
async function runTask(
|
||||
task: ScheduledTask,
|
||||
deps: SchedulerDependencies,
|
||||
@@ -149,14 +171,10 @@ async function runTask(
|
||||
});
|
||||
|
||||
log.info('Running scheduled task');
|
||||
const taskAgentType = context.taskAgentType;
|
||||
|
||||
// Update tasks snapshot for agent to read (filtered by group)
|
||||
writeTaskSnapshotForGroup(
|
||||
context.taskAgentType,
|
||||
task.group_folder,
|
||||
context.isMain,
|
||||
context.runtimeTaskId,
|
||||
);
|
||||
writeCurrentTaskSnapshot(context, task, taskAgentType);
|
||||
|
||||
let result: string | null = null;
|
||||
let error: string | null;
|
||||
@@ -164,7 +182,7 @@ async function runTask(
|
||||
sendTrackedMessage: deps.sendTrackedMessage,
|
||||
editTrackedMessage: deps.editTrackedMessage,
|
||||
});
|
||||
const isClaudeAgent = context.taskAgentType === 'claude-code';
|
||||
const isClaudeAgent = taskAgentType === 'claude-code';
|
||||
|
||||
try {
|
||||
await statusTracker.update('checking');
|
||||
@@ -233,8 +251,12 @@ async function runTask(
|
||||
|
||||
if (outputText) {
|
||||
attemptResult = outputText;
|
||||
// Paired-room scheduler output must use the reviewer bot slot.
|
||||
await sendScheduledMessage(deps, task.chat_jid, outputText);
|
||||
await forwardScheduledOutput(
|
||||
deps,
|
||||
task,
|
||||
outputText,
|
||||
task.room_role,
|
||||
);
|
||||
}
|
||||
|
||||
if (streamedOutput.status === 'error') {
|
||||
|
||||
@@ -220,6 +220,7 @@ export interface ScheduledTask {
|
||||
group_folder: string;
|
||||
chat_jid: string;
|
||||
agent_type: AgentType | null;
|
||||
room_role?: PairedRoomRole | null;
|
||||
ci_provider?: 'github' | null;
|
||||
ci_metadata?: string | null;
|
||||
max_duration_ms?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user