clementine-agent 1.0.86 → 1.0.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/background-tasks.d.ts +56 -0
- package/dist/agent/background-tasks.js +159 -0
- package/dist/cli/dashboard.js +21 -0
- package/dist/gateway/cron-scheduler.d.ts +11 -0
- package/dist/gateway/cron-scheduler.js +66 -0
- package/dist/index.js +13 -0
- package/dist/tools/background-task-tools.d.ts +16 -0
- package/dist/tools/background-task-tools.js +105 -0
- package/dist/tools/mcp-server.js +2 -0
- package/dist/types.d.ts +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Background task persistence + lifecycle helpers.
|
|
3
|
+
*
|
|
4
|
+
* A "background task" is an unleashed multi-turn job that an agent kicks
|
|
5
|
+
* off via the `start_background_task` MCP tool. Persistence is one JSON
|
|
6
|
+
* file per task at ~/.clementine/background-tasks/<id>.json. The file is
|
|
7
|
+
* the source of truth — the MCP tool writes the initial pending state,
|
|
8
|
+
* the daemon picks it up, runs it, and updates the same file as the
|
|
9
|
+
* lifecycle progresses.
|
|
10
|
+
*
|
|
11
|
+
* Process boundary: the MCP tool runs in an SDK subprocess, so it can't
|
|
12
|
+
* call the gateway directly. It writes a pending file; the daemon's
|
|
13
|
+
* cron-scheduler tick picks up pending tasks within ~3 seconds.
|
|
14
|
+
*
|
|
15
|
+
* Restart safety: on daemon startup, any task left in 'running' is
|
|
16
|
+
* aborted (its process is gone). P6b can add resumability; for now,
|
|
17
|
+
* fail-fast is clearer than silently re-running a task that may have
|
|
18
|
+
* already partially completed.
|
|
19
|
+
*/
|
|
20
|
+
import type { BackgroundTask } from '../types.js';
|
|
21
|
+
export declare const BACKGROUND_TASK_DIR: string;
|
|
22
|
+
export interface BackgroundTaskOptions {
|
|
23
|
+
/** Override storage directory for tests. Defaults to BASE_DIR/background-tasks/. */
|
|
24
|
+
dir?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Create a new pending task on disk and return it. Caller (the MCP tool)
|
|
28
|
+
* doesn't await execution — the daemon picks the task up asynchronously.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createBackgroundTask(input: {
|
|
31
|
+
fromAgent: string;
|
|
32
|
+
prompt: string;
|
|
33
|
+
maxMinutes: number;
|
|
34
|
+
}, opts?: BackgroundTaskOptions): BackgroundTask;
|
|
35
|
+
/** Load a task by id, or null if not found / malformed. */
|
|
36
|
+
export declare function loadBackgroundTask(id: string, opts?: BackgroundTaskOptions): BackgroundTask | null;
|
|
37
|
+
/** List tasks with optional status / agent filters, newest first. */
|
|
38
|
+
export declare function listBackgroundTasks(filter?: {
|
|
39
|
+
status?: BackgroundTask['status'];
|
|
40
|
+
fromAgent?: string;
|
|
41
|
+
}, opts?: BackgroundTaskOptions): BackgroundTask[];
|
|
42
|
+
/** Transition a task to 'running' — daemon picked it up. */
|
|
43
|
+
export declare function markRunning(id: string, opts?: BackgroundTaskOptions): BackgroundTask | null;
|
|
44
|
+
/** Transition to 'done' with final result. */
|
|
45
|
+
export declare function markDone(id: string, result: string, deliverableNote?: string, opts?: BackgroundTaskOptions): BackgroundTask | null;
|
|
46
|
+
/** Transition to 'failed' or 'aborted' with error message. */
|
|
47
|
+
export declare function markFailed(id: string, error: string, reason?: 'failed' | 'aborted', opts?: BackgroundTaskOptions): BackgroundTask | null;
|
|
48
|
+
/**
|
|
49
|
+
* Daemon-restart hygiene: any task still in 'running' must be from a
|
|
50
|
+
* prior daemon process. Mark them aborted so the lifecycle is honest.
|
|
51
|
+
* Returns the count of tasks aborted.
|
|
52
|
+
*/
|
|
53
|
+
export declare function abortStaleRunningTasks(opts?: BackgroundTaskOptions): number;
|
|
54
|
+
/** Test-only: delete a task file. Production code never deletes — history matters. */
|
|
55
|
+
export declare function _deleteBackgroundTask(id: string, opts?: BackgroundTaskOptions): void;
|
|
56
|
+
//# sourceMappingURL=background-tasks.d.ts.map
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Background task persistence + lifecycle helpers.
|
|
3
|
+
*
|
|
4
|
+
* A "background task" is an unleashed multi-turn job that an agent kicks
|
|
5
|
+
* off via the `start_background_task` MCP tool. Persistence is one JSON
|
|
6
|
+
* file per task at ~/.clementine/background-tasks/<id>.json. The file is
|
|
7
|
+
* the source of truth — the MCP tool writes the initial pending state,
|
|
8
|
+
* the daemon picks it up, runs it, and updates the same file as the
|
|
9
|
+
* lifecycle progresses.
|
|
10
|
+
*
|
|
11
|
+
* Process boundary: the MCP tool runs in an SDK subprocess, so it can't
|
|
12
|
+
* call the gateway directly. It writes a pending file; the daemon's
|
|
13
|
+
* cron-scheduler tick picks up pending tasks within ~3 seconds.
|
|
14
|
+
*
|
|
15
|
+
* Restart safety: on daemon startup, any task left in 'running' is
|
|
16
|
+
* aborted (its process is gone). P6b can add resumability; for now,
|
|
17
|
+
* fail-fast is clearer than silently re-running a task that may have
|
|
18
|
+
* already partially completed.
|
|
19
|
+
*/
|
|
20
|
+
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { BASE_DIR } from '../config.js';
|
|
24
|
+
const DEFAULT_DIR = path.join(BASE_DIR, 'background-tasks');
|
|
25
|
+
const RESULT_TRUNCATE_BYTES = 3000;
|
|
26
|
+
export const BACKGROUND_TASK_DIR = DEFAULT_DIR;
|
|
27
|
+
function dirFor(opts) {
|
|
28
|
+
return opts?.dir ?? DEFAULT_DIR;
|
|
29
|
+
}
|
|
30
|
+
function makeId(now = new Date()) {
|
|
31
|
+
// Sortable-by-time prefix + 6 hex chars of randomness
|
|
32
|
+
return `bg-${now.getTime().toString(36)}-${randomBytes(3).toString('hex')}`;
|
|
33
|
+
}
|
|
34
|
+
function pathFor(id, opts) {
|
|
35
|
+
return path.join(dirFor(opts), `${id}.json`);
|
|
36
|
+
}
|
|
37
|
+
function safeWrite(file, task) {
|
|
38
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
39
|
+
// Truncate result so a runaway task can't blow the file size
|
|
40
|
+
const slim = task.result && task.result.length > RESULT_TRUNCATE_BYTES
|
|
41
|
+
? { ...task, result: task.result.slice(0, RESULT_TRUNCATE_BYTES) + '\n...[truncated]' }
|
|
42
|
+
: task;
|
|
43
|
+
writeFileSync(file, JSON.stringify(slim, null, 2));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create a new pending task on disk and return it. Caller (the MCP tool)
|
|
47
|
+
* doesn't await execution — the daemon picks the task up asynchronously.
|
|
48
|
+
*/
|
|
49
|
+
export function createBackgroundTask(input, opts) {
|
|
50
|
+
const now = new Date();
|
|
51
|
+
const task = {
|
|
52
|
+
id: makeId(now),
|
|
53
|
+
fromAgent: input.fromAgent,
|
|
54
|
+
prompt: input.prompt,
|
|
55
|
+
maxMinutes: Math.max(1, Math.min(240, Math.floor(input.maxMinutes))), // 1m–4h
|
|
56
|
+
status: 'pending',
|
|
57
|
+
createdAt: now.toISOString(),
|
|
58
|
+
};
|
|
59
|
+
safeWrite(pathFor(task.id, opts), task);
|
|
60
|
+
return task;
|
|
61
|
+
}
|
|
62
|
+
/** Load a task by id, or null if not found / malformed. */
|
|
63
|
+
export function loadBackgroundTask(id, opts) {
|
|
64
|
+
try {
|
|
65
|
+
const file = pathFor(id, opts);
|
|
66
|
+
if (!existsSync(file))
|
|
67
|
+
return null;
|
|
68
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** List tasks with optional status / agent filters, newest first. */
|
|
75
|
+
export function listBackgroundTasks(filter = {}, opts) {
|
|
76
|
+
const dir = dirFor(opts);
|
|
77
|
+
if (!existsSync(dir))
|
|
78
|
+
return [];
|
|
79
|
+
const out = [];
|
|
80
|
+
let files;
|
|
81
|
+
try {
|
|
82
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
try {
|
|
89
|
+
const task = JSON.parse(readFileSync(path.join(dir, file), 'utf-8'));
|
|
90
|
+
if (filter.status && task.status !== filter.status)
|
|
91
|
+
continue;
|
|
92
|
+
if (filter.fromAgent && task.fromAgent !== filter.fromAgent)
|
|
93
|
+
continue;
|
|
94
|
+
out.push(task);
|
|
95
|
+
}
|
|
96
|
+
catch { /* skip malformed */ }
|
|
97
|
+
}
|
|
98
|
+
// Newest first by createdAt; falls back to id (which is timestamp-prefixed)
|
|
99
|
+
out.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
/** Transition a task to 'running' — daemon picked it up. */
|
|
103
|
+
export function markRunning(id, opts) {
|
|
104
|
+
const task = loadBackgroundTask(id, opts);
|
|
105
|
+
if (!task)
|
|
106
|
+
return null;
|
|
107
|
+
task.status = 'running';
|
|
108
|
+
task.startedAt = new Date().toISOString();
|
|
109
|
+
safeWrite(pathFor(id, opts), task);
|
|
110
|
+
return task;
|
|
111
|
+
}
|
|
112
|
+
/** Transition to 'done' with final result. */
|
|
113
|
+
export function markDone(id, result, deliverableNote, opts) {
|
|
114
|
+
const task = loadBackgroundTask(id, opts);
|
|
115
|
+
if (!task)
|
|
116
|
+
return null;
|
|
117
|
+
task.status = 'done';
|
|
118
|
+
task.completedAt = new Date().toISOString();
|
|
119
|
+
task.result = result;
|
|
120
|
+
if (deliverableNote)
|
|
121
|
+
task.deliverableNote = deliverableNote;
|
|
122
|
+
safeWrite(pathFor(id, opts), task);
|
|
123
|
+
return task;
|
|
124
|
+
}
|
|
125
|
+
/** Transition to 'failed' or 'aborted' with error message. */
|
|
126
|
+
export function markFailed(id, error, reason = 'failed', opts) {
|
|
127
|
+
const task = loadBackgroundTask(id, opts);
|
|
128
|
+
if (!task)
|
|
129
|
+
return null;
|
|
130
|
+
task.status = reason;
|
|
131
|
+
task.completedAt = new Date().toISOString();
|
|
132
|
+
task.error = error.slice(0, 1000);
|
|
133
|
+
safeWrite(pathFor(id, opts), task);
|
|
134
|
+
return task;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Daemon-restart hygiene: any task still in 'running' must be from a
|
|
138
|
+
* prior daemon process. Mark them aborted so the lifecycle is honest.
|
|
139
|
+
* Returns the count of tasks aborted.
|
|
140
|
+
*/
|
|
141
|
+
export function abortStaleRunningTasks(opts) {
|
|
142
|
+
const stuck = listBackgroundTasks({ status: 'running' }, opts);
|
|
143
|
+
let aborted = 0;
|
|
144
|
+
for (const t of stuck) {
|
|
145
|
+
markFailed(t.id, 'daemon restarted while task was in flight', 'aborted', opts);
|
|
146
|
+
aborted++;
|
|
147
|
+
}
|
|
148
|
+
return aborted;
|
|
149
|
+
}
|
|
150
|
+
/** Test-only: delete a task file. Production code never deletes — history matters. */
|
|
151
|
+
export function _deleteBackgroundTask(id, opts) {
|
|
152
|
+
try {
|
|
153
|
+
const file = pathFor(id, opts);
|
|
154
|
+
if (existsSync(file))
|
|
155
|
+
unlinkSync(file);
|
|
156
|
+
}
|
|
157
|
+
catch { /* ignore */ }
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=background-tasks.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -1978,6 +1978,27 @@ export async function cmdDashboard(opts) {
|
|
|
1978
1978
|
app.get('/api/agent-heartbeats', (_req, res) => {
|
|
1979
1979
|
res.json(getAgentHeartbeats());
|
|
1980
1980
|
});
|
|
1981
|
+
app.get('/api/background-tasks', async (_req, res) => {
|
|
1982
|
+
try {
|
|
1983
|
+
const { listBackgroundTasks } = await import('../agent/background-tasks.js');
|
|
1984
|
+
const tasks = listBackgroundTasks();
|
|
1985
|
+
const now = Date.now();
|
|
1986
|
+
// Add derived fields convenient for UI use
|
|
1987
|
+
const out = tasks.map((t) => {
|
|
1988
|
+
const startedMs = t.startedAt ? new Date(t.startedAt).getTime() : 0;
|
|
1989
|
+
const completedMs = t.completedAt ? new Date(t.completedAt).getTime() : 0;
|
|
1990
|
+
return {
|
|
1991
|
+
...t,
|
|
1992
|
+
runningForMs: t.status === 'running' && startedMs > 0 ? now - startedMs : null,
|
|
1993
|
+
totalDurationMs: startedMs > 0 && completedMs > 0 ? completedMs - startedMs : null,
|
|
1994
|
+
};
|
|
1995
|
+
});
|
|
1996
|
+
res.json(out);
|
|
1997
|
+
}
|
|
1998
|
+
catch (err) {
|
|
1999
|
+
res.status(500).json({ error: String(err).slice(0, 200) });
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
1981
2002
|
app.get('/api/heartbeat/agent/:slug', (req, res) => {
|
|
1982
2003
|
const slug = req.params.slug;
|
|
1983
2004
|
const state = getHeartbeat();
|
|
@@ -150,6 +150,17 @@ export declare class CronScheduler {
|
|
|
150
150
|
private unwatchWorkflowDir;
|
|
151
151
|
/** Watch the triggers directory for MCP-initiated job runs and goal work sessions. */
|
|
152
152
|
private watchTriggers;
|
|
153
|
+
/**
|
|
154
|
+
* Pick up pending background tasks and run them via the unleashed
|
|
155
|
+
* cron path. Each task gets the originating agent's profile and
|
|
156
|
+
* Discord channel for the completion notification.
|
|
157
|
+
*
|
|
158
|
+
* Concurrency: a task moves from 'pending' to 'running' synchronously
|
|
159
|
+
* before the long-running work starts, so a second tick won't pick up
|
|
160
|
+
* the same task twice. Failures are logged + persisted; we never throw
|
|
161
|
+
* out of the trigger interval.
|
|
162
|
+
*/
|
|
163
|
+
private processBackgroundTasks;
|
|
153
164
|
/** Process any pending trigger files and run the corresponding jobs. */
|
|
154
165
|
private processTriggers;
|
|
155
166
|
/** Process any pending goal work trigger files. Routes through the execution advisor. */
|
|
@@ -18,6 +18,7 @@ import { scanner } from '../security/scanner.js';
|
|
|
18
18
|
import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
|
|
19
19
|
import { SelfImproveLoop } from '../agent/self-improve.js';
|
|
20
20
|
import { logAuditJsonl } from '../agent/hooks.js';
|
|
21
|
+
import { listBackgroundTasks, markDone as markBgTaskDone, markFailed as markBgTaskFailed, markRunning as markBgTaskRunning, } from '../agent/background-tasks.js';
|
|
21
22
|
import { outcomeStatusFromGoalDisposition, recentDecisions, recordDecisionOutcome, } from '../agent/proactive-ledger.js';
|
|
22
23
|
const logger = pino({ name: 'clementine.cron' });
|
|
23
24
|
/** Default timeout for standard cron jobs (10 minutes). */
|
|
@@ -1501,8 +1502,73 @@ export class CronScheduler {
|
|
|
1501
1502
|
this.triggerTimer = setInterval(() => {
|
|
1502
1503
|
this.processTriggers();
|
|
1503
1504
|
this.processGoalTriggers();
|
|
1505
|
+
this.processBackgroundTasks();
|
|
1504
1506
|
}, 3000);
|
|
1505
1507
|
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Pick up pending background tasks and run them via the unleashed
|
|
1510
|
+
* cron path. Each task gets the originating agent's profile and
|
|
1511
|
+
* Discord channel for the completion notification.
|
|
1512
|
+
*
|
|
1513
|
+
* Concurrency: a task moves from 'pending' to 'running' synchronously
|
|
1514
|
+
* before the long-running work starts, so a second tick won't pick up
|
|
1515
|
+
* the same task twice. Failures are logged + persisted; we never throw
|
|
1516
|
+
* out of the trigger interval.
|
|
1517
|
+
*/
|
|
1518
|
+
processBackgroundTasks() {
|
|
1519
|
+
let pending;
|
|
1520
|
+
try {
|
|
1521
|
+
pending = listBackgroundTasks({ status: 'pending' });
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
if (pending.length === 0)
|
|
1527
|
+
return;
|
|
1528
|
+
for (const task of pending) {
|
|
1529
|
+
// Move to 'running' synchronously so the next tick (3s away) won't
|
|
1530
|
+
// re-pick. Even if the work below errors, the state is honest.
|
|
1531
|
+
const started = markBgTaskRunning(task.id);
|
|
1532
|
+
if (!started)
|
|
1533
|
+
continue;
|
|
1534
|
+
logger.info({ id: task.id, fromAgent: task.fromAgent, maxMinutes: task.maxMinutes }, 'Background task picked up');
|
|
1535
|
+
// Don't await — fire-and-forget. The 3s tick continues to scan.
|
|
1536
|
+
const jobName = `bg:${task.id}`;
|
|
1537
|
+
const maxHours = Math.max(0.05, task.maxMinutes / 60);
|
|
1538
|
+
this.gateway.handleCronJob(jobName, task.prompt, 2, // tier 2 (Bash/Write/Edit available)
|
|
1539
|
+
undefined, // default maxTurns
|
|
1540
|
+
undefined, // default model
|
|
1541
|
+
undefined, // default workDir
|
|
1542
|
+
'unleashed', // long-running mode
|
|
1543
|
+
maxHours, undefined, // timeoutMs (maxHours covers it)
|
|
1544
|
+
undefined, // successCriteria
|
|
1545
|
+
task.fromAgent).then((result) => {
|
|
1546
|
+
try {
|
|
1547
|
+
markBgTaskDone(task.id, result ?? '(no output)');
|
|
1548
|
+
}
|
|
1549
|
+
catch (err) {
|
|
1550
|
+
logger.warn({ err, id: task.id }, 'Failed to mark background task done');
|
|
1551
|
+
}
|
|
1552
|
+
// Dispatch the deliverable to the originating agent's channel.
|
|
1553
|
+
const deliveryHead = `**Background task ${task.id} done** — ${task.prompt.slice(0, 100).replace(/\s+/g, ' ')}${task.prompt.length > 100 ? '...' : ''}\n\n`;
|
|
1554
|
+
const body = (result ?? '').slice(0, 1500);
|
|
1555
|
+
this.dispatcher
|
|
1556
|
+
.send(deliveryHead + body, { agentSlug: task.fromAgent !== 'clementine' ? task.fromAgent : undefined })
|
|
1557
|
+
.catch((err) => logger.debug({ err, id: task.id }, 'Failed to dispatch background task result'));
|
|
1558
|
+
}).catch((err) => {
|
|
1559
|
+
const errStr = String(err).slice(0, 500);
|
|
1560
|
+
try {
|
|
1561
|
+
markBgTaskFailed(task.id, errStr, 'failed');
|
|
1562
|
+
}
|
|
1563
|
+
catch (saveErr) {
|
|
1564
|
+
logger.warn({ err: saveErr, id: task.id }, 'Failed to mark background task failed');
|
|
1565
|
+
}
|
|
1566
|
+
this.dispatcher
|
|
1567
|
+
.send(`**Background task ${task.id} failed** — ${errStr.slice(0, 200)}`, { agentSlug: task.fromAgent !== 'clementine' ? task.fromAgent : undefined })
|
|
1568
|
+
.catch(() => { });
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1506
1572
|
/** Process any pending trigger files and run the corresponding jobs. */
|
|
1507
1573
|
processTriggers() {
|
|
1508
1574
|
if (!existsSync(this.triggerDir))
|
package/dist/index.js
CHANGED
|
@@ -762,6 +762,19 @@ async function asyncMain() {
|
|
|
762
762
|
heartbeat.start();
|
|
763
763
|
cronScheduler.start();
|
|
764
764
|
agentHeartbeats.start();
|
|
765
|
+
// Background-task hygiene: any task left in 'running' is from a prior
|
|
766
|
+
// process. Mark them aborted so the lifecycle is honest. (P6b will add
|
|
767
|
+
// resumability; for now fail-fast is clearer than silently re-running.)
|
|
768
|
+
try {
|
|
769
|
+
const { abortStaleRunningTasks } = await import('./agent/background-tasks.js');
|
|
770
|
+
const aborted = abortStaleRunningTasks();
|
|
771
|
+
if (aborted > 0) {
|
|
772
|
+
logger.info({ count: aborted }, 'Aborted stale running background tasks from prior daemon');
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
logger.warn({ err }, 'Background task hygiene check failed — non-fatal');
|
|
777
|
+
}
|
|
765
778
|
const timerInterval = startTimerChecker(dispatcher, gateway);
|
|
766
779
|
// Start brain ingest scheduler (polls registered REST sources on their cron)
|
|
767
780
|
try {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Background task MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* `start_background_task` lets an agent kick off a long-running job
|
|
5
|
+
* (research, multi-page extraction, batch outreach) without blocking
|
|
6
|
+
* the conversation. The agent gets a task id immediately and is
|
|
7
|
+
* notified in their channel when the work completes.
|
|
8
|
+
*
|
|
9
|
+
* Internally: the tool writes a pending task file. The daemon's
|
|
10
|
+
* cron-scheduler tick picks it up within ~3 seconds, runs it via
|
|
11
|
+
* runUnleashedTask with the agent's profile, then dispatches the
|
|
12
|
+
* result to the agent's Discord channel.
|
|
13
|
+
*/
|
|
14
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
15
|
+
export declare function registerBackgroundTaskTools(server: McpServer): void;
|
|
16
|
+
//# sourceMappingURL=background-task-tools.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Background task MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* `start_background_task` lets an agent kick off a long-running job
|
|
5
|
+
* (research, multi-page extraction, batch outreach) without blocking
|
|
6
|
+
* the conversation. The agent gets a task id immediately and is
|
|
7
|
+
* notified in their channel when the work completes.
|
|
8
|
+
*
|
|
9
|
+
* Internally: the tool writes a pending task file. The daemon's
|
|
10
|
+
* cron-scheduler tick picks it up within ~3 seconds, runs it via
|
|
11
|
+
* runUnleashedTask with the agent's profile, then dispatches the
|
|
12
|
+
* result to the agent's Discord channel.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { createBackgroundTask, listBackgroundTasks, loadBackgroundTask, } from '../agent/background-tasks.js';
|
|
16
|
+
import { ACTIVE_AGENT_SLUG, logger, textResult } from './shared.js';
|
|
17
|
+
const DEFAULT_MAX_MINUTES = 30;
|
|
18
|
+
export function registerBackgroundTaskTools(server) {
|
|
19
|
+
server.tool('start_background_task', 'Kick off a long-running autonomous task in the background. Use when the work would burn the chat context (deep research, multi-page extraction, batch processing) or take longer than a chat turn. Returns a task id immediately. The daemon picks the task up within seconds, runs it with your profile + tools, and posts the deliverable to your Discord channel when done.', {
|
|
20
|
+
prompt: z.string().describe('The full task description — be specific about what you want produced. Use the same level of detail you would give a teammate.'),
|
|
21
|
+
max_minutes: z.number().optional().describe(`Hard wall-clock cap on the task. Default ${DEFAULT_MAX_MINUTES} min. Range 1–240. Use longer caps for sustained research.`),
|
|
22
|
+
}, async ({ prompt, max_minutes }) => {
|
|
23
|
+
const fromAgent = ACTIVE_AGENT_SLUG || 'clementine';
|
|
24
|
+
const trimmed = (prompt ?? '').trim();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
return textResult('start_background_task: prompt is required.');
|
|
27
|
+
}
|
|
28
|
+
const cap = typeof max_minutes === 'number' ? max_minutes : DEFAULT_MAX_MINUTES;
|
|
29
|
+
const task = createBackgroundTask({
|
|
30
|
+
fromAgent,
|
|
31
|
+
prompt: trimmed,
|
|
32
|
+
maxMinutes: cap,
|
|
33
|
+
});
|
|
34
|
+
logger.info({ id: task.id, fromAgent, maxMinutes: task.maxMinutes }, 'Background task queued');
|
|
35
|
+
return textResult(`Queued **${task.id}** (max ${task.maxMinutes} min). The daemon will pick it up within a few seconds and run it in the background. You'll get a notification in your channel when the deliverable lands. Use \`get_background_task\` to check status.`);
|
|
36
|
+
});
|
|
37
|
+
server.tool('get_background_task', 'Check the status of a background task. Returns its lifecycle state (pending|running|done|failed|aborted), how long it has been running, and the result/error if terminal.', {
|
|
38
|
+
task_id: z.string().describe('Task id returned by start_background_task (e.g., "bg-abc123-def4")'),
|
|
39
|
+
}, async ({ task_id }) => {
|
|
40
|
+
const task = loadBackgroundTask(task_id);
|
|
41
|
+
if (!task) {
|
|
42
|
+
return textResult(`get_background_task: no task found with id "${task_id}".`);
|
|
43
|
+
}
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(`**${task.id}** — ${task.status}`);
|
|
46
|
+
lines.push(`From: ${task.fromAgent}`);
|
|
47
|
+
lines.push(`Created: ${task.createdAt}`);
|
|
48
|
+
if (task.startedAt)
|
|
49
|
+
lines.push(`Started: ${task.startedAt}`);
|
|
50
|
+
if (task.completedAt)
|
|
51
|
+
lines.push(`Completed: ${task.completedAt}`);
|
|
52
|
+
lines.push(`Max minutes: ${task.maxMinutes}`);
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push(`Prompt: ${task.prompt.slice(0, 300)}${task.prompt.length > 300 ? '...' : ''}`);
|
|
55
|
+
if (task.status === 'running' && task.startedAt) {
|
|
56
|
+
const elapsedMin = Math.round((Date.now() - new Date(task.startedAt).getTime()) / 60000);
|
|
57
|
+
lines.push('');
|
|
58
|
+
lines.push(`Running for ${elapsedMin}m / ${task.maxMinutes}m cap.`);
|
|
59
|
+
}
|
|
60
|
+
if (task.error) {
|
|
61
|
+
lines.push('');
|
|
62
|
+
lines.push(`Error: ${task.error}`);
|
|
63
|
+
}
|
|
64
|
+
if (task.result) {
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push(`Result:\n${task.result}`);
|
|
67
|
+
}
|
|
68
|
+
if (task.deliverableNote) {
|
|
69
|
+
lines.push('');
|
|
70
|
+
lines.push(`Deliverable: ${task.deliverableNote}`);
|
|
71
|
+
}
|
|
72
|
+
return textResult(lines.join('\n'));
|
|
73
|
+
});
|
|
74
|
+
server.tool('list_background_tasks', 'List background tasks, optionally filtered by status or originating agent. Newest first. Use to see what work is in flight or completed recently.', {
|
|
75
|
+
status: z
|
|
76
|
+
.enum(['pending', 'running', 'done', 'failed', 'aborted'])
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('Filter by lifecycle status'),
|
|
79
|
+
from_agent: z.string().optional().describe('Filter by originating agent slug'),
|
|
80
|
+
limit: z.number().optional().describe('Max number to return (default 20, max 100)'),
|
|
81
|
+
}, async ({ status, from_agent, limit }) => {
|
|
82
|
+
const filter = {};
|
|
83
|
+
if (status)
|
|
84
|
+
filter.status = status;
|
|
85
|
+
if (from_agent)
|
|
86
|
+
filter.fromAgent = from_agent;
|
|
87
|
+
const all = listBackgroundTasks(filter);
|
|
88
|
+
const cap = Math.max(1, Math.min(100, typeof limit === 'number' ? limit : 20));
|
|
89
|
+
const tasks = all.slice(0, cap);
|
|
90
|
+
if (tasks.length === 0) {
|
|
91
|
+
const filterDesc = [
|
|
92
|
+
status ? `status=${status}` : '',
|
|
93
|
+
from_agent ? `from_agent=${from_agent}` : '',
|
|
94
|
+
].filter(Boolean).join(', ');
|
|
95
|
+
return textResult(`No background tasks found${filterDesc ? ` (${filterDesc})` : ''}.`);
|
|
96
|
+
}
|
|
97
|
+
const lines = [`## Background tasks (${tasks.length}${all.length > tasks.length ? ` of ${all.length}` : ''})`];
|
|
98
|
+
for (const t of tasks) {
|
|
99
|
+
const promptHead = t.prompt.replace(/\s+/g, ' ').slice(0, 80);
|
|
100
|
+
lines.push(`- **${t.id}** [${t.status}] ${t.fromAgent}: ${promptHead}${t.prompt.length > 80 ? '...' : ''}`);
|
|
101
|
+
}
|
|
102
|
+
return textResult(lines.join('\n'));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=background-task-tools.js.map
|
package/dist/tools/mcp-server.js
CHANGED
|
@@ -27,6 +27,7 @@ import { registerSessionTools } from './session-tools.js';
|
|
|
27
27
|
import { registerArtifactTools } from './artifact-tools.js';
|
|
28
28
|
import { registerBrainTools } from './brain-tools.js';
|
|
29
29
|
import { registerAgentHeartbeatTools } from './agent-heartbeat-tools.js';
|
|
30
|
+
import { registerBackgroundTaskTools } from './background-task-tools.js';
|
|
30
31
|
// ── Server ──────────────────────────────────────────────────────────────
|
|
31
32
|
const serverName = (env['ASSISTANT_NAME'] ?? 'Clementine').toLowerCase() + '-tools';
|
|
32
33
|
const server = new McpServer({ name: serverName, version: '1.0.0' });
|
|
@@ -41,6 +42,7 @@ registerSessionTools(server);
|
|
|
41
42
|
registerArtifactTools(server);
|
|
42
43
|
registerBrainTools(server);
|
|
43
44
|
registerAgentHeartbeatTools(server);
|
|
45
|
+
registerBackgroundTaskTools(server);
|
|
44
46
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
45
47
|
async function main() {
|
|
46
48
|
// Initialize memory store and run full sync on startup
|
package/dist/types.d.ts
CHANGED
|
@@ -239,6 +239,30 @@ export interface HeartbeatWorkItem {
|
|
|
239
239
|
error?: string;
|
|
240
240
|
agentSlug?: string;
|
|
241
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Long-running autonomous task an agent kicks off via the
|
|
244
|
+
* `start_background_task` MCP tool. The task runs in the daemon as an
|
|
245
|
+
* unleashed cron-style job with the requesting agent's profile, then
|
|
246
|
+
* notifies that agent's Discord channel on completion.
|
|
247
|
+
*
|
|
248
|
+
* Lifecycle: pending → running → (done | failed | aborted)
|
|
249
|
+
*
|
|
250
|
+
* Persisted as ~/.clementine/background-tasks/<id>.json. The file is
|
|
251
|
+
* the source of truth; status is updated in place as the task progresses.
|
|
252
|
+
*/
|
|
253
|
+
export interface BackgroundTask {
|
|
254
|
+
id: string;
|
|
255
|
+
fromAgent: string;
|
|
256
|
+
prompt: string;
|
|
257
|
+
maxMinutes: number;
|
|
258
|
+
status: 'pending' | 'running' | 'done' | 'failed' | 'aborted';
|
|
259
|
+
createdAt: string;
|
|
260
|
+
startedAt?: string;
|
|
261
|
+
completedAt?: string;
|
|
262
|
+
result?: string;
|
|
263
|
+
error?: string;
|
|
264
|
+
deliverableNote?: string;
|
|
265
|
+
}
|
|
242
266
|
/**
|
|
243
267
|
* State for one specialist agent's heartbeat scheduler. Persisted at
|
|
244
268
|
* ~/.clementine/heartbeat/agents/<slug>/state.json. Manager reads
|