feat(v2): add update_task MCP tool, dedup list_tasks by series

update_task lets the agent adjust prompt/recurrence/processAfter/script
on a live scheduled task without losing the series id the user already
knows. Empty string clears recurrence/script.

list_tasks now groups by series_id so recurring tasks show as one row
(the live pending/paused occurrence) instead of one per firing — the
id displayed is the stable series handle that update/cancel/pause/resume
all match against.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-04-16 16:30:00 +00:00
committed by Daniel
parent 8ef30ad289
commit cdf18e608f
7 changed files with 276 additions and 7 deletions

View File

@@ -17,6 +17,7 @@ import {
cancelTask,
pauseTask,
resumeTask,
updateTask,
getCompletedRecurring,
migrateMessagesInTable,
type RecurringMessage,
@@ -135,6 +136,127 @@ describe('cancelTask / pauseTask / resumeTask series matching', () => {
});
});
describe('updateTask', () => {
it('merges supplied fields into content JSON without clobbering others', () => {
const db = freshDb();
insertTask(db, {
id: 'task-1',
processAfter: new Date().toISOString(),
recurrence: null,
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({ prompt: 'old', script: 'echo old', extra: 'keep me' }),
});
const touched = updateTask(db, 'task-1', { prompt: 'new' });
expect(touched).toBe(1);
const row = db.prepare('SELECT content FROM messages_in WHERE id = ?').get('task-1') as { content: string };
const parsed = JSON.parse(row.content);
expect(parsed.prompt).toBe('new');
expect(parsed.script).toBe('echo old');
expect(parsed.extra).toBe('keep me');
});
it('updates recurrence and process_after when supplied', () => {
const db = freshDb();
insertTask(db, {
id: 'task-1',
processAfter: '2026-01-01T00:00:00Z',
recurrence: '0 9 * * *',
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({ prompt: 'p' }),
});
updateTask(db, 'task-1', { recurrence: '0 18 * * *', processAfter: '2026-02-01T00:00:00Z' });
const row = db.prepare('SELECT recurrence, process_after FROM messages_in WHERE id = ?').get('task-1') as {
recurrence: string;
process_after: string;
};
expect(row.recurrence).toBe('0 18 * * *');
expect(row.process_after).toBe('2026-02-01T00:00:00Z');
});
it('clears recurrence when null is passed', () => {
const db = freshDb();
insertTask(db, {
id: 'task-1',
processAfter: '2026-01-01T00:00:00Z',
recurrence: '0 9 * * *',
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({ prompt: 'p' }),
});
updateTask(db, 'task-1', { recurrence: null });
const row = db.prepare('SELECT recurrence FROM messages_in WHERE id = ?').get('task-1') as {
recurrence: string | null;
};
expect(row.recurrence).toBeNull();
});
it('reaches the live follow-up via series_id when called with the original id', () => {
const db = freshDb();
insertTask(db, {
id: 'task-orig',
processAfter: new Date().toISOString(),
recurrence: '0 9 * * *',
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({ prompt: 'old' }),
});
db.prepare("UPDATE messages_in SET status = 'completed' WHERE id = 'task-orig'").run();
const msg: RecurringMessage = {
id: 'task-orig',
kind: 'task',
content: JSON.stringify({ prompt: 'old' }),
recurrence: '0 9 * * *',
process_after: null,
platform_id: null,
channel_type: null,
thread_id: null,
series_id: 'task-orig',
};
insertRecurrence(db, msg, 'task-next', new Date(Date.now() + 86400000).toISOString());
const touched = updateTask(db, 'task-orig', { prompt: 'new' });
// Only the live follow-up should be touched — completed rows are excluded.
expect(touched).toBe(1);
const live = db.prepare("SELECT content FROM messages_in WHERE id = 'task-next'").get() as { content: string };
expect(JSON.parse(live.content).prompt).toBe('new');
// Original (completed) row left alone.
const orig = db.prepare("SELECT content FROM messages_in WHERE id = 'task-orig'").get() as { content: string };
expect(JSON.parse(orig.content).prompt).toBe('old');
});
it('returns 0 when no live task matches', () => {
const db = freshDb();
insertTask(db, {
id: 'task-1',
processAfter: new Date().toISOString(),
recurrence: null,
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({ prompt: 'p' }),
});
db.prepare("UPDATE messages_in SET status = 'completed' WHERE id = 'task-1'").run();
const touched = updateTask(db, 'task-1', { prompt: 'new' });
expect(touched).toBe(0);
});
});
describe('insertRecurrence', () => {
it('copies series_id forward', () => {
const db = freshDb();

View File

@@ -143,6 +143,60 @@ export function resumeTask(db: Database.Database, taskId: string): void {
).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 function countDueMessages(db: Database.Database): number {
return (
db