diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index fd0624b..5fa5e82 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -10,10 +10,16 @@ import { z } from 'zod'; import fs from 'fs'; import path from 'path'; import { CronExpressionParser } from 'cron-parser'; -import { EJCLAW_ENV, normalizePairedRoomRole } from 'ejclaw-runners-shared'; +import { + DEFAULT_SCHEDULE_TASK_CONTEXT_MODE, + DEFAULT_TASK_CONTEXT_MODE, + DEFAULT_WATCH_CI_CONTEXT_MODE, + EJCLAW_ENV, + TASK_CONTEXT_MODES, + normalizePairedRoomRole, +} from 'ejclaw-runners-shared'; import { buildCiWatchPrompt, - DEFAULT_WATCH_CI_CONTEXT_MODE, normalizeWatchCiIntervalSeconds, } from './watch-ci.js'; import { resolveHostEvidenceResponsesDir } from './host-evidence.js'; @@ -142,10 +148,10 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): 'cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)', ), context_mode: z - .enum(['group', 'isolated']) - .default('group') + .enum(TASK_CONTEXT_MODES) + .default(DEFAULT_SCHEDULE_TASK_CONTEXT_MODE) .describe( - 'group=runs with chat history and memory, isolated=fresh session (include context in prompt)', + 'group=runs with chat history and memory, isolated=fresh session (include context in prompt). Default: group.', ), target_group_jid: z .string() @@ -226,7 +232,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): schedule_value: args.schedule_value, agent_type: currentAgentType(), room_role: currentRoomRole(), - context_mode: args.context_mode || 'group', + context_mode: args.context_mode || DEFAULT_SCHEDULE_TASK_CONTEXT_MODE, targetJid, createdBy: groupFolder, timestamp: new Date().toISOString(), @@ -300,7 +306,7 @@ server.tool( 'How often to poll in seconds. Defaults to 60 for generic watchers and 15 for GitHub host-driven watchers. Generic watchers require 30+, GitHub host-driven watchers allow 10+.', ), context_mode: z - .enum(['group', 'isolated']) + .enum(TASK_CONTEXT_MODES) .default(DEFAULT_WATCH_CI_CONTEXT_MODE) .describe( 'group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions). Default: isolated.', diff --git a/runners/agent-runner/src/watch-ci.ts b/runners/agent-runner/src/watch-ci.ts index f452192..44cca21 100644 --- a/runners/agent-runner/src/watch-ci.ts +++ b/runners/agent-runner/src/watch-ci.ts @@ -1,9 +1,13 @@ +import { + DEFAULT_WATCH_CI_CONTEXT_MODE, + WATCH_CI_PROMPT_PREFIX, +} from 'ejclaw-runners-shared'; + export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60; export const MIN_WATCH_CI_INTERVAL_SECONDS = 30; export const DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS = 15; export const MIN_GITHUB_WATCH_CI_INTERVAL_SECONDS = 10; export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600; -export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated'; export interface NormalizeWatchCiIntervalOptions { ciProvider?: 'github'; @@ -48,7 +52,7 @@ export function buildCiWatchPrompt({ checkInstructions, }: BuildCiWatchPromptArgs): string { return ` -[BACKGROUND CI WATCH] +${WATCH_CI_PROMPT_PREFIX} You are running as an EJClaw background CI watcher. diff --git a/runners/agent-runner/test/watch-ci.test.ts b/runners/agent-runner/test/watch-ci.test.ts index 3dcc024..4f5a21e 100644 --- a/runners/agent-runner/test/watch-ci.test.ts +++ b/runners/agent-runner/test/watch-ci.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { + DEFAULT_WATCH_CI_CONTEXT_MODE, + WATCH_CI_PROMPT_PREFIX, +} from 'ejclaw-runners-shared'; import { buildCiWatchPrompt, - DEFAULT_WATCH_CI_CONTEXT_MODE, DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS, normalizeWatchCiIntervalSeconds, } from '../src/watch-ci.js'; @@ -16,6 +19,7 @@ describe('watch-ci helpers', () => { }); expect(prompt).toContain('PR #42 checks'); + expect(prompt.startsWith(WATCH_CI_PROMPT_PREFIX)).toBe(true); expect(prompt).not.toContain('Task ID:'); expect(prompt).toContain('cancel_task'); expect(prompt).toContain('send_message'); diff --git a/runners/shared/src/index.ts b/runners/shared/src/index.ts index c8fe8dd..e9a1eef 100644 --- a/runners/shared/src/index.ts +++ b/runners/shared/src/index.ts @@ -27,6 +27,16 @@ export { type GitHubEvidenceAction, type HostEvidenceAction, } from './evidence-actions.js'; +export { + DEFAULT_SCHEDULE_TASK_CONTEXT_MODE, + DEFAULT_TASK_CONTEXT_MODE, + DEFAULT_WATCH_CI_CONTEXT_MODE, + TASK_CONTEXT_MODES, + WATCH_CI_PROMPT_PREFIX, + isTaskContextMode, + normalizeTaskContextMode, + type TaskContextMode, +} from './task-runtime.js'; export { extractMarkdownImageAttachments, extractMediaAttachments, diff --git a/runners/shared/src/task-runtime.ts b/runners/shared/src/task-runtime.ts new file mode 100644 index 0000000..cede93f --- /dev/null +++ b/runners/shared/src/task-runtime.ts @@ -0,0 +1,18 @@ +export const TASK_CONTEXT_MODES = ['group', 'isolated'] as const; +export type TaskContextMode = (typeof TASK_CONTEXT_MODES)[number]; + +export const DEFAULT_TASK_CONTEXT_MODE: TaskContextMode = 'isolated'; +export const DEFAULT_SCHEDULE_TASK_CONTEXT_MODE: TaskContextMode = 'group'; +export const DEFAULT_WATCH_CI_CONTEXT_MODE: TaskContextMode = 'isolated'; +export const WATCH_CI_PROMPT_PREFIX = '[BACKGROUND CI WATCH]'; + +export function isTaskContextMode(value: unknown): value is TaskContextMode { + return value === 'group' || value === 'isolated'; +} + +export function normalizeTaskContextMode( + value: unknown, + fallback: TaskContextMode = DEFAULT_TASK_CONTEXT_MODE, +): TaskContextMode { + return isTaskContextMode(value) ? value : fallback; +} diff --git a/runners/shared/test/task-runtime.test.ts b/runners/shared/test/task-runtime.test.ts new file mode 100644 index 0000000..1605993 --- /dev/null +++ b/runners/shared/test/task-runtime.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_SCHEDULE_TASK_CONTEXT_MODE, + DEFAULT_TASK_CONTEXT_MODE, + DEFAULT_WATCH_CI_CONTEXT_MODE, + TASK_CONTEXT_MODES, + WATCH_CI_PROMPT_PREFIX, + isTaskContextMode, + normalizeTaskContextMode, +} from '../src/task-runtime.js'; + +describe('task runtime constants', () => { + it('defines the task context modes and default', () => { + expect(TASK_CONTEXT_MODES).toEqual(['group', 'isolated']); + expect(DEFAULT_TASK_CONTEXT_MODE).toBe('isolated'); + expect(DEFAULT_SCHEDULE_TASK_CONTEXT_MODE).toBe('group'); + expect(DEFAULT_WATCH_CI_CONTEXT_MODE).toBe('isolated'); + }); + + it('normalizes task context modes consistently', () => { + expect(isTaskContextMode('group')).toBe(true); + expect(isTaskContextMode('isolated')).toBe(true); + expect(isTaskContextMode('invalid')).toBe(false); + expect(normalizeTaskContextMode('group')).toBe('group'); + expect(normalizeTaskContextMode('invalid')).toBe('isolated'); + }); + + it('defines the watch CI prompt sentinel', () => { + expect(WATCH_CI_PROMPT_PREFIX).toBe('[BACKGROUND CI WATCH]'); + }); +}); diff --git a/src/db/tasks.ts b/src/db/tasks.ts index 3129e61..cd513bc 100644 --- a/src/db/tasks.ts +++ b/src/db/tasks.ts @@ -1,4 +1,8 @@ import { Database } from 'bun:sqlite'; +import { + DEFAULT_TASK_CONTEXT_MODE, + WATCH_CI_PROMPT_PREFIX, +} from 'ejclaw-runners-shared'; import { AgentType, @@ -70,7 +74,7 @@ export function createTaskInDatabase( task.prompt, task.schedule_type, task.schedule_value, - task.context_mode || 'isolated', + task.context_mode || DEFAULT_TASK_CONTEXT_MODE, task.next_run, task.status, task.created_at, @@ -217,10 +221,10 @@ export function hasActiveCiWatcherForChatInDatabase( const row = database .prepare( `SELECT 1 FROM scheduled_tasks - WHERE chat_jid = ? AND status = 'active' AND prompt LIKE '[BACKGROUND CI WATCH]%' + WHERE chat_jid = ? AND status = 'active' AND prompt LIKE ? LIMIT 1`, ) - .get(chatJid); + .get(chatJid, `${WATCH_CI_PROMPT_PREFIX}%`); return !!row; } diff --git a/src/ipc-task-processor/schedule.ts b/src/ipc-task-processor/schedule.ts index d8074ee..9f649ab 100644 --- a/src/ipc-task-processor/schedule.ts +++ b/src/ipc-task-processor/schedule.ts @@ -1,4 +1,5 @@ import { CronExpressionParser } from 'cron-parser'; +import { normalizeTaskContextMode } from 'ejclaw-runners-shared'; import { createTask, findDuplicateCiWatcher } from '../db.js'; import { normalizeStoredAgentType } from '../db/room-registration.js'; @@ -49,10 +50,7 @@ export function handleScheduleTask( const taskId = data.taskId || `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const contextMode = - data.context_mode === 'group' || data.context_mode === 'isolated' - ? data.context_mode - : 'isolated'; + const contextMode = normalizeTaskContextMode(data.context_mode); const scheduledAgentType = normalizeStoredAgentType(data.agent_type) ?? target.room.agentType ?? diff --git a/src/message-source.test.ts b/src/message-source.test.ts index d0aa110..e66504f 100644 --- a/src/message-source.test.ts +++ b/src/message-source.test.ts @@ -1,43 +1,39 @@ import { describe, expect, it } from 'vitest'; import { - isBotMessageSourceKind, + inferMessageSourceKindFromBotFlag, normalizeMessageSourceKind, resolveInjectedMessageSourceKind, } from './message-source.js'; +import { DEFAULT_MESSAGE_SOURCE_KIND, MESSAGE_SOURCE_KINDS } from './types.js'; -describe('message source helpers', () => { - it('defaults IPC injected messages to trusted human-equivalent provenance', () => { +describe('message source kind helpers', () => { + it('uses the canonical source kind list', () => { + expect(MESSAGE_SOURCE_KINDS).toEqual([ + 'human', + 'bot', + 'trusted_external_bot', + 'ipc_injected_human', + 'ipc_injected_bot', + ]); + expect(DEFAULT_MESSAGE_SOURCE_KIND).toBe('human'); + }); + + it('normalizes unknown source kinds to the default', () => { + expect(normalizeMessageSourceKind('trusted_external_bot')).toBe( + 'trusted_external_bot', + ); + expect(normalizeMessageSourceKind('unknown')).toBe('human'); + }); + + it('derives source kinds from legacy and injected inputs', () => { + expect(inferMessageSourceKindFromBotFlag(true)).toBe('bot'); + expect(inferMessageSourceKindFromBotFlag(false)).toBe('human'); expect(resolveInjectedMessageSourceKind({ treatAsHuman: true })).toBe( 'trusted_external_bot', ); - expect( - isBotMessageSourceKind( - resolveInjectedMessageSourceKind({ treatAsHuman: true }), - ), - ).toBe(false); - }); - - it('defaults non-human IPC injected messages to bot-equivalent provenance', () => { expect(resolveInjectedMessageSourceKind({ treatAsHuman: false })).toBe( 'ipc_injected_bot', ); - expect( - isBotMessageSourceKind( - resolveInjectedMessageSourceKind({ treatAsHuman: false }), - ), - ).toBe(true); - }); - - it('honors valid explicit source kinds and normalizes invalid values', () => { - expect( - resolveInjectedMessageSourceKind({ - treatAsHuman: true, - sourceKind: 'ipc_injected_human', - }), - ).toBe('ipc_injected_human'); - - expect(normalizeMessageSourceKind('bot', 'human')).toBe('bot'); - expect(normalizeMessageSourceKind('not-real', 'human')).toBe('human'); }); }); diff --git a/src/message-source.ts b/src/message-source.ts index 2e36acb..5234434 100644 --- a/src/message-source.ts +++ b/src/message-source.ts @@ -1,19 +1,19 @@ -import type { MessageSourceKind } from './types.js'; +import { + DEFAULT_MESSAGE_SOURCE_KIND, + MESSAGE_SOURCE_KINDS, + type MessageSourceKind, +} from './types.js'; -const MESSAGE_SOURCE_KINDS = new Set([ - 'human', - 'bot', - 'trusted_external_bot', - 'ipc_injected_human', - 'ipc_injected_bot', -]); +const MESSAGE_SOURCE_KIND_SET = new Set( + MESSAGE_SOURCE_KINDS, +); export function normalizeMessageSourceKind( value: unknown, - fallback: MessageSourceKind = 'human', + fallback: MessageSourceKind = DEFAULT_MESSAGE_SOURCE_KIND, ): MessageSourceKind { return typeof value === 'string' && - MESSAGE_SOURCE_KINDS.has(value as MessageSourceKind) + MESSAGE_SOURCE_KIND_SET.has(value as MessageSourceKind) ? (value as MessageSourceKind) : fallback; } @@ -25,7 +25,7 @@ export function isBotMessageSourceKind(kind: MessageSourceKind): boolean { export function inferMessageSourceKindFromBotFlag( isBotMessage: boolean | number | null | undefined, ): MessageSourceKind { - return isBotMessage ? 'bot' : 'human'; + return isBotMessage ? 'bot' : DEFAULT_MESSAGE_SOURCE_KIND; } export function resolveInjectedMessageSourceKind(args: { diff --git a/src/paired-verdict.ts b/src/paired-verdict.ts index 0b247f0..c6c71e9 100644 --- a/src/paired-verdict.ts +++ b/src/paired-verdict.ts @@ -1,3 +1,5 @@ +import type { ArbiterVerdict } from './types.js'; + export type VisibleVerdict = | 'step_done' | 'task_done' @@ -7,12 +9,7 @@ export type VisibleVerdict = | 'needs_context' | 'continue'; -export type ArbiterVerdictResult = - | 'proceed' - | 'revise' - | 'reset' - | 'escalate' - | 'unknown'; +export type ArbiterVerdictResult = ArbiterVerdict | 'unknown'; const VISIBLE_VERDICT_SCAN_LINE_LIMIT = 5; @@ -81,7 +78,7 @@ export function classifyArbiterVerdict( const normalized = verdictMatch[1].toLowerCase(); return normalized === 'continue' ? 'proceed' - : (normalized as ArbiterVerdictResult); + : (normalized as ArbiterVerdict); } return 'unknown'; } diff --git a/src/task-watch-status.ts b/src/task-watch-status.ts index 347c9f7..d3c55bb 100644 --- a/src/task-watch-status.ts +++ b/src/task-watch-status.ts @@ -1,3 +1,5 @@ +import { WATCH_CI_PROMPT_PREFIX } from 'ejclaw-runners-shared'; + import { TIMEZONE } from './config.js'; import type { ScheduledTask } from './types.js'; import { formatElapsedKorean } from './utils.js'; @@ -8,7 +10,7 @@ export type WatcherStatusPhase = | 'retrying' | 'completed'; -export const WATCH_CI_PREFIX = '[BACKGROUND CI WATCH]'; +export const WATCH_CI_PREFIX = WATCH_CI_PROMPT_PREFIX; export const TASK_STATUS_MESSAGE_PREFIX = '\u2063\u2063\u2063'; export const DEFAULT_WATCH_CI_MAX_DURATION_MS = 24 * 60 * 60 * 1000; diff --git a/src/types.ts b/src/types.ts index 422f1b4..7caa7ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ import { normalizePairedRoomRole, normalizePairedRoomRoleOrNull, type PairedRoomRole, + type TaskContextMode, } from 'ejclaw-runners-shared'; import type { VisibleVerdict } from './paired-verdict.js'; @@ -208,12 +209,15 @@ export interface RegisteredGroup { workDir?: string; // Working directory for the agent (defaults to group folder) } -export type MessageSourceKind = - | 'human' - | 'bot' - | 'trusted_external_bot' - | 'ipc_injected_human' - | 'ipc_injected_bot'; +export const MESSAGE_SOURCE_KINDS = [ + 'human', + 'bot', + 'trusted_external_bot', + 'ipc_injected_human', + 'ipc_injected_bot', +] as const; +export type MessageSourceKind = (typeof MESSAGE_SOURCE_KINDS)[number]; +export const DEFAULT_MESSAGE_SOURCE_KIND: MessageSourceKind = 'human'; export interface NewMessage { id: string; @@ -242,7 +246,7 @@ export interface ScheduledTask { prompt: string; schedule_type: 'cron' | 'interval' | 'once'; schedule_value: string; - context_mode: 'group' | 'isolated'; + context_mode: TaskContextMode; next_run: string | null; last_run: string | null; last_result: string | null; diff --git a/src/web-dashboard-task-routes.ts b/src/web-dashboard-task-routes.ts index b4b43a8..302ed6c 100644 --- a/src/web-dashboard-task-routes.ts +++ b/src/web-dashboard-task-routes.ts @@ -1,4 +1,8 @@ import { CronExpressionParser } from 'cron-parser'; +import { + DEFAULT_TASK_CONTEXT_MODE, + isTaskContextMode, +} from 'ejclaw-runners-shared'; import { TIMEZONE } from './config.js'; import { isWatchCiTask } from './task-watch-status.js'; @@ -100,10 +104,6 @@ function isScheduleType( return value === 'cron' || value === 'interval' || value === 'once'; } -function isContextMode(value: unknown): value is ScheduledTask['context_mode'] { - return value === 'group' || value === 'isolated'; -} - function isAgentType(value: unknown): value is AgentType { return value === 'claude-code' || value === 'codex'; } @@ -294,9 +294,9 @@ async function handleTaskCreateRoute( return jsonResponse({ error: next.error }, { status: 400 }); } - const contextMode = isContextMode(body.contextMode) + const contextMode = isTaskContextMode(body.contextMode) ? body.contextMode - : 'isolated'; + : DEFAULT_TASK_CONTEXT_MODE; const agentType = isAgentType(body.agentType) ? body.agentType : (room.group.agentType ?? 'claude-code');