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
@@ -81,25 +81,46 @@ export const scheduleTask: McpToolDefinition = {
export const listTasks: McpToolDefinition = {
tool: {
name: 'list_tasks',
description: 'List scheduled and pending tasks.',
description:
'List scheduled tasks. Returns one row per series — the live (pending or paused) occurrence. The id shown is the series id, which is what update_task / cancel_task / pause_task / resume_task expect.',
inputSchema: {
type: 'object' as const,
properties: {
status: { type: 'string', description: 'Filter by status: pending, processing, completed, paused (default: all non-completed)' },
status: { type: 'string', description: 'Filter by status: pending or paused (default: both)' },
},
},
},
async handler(args) {
const status = args.status as string | undefined;
const db = getInboundDb();
// One row per series — the live (pending or paused) occurrence. Recurring
// tasks accumulate one completed row per firing plus one live follow-up;
// exposing the whole pile to the agent is noisy and confuses task identity
// ("which id do I cancel?"). The series_id is the stable handle.
//
// SQLite quirk: when MAX(seq) appears in the SELECT list of a GROUP BY
// query, the bare columns take values from the row that contains that max
// — that's how we pick "the latest live row per series" in one pass.
let rows;
if (status) {
rows = db
.prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status = ? ORDER BY process_after ASC")
.prepare(
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
FROM messages_in
WHERE kind = 'task' AND status = ?
GROUP BY series_id
ORDER BY process_after ASC`,
)
.all(status);
} else {
rows = db
.prepare("SELECT id, status, process_after, recurrence, content FROM messages_in WHERE kind = 'task' AND status NOT IN ('completed') ORDER BY process_after ASC")
.prepare(
`SELECT series_id AS id, status, process_after, recurrence, content, MAX(seq) AS _seq
FROM messages_in
WHERE kind = 'task' AND status IN ('pending', 'paused')
GROUP BY series_id
ORDER BY process_after ASC`,
)
.all();
}
@@ -197,4 +218,51 @@ export const resumeTask: McpToolDefinition = {
},
};
export const schedulingTools: McpToolDefinition[] = [scheduleTask, listTasks, cancelTask, pauseTask, resumeTask];
export const updateTask: McpToolDefinition = {
tool: {
name: 'update_task',
description:
'Update a scheduled task. Pass the series id from list_tasks. Any field omitted is left unchanged. Use this instead of cancel + reschedule when adjusting an existing task.',
inputSchema: {
type: 'object' as const,
properties: {
taskId: { type: 'string', description: 'Series id of the task to update (as shown by list_tasks)' },
prompt: { type: 'string', description: 'New task prompt (optional)' },
recurrence: {
type: 'string',
description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.',
},
processAfter: { type: 'string', description: 'New ISO timestamp for the next run (optional)' },
script: {
type: 'string',
description: 'New pre-agent script (optional). Pass empty string to clear.',
},
},
required: ['taskId'],
},
},
async handler(args) {
const taskId = args.taskId as string;
if (!taskId) return err('taskId is required');
const update: Record<string, unknown> = { taskId };
if (typeof args.prompt === 'string') update.prompt = args.prompt;
if (typeof args.processAfter === 'string') update.processAfter = args.processAfter;
// Empty string clears recurrence/script; undefined leaves them as-is.
if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence;
if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script;
if (Object.keys(update).length === 1) return err('at least one field to update is required');
writeMessageOut({
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'system',
content: JSON.stringify({ action: 'update_task', ...update }),
});
log(`update_task: ${taskId}`);
return ok(`Task update requested: ${taskId}`);
},
};
export const schedulingTools: McpToolDefinition[] = [scheduleTask, listTasks, updateTask, cancelTask, pauseTask, resumeTask];
+3 -2
View File
@@ -609,7 +609,7 @@ List active scheduled/recurring tasks.
Implementation: query `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`.
#### cancel_task / pause_task / resume_task
#### cancel_task / pause_task / resume_task / update_task
Modify a scheduled task.
@@ -620,9 +620,10 @@ Modify a scheduled task.
}
// pause_task: set status = 'paused' (new status value for recurring tasks)
// resume_task: set status = 'pending'
// update_task: merge { prompt?, recurrence?, processAfter?, script? } into the live row
```
Implementation: update the messages_in row directly.
Implementation: cancel/pause/resume update the live row(s) directly. update_task is sent as a system action — the host reads current content, merges supplied fields, and writes back. All four match by `(id = ? OR series_id = ?) AND kind='task' AND status IN ('pending','paused')`, so they reach the live next occurrence of a recurring task even when the agent passes the original (now-completed) id.
#### register_agent_group
+2
View File
@@ -128,6 +128,8 @@ request_rebuild({ reason: "Add memory MCP server" })
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`.
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows.
Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
### How it works
+2
View File
@@ -276,6 +276,8 @@ The task will run in that group's context with access to their files and memory.
For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
Use `list_tasks` to see existing tasks (one row per series with the stable id), and `update_task` / `cancel_task` / `pause_task` / `resume_task` to modify them. Prefer `update_task` over cancel + reschedule when adjusting an existing task.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
+122
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();
+54
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
+20
View File
@@ -33,6 +33,7 @@ import {
cancelTask,
pauseTask,
resumeTask,
updateTask,
} from './db/session-db.js';
import { log } from './log.js';
import { normalizeOptions, type RawOption } from './channels/ask-question.js';
@@ -656,6 +657,25 @@ async function handleSystemAction(
break;
}
case 'update_task': {
const taskId = content.taskId as string;
const update: Parameters<typeof updateTask>[2] = {};
if (typeof content.prompt === 'string') update.prompt = content.prompt;
if (typeof content.processAfter === 'string') update.processAfter = content.processAfter;
if (content.recurrence === null || typeof content.recurrence === 'string') {
update.recurrence = content.recurrence as string | null;
}
if (content.script === null || typeof content.script === 'string') {
update.script = content.script as string | null;
}
const touched = updateTask(inDb, taskId, update);
log.info('Task updated', { taskId, touched, fields: Object.keys(update) });
if (touched === 0) {
notifyAgent(session, `update_task: no live task matched id "${taskId}".`);
}
break;
}
case 'create_agent': {
const requestId = content.requestId as string;
const name = content.name as string;