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 groupFolder = process.env.EJCLAW_GROUP_FOLDER!;
|
||||||
const isMain = process.env.EJCLAW_IS_MAIN === '1';
|
const isMain = process.env.EJCLAW_IS_MAIN === '1';
|
||||||
const agentType = process.env.EJCLAW_AGENT_TYPE || 'claude-code';
|
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 runtimeTaskId = process.env.EJCLAW_RUNTIME_TASK_ID;
|
||||||
const allowGenericScheduling = agentType !== 'codex';
|
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 {
|
function writeIpcFile(dir: string, data: object): string {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
@@ -214,6 +227,8 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
|||||||
prompt: args.prompt,
|
prompt: args.prompt,
|
||||||
schedule_type: args.schedule_type,
|
schedule_type: args.schedule_type,
|
||||||
schedule_value: args.schedule_value,
|
schedule_value: args.schedule_value,
|
||||||
|
agent_type: currentAgentType(),
|
||||||
|
room_role: currentRoomRole(),
|
||||||
context_mode: args.context_mode || 'group',
|
context_mode: args.context_mode || 'group',
|
||||||
targetJid,
|
targetJid,
|
||||||
createdBy: groupFolder,
|
createdBy: groupFolder,
|
||||||
@@ -301,6 +316,18 @@ server.tool(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
async (args) => {
|
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 isGitHubWatcher = args.ci_provider === 'github';
|
||||||
const target =
|
const target =
|
||||||
args.target ||
|
args.target ||
|
||||||
@@ -383,6 +410,8 @@ server.tool(
|
|||||||
prompt,
|
prompt,
|
||||||
schedule_type: 'interval' as const,
|
schedule_type: 'interval' as const,
|
||||||
schedule_value: String(pollSeconds * 1000),
|
schedule_value: String(pollSeconds * 1000),
|
||||||
|
agent_type: currentAgentType(),
|
||||||
|
room_role: currentRoomRole(),
|
||||||
context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE,
|
context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
ci_provider: args.ci_provider,
|
ci_provider: args.ci_provider,
|
||||||
ci_metadata: isGitHubWatcher
|
ci_metadata: isGitHubWatcher
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function getExpectedSchemaMigrations(): Array<{
|
|||||||
{ version: 14, name: 'work_item_attachments' },
|
{ version: 14, name: 'work_item_attachments' },
|
||||||
{ version: 15, name: 'turn_progress_text' },
|
{ version: 15, name: 'turn_progress_text' },
|
||||||
{ version: 16, name: 'room_skill_overrides' },
|
{ 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 { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
|
||||||
import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js';
|
import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js';
|
||||||
import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.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 {
|
import type {
|
||||||
SchemaMigrationArgs,
|
SchemaMigrationArgs,
|
||||||
SchemaMigrationDefinition,
|
SchemaMigrationDefinition,
|
||||||
@@ -40,6 +41,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
|
|||||||
WORK_ITEM_ATTACHMENTS_MIGRATION,
|
WORK_ITEM_ATTACHMENTS_MIGRATION,
|
||||||
TURN_PROGRESS_TEXT_MIGRATION,
|
TURN_PROGRESS_TEXT_MIGRATION,
|
||||||
ROOM_SKILL_OVERRIDES_MIGRATION,
|
ROOM_SKILL_OVERRIDES_MIGRATION,
|
||||||
|
SCHEDULED_TASK_ROOM_ROLE_MIGRATION,
|
||||||
];
|
];
|
||||||
|
|
||||||
function ensureSchemaMigrationsTable(database: Database): void {
|
function ensureSchemaMigrationsTable(database: Database): void {
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
import { AgentType, ScheduledTask, TaskRunLog } from '../types.js';
|
import {
|
||||||
|
AgentType,
|
||||||
|
PairedRoomRole,
|
||||||
|
ScheduledTask,
|
||||||
|
TaskRunLog,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
export type CreateScheduledTaskInput = Omit<
|
export type CreateScheduledTaskInput = Omit<
|
||||||
ScheduledTask,
|
ScheduledTask,
|
||||||
| 'last_run'
|
| 'last_run'
|
||||||
| 'last_result'
|
| 'last_result'
|
||||||
| 'agent_type'
|
| 'agent_type'
|
||||||
|
| 'room_role'
|
||||||
| 'ci_provider'
|
| 'ci_provider'
|
||||||
| 'ci_metadata'
|
| 'ci_metadata'
|
||||||
| 'max_duration_ms'
|
| 'max_duration_ms'
|
||||||
@@ -14,6 +20,7 @@ export type CreateScheduledTaskInput = Omit<
|
|||||||
| 'status_started_at'
|
| 'status_started_at'
|
||||||
> & {
|
> & {
|
||||||
agent_type?: AgentType | null;
|
agent_type?: AgentType | null;
|
||||||
|
room_role?: PairedRoomRole | null;
|
||||||
ci_provider?: ScheduledTask['ci_provider'];
|
ci_provider?: ScheduledTask['ci_provider'];
|
||||||
ci_metadata?: string | null;
|
ci_metadata?: string | null;
|
||||||
max_duration_ms?: number | null;
|
max_duration_ms?: number | null;
|
||||||
@@ -45,8 +52,8 @@ export function createTaskInDatabase(
|
|||||||
database
|
database
|
||||||
.prepare(
|
.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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
@@ -54,6 +61,7 @@ export function createTaskInDatabase(
|
|||||||
task.group_folder,
|
task.group_folder,
|
||||||
task.chat_jid,
|
task.chat_jid,
|
||||||
task.agent_type || 'claude-code',
|
task.agent_type || 'claude-code',
|
||||||
|
task.room_role ?? null,
|
||||||
task.ci_provider ?? null,
|
task.ci_provider ?? null,
|
||||||
task.ci_metadata ?? null,
|
task.ci_metadata ?? null,
|
||||||
task.max_duration_ms ?? null,
|
task.max_duration_ms ?? null,
|
||||||
|
|||||||
@@ -139,6 +139,34 @@ describe('schedule_task authorization', () => {
|
|||||||
expect(allTasks[0].agent_type).toBe('codex');
|
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 () => {
|
it('non-main group cannot schedule for another group', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,17 +16,27 @@ import {
|
|||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { isValidGroupFolder } from './group-folder.js';
|
import { isValidGroupFolder } from './group-folder.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { normalizeStoredAgentType } from './db/room-registration.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_WATCH_CI_MAX_DURATION_MS,
|
DEFAULT_WATCH_CI_MAX_DURATION_MS,
|
||||||
isWatchCiTask,
|
isWatchCiTask,
|
||||||
} from './task-watch-status.js';
|
} from './task-watch-status.js';
|
||||||
import type { IpcDeps, TaskIpcPayload } from './ipc-types.js';
|
import type { IpcDeps, TaskIpcPayload } from './ipc-types.js';
|
||||||
|
import type { PairedRoomRole } from './types.js';
|
||||||
|
|
||||||
type RoomBindings = ReturnType<IpcDeps['roomBindings']>;
|
type RoomBindings = ReturnType<IpcDeps['roomBindings']>;
|
||||||
type ScheduleType = 'cron' | 'interval' | 'once';
|
type ScheduleType = 'cron' | 'interval' | 'once';
|
||||||
type TaskUpdates = Parameters<typeof updateTask>[1];
|
type TaskUpdates = Parameters<typeof updateTask>[1];
|
||||||
type MemorySourceKind = Parameters<typeof rememberMemory>[0]['sourceKind'];
|
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(
|
export async function processTaskIpc(
|
||||||
data: TaskIpcPayload,
|
data: TaskIpcPayload,
|
||||||
sourceGroup: string,
|
sourceGroup: string,
|
||||||
@@ -173,11 +183,17 @@ function handleScheduleTask(
|
|||||||
data.context_mode === 'group' || data.context_mode === 'isolated'
|
data.context_mode === 'group' || data.context_mode === 'isolated'
|
||||||
? data.context_mode
|
? data.context_mode
|
||||||
: 'isolated';
|
: 'isolated';
|
||||||
|
const scheduledAgentType =
|
||||||
|
normalizeStoredAgentType(data.agent_type) ??
|
||||||
|
targetGroupEntry.agentType ??
|
||||||
|
'claude-code';
|
||||||
|
const scheduledRoomRole = normalizePairedRoomRole(data.room_role);
|
||||||
createTask({
|
createTask({
|
||||||
id: taskId,
|
id: taskId,
|
||||||
group_folder: targetFolder,
|
group_folder: targetFolder,
|
||||||
chat_jid: resolvedTargetJid,
|
chat_jid: resolvedTargetJid,
|
||||||
agent_type: targetGroupEntry.agentType || 'claude-code',
|
agent_type: scheduledAgentType,
|
||||||
|
room_role: scheduledRoomRole ?? null,
|
||||||
ci_provider: data.ci_provider ?? null,
|
ci_provider: data.ci_provider ?? null,
|
||||||
ci_metadata: data.ci_metadata ?? null,
|
ci_metadata: data.ci_metadata ?? null,
|
||||||
max_duration_ms: isWatchCiTask({ prompt: data.prompt })
|
max_duration_ms: isWatchCiTask({ prompt: data.prompt })
|
||||||
@@ -197,7 +213,8 @@ function handleScheduleTask(
|
|||||||
sourceGroup,
|
sourceGroup,
|
||||||
targetFolder,
|
targetFolder,
|
||||||
contextMode,
|
contextMode,
|
||||||
agentType: targetGroupEntry.agentType || 'claude-code',
|
agentType: scheduledAgentType,
|
||||||
|
roomRole: scheduledRoomRole ?? null,
|
||||||
},
|
},
|
||||||
'Task created via IPC',
|
'Task created via IPC',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AgentType,
|
AgentType,
|
||||||
MessageSourceKind,
|
MessageSourceKind,
|
||||||
OutboundAttachment,
|
OutboundAttachment,
|
||||||
|
PairedRoomRole,
|
||||||
PairedTask,
|
PairedTask,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
RoomMode,
|
RoomMode,
|
||||||
@@ -120,6 +121,8 @@ export interface TaskIpcPayload {
|
|||||||
schedule_type?: string;
|
schedule_type?: string;
|
||||||
schedule_value?: string;
|
schedule_value?: string;
|
||||||
context_mode?: string;
|
context_mode?: string;
|
||||||
|
agent_type?: AgentType;
|
||||||
|
room_role?: PairedRoomRole;
|
||||||
ci_provider?: 'github';
|
ci_provider?: 'github';
|
||||||
ci_metadata?: string;
|
ci_metadata?: string;
|
||||||
groupFolder?: 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,
|
deps,
|
||||||
task.chat_jid,
|
task.chat_jid,
|
||||||
check.completionMessage,
|
check.completionMessage,
|
||||||
|
task.room_role,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
deleteTask(task.id);
|
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,
|
getTaskRuntimeTaskId,
|
||||||
shouldUseTaskScopedSession,
|
shouldUseTaskScopedSession,
|
||||||
} from './task-watch-status.js';
|
} from './task-watch-status.js';
|
||||||
import type { AgentType, ScheduledTask } from './types.js';
|
import type { AgentType, PairedRoomRole, ScheduledTask } from './types.js';
|
||||||
import type {
|
import type {
|
||||||
SchedulerDependencies,
|
SchedulerDependencies,
|
||||||
TaskExecutionContext,
|
TaskExecutionContext,
|
||||||
@@ -49,8 +49,9 @@ export async function sendScheduledMessage(
|
|||||||
deps: SchedulerDependencies,
|
deps: SchedulerDependencies,
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
text: string,
|
text: string,
|
||||||
|
senderRoomRole?: PairedRoomRole | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!hasReviewerLease(chatJid)) {
|
if (!hasReviewerLease(chatJid) || senderRoomRole !== 'reviewer') {
|
||||||
await deps.sendMessage(chatJid, text);
|
await deps.sendMessage(chatJid, text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,146 +448,6 @@ describe('task scheduler', () => {
|
|||||||
expect(task?.next_run).not.toBeNull();
|
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 () => {
|
it('keeps group-context tasks on the chat queue key', async () => {
|
||||||
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
const dueAt = new Date(Date.now() - 60_000).toISOString();
|
||||||
createTask({
|
createTask({
|
||||||
|
|||||||
@@ -107,6 +107,28 @@ export function computeNextRun(task: ScheduledTask): string | null {
|
|||||||
return 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(
|
async function runTask(
|
||||||
task: ScheduledTask,
|
task: ScheduledTask,
|
||||||
deps: SchedulerDependencies,
|
deps: SchedulerDependencies,
|
||||||
@@ -149,14 +171,10 @@ async function runTask(
|
|||||||
});
|
});
|
||||||
|
|
||||||
log.info('Running scheduled task');
|
log.info('Running scheduled task');
|
||||||
|
const taskAgentType = context.taskAgentType;
|
||||||
|
|
||||||
// Update tasks snapshot for agent to read (filtered by group)
|
// Update tasks snapshot for agent to read (filtered by group)
|
||||||
writeTaskSnapshotForGroup(
|
writeCurrentTaskSnapshot(context, task, taskAgentType);
|
||||||
context.taskAgentType,
|
|
||||||
task.group_folder,
|
|
||||||
context.isMain,
|
|
||||||
context.runtimeTaskId,
|
|
||||||
);
|
|
||||||
|
|
||||||
let result: string | null = null;
|
let result: string | null = null;
|
||||||
let error: string | null;
|
let error: string | null;
|
||||||
@@ -164,7 +182,7 @@ async function runTask(
|
|||||||
sendTrackedMessage: deps.sendTrackedMessage,
|
sendTrackedMessage: deps.sendTrackedMessage,
|
||||||
editTrackedMessage: deps.editTrackedMessage,
|
editTrackedMessage: deps.editTrackedMessage,
|
||||||
});
|
});
|
||||||
const isClaudeAgent = context.taskAgentType === 'claude-code';
|
const isClaudeAgent = taskAgentType === 'claude-code';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await statusTracker.update('checking');
|
await statusTracker.update('checking');
|
||||||
@@ -233,8 +251,12 @@ async function runTask(
|
|||||||
|
|
||||||
if (outputText) {
|
if (outputText) {
|
||||||
attemptResult = outputText;
|
attemptResult = outputText;
|
||||||
// Paired-room scheduler output must use the reviewer bot slot.
|
await forwardScheduledOutput(
|
||||||
await sendScheduledMessage(deps, task.chat_jid, outputText);
|
deps,
|
||||||
|
task,
|
||||||
|
outputText,
|
||||||
|
task.room_role,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamedOutput.status === 'error') {
|
if (streamedOutput.status === 'error') {
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export interface ScheduledTask {
|
|||||||
group_folder: string;
|
group_folder: string;
|
||||||
chat_jid: string;
|
chat_jid: string;
|
||||||
agent_type: AgentType | null;
|
agent_type: AgentType | null;
|
||||||
|
room_role?: PairedRoomRole | null;
|
||||||
ci_provider?: 'github' | null;
|
ci_provider?: 'github' | null;
|
||||||
ci_metadata?: string | null;
|
ci_metadata?: string | null;
|
||||||
max_duration_ms?: number | null;
|
max_duration_ms?: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user