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:
@@ -81,25 +81,46 @@ export const scheduleTask: McpToolDefinition = {
|
|||||||
export const listTasks: McpToolDefinition = {
|
export const listTasks: McpToolDefinition = {
|
||||||
tool: {
|
tool: {
|
||||||
name: 'list_tasks',
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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) {
|
async handler(args) {
|
||||||
const status = args.status as string | undefined;
|
const status = args.status as string | undefined;
|
||||||
const db = getInboundDb();
|
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;
|
let rows;
|
||||||
if (status) {
|
if (status) {
|
||||||
rows = db
|
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);
|
.all(status);
|
||||||
} else {
|
} else {
|
||||||
rows = db
|
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();
|
.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];
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ List active scheduled/recurring tasks.
|
|||||||
|
|
||||||
Implementation: query `messages_in WHERE recurrence IS NOT NULL AND status != 'failed'`.
|
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.
|
Modify a scheduled task.
|
||||||
|
|
||||||
@@ -620,9 +620,10 @@ Modify a scheduled task.
|
|||||||
}
|
}
|
||||||
// pause_task: set status = 'paused' (new status value for recurring tasks)
|
// pause_task: set status = 'paused' (new status value for recurring tasks)
|
||||||
// resume_task: set status = 'pending'
|
// 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
|
#### register_agent_group
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
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.
|
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
|
### How it works
|
||||||
|
|||||||
@@ -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.
|
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
|
### How it works
|
||||||
|
|
||||||
1. You provide a bash `script` alongside the `prompt` when scheduling
|
1. You provide a bash `script` alongside the `prompt` when scheduling
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
cancelTask,
|
cancelTask,
|
||||||
pauseTask,
|
pauseTask,
|
||||||
resumeTask,
|
resumeTask,
|
||||||
|
updateTask,
|
||||||
getCompletedRecurring,
|
getCompletedRecurring,
|
||||||
migrateMessagesInTable,
|
migrateMessagesInTable,
|
||||||
type RecurringMessage,
|
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', () => {
|
describe('insertRecurrence', () => {
|
||||||
it('copies series_id forward', () => {
|
it('copies series_id forward', () => {
|
||||||
const db = freshDb();
|
const db = freshDb();
|
||||||
|
|||||||
@@ -143,6 +143,60 @@ export function resumeTask(db: Database.Database, taskId: string): void {
|
|||||||
).run(taskId, taskId);
|
).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 {
|
export function countDueMessages(db: Database.Database): number {
|
||||||
return (
|
return (
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
cancelTask,
|
cancelTask,
|
||||||
pauseTask,
|
pauseTask,
|
||||||
resumeTask,
|
resumeTask,
|
||||||
|
updateTask,
|
||||||
} from './db/session-db.js';
|
} from './db/session-db.js';
|
||||||
import { log } from './log.js';
|
import { log } from './log.js';
|
||||||
import { normalizeOptions, type RawOption } from './channels/ask-question.js';
|
import { normalizeOptions, type RawOption } from './channels/ask-question.js';
|
||||||
@@ -656,6 +657,25 @@ async function handleSystemAction(
|
|||||||
break;
|
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': {
|
case 'create_agent': {
|
||||||
const requestId = content.requestId as string;
|
const requestId = content.requestId as string;
|
||||||
const name = content.name as string;
|
const name = content.name as string;
|
||||||
|
|||||||
Reference in New Issue
Block a user