From cdf18e608f2d56d9af7e42f5bc0bbb3b8cbfff13 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 16 Apr 2026 16:30:00 +0000 Subject: [PATCH] feat(v2): add update_task MCP tool, dedup list_tasks by series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../agent-runner/src/mcp-tools/scheduling.ts | 78 ++++++++++- docs/v2-agent-runner-details.md | 5 +- groups/global/CLAUDE.md | 2 + groups/main/CLAUDE.md | 2 + src/db/session-db.test.ts | 122 ++++++++++++++++++ src/db/session-db.ts | 54 ++++++++ src/delivery.ts | 20 +++ 7 files changed, 276 insertions(+), 7 deletions(-) diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts index 6d32e88d5..1e362e214 100644 --- a/container/agent-runner/src/mcp-tools/scheduling.ts +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -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 = { 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]; diff --git a/docs/v2-agent-runner-details.md b/docs/v2-agent-runner-details.md index 30f8af8c9..22a8744ec 100644 --- a/docs/v2-agent-runner-details.md +++ b/docs/v2-agent-runner-details.md @@ -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 diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index ec97965ad..dc585e285 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -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 diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index cb1c3cc6b..011a906aa 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -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 diff --git a/src/db/session-db.test.ts b/src/db/session-db.test.ts index cf544181c..3b18bc81e 100644 --- a/src/db/session-db.test.ts +++ b/src/db/session-db.test.ts @@ -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(); diff --git a/src/db/session-db.ts b/src/db/session-db.ts index 8bb9e2e6f..aafb39e56 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -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; + 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 diff --git a/src/delivery.ts b/src/delivery.ts index d5f798edf..e050955d8 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -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[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;