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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assistme",
3
- "version": "0.7.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
  ]);
@@ -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 SessionManager when agent starts
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
- ` Enabled: ${status.enabled ? chalk.green("yes") : chalk.red("no")}`
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}`);
@@ -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 { SessionManager } from "../agent/session.js";
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 processor = new TaskProcessor();
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 sessionManager.stop();
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 sessionManager.start(userId, async (task) => {
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 and processor
122
- setSharedMonitor(sessionManager.getHeartbeatEngine());
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 = sessionManager.getSession();
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 sessionManager.submitTask(input);
179
+ await orchestrator.submitTask(input);
173
180
  } catch (err) {
174
181
  log.error(`${err instanceof Error ? err.message : err}`);
175
182
  }
@@ -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) {
@@ -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
+ }