assistme 0.7.0 → 0.8.2
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/{chunk-QHMIXIWO.js → chunk-A2NR7LCQ.js} +69 -9
- package/dist/chunk-IKYXC4RJ.js +3383 -0
- package/dist/index.js +1324 -7269
- package/dist/{job-runner-YM2NBIL3.js → job-runner-PECVS424.js} +1 -1
- package/dist/workers/entry.d.ts +1 -0
- package/dist/workers/entry.js +3280 -0
- package/package.json +3 -3
- package/src/agent/self-analyzer.ts +1 -1
- package/src/commands/monitor.ts +4 -6
- package/src/commands/start.ts +24 -17
- package/src/db/analysis-data.ts +4 -1
- package/src/db/session-log.ts +3 -2
- package/src/orchestrator.ts +492 -0
- package/src/utils/logger.ts +60 -10
- package/src/workers/base-handler.ts +94 -0
- package/src/workers/conversation.ts +74 -0
- package/src/workers/entry.ts +37 -0
- package/src/workers/index.ts +9 -0
- package/src/workers/manager.ts +506 -0
- package/src/workers/types.ts +61 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "assistme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "AssistMe CLI Agent - AI-powered agentic assistant for code, browser, and automation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"assistme": "dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
12
|
-
"dev": "tsup src/index.ts --format esm --watch",
|
|
11
|
+
"build": "tsup src/index.ts src/workers/entry.ts --format esm --dts --clean",
|
|
12
|
+
"dev": "tsup src/index.ts src/workers/entry.ts --format esm --watch",
|
|
13
13
|
"start": "node dist/index.js",
|
|
14
14
|
"typecheck": "tsc --noEmit",
|
|
15
15
|
"test": "vitest run",
|
|
@@ -166,7 +166,7 @@ function filterAndAggregateEvents(
|
|
|
166
166
|
async function buildAnalysisContext(ctx: SelfAnalysisContext): Promise<string> {
|
|
167
167
|
// Fetch all data sources in parallel
|
|
168
168
|
const [sessionLogs, messageEvents, conversationMessages] = await Promise.all([
|
|
169
|
-
getSessionLogs(ctx.sessionId, SELF_ANALYSIS_MAX_SESSION_LOGS),
|
|
169
|
+
getSessionLogs(ctx.sessionId, SELF_ANALYSIS_MAX_SESSION_LOGS, ctx.conversationId),
|
|
170
170
|
getMessageEvents(ctx.taskId, SELF_ANALYSIS_MAX_MESSAGE_EVENTS),
|
|
171
171
|
getConversationMessages(ctx.conversationId, SELF_ANALYSIS_MAX_CONVERSATION_MESSAGES),
|
|
172
172
|
]);
|
package/src/commands/monitor.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { getCurrentUserId } from "../db/supabase.js";
|
|
|
4
4
|
import { log } from "../utils/logger.js";
|
|
5
5
|
import type { HeartbeatEngine } from "../agent/proactive-monitor.js";
|
|
6
6
|
|
|
7
|
-
// Shared heartbeat engine instance — set by
|
|
7
|
+
// Shared heartbeat engine instance — set by Orchestrator when agent starts
|
|
8
8
|
let sharedHeartbeat: HeartbeatEngine | null = null;
|
|
9
9
|
|
|
10
10
|
export function setSharedMonitor(engine: HeartbeatEngine): void {
|
|
@@ -59,17 +59,15 @@ export function registerMonitorCommands(program: Command): void {
|
|
|
59
59
|
|
|
60
60
|
const status = sharedHeartbeat.getStatus();
|
|
61
61
|
console.log(chalk.bold("\nHeartbeat Status:"));
|
|
62
|
+
console.log(` Enabled: ${status.enabled ? chalk.green("yes") : chalk.red("no")}`);
|
|
62
63
|
console.log(
|
|
63
|
-
`
|
|
64
|
+
` Interval: ${status.intervalMs / 1000}s (${status.intervalMs / 60_000} min)`
|
|
64
65
|
);
|
|
65
|
-
console.log(` Interval: ${status.intervalMs / 1000}s (${status.intervalMs / 60_000} min)`);
|
|
66
66
|
console.log(` Cycles: ${status.cycleCount}`);
|
|
67
67
|
console.log(
|
|
68
68
|
` Checklist: ${status.hasChecklist ? chalk.green("found") : chalk.yellow("no HEARTBEAT.md")}`
|
|
69
69
|
);
|
|
70
|
-
console.log(
|
|
71
|
-
` Executing: ${status.executing ? chalk.yellow("running now") : "idle"}`
|
|
72
|
-
);
|
|
70
|
+
console.log(` Executing: ${status.executing ? chalk.yellow("running now") : "idle"}`);
|
|
73
71
|
console.log();
|
|
74
72
|
} catch (err) {
|
|
75
73
|
log.error(`${err instanceof Error ? err.message : err}`);
|
package/src/commands/start.ts
CHANGED
|
@@ -5,8 +5,7 @@ import { createInterface } from "readline";
|
|
|
5
5
|
import { getCurrentUserId } from "../db/supabase.js";
|
|
6
6
|
import { setConfig } from "../utils/config.js";
|
|
7
7
|
import { log, setLogLevel, setLogHook } from "../utils/logger.js";
|
|
8
|
-
import {
|
|
9
|
-
import { TaskProcessor } from "../agent/processor.js";
|
|
8
|
+
import { Orchestrator } from "../orchestrator.js";
|
|
10
9
|
import { getBrowser, ensureBrowserAvailable } from "../tools/browser.js";
|
|
11
10
|
import { SessionLogEmitter } from "../db/session-log.js";
|
|
12
11
|
import { setSharedMonitor } from "./monitor.js";
|
|
@@ -84,9 +83,7 @@ async function runAgent(opts: { workspace?: string; name?: string; verbose?: boo
|
|
|
84
83
|
}
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
const
|
|
88
|
-
processor.init(userId);
|
|
89
|
-
const sessionManager = new SessionManager();
|
|
86
|
+
const orchestrator = new Orchestrator();
|
|
90
87
|
let logEmitter: SessionLogEmitter | null = null;
|
|
91
88
|
|
|
92
89
|
// Graceful shutdown
|
|
@@ -105,7 +102,7 @@ async function runAgent(opts: { workspace?: string; name?: string; verbose?: boo
|
|
|
105
102
|
} catch {
|
|
106
103
|
/* ignore */
|
|
107
104
|
}
|
|
108
|
-
await
|
|
105
|
+
await orchestrator.stop();
|
|
109
106
|
process.exit(0);
|
|
110
107
|
};
|
|
111
108
|
|
|
@@ -113,22 +110,19 @@ async function runAgent(opts: { workspace?: string; name?: string; verbose?: boo
|
|
|
113
110
|
process.on("SIGTERM", shutdown);
|
|
114
111
|
|
|
115
112
|
try {
|
|
116
|
-
const session = await
|
|
117
|
-
await processor.processTask(task);
|
|
118
|
-
});
|
|
119
|
-
processor.setSessionId(session.id);
|
|
113
|
+
const session = await orchestrator.start(userId);
|
|
120
114
|
|
|
121
|
-
// Share HeartbeatEngine with CLI commands
|
|
122
|
-
setSharedMonitor(
|
|
123
|
-
processor.setHeartbeatEngine(sessionManager.getHeartbeatEngine());
|
|
115
|
+
// Share HeartbeatEngine with CLI commands
|
|
116
|
+
setSharedMonitor(orchestrator.getHeartbeatEngine());
|
|
124
117
|
|
|
125
118
|
// Start persisting logs to Supabase
|
|
126
119
|
logEmitter = new SessionLogEmitter(session.id);
|
|
127
|
-
setLogHook((logType, message) => {
|
|
128
|
-
logEmitter?.push(logType, message);
|
|
120
|
+
setLogHook((logType, message, conversationId) => {
|
|
121
|
+
logEmitter?.push(logType, message, conversationId);
|
|
129
122
|
});
|
|
130
123
|
|
|
131
124
|
log.info("Listening for tasks (chat + jobs) from web UI...");
|
|
125
|
+
log.info(`Workers: conversation (per-conversation)`);
|
|
132
126
|
log.info("Press Ctrl+C to stop.\n");
|
|
133
127
|
|
|
134
128
|
// Interactive mode: also accept direct input from terminal
|
|
@@ -153,7 +147,7 @@ async function runAgent(opts: { workspace?: string; name?: string; verbose?: boo
|
|
|
153
147
|
}
|
|
154
148
|
|
|
155
149
|
if (input === "status") {
|
|
156
|
-
const s =
|
|
150
|
+
const s = orchestrator.getSession();
|
|
157
151
|
if (s) {
|
|
158
152
|
console.log(`Session: ${s.id}`);
|
|
159
153
|
console.log(`Status: ${s.status}`);
|
|
@@ -162,6 +156,19 @@ async function runAgent(opts: { workspace?: string; name?: string; verbose?: boo
|
|
|
162
156
|
`Browser: ${browserRef.isConnected() ? "connected" : launchResult.success ? "available" : "not available"}`
|
|
163
157
|
);
|
|
164
158
|
}
|
|
159
|
+
|
|
160
|
+
const workers = orchestrator.getWorkerManager()?.getWorkerInfos() ?? [];
|
|
161
|
+
if (workers.length > 0) {
|
|
162
|
+
console.log(`\nActive workers: ${workers.length}`);
|
|
163
|
+
for (const w of workers) {
|
|
164
|
+
console.log(
|
|
165
|
+
` ${w.type} (${w.id}): ${w.status}, pid=${w.pid}, tasks=${w.tasksProcessed}${w.conversationId ? `, conv=${w.conversationId.slice(0, 8)}` : ""}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
console.log("Workers: none active (will spawn on demand)");
|
|
170
|
+
}
|
|
171
|
+
|
|
165
172
|
rl.prompt();
|
|
166
173
|
return;
|
|
167
174
|
}
|
|
@@ -169,7 +176,7 @@ async function runAgent(opts: { workspace?: string; name?: string; verbose?: boo
|
|
|
169
176
|
// Process as a direct task
|
|
170
177
|
log.agent(`Processing: "${input}"`);
|
|
171
178
|
try {
|
|
172
|
-
await
|
|
179
|
+
await orchestrator.submitTask(input);
|
|
173
180
|
} catch (err) {
|
|
174
181
|
log.error(`${err instanceof Error ? err.message : err}`);
|
|
175
182
|
}
|
package/src/db/analysis-data.ts
CHANGED
|
@@ -17,16 +17,19 @@ export interface MessageEventEntry {
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Fetch agent session logs for a given session.
|
|
20
|
+
* Optionally filter by conversation_id to get only logs from a specific conversation.
|
|
20
21
|
* Uses the log.list MCP handler endpoint.
|
|
21
22
|
*/
|
|
22
23
|
export async function getSessionLogs(
|
|
23
24
|
sessionId: string,
|
|
24
|
-
limit = 500
|
|
25
|
+
limit = 500,
|
|
26
|
+
conversationId?: string
|
|
25
27
|
): Promise<SessionLogEntry[]> {
|
|
26
28
|
try {
|
|
27
29
|
const data = await callMcpHandler<SessionLogEntry[]>("log.list", {
|
|
28
30
|
session_id: sessionId,
|
|
29
31
|
limit,
|
|
32
|
+
...(conversationId ? { conversation_id: conversationId } : {}),
|
|
30
33
|
});
|
|
31
34
|
return data || [];
|
|
32
35
|
} catch (err) {
|
package/src/db/session-log.ts
CHANGED
|
@@ -8,6 +8,7 @@ interface PendingLog {
|
|
|
8
8
|
log_type: "stdout" | "stderr" | "status";
|
|
9
9
|
message: string;
|
|
10
10
|
seq: number;
|
|
11
|
+
conversation_id?: string | null;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -25,9 +26,9 @@ export class SessionLogEmitter {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/** Queue a log entry for batch insertion */
|
|
28
|
-
push(logType: "stdout" | "stderr" | "status", message: string): void {
|
|
29
|
+
push(logType: "stdout" | "stderr" | "status", message: string, conversationId?: string | null): void {
|
|
29
30
|
this.sequence++;
|
|
30
|
-
this.buffer.push({ log_type: logType, message, seq: this.sequence });
|
|
31
|
+
this.buffer.push({ log_type: logType, message, seq: this.sequence, conversation_id: conversationId || null });
|
|
31
32
|
|
|
32
33
|
// Flush immediately if buffer is large
|
|
33
34
|
if (this.buffer.length >= MAX_BATCH_SIZE) {
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator — main process coordinator.
|
|
3
|
+
*
|
|
4
|
+
* Multi-process architecture:
|
|
5
|
+
* - Manages agent session lifecycle (DB heartbeat, status)
|
|
6
|
+
* - Polls for tasks and dispatches them to worker processes (non-blocking)
|
|
7
|
+
* - Runs scheduler and heartbeat engine in the main process
|
|
8
|
+
* - Tracks session busy state via worker busy count (reference counting)
|
|
9
|
+
* - Polls for unanalyzed jobs and dispatches analysis as regular tasks
|
|
10
|
+
*
|
|
11
|
+
* Each conversation gets its own worker process for isolation and concurrency.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createSession,
|
|
16
|
+
updateHeartbeat,
|
|
17
|
+
endSession,
|
|
18
|
+
setSessionBusy,
|
|
19
|
+
pollAndClaimTask,
|
|
20
|
+
pollAndClaimJobRun,
|
|
21
|
+
claimTask,
|
|
22
|
+
createTask,
|
|
23
|
+
getOrCreateCliConversation,
|
|
24
|
+
cleanupStaleSessions,
|
|
25
|
+
type AgentSession,
|
|
26
|
+
type PendingJobRun,
|
|
27
|
+
} from "./db/supabase.js";
|
|
28
|
+
import { callMcpHandler } from "./db/api-client.js";
|
|
29
|
+
import { getConfig } from "./utils/config.js";
|
|
30
|
+
import { log } from "./utils/logger.js";
|
|
31
|
+
import { Scheduler, type ScheduledTask } from "./agent/scheduler.js";
|
|
32
|
+
import { JobRunner } from "./agent/job-runner.js";
|
|
33
|
+
import { HeartbeatEngine } from "./agent/proactive-monitor.js";
|
|
34
|
+
import { WorkerManager } from "./workers/manager.js";
|
|
35
|
+
|
|
36
|
+
const DEFAULT_HEARTBEAT_INTERVAL = 30_000; // 30 seconds
|
|
37
|
+
const DEFAULT_POLL_INTERVAL = 2_000; // 2 seconds
|
|
38
|
+
const MAX_POLL_INTERVAL = 30_000; // Max backoff: 30 seconds
|
|
39
|
+
const JOB_ANALYSIS_POLL_INTERVAL = 30_000; // 30 seconds
|
|
40
|
+
|
|
41
|
+
export class Orchestrator {
|
|
42
|
+
private session: AgentSession | null = null;
|
|
43
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
44
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
45
|
+
private running = false;
|
|
46
|
+
private scheduler: Scheduler;
|
|
47
|
+
private heartbeatEngine: HeartbeatEngine;
|
|
48
|
+
private workerManager: WorkerManager | null = null;
|
|
49
|
+
private userId: string | null = null;
|
|
50
|
+
private conversationId: string | null = null;
|
|
51
|
+
private consecutivePollFailures = 0;
|
|
52
|
+
private heartbeatInterval: number;
|
|
53
|
+
private basePollInterval: number;
|
|
54
|
+
private jobAnalysisTimer: ReturnType<typeof setInterval> | null = null;
|
|
55
|
+
/** Track jobs currently being analyzed to avoid duplicate tasks. */
|
|
56
|
+
private analyzingJobIds = new Set<string>();
|
|
57
|
+
|
|
58
|
+
constructor() {
|
|
59
|
+
this.scheduler = new Scheduler();
|
|
60
|
+
this.heartbeatEngine = new HeartbeatEngine();
|
|
61
|
+
this.heartbeatInterval = DEFAULT_HEARTBEAT_INTERVAL;
|
|
62
|
+
this.basePollInterval = DEFAULT_POLL_INTERVAL;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async start(userId: string): Promise<AgentSession> {
|
|
66
|
+
const config = getConfig();
|
|
67
|
+
this.userId = userId;
|
|
68
|
+
|
|
69
|
+
this.session = await createSession(config.sessionName, config.workspacePath, "0.1.0");
|
|
70
|
+
this.conversationId = await getOrCreateCliConversation();
|
|
71
|
+
|
|
72
|
+
// Initialize the worker manager with busy-count callback
|
|
73
|
+
this.workerManager = new WorkerManager(userId, this.session.id);
|
|
74
|
+
this.workerManager.setBusyChangeCallback((busyCount) => {
|
|
75
|
+
this.onBusyCountChange(busyCount);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this.running = true;
|
|
79
|
+
log.success(`Session started: ${this.session.id} (${config.sessionName})`);
|
|
80
|
+
log.info(`Workspace: ${config.workspacePath}`);
|
|
81
|
+
|
|
82
|
+
// Start heartbeat (session keepalive)
|
|
83
|
+
this.heartbeatTimer = setInterval(async () => {
|
|
84
|
+
if (this.session) {
|
|
85
|
+
await updateHeartbeat(this.session.id);
|
|
86
|
+
log.debug("Heartbeat sent");
|
|
87
|
+
}
|
|
88
|
+
}, this.heartbeatInterval);
|
|
89
|
+
|
|
90
|
+
// Start polling for tasks
|
|
91
|
+
this.schedulePoll();
|
|
92
|
+
|
|
93
|
+
// Start scheduler for cron tasks
|
|
94
|
+
await this.scheduler.start(async (scheduledTask: ScheduledTask) => {
|
|
95
|
+
await this.executeScheduledTask(scheduledTask);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Start heartbeat engine
|
|
99
|
+
if (this.conversationId) {
|
|
100
|
+
this.heartbeatEngine.setConversationId(this.conversationId);
|
|
101
|
+
}
|
|
102
|
+
this.heartbeatEngine.start(async (prompt: string) => {
|
|
103
|
+
await this.executeHeartbeatAction(prompt);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Start polling for unanalyzed jobs (dispatched as regular tasks)
|
|
107
|
+
this.startJobAnalysisPoll();
|
|
108
|
+
|
|
109
|
+
// Clean up stale sessions on startup
|
|
110
|
+
this.cleanupStaleSessions().catch(() => {});
|
|
111
|
+
|
|
112
|
+
return this.session;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Busy State (reference counting) ─────────────────────────────
|
|
116
|
+
|
|
117
|
+
private onBusyCountChange(busyCount: number): void {
|
|
118
|
+
if (!this.session) return;
|
|
119
|
+
|
|
120
|
+
if (busyCount > 0) {
|
|
121
|
+
setSessionBusy(this.session.id, true).catch((err) => {
|
|
122
|
+
log.debug(`Failed to set session busy: ${err}`);
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
setSessionBusy(this.session.id, false).catch((err) => {
|
|
126
|
+
log.debug(`Failed to clear session busy: ${err}`);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Polling ─────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
private schedulePoll(): void {
|
|
134
|
+
if (!this.running) return;
|
|
135
|
+
|
|
136
|
+
const delay =
|
|
137
|
+
this.consecutivePollFailures === 0
|
|
138
|
+
? this.basePollInterval
|
|
139
|
+
: Math.min(
|
|
140
|
+
this.basePollInterval * Math.pow(2, this.consecutivePollFailures),
|
|
141
|
+
MAX_POLL_INTERVAL
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
this.pollTimer = setTimeout(() => this.pollForTasks(), delay);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async pollForTasks() {
|
|
148
|
+
if (!this.session || !this.running) {
|
|
149
|
+
this.schedulePoll();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
// Priority 1: Chat tasks from web UI
|
|
155
|
+
const task = await pollAndClaimTask(this.session.id);
|
|
156
|
+
this.consecutivePollFailures = 0;
|
|
157
|
+
|
|
158
|
+
if (task) {
|
|
159
|
+
log.info(`New task claimed: ${task.id}`);
|
|
160
|
+
log.agent(`Task from conversation: ${task.conversation_id}`);
|
|
161
|
+
|
|
162
|
+
// Non-blocking dispatch — returns immediately after sending to worker
|
|
163
|
+
this.workerManager!.dispatchTask(task).catch((err) => {
|
|
164
|
+
log.error(`Task dispatch error: ${err}`);
|
|
165
|
+
});
|
|
166
|
+
} else if (this.userId) {
|
|
167
|
+
// Priority 2: Pending job runs (triggered from web UI)
|
|
168
|
+
const jobRun = await pollAndClaimJobRun();
|
|
169
|
+
|
|
170
|
+
if (jobRun) {
|
|
171
|
+
// Non-blocking — prepare and dispatch in background
|
|
172
|
+
this.executeJobRun(jobRun).catch((err) => {
|
|
173
|
+
log.error(`Job run error: ${err}`);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
this.consecutivePollFailures++;
|
|
179
|
+
const delay = Math.min(
|
|
180
|
+
this.basePollInterval * Math.pow(2, this.consecutivePollFailures),
|
|
181
|
+
MAX_POLL_INTERVAL
|
|
182
|
+
);
|
|
183
|
+
log.debug(`Poll error (retry in ${delay}ms): ${err}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Always schedule next poll immediately — don't wait for task completion
|
|
187
|
+
this.schedulePoll();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Job Execution ───────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
private async executeJobRun(jobRun: PendingJobRun): Promise<void> {
|
|
193
|
+
if (!this.session || !this.userId || !this.conversationId) return;
|
|
194
|
+
|
|
195
|
+
log.info(`Executing job run: ${jobRun.job_name} (${jobRun.id.slice(0, 8)}...)`);
|
|
196
|
+
|
|
197
|
+
const runner = new JobRunner();
|
|
198
|
+
const job = await runner.loadJob(jobRun.job_name);
|
|
199
|
+
|
|
200
|
+
if (!job) {
|
|
201
|
+
log.error(`Job "${jobRun.job_name}" not found, marking run as failed`);
|
|
202
|
+
try {
|
|
203
|
+
await runner.completeRun(jobRun.id, "failed", `Job "${jobRun.job_name}" not found`);
|
|
204
|
+
} catch {
|
|
205
|
+
/* already logged */
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (job.skills.length === 0) {
|
|
211
|
+
log.error(`Job "${jobRun.job_name}" has no skills, marking run as failed`);
|
|
212
|
+
try {
|
|
213
|
+
await runner.completeRun(jobRun.id, "failed", "Job has no linked skills");
|
|
214
|
+
} catch {
|
|
215
|
+
/* already logged */
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Link session to run record
|
|
221
|
+
try {
|
|
222
|
+
await callMcpHandler("job.link_run_session", {
|
|
223
|
+
run_id: jobRun.id,
|
|
224
|
+
session_id: this.session.id,
|
|
225
|
+
});
|
|
226
|
+
} catch (linkErr) {
|
|
227
|
+
log.debug(`Failed to link session to job run: ${linkErr}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Build prompt and dispatch — wait for task completion before marking run done
|
|
231
|
+
const prompt = runner.buildJobPrompt(job, jobRun.id);
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await this.dispatchAndWait(`[JobRun: ${jobRun.job_name}] ${prompt}`);
|
|
235
|
+
await runner.completeRun(jobRun.id, "completed", "Job executed via web trigger");
|
|
236
|
+
} catch (err) {
|
|
237
|
+
log.error(`Job run failed: ${err}`);
|
|
238
|
+
try {
|
|
239
|
+
await runner.completeRun(
|
|
240
|
+
jobRun.id,
|
|
241
|
+
"failed",
|
|
242
|
+
`Execution error: ${err instanceof Error ? err.message : err}`
|
|
243
|
+
);
|
|
244
|
+
} catch (completeErr) {
|
|
245
|
+
log.error(`Failed to mark run as failed: ${completeErr}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Job Analysis (as regular tasks) ─────────────────────────────
|
|
251
|
+
|
|
252
|
+
private startJobAnalysisPoll(): void {
|
|
253
|
+
// Run first check after a short delay to let the system settle
|
|
254
|
+
setTimeout(() => this.pollForUnanalyzedJobs(), 5_000);
|
|
255
|
+
|
|
256
|
+
this.jobAnalysisTimer = setInterval(() => {
|
|
257
|
+
this.pollForUnanalyzedJobs();
|
|
258
|
+
}, JOB_ANALYSIS_POLL_INTERVAL);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async pollForUnanalyzedJobs(): Promise<void> {
|
|
262
|
+
if (!this.running || !this.session || !this.conversationId) return;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const jobs = await callMcpHandler<Array<{ id: string; name: string }>>(
|
|
266
|
+
"job.list_unanalyzed"
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (!jobs || jobs.length === 0) return;
|
|
270
|
+
|
|
271
|
+
for (const job of jobs) {
|
|
272
|
+
if (this.analyzingJobIds.has(job.id)) continue;
|
|
273
|
+
|
|
274
|
+
this.analyzingJobIds.add(job.id);
|
|
275
|
+
log.info(`Dispatching job analysis task for "${job.name}"`);
|
|
276
|
+
|
|
277
|
+
const runner = new JobRunner();
|
|
278
|
+
const jobInfo = await runner.loadJob(job.name);
|
|
279
|
+
|
|
280
|
+
if (!jobInfo) {
|
|
281
|
+
log.debug(`Job "${job.name}" not found, skipping analysis`);
|
|
282
|
+
await this.markJobAnalyzed(job.id);
|
|
283
|
+
this.analyzingJobIds.delete(job.id);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const prompt = this.buildJobAnalysisPrompt(jobInfo);
|
|
288
|
+
|
|
289
|
+
// Mark analyzed immediately so we don't re-dispatch on next poll.
|
|
290
|
+
// Non-blocking dispatch — don't await completion to avoid blocking the poll loop.
|
|
291
|
+
await this.markJobAnalyzed(job.id);
|
|
292
|
+
|
|
293
|
+
this.dispatchTask(`[JobAnalysis: ${job.name}] ${prompt}`)
|
|
294
|
+
.then(() => {
|
|
295
|
+
log.info(`Job "${job.name}" analysis task dispatched`);
|
|
296
|
+
})
|
|
297
|
+
.catch((err) => {
|
|
298
|
+
log.debug(`Job analysis dispatch failed for "${job.name}": ${err}`);
|
|
299
|
+
})
|
|
300
|
+
.finally(() => {
|
|
301
|
+
this.analyzingJobIds.delete(job.id);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
log.debug(`Job analysis poll error: ${err}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private buildJobAnalysisPrompt(job: { jobName: string; jobDescription: string; skills: Array<{ skillName: string; skillDescription: string; skillEmoji: string; skillContent: string }> }): string {
|
|
310
|
+
let prompt = `Analyze this job definition and determine what skills are needed to execute it effectively.\n\n`;
|
|
311
|
+
prompt += `## Job Definition\n`;
|
|
312
|
+
prompt += `**Name:** ${job.jobName}\n`;
|
|
313
|
+
prompt += `**Description:** ${job.jobDescription}\n\n`;
|
|
314
|
+
|
|
315
|
+
if (job.skills.length > 0) {
|
|
316
|
+
prompt += `**Current Skills:**\n`;
|
|
317
|
+
for (const skill of job.skills) {
|
|
318
|
+
const emoji = skill.skillEmoji ? `${skill.skillEmoji} ` : "";
|
|
319
|
+
prompt += `- ${emoji}${skill.skillName}: ${skill.skillDescription}\n`;
|
|
320
|
+
}
|
|
321
|
+
prompt += `\n`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
prompt += `## Instructions\n`;
|
|
325
|
+
prompt += `For each capability the job needs:\n`;
|
|
326
|
+
prompt += `1. Check if an existing skill covers it (use \`skill_search\` to check)\n`;
|
|
327
|
+
prompt += `2. If no existing skill covers it, create a new one using \`skill_create\`\n`;
|
|
328
|
+
prompt += `3. If an existing skill needs improvement, update it using \`skill_improve\`\n`;
|
|
329
|
+
prompt += `4. Link all relevant skills to the job using \`skill_link_job\`\n\n`;
|
|
330
|
+
prompt += `Be practical — only create skills that would genuinely help automate this job.\n`;
|
|
331
|
+
prompt += `When done, summarize what skills were created, updated, or already existed.\n`;
|
|
332
|
+
|
|
333
|
+
return prompt;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private async markJobAnalyzed(jobId: string): Promise<void> {
|
|
337
|
+
try {
|
|
338
|
+
await callMcpHandler("job.mark_analyzed", { job_id: jobId });
|
|
339
|
+
} catch (err) {
|
|
340
|
+
log.debug(`Failed to mark job analyzed: ${err}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Scheduled & Heartbeat Tasks ─────────────────────────────────
|
|
345
|
+
|
|
346
|
+
private async executeScheduledTask(scheduledTask: ScheduledTask): Promise<void> {
|
|
347
|
+
if (!this.session || !this.userId || !this.conversationId) return;
|
|
348
|
+
|
|
349
|
+
log.info(`Running scheduled task: "${scheduledTask.name}"`);
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
await this.dispatchTask(`[Scheduled: ${scheduledTask.name}] ${scheduledTask.prompt}`);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
log.error(`Scheduled task error: ${err}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private async executeHeartbeatAction(prompt: string): Promise<void> {
|
|
359
|
+
if (!this.session || !this.userId || !this.conversationId) return;
|
|
360
|
+
|
|
361
|
+
log.info("Heartbeat action needed — submitting task");
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
await this.dispatchTask(prompt);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
log.error(`Heartbeat task error: ${err}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Task Dispatch ───────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Create a task in the DB and dispatch to a conversation worker.
|
|
374
|
+
* Non-blocking — returns after dispatching, does not wait for completion.
|
|
375
|
+
*/
|
|
376
|
+
async dispatchTask(prompt: string): Promise<void> {
|
|
377
|
+
if (!this.session || !this.userId || !this.conversationId) {
|
|
378
|
+
throw new Error("Session not started");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const task = await createTask(this.conversationId, this.session.id, prompt);
|
|
382
|
+
await claimTask(task.id);
|
|
383
|
+
|
|
384
|
+
// Non-blocking dispatch to worker
|
|
385
|
+
await this.workerManager!.dispatchTask(task);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Create a task, dispatch to a worker, and wait for completion.
|
|
390
|
+
* Used when the caller needs to know the task finished
|
|
391
|
+
* (e.g. job runs, interactive CLI).
|
|
392
|
+
*/
|
|
393
|
+
async dispatchAndWait(prompt: string): Promise<void> {
|
|
394
|
+
if (!this.session || !this.userId || !this.conversationId) {
|
|
395
|
+
throw new Error("Session not started");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const task = await createTask(this.conversationId, this.session.id, prompt);
|
|
399
|
+
await claimTask(task.id);
|
|
400
|
+
|
|
401
|
+
await this.workerManager!.dispatchAndWait(task);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Submit a task from interactive CLI.
|
|
406
|
+
* Non-blocking — output streams via log messages.
|
|
407
|
+
*/
|
|
408
|
+
async submitTask(prompt: string): Promise<void> {
|
|
409
|
+
await this.dispatchTask(prompt);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Cleanup ─────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
private async cleanupStaleSessions(): Promise<void> {
|
|
415
|
+
if (!this.userId || !this.session) return;
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const cleaned = await cleanupStaleSessions(this.session.id);
|
|
419
|
+
if (cleaned > 0) {
|
|
420
|
+
log.info(`Cleaned up ${cleaned} stale session(s)`);
|
|
421
|
+
}
|
|
422
|
+
} catch (err) {
|
|
423
|
+
log.debug(`Stale session cleanup error: ${err}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Shutdown ────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
async stop(timeoutMs = 5_000): Promise<void> {
|
|
430
|
+
this.running = false;
|
|
431
|
+
this.scheduler.stop();
|
|
432
|
+
this.heartbeatEngine.stop();
|
|
433
|
+
|
|
434
|
+
if (this.heartbeatTimer) {
|
|
435
|
+
clearInterval(this.heartbeatTimer);
|
|
436
|
+
this.heartbeatTimer = null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (this.pollTimer) {
|
|
440
|
+
clearTimeout(this.pollTimer);
|
|
441
|
+
this.pollTimer = null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (this.jobAnalysisTimer) {
|
|
445
|
+
clearInterval(this.jobAnalysisTimer);
|
|
446
|
+
this.jobAnalysisTimer = null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Stop all workers
|
|
450
|
+
if (this.workerManager) {
|
|
451
|
+
await this.workerManager.shutdown(timeoutMs);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (this.session) {
|
|
455
|
+
try {
|
|
456
|
+
await Promise.race([
|
|
457
|
+
endSession(this.session.id),
|
|
458
|
+
new Promise<never>((_, reject) =>
|
|
459
|
+
setTimeout(() => reject(new Error("Shutdown timeout")), timeoutMs)
|
|
460
|
+
),
|
|
461
|
+
]);
|
|
462
|
+
log.success(`Session ended: ${this.session.id}`);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
log.warn(`Session cleanup error: ${err}`);
|
|
465
|
+
} finally {
|
|
466
|
+
this.session = null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── Getters ─────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
getSession(): AgentSession | null {
|
|
474
|
+
return this.session;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
getConversationId(): string | null {
|
|
478
|
+
return this.conversationId;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
getHeartbeatEngine(): HeartbeatEngine {
|
|
482
|
+
return this.heartbeatEngine;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
getWorkerManager(): WorkerManager | null {
|
|
486
|
+
return this.workerManager;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
isRunning(): boolean {
|
|
490
|
+
return this.running;
|
|
491
|
+
}
|
|
492
|
+
}
|