refactor: centralize task runtime contracts (#199)

This commit is contained in:
Eyejoker
2026-05-30 01:56:41 +09:00
committed by GitHub
parent 1531482363
commit 37b57b20bb
14 changed files with 152 additions and 77 deletions

View File

@@ -10,10 +10,16 @@ import { z } from 'zod';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { CronExpressionParser } from 'cron-parser'; 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 { import {
buildCiWatchPrompt, buildCiWatchPrompt,
DEFAULT_WATCH_CI_CONTEXT_MODE,
normalizeWatchCiIntervalSeconds, normalizeWatchCiIntervalSeconds,
} from './watch-ci.js'; } from './watch-ci.js';
import { resolveHostEvidenceResponsesDir } from './host-evidence.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!)', 'cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)',
), ),
context_mode: z context_mode: z
.enum(['group', 'isolated']) .enum(TASK_CONTEXT_MODES)
.default('group') .default(DEFAULT_SCHEDULE_TASK_CONTEXT_MODE)
.describe( .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 target_group_jid: z
.string() .string()
@@ -226,7 +232,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
schedule_value: args.schedule_value, schedule_value: args.schedule_value,
agent_type: currentAgentType(), agent_type: currentAgentType(),
room_role: currentRoomRole(), room_role: currentRoomRole(),
context_mode: args.context_mode || 'group', context_mode: args.context_mode || DEFAULT_SCHEDULE_TASK_CONTEXT_MODE,
targetJid, targetJid,
createdBy: groupFolder, createdBy: groupFolder,
timestamp: new Date().toISOString(), 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+.', '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 context_mode: z
.enum(['group', 'isolated']) .enum(TASK_CONTEXT_MODES)
.default(DEFAULT_WATCH_CI_CONTEXT_MODE) .default(DEFAULT_WATCH_CI_CONTEXT_MODE)
.describe( .describe(
'group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions). Default: isolated.', 'group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions). Default: isolated.',

View File

@@ -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 DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60;
export const MIN_WATCH_CI_INTERVAL_SECONDS = 30; export const MIN_WATCH_CI_INTERVAL_SECONDS = 30;
export const DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS = 15; export const DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS = 15;
export const MIN_GITHUB_WATCH_CI_INTERVAL_SECONDS = 10; export const MIN_GITHUB_WATCH_CI_INTERVAL_SECONDS = 10;
export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600; export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600;
export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated';
export interface NormalizeWatchCiIntervalOptions { export interface NormalizeWatchCiIntervalOptions {
ciProvider?: 'github'; ciProvider?: 'github';
@@ -48,7 +52,7 @@ export function buildCiWatchPrompt({
checkInstructions, checkInstructions,
}: BuildCiWatchPromptArgs): string { }: BuildCiWatchPromptArgs): string {
return ` return `
[BACKGROUND CI WATCH] ${WATCH_CI_PROMPT_PREFIX}
You are running as an EJClaw background CI watcher. You are running as an EJClaw background CI watcher.

View File

@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import {
DEFAULT_WATCH_CI_CONTEXT_MODE,
WATCH_CI_PROMPT_PREFIX,
} from 'ejclaw-runners-shared';
import { import {
buildCiWatchPrompt, buildCiWatchPrompt,
DEFAULT_WATCH_CI_CONTEXT_MODE,
DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS, DEFAULT_GITHUB_WATCH_CI_INTERVAL_SECONDS,
normalizeWatchCiIntervalSeconds, normalizeWatchCiIntervalSeconds,
} from '../src/watch-ci.js'; } from '../src/watch-ci.js';
@@ -16,6 +19,7 @@ describe('watch-ci helpers', () => {
}); });
expect(prompt).toContain('PR #42 checks'); expect(prompt).toContain('PR #42 checks');
expect(prompt.startsWith(WATCH_CI_PROMPT_PREFIX)).toBe(true);
expect(prompt).not.toContain('Task ID:'); expect(prompt).not.toContain('Task ID:');
expect(prompt).toContain('cancel_task'); expect(prompt).toContain('cancel_task');
expect(prompt).toContain('send_message'); expect(prompt).toContain('send_message');

View File

@@ -27,6 +27,16 @@ export {
type GitHubEvidenceAction, type GitHubEvidenceAction,
type HostEvidenceAction, type HostEvidenceAction,
} from './evidence-actions.js'; } 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 { export {
extractMarkdownImageAttachments, extractMarkdownImageAttachments,
extractMediaAttachments, extractMediaAttachments,

View File

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

View File

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

View File

@@ -1,4 +1,8 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import {
DEFAULT_TASK_CONTEXT_MODE,
WATCH_CI_PROMPT_PREFIX,
} from 'ejclaw-runners-shared';
import { import {
AgentType, AgentType,
@@ -70,7 +74,7 @@ export function createTaskInDatabase(
task.prompt, task.prompt,
task.schedule_type, task.schedule_type,
task.schedule_value, task.schedule_value,
task.context_mode || 'isolated', task.context_mode || DEFAULT_TASK_CONTEXT_MODE,
task.next_run, task.next_run,
task.status, task.status,
task.created_at, task.created_at,
@@ -217,10 +221,10 @@ export function hasActiveCiWatcherForChatInDatabase(
const row = database const row = database
.prepare( .prepare(
`SELECT 1 FROM scheduled_tasks `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`, LIMIT 1`,
) )
.get(chatJid); .get(chatJid, `${WATCH_CI_PROMPT_PREFIX}%`);
return !!row; return !!row;
} }

View File

@@ -1,4 +1,5 @@
import { CronExpressionParser } from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import { normalizeTaskContextMode } from 'ejclaw-runners-shared';
import { createTask, findDuplicateCiWatcher } from '../db.js'; import { createTask, findDuplicateCiWatcher } from '../db.js';
import { normalizeStoredAgentType } from '../db/room-registration.js'; import { normalizeStoredAgentType } from '../db/room-registration.js';
@@ -49,10 +50,7 @@ export function handleScheduleTask(
const taskId = const taskId =
data.taskId || data.taskId ||
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contextMode = const contextMode = normalizeTaskContextMode(data.context_mode);
data.context_mode === 'group' || data.context_mode === 'isolated'
? data.context_mode
: 'isolated';
const scheduledAgentType = const scheduledAgentType =
normalizeStoredAgentType(data.agent_type) ?? normalizeStoredAgentType(data.agent_type) ??
target.room.agentType ?? target.room.agentType ??

View File

@@ -1,43 +1,39 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
isBotMessageSourceKind, inferMessageSourceKindFromBotFlag,
normalizeMessageSourceKind, normalizeMessageSourceKind,
resolveInjectedMessageSourceKind, resolveInjectedMessageSourceKind,
} from './message-source.js'; } from './message-source.js';
import { DEFAULT_MESSAGE_SOURCE_KIND, MESSAGE_SOURCE_KINDS } from './types.js';
describe('message source helpers', () => { describe('message source kind helpers', () => {
it('defaults IPC injected messages to trusted human-equivalent provenance', () => { 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( expect(resolveInjectedMessageSourceKind({ treatAsHuman: true })).toBe(
'trusted_external_bot', '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( expect(resolveInjectedMessageSourceKind({ treatAsHuman: false })).toBe(
'ipc_injected_bot', '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');
}); });
}); });

View File

@@ -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<MessageSourceKind>([ const MESSAGE_SOURCE_KIND_SET = new Set<MessageSourceKind>(
'human', MESSAGE_SOURCE_KINDS,
'bot', );
'trusted_external_bot',
'ipc_injected_human',
'ipc_injected_bot',
]);
export function normalizeMessageSourceKind( export function normalizeMessageSourceKind(
value: unknown, value: unknown,
fallback: MessageSourceKind = 'human', fallback: MessageSourceKind = DEFAULT_MESSAGE_SOURCE_KIND,
): MessageSourceKind { ): MessageSourceKind {
return typeof value === 'string' && return typeof value === 'string' &&
MESSAGE_SOURCE_KINDS.has(value as MessageSourceKind) MESSAGE_SOURCE_KIND_SET.has(value as MessageSourceKind)
? (value as MessageSourceKind) ? (value as MessageSourceKind)
: fallback; : fallback;
} }
@@ -25,7 +25,7 @@ export function isBotMessageSourceKind(kind: MessageSourceKind): boolean {
export function inferMessageSourceKindFromBotFlag( export function inferMessageSourceKindFromBotFlag(
isBotMessage: boolean | number | null | undefined, isBotMessage: boolean | number | null | undefined,
): MessageSourceKind { ): MessageSourceKind {
return isBotMessage ? 'bot' : 'human'; return isBotMessage ? 'bot' : DEFAULT_MESSAGE_SOURCE_KIND;
} }
export function resolveInjectedMessageSourceKind(args: { export function resolveInjectedMessageSourceKind(args: {

View File

@@ -1,3 +1,5 @@
import type { ArbiterVerdict } from './types.js';
export type VisibleVerdict = export type VisibleVerdict =
| 'step_done' | 'step_done'
| 'task_done' | 'task_done'
@@ -7,12 +9,7 @@ export type VisibleVerdict =
| 'needs_context' | 'needs_context'
| 'continue'; | 'continue';
export type ArbiterVerdictResult = export type ArbiterVerdictResult = ArbiterVerdict | 'unknown';
| 'proceed'
| 'revise'
| 'reset'
| 'escalate'
| 'unknown';
const VISIBLE_VERDICT_SCAN_LINE_LIMIT = 5; const VISIBLE_VERDICT_SCAN_LINE_LIMIT = 5;
@@ -81,7 +78,7 @@ export function classifyArbiterVerdict(
const normalized = verdictMatch[1].toLowerCase(); const normalized = verdictMatch[1].toLowerCase();
return normalized === 'continue' return normalized === 'continue'
? 'proceed' ? 'proceed'
: (normalized as ArbiterVerdictResult); : (normalized as ArbiterVerdict);
} }
return 'unknown'; return 'unknown';
} }

View File

@@ -1,3 +1,5 @@
import { WATCH_CI_PROMPT_PREFIX } from 'ejclaw-runners-shared';
import { TIMEZONE } from './config.js'; import { TIMEZONE } from './config.js';
import type { ScheduledTask } from './types.js'; import type { ScheduledTask } from './types.js';
import { formatElapsedKorean } from './utils.js'; import { formatElapsedKorean } from './utils.js';
@@ -8,7 +10,7 @@ export type WatcherStatusPhase =
| 'retrying' | 'retrying'
| 'completed'; | '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 TASK_STATUS_MESSAGE_PREFIX = '\u2063\u2063\u2063';
export const DEFAULT_WATCH_CI_MAX_DURATION_MS = 24 * 60 * 60 * 1000; export const DEFAULT_WATCH_CI_MAX_DURATION_MS = 24 * 60 * 60 * 1000;

View File

@@ -4,6 +4,7 @@ import {
normalizePairedRoomRole, normalizePairedRoomRole,
normalizePairedRoomRoleOrNull, normalizePairedRoomRoleOrNull,
type PairedRoomRole, type PairedRoomRole,
type TaskContextMode,
} from 'ejclaw-runners-shared'; } from 'ejclaw-runners-shared';
import type { VisibleVerdict } from './paired-verdict.js'; 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) workDir?: string; // Working directory for the agent (defaults to group folder)
} }
export type MessageSourceKind = export const MESSAGE_SOURCE_KINDS = [
| 'human' 'human',
| 'bot' 'bot',
| 'trusted_external_bot' 'trusted_external_bot',
| 'ipc_injected_human' 'ipc_injected_human',
| 'ipc_injected_bot'; 'ipc_injected_bot',
] as const;
export type MessageSourceKind = (typeof MESSAGE_SOURCE_KINDS)[number];
export const DEFAULT_MESSAGE_SOURCE_KIND: MessageSourceKind = 'human';
export interface NewMessage { export interface NewMessage {
id: string; id: string;
@@ -242,7 +246,7 @@ export interface ScheduledTask {
prompt: string; prompt: string;
schedule_type: 'cron' | 'interval' | 'once'; schedule_type: 'cron' | 'interval' | 'once';
schedule_value: string; schedule_value: string;
context_mode: 'group' | 'isolated'; context_mode: TaskContextMode;
next_run: string | null; next_run: string | null;
last_run: string | null; last_run: string | null;
last_result: string | null; last_result: string | null;

View File

@@ -1,4 +1,8 @@
import { CronExpressionParser } from 'cron-parser'; import { CronExpressionParser } from 'cron-parser';
import {
DEFAULT_TASK_CONTEXT_MODE,
isTaskContextMode,
} from 'ejclaw-runners-shared';
import { TIMEZONE } from './config.js'; import { TIMEZONE } from './config.js';
import { isWatchCiTask } from './task-watch-status.js'; import { isWatchCiTask } from './task-watch-status.js';
@@ -100,10 +104,6 @@ function isScheduleType(
return value === 'cron' || value === 'interval' || value === 'once'; 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 { function isAgentType(value: unknown): value is AgentType {
return value === 'claude-code' || value === 'codex'; return value === 'claude-code' || value === 'codex';
} }
@@ -294,9 +294,9 @@ async function handleTaskCreateRoute(
return jsonResponse({ error: next.error }, { status: 400 }); return jsonResponse({ error: next.error }, { status: 400 });
} }
const contextMode = isContextMode(body.contextMode) const contextMode = isTaskContextMode(body.contextMode)
? body.contextMode ? body.contextMode
: 'isolated'; : DEFAULT_TASK_CONTEXT_MODE;
const agentType = isAgentType(body.agentType) const agentType = isAgentType(body.agentType)
? body.agentType ? body.agentType
: (room.group.agentType ?? 'claude-code'); : (room.group.agentType ?? 'claude-code');