mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
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 = {
|
||||
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];
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user