fix: route scheduled watcher output by room role

This commit is contained in:
ejclaw
2026-05-26 04:17:59 +09:00
parent d770c40862
commit a6e1b8172c
15 changed files with 288 additions and 156 deletions

View File

@@ -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

View File

@@ -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' },
];
}

View File

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

View File

@@ -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 {

View File

@@ -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,

View File

@@ -139,6 +139,34 @@ describe('schedule_task authorization', () => {
expect(allTasks[0].agent_type).toBe('codex');
});
it('stores the scheduling caller agent type when provided', async () => {
groups['other@g.us'] = {
...OTHER_GROUP,
agentType: 'claude-code',
};
_setRegisteredGroupForTests('other@g.us', groups['other@g.us']);
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'caller owned task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
agent_type: 'codex',
room_role: 'owner',
},
'whatsapp_main',
true,
deps,
);
const allTasks = getAllTasks();
expect(allTasks).toHaveLength(1);
expect(allTasks[0].agent_type).toBe('codex');
expect(allTasks[0].room_role).toBe('owner');
});
it('non-main group cannot schedule for another group', async () => {
await processTaskIpc(
{

View File

@@ -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',
);

View File

@@ -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;

View 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();
});
});

View File

@@ -61,6 +61,7 @@ export async function runGithubCiTask(
deps,
task.chat_jid,
check.completionMessage,
task.room_role,
);
}
deleteTask(task.id);

View 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();
});
});

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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') {

View File

@@ -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;