refactor(modules): extract scheduling as registry-based module
Moves the scheduling surface — 5 delivery actions (schedule_task, cancel_task, pause_task, resume_task, update_task), handleRecurrence, applyPreTaskScripts, and task DB helpers — out of core and into src/modules/scheduling/ (host) and container/agent-runner/src/scheduling/ (container). First PR to fill the MODULE-HOOK markers introduced in PR #2: - src/host-sweep.ts MODULE-HOOK:scheduling-recurrence now dynamically imports handleRecurrence from the module each sweep tick. - container/agent-runner/src/poll-loop.ts MODULE-HOOK:scheduling-pre-task dynamically imports applyPreTaskScripts before the provider call. When the marker block is empty (scheduling uninstalled), `keep` falls back to `normalMessages` so non-task messages still flow. The 5 task cases are removed from delivery.ts's handleSystemAction switch — the registry now routes them. Task DB helpers moved out of src/db/session-db.ts (which kept `nextEvenSeq` as a named export so the module can uphold the host-writes-even-seq invariant). Test suite split to match: scheduling-specific tests live in the module. No migration — tasks are messages_in rows with kind='task'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
153
src/modules/scheduling/db.ts
Normal file
153
src/modules/scheduling/db.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Task DB helpers used by the scheduling module.
|
||||
*
|
||||
* Tasks are `messages_in` rows with `kind='task'`. This module doesn't own
|
||||
* its own table — it piggybacks on the core schema. That's why there's no
|
||||
* `module-scheduling-*.ts` migration file.
|
||||
*
|
||||
* cancel/pause/resume match any live row in the series, not just the exact id.
|
||||
* Recurring tasks get a new row per occurrence (see handleRecurrence), all
|
||||
* sharing series_id. Matching by id alone would only hit the completed row
|
||||
* the agent remembers, missing the live next occurrence.
|
||||
*/
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import { nextEvenSeq } from '../../db/session-db.js';
|
||||
|
||||
export function insertTask(
|
||||
db: Database.Database,
|
||||
task: {
|
||||
id: string;
|
||||
processAfter: string;
|
||||
recurrence: string | null;
|
||||
platformId: string | null;
|
||||
channelType: string | null;
|
||||
threadId: string | null;
|
||||
content: string;
|
||||
},
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, timestamp, status, tries, process_after, recurrence, kind, platform_id, channel_type, thread_id, content, series_id)
|
||||
VALUES (@id, @seq, datetime('now'), 'pending', 0, @processAfter, @recurrence, 'task', @platformId, @channelType, @threadId, @content, @id)`,
|
||||
).run({
|
||||
...task,
|
||||
seq: nextEvenSeq(db),
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'completed', recurrence = NULL WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN ('pending', 'paused')",
|
||||
).run(taskId, taskId);
|
||||
}
|
||||
|
||||
export function pauseTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'paused' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'pending'",
|
||||
).run(taskId, taskId);
|
||||
}
|
||||
|
||||
export function resumeTask(db: Database.Database, taskId: string): void {
|
||||
db.prepare(
|
||||
"UPDATE messages_in SET status = 'pending' WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status = 'paused'",
|
||||
).run(taskId, taskId);
|
||||
}
|
||||
|
||||
export interface TaskUpdate {
|
||||
prompt?: string;
|
||||
script?: string | null;
|
||||
recurrence?: string | null;
|
||||
processAfter?: string;
|
||||
}
|
||||
|
||||
// Merges content JSON in-place so callers can update prompt/script without
|
||||
// clobbering other fields. Matches by id OR series_id so the live next
|
||||
// occurrence of a recurring task is updated, not just the completed row the
|
||||
// agent last saw. Returns the number of rows touched.
|
||||
export function updateTask(db: Database.Database, taskId: string, update: TaskUpdate): number {
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT id, content FROM messages_in WHERE (id = ? OR series_id = ?) AND kind = 'task' AND status IN ('pending', 'paused')",
|
||||
)
|
||||
.all(taskId, taskId) as Array<{ id: string; content: string }>;
|
||||
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
const setProcessAfter = update.processAfter !== undefined;
|
||||
const setRecurrence = update.recurrence !== undefined;
|
||||
const mergeContent = update.prompt !== undefined || update.script !== undefined;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const row of rows) {
|
||||
let content = row.content;
|
||||
if (mergeContent) {
|
||||
const parsed = JSON.parse(row.content) as Record<string, unknown>;
|
||||
if (update.prompt !== undefined) parsed.prompt = update.prompt;
|
||||
if (update.script !== undefined) parsed.script = update.script;
|
||||
content = JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
// Build SET clause dynamically so callers can update fields independently.
|
||||
const sets: string[] = ['content = ?'];
|
||||
const params: unknown[] = [content];
|
||||
if (setProcessAfter) {
|
||||
sets.push('process_after = ?');
|
||||
params.push(update.processAfter);
|
||||
}
|
||||
if (setRecurrence) {
|
||||
sets.push('recurrence = ?');
|
||||
params.push(update.recurrence);
|
||||
}
|
||||
params.push(row.id);
|
||||
|
||||
db.prepare(`UPDATE messages_in SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export interface RecurringMessage {
|
||||
id: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
recurrence: string;
|
||||
process_after: string | null;
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
series_id: string;
|
||||
}
|
||||
|
||||
export function getCompletedRecurring(db: Database.Database): RecurringMessage[] {
|
||||
return db
|
||||
.prepare("SELECT * FROM messages_in WHERE status = 'completed' AND recurrence IS NOT NULL")
|
||||
.all() as RecurringMessage[];
|
||||
}
|
||||
|
||||
export function insertRecurrence(
|
||||
db: Database.Database,
|
||||
msg: RecurringMessage,
|
||||
newId: string,
|
||||
nextRun: string | null,
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO messages_in (id, seq, kind, timestamp, status, process_after, recurrence, platform_id, channel_type, thread_id, content, series_id)
|
||||
VALUES (?, ?, ?, datetime('now'), 'pending', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
newId,
|
||||
nextEvenSeq(db),
|
||||
msg.kind,
|
||||
nextRun,
|
||||
msg.recurrence,
|
||||
msg.platform_id,
|
||||
msg.channel_type,
|
||||
msg.thread_id,
|
||||
msg.content,
|
||||
msg.series_id,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearRecurrence(db: Database.Database, messageId: string): void {
|
||||
db.prepare('UPDATE messages_in SET recurrence = NULL WHERE id = ?').run(messageId);
|
||||
}
|
||||
Reference in New Issue
Block a user