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>
154 lines
5.1 KiB
TypeScript
154 lines
5.1 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|