@miphamai/cli 0.31.0 → 0.32.0
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/bin/daemon.ts +40 -0
- package/bin/mipham.ts +804 -4
- package/package.json +1 -1
- package/src/agent/background-registry.ts +80 -1
- package/src/daemon/agent-manager.ts +110 -0
- package/src/daemon/attach-protocol.ts +74 -0
- package/src/daemon/auth.ts +104 -0
- package/src/daemon/cors.ts +68 -0
- package/src/daemon/database.ts +449 -0
- package/src/daemon/goal-manager.ts +53 -0
- package/src/daemon/index.ts +307 -0
- package/src/daemon/message-bus.ts +84 -0
- package/src/daemon/rate-limiter.ts +70 -0
- package/src/daemon/remote-engine.ts +434 -0
- package/src/daemon/schedule-manager.ts +159 -0
- package/src/daemon/server.ts +632 -0
- package/src/daemon/session-manager.ts +45 -0
- package/src/daemon/session-worker.ts +418 -0
- package/src/daemon/types.ts +98 -0
- package/src/daemon/worker-pool.ts +174 -0
- package/src/index.tsx +44 -4
- package/src/shared/update.ts +14 -3
- package/src/ui/app.tsx +6 -2
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// apps/cli/src/daemon/session-worker.ts — Phase 2 Core: Headless Prompt Processing
|
|
2
|
+
//
|
|
3
|
+
// SessionWorker wraps a QueryEngine and provides headless prompt processing
|
|
4
|
+
// with WebSocket streaming to attached clients. It is the central bridge
|
|
5
|
+
// between the HTTP/WS server and the AI query engine.
|
|
6
|
+
//
|
|
7
|
+
// Architecture:
|
|
8
|
+
// SessionWorker
|
|
9
|
+
// owns QueryEngine (AI processing — async generator)
|
|
10
|
+
// owns DaemonDatabase reference (persistence — SQLite)
|
|
11
|
+
// owns DaemonSession record (session metadata)
|
|
12
|
+
// tracks WebSocket clients (real-time streaming to N viewers)
|
|
13
|
+
//
|
|
14
|
+
// Lifecycle:
|
|
15
|
+
// 1. Created when a session becomes active (on first attach or first prompt)
|
|
16
|
+
// 2. processPrompt() streams engine output to all WS clients
|
|
17
|
+
// 3. interrupt() aborts in-progress generation
|
|
18
|
+
// 4. getSessionState() serves attaching clients with full context
|
|
19
|
+
// 5. saveToDatabase() persists full conversation on close/checkpoint
|
|
20
|
+
|
|
21
|
+
import type { ServerWebSocket } from 'bun'
|
|
22
|
+
import type { QueryEngine } from '../core/engine'
|
|
23
|
+
import type { DaemonDatabase } from './database'
|
|
24
|
+
import type { DaemonSession, MessageRecord } from './types'
|
|
25
|
+
import type { StreamChunk } from '../shared/types'
|
|
26
|
+
import type {
|
|
27
|
+
ClientInterruptMessage,
|
|
28
|
+
ServerMessage,
|
|
29
|
+
ServerTextMessage,
|
|
30
|
+
ServerToolUseMessage,
|
|
31
|
+
ServerToolResultMessage,
|
|
32
|
+
ServerUsageMessage,
|
|
33
|
+
ServerTaskNotificationMessage,
|
|
34
|
+
ServerDoneMessage,
|
|
35
|
+
ServerErrorMessage,
|
|
36
|
+
ServerSessionStateMessage,
|
|
37
|
+
} from './attach-protocol'
|
|
38
|
+
|
|
39
|
+
// ── Session State Snapshot ────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export interface SessionStateSnapshot {
|
|
42
|
+
sessionId: string
|
|
43
|
+
name: string
|
|
44
|
+
provider: string
|
|
45
|
+
model: string
|
|
46
|
+
turnCount: number
|
|
47
|
+
status: string
|
|
48
|
+
/** Last N messages for context restoration on attach. */
|
|
49
|
+
messages: MessageRecord[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── SessionWorker ─────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export class SessionWorker {
|
|
55
|
+
private engine: QueryEngine
|
|
56
|
+
private db: DaemonDatabase
|
|
57
|
+
private session: DaemonSession
|
|
58
|
+
private clients: Set<ServerWebSocket<unknown>> = new Set()
|
|
59
|
+
private currentController: AbortController | null = null
|
|
60
|
+
private processing = false
|
|
61
|
+
|
|
62
|
+
constructor(engine: QueryEngine, db: DaemonDatabase, session: DaemonSession) {
|
|
63
|
+
this.engine = engine
|
|
64
|
+
this.db = db
|
|
65
|
+
this.session = session
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Client Management ──────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Add a WebSocket client to this session's broadcast set.
|
|
72
|
+
* Sends a session_state snapshot to the newly attached client immediately.
|
|
73
|
+
*/
|
|
74
|
+
addClient(ws: ServerWebSocket<unknown>): void {
|
|
75
|
+
this.clients.add(ws)
|
|
76
|
+
this.sendState(ws)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Remove a WebSocket client from this session's broadcast set. */
|
|
80
|
+
removeClient(ws: ServerWebSocket<unknown>): void {
|
|
81
|
+
this.clients.delete(ws)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Number of currently attached WebSocket clients. */
|
|
85
|
+
getClientCount(): number {
|
|
86
|
+
return this.clients.size
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a prompt is currently being processed. */
|
|
90
|
+
isProcessing(): boolean {
|
|
91
|
+
return this.processing
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Prompt Processing ──────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Process a user prompt through the QueryEngine, streaming results to all
|
|
98
|
+
* attached WebSocket clients and persisting messages to the database.
|
|
99
|
+
*
|
|
100
|
+
* Flow:
|
|
101
|
+
* 1. Guard: reject if already processing
|
|
102
|
+
* 2. Save user message to DB
|
|
103
|
+
* 3. Call engine.process(prompt, signal) — async generator
|
|
104
|
+
* 4. Map each StreamChunk → ServerMessage and broadcast to all clients
|
|
105
|
+
* 5. On natural completion: save assistant content to DB, increment turn,
|
|
106
|
+
* broadcast `done`
|
|
107
|
+
* 6. On error: broadcast `error` to clients, still increment turn
|
|
108
|
+
* 7. On interrupt: save partial content, broadcast `done` with stop_reason
|
|
109
|
+
* 'interrupted'
|
|
110
|
+
*/
|
|
111
|
+
async processPrompt(prompt: string): Promise<void> {
|
|
112
|
+
if (this.processing) {
|
|
113
|
+
this.broadcast(this.errorMessage('A prompt is already being processed'))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.processing = true
|
|
118
|
+
this.currentController = new AbortController()
|
|
119
|
+
const signal = this.currentController.signal
|
|
120
|
+
|
|
121
|
+
// Step 1: Persist the user message immediately
|
|
122
|
+
this.db.saveMessage(this.session.id, 'user', JSON.stringify({ role: 'user', content: prompt }))
|
|
123
|
+
|
|
124
|
+
// Step 2-3: Stream engine output to clients
|
|
125
|
+
let assistantContent = ''
|
|
126
|
+
let totalInputTokens = 0
|
|
127
|
+
let totalOutputTokens = 0
|
|
128
|
+
let stopReason: string = 'end_turn'
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
for await (const chunk of this.engine.process(prompt, signal)) {
|
|
132
|
+
// Check for abort between chunks
|
|
133
|
+
if (signal.aborted) {
|
|
134
|
+
stopReason = 'interrupted'
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Map the engine StreamChunk to the appropriate ServerMessage and broadcast
|
|
139
|
+
const serverMsg = this.mapChunkToMessage(chunk)
|
|
140
|
+
if (serverMsg) {
|
|
141
|
+
this.broadcast(serverMsg)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Accumulate assistant text content (used for final DB write)
|
|
145
|
+
if (chunk.type === 'text' && chunk.content) {
|
|
146
|
+
assistantContent += chunk.content
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Accumulate API-reported token usage (used for DB incrementTurn)
|
|
150
|
+
if (chunk.type === 'usage') {
|
|
151
|
+
if (chunk.inputTokens) totalInputTokens += chunk.inputTokens
|
|
152
|
+
if (chunk.outputTokens) totalOutputTokens += chunk.outputTokens
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Stop signal from engine — includes 'stop' and 'error' types
|
|
156
|
+
if (chunk.type === 'stop') {
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
stopReason = 'error'
|
|
162
|
+
this.broadcast(this.errorMessage(err instanceof Error ? err.message : String(err)))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Check interrupt after catch (catch won't fire for AbortError in async generators) ──
|
|
166
|
+
if (signal.aborted && stopReason !== 'error') {
|
|
167
|
+
stopReason = 'interrupted'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Step 4: Persist assistant response and finalize
|
|
171
|
+
if (assistantContent) {
|
|
172
|
+
// Save the final (or partial) assistant message
|
|
173
|
+
this.db.saveMessage(
|
|
174
|
+
this.session.id,
|
|
175
|
+
'assistant',
|
|
176
|
+
JSON.stringify({ role: 'assistant', content: assistantContent }),
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Increment turn count with accumulated token usage
|
|
181
|
+
this.db.incrementTurn(this.session.id, totalInputTokens, totalOutputTokens)
|
|
182
|
+
|
|
183
|
+
// Refresh session metadata from DB so turnCount/tokenIn/tokenOut are up to date
|
|
184
|
+
const refreshed = this.db.getSession(this.session.id)
|
|
185
|
+
if (refreshed) {
|
|
186
|
+
this.session = refreshed
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Broadcast completion
|
|
190
|
+
const doneMsg: ServerDoneMessage = {
|
|
191
|
+
type: 'done',
|
|
192
|
+
sessionId: this.session.id,
|
|
193
|
+
stopReason,
|
|
194
|
+
}
|
|
195
|
+
this.broadcast(doneMsg)
|
|
196
|
+
|
|
197
|
+
this.currentController = null
|
|
198
|
+
this.processing = false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Interrupt ──────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Abort the current prompt processing (if any).
|
|
205
|
+
* Called when a client sends an interrupt message or when the session
|
|
206
|
+
* is being force-closed.
|
|
207
|
+
*/
|
|
208
|
+
interrupt(): void {
|
|
209
|
+
if (this.currentController) {
|
|
210
|
+
this.currentController.abort()
|
|
211
|
+
this.currentController = null
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Handle an interrupt message from a WebSocket client. */
|
|
216
|
+
handleClientInterrupt(_msg: ClientInterruptMessage): void {
|
|
217
|
+
this.interrupt()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Session State ──────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Return session state including messages, provider, model, and turn count.
|
|
224
|
+
* Used by attaching clients to restore their conversation context.
|
|
225
|
+
*/
|
|
226
|
+
getSessionState(): SessionStateSnapshot {
|
|
227
|
+
const messages = this.db.getMessages(this.session.id, 100)
|
|
228
|
+
return {
|
|
229
|
+
sessionId: this.session.id,
|
|
230
|
+
name: this.session.name,
|
|
231
|
+
provider: this.session.provider,
|
|
232
|
+
model: this.session.model,
|
|
233
|
+
turnCount: this.session.turnCount,
|
|
234
|
+
status: this.session.status,
|
|
235
|
+
messages,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Return the session metadata record, refreshed from the database
|
|
241
|
+
* to ensure turn count and token totals are current.
|
|
242
|
+
*/
|
|
243
|
+
getSession(): DaemonSession {
|
|
244
|
+
const fresh = this.db.getSession(this.session.id)
|
|
245
|
+
if (fresh) {
|
|
246
|
+
this.session = fresh
|
|
247
|
+
}
|
|
248
|
+
return this.session
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Return the session ID for routing. */
|
|
252
|
+
getSessionId(): string {
|
|
253
|
+
return this.session.id
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Persistence ────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Persist the full conversation state to the database.
|
|
260
|
+
*
|
|
261
|
+
* Syncs messages from the engine's ContextManager into the messages table,
|
|
262
|
+
* avoiding duplicates by comparing message content hashes. Also updates
|
|
263
|
+
* the session status in the sessions table.
|
|
264
|
+
*
|
|
265
|
+
* Called on session close or periodic checkpoint.
|
|
266
|
+
*/
|
|
267
|
+
saveToDatabase(): void {
|
|
268
|
+
const ctxMessages = this.engine.getContext().getMessages()
|
|
269
|
+
const dbMessages = this.db.getMessages(this.session.id, 10000)
|
|
270
|
+
|
|
271
|
+
// Build a set of already-persisted message keys
|
|
272
|
+
const existingContents = new Set(dbMessages.map((m) => this.messageKey(m.role, m.content)))
|
|
273
|
+
|
|
274
|
+
for (const msg of ctxMessages) {
|
|
275
|
+
const role = msg.role
|
|
276
|
+
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
|
|
277
|
+
const key = this.messageKey(role, content)
|
|
278
|
+
if (!existingContents.has(key)) {
|
|
279
|
+
this.db.saveMessage(this.session.id, role, content)
|
|
280
|
+
existingContents.add(key)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Sync session status
|
|
285
|
+
this.db.updateSessionStatus(this.session.id, this.session.status)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── StreamChunk → ServerMessage Mapping ────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Map an engine StreamChunk to the corresponding typed ServerMessage
|
|
292
|
+
* from the attach protocol. Returns null for chunk types that don't
|
|
293
|
+
* need to be broadcast (e.g., intermediate internal signals).
|
|
294
|
+
*/
|
|
295
|
+
private mapChunkToMessage(chunk: StreamChunk): ServerMessage | null {
|
|
296
|
+
switch (chunk.type) {
|
|
297
|
+
case 'text': {
|
|
298
|
+
const msg: ServerTextMessage = {
|
|
299
|
+
type: 'text',
|
|
300
|
+
sessionId: this.session.id,
|
|
301
|
+
content: chunk.content ?? '',
|
|
302
|
+
}
|
|
303
|
+
return msg
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
case 'tool_use': {
|
|
307
|
+
if (!chunk.toolUse) return null
|
|
308
|
+
const msg: ServerToolUseMessage = {
|
|
309
|
+
type: 'tool_use',
|
|
310
|
+
sessionId: this.session.id,
|
|
311
|
+
toolName: chunk.toolUse.name,
|
|
312
|
+
toolInput: chunk.toolUse.input,
|
|
313
|
+
toolId: chunk.toolUse.id,
|
|
314
|
+
}
|
|
315
|
+
return msg
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case 'tool_result': {
|
|
319
|
+
const msg: ServerToolResultMessage = {
|
|
320
|
+
type: 'tool_result',
|
|
321
|
+
sessionId: this.session.id,
|
|
322
|
+
toolId: chunk.tool_use_id ?? 'unknown',
|
|
323
|
+
content: chunk.content ?? '',
|
|
324
|
+
}
|
|
325
|
+
return msg
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case 'usage': {
|
|
329
|
+
const msg: ServerUsageMessage = {
|
|
330
|
+
type: 'usage',
|
|
331
|
+
sessionId: this.session.id,
|
|
332
|
+
inputTokens: chunk.inputTokens ?? 0,
|
|
333
|
+
outputTokens: chunk.outputTokens ?? 0,
|
|
334
|
+
}
|
|
335
|
+
return msg
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
case 'task_notification': {
|
|
339
|
+
if (!chunk.taskNotification) return null
|
|
340
|
+
const msg: ServerTaskNotificationMessage = {
|
|
341
|
+
type: 'task_notification',
|
|
342
|
+
sessionId: this.session.id,
|
|
343
|
+
taskId: chunk.taskNotification.taskId,
|
|
344
|
+
status: chunk.taskNotification.status,
|
|
345
|
+
}
|
|
346
|
+
return msg
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
case 'error': {
|
|
350
|
+
const msg: ServerErrorMessage = {
|
|
351
|
+
type: 'error',
|
|
352
|
+
sessionId: this.session.id,
|
|
353
|
+
message: chunk.error ?? 'Unknown error',
|
|
354
|
+
}
|
|
355
|
+
return msg
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 'stop' is handled by processPrompt() which sends ServerDoneMessage
|
|
359
|
+
// 'thinking' is internal — model reasoning, not broadcast separately
|
|
360
|
+
case 'stop':
|
|
361
|
+
case 'thinking':
|
|
362
|
+
default:
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── Internal Helpers ───────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
/** Broadcast a ServerMessage to all attached WebSocket clients. */
|
|
370
|
+
private broadcast(msg: ServerMessage): void {
|
|
371
|
+
const raw = JSON.stringify(msg)
|
|
372
|
+
for (const ws of this.clients) {
|
|
373
|
+
try {
|
|
374
|
+
ws.send(raw)
|
|
375
|
+
} catch {
|
|
376
|
+
// Client disconnected — eager cleanup (the server's WS close handler
|
|
377
|
+
// will also remove it properly, this just avoids future send attempts).
|
|
378
|
+
this.clients.delete(ws)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Send the current session state snapshot to a single WebSocket client. */
|
|
384
|
+
private sendState(ws: ServerWebSocket<unknown>): void {
|
|
385
|
+
const state = this.getSessionState()
|
|
386
|
+
const msg: ServerSessionStateMessage = {
|
|
387
|
+
type: 'session_state',
|
|
388
|
+
sessionId: this.session.id,
|
|
389
|
+
messages: state.messages,
|
|
390
|
+
provider: state.provider,
|
|
391
|
+
model: state.model,
|
|
392
|
+
turnCount: state.turnCount,
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
ws.send(JSON.stringify(msg))
|
|
396
|
+
} catch {
|
|
397
|
+
this.clients.delete(ws)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Build an error ServerMessage for the current session. */
|
|
402
|
+
private errorMessage(message: string): ServerErrorMessage {
|
|
403
|
+
return {
|
|
404
|
+
type: 'error',
|
|
405
|
+
sessionId: this.session.id,
|
|
406
|
+
message,
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Generate a stable key for message deduplication.
|
|
412
|
+
* Uses role + first 200 chars of content to avoid storing duplicate messages
|
|
413
|
+
* while being tolerant of minor content variation.
|
|
414
|
+
*/
|
|
415
|
+
private messageKey(role: string, content: string): string {
|
|
416
|
+
return `${role}:${content.slice(0, 200)}`
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// apps/cli/src/daemon/types.ts
|
|
2
|
+
|
|
3
|
+
export type SessionStatus = 'active' | 'idle' | 'compacting' | 'closed'
|
|
4
|
+
export type AgentStatus = 'running' | 'completed' | 'failed'
|
|
5
|
+
export type AgentKind = 'interactive' | 'forked' | 'attached' | 'unattended'
|
|
6
|
+
export type GoalStatus = 'active' | 'completed' | 'paused' | 'cleared'
|
|
7
|
+
|
|
8
|
+
export interface DaemonSession {
|
|
9
|
+
id: string
|
|
10
|
+
name: string
|
|
11
|
+
cwd: string
|
|
12
|
+
provider: string
|
|
13
|
+
model: string
|
|
14
|
+
status: SessionStatus
|
|
15
|
+
createdAt: string
|
|
16
|
+
updatedAt: string
|
|
17
|
+
closedAt: string | null
|
|
18
|
+
turnCount: number
|
|
19
|
+
tokenIn: number
|
|
20
|
+
tokenOut: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CreateSessionInput {
|
|
24
|
+
name: string
|
|
25
|
+
cwd: string
|
|
26
|
+
provider: string
|
|
27
|
+
model: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DaemonAgent {
|
|
31
|
+
id: string
|
|
32
|
+
sessionId: string
|
|
33
|
+
parentId: string | null
|
|
34
|
+
agentType: string
|
|
35
|
+
description: string
|
|
36
|
+
status: AgentStatus
|
|
37
|
+
kind: AgentKind
|
|
38
|
+
worktree: string | null
|
|
39
|
+
branch: string | null
|
|
40
|
+
prUrl: string | null
|
|
41
|
+
createdAt: string
|
|
42
|
+
completedAt: string | null
|
|
43
|
+
result: string | null
|
|
44
|
+
error: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DaemonGoal {
|
|
48
|
+
id: number
|
|
49
|
+
sessionId: string
|
|
50
|
+
description: string
|
|
51
|
+
status: GoalStatus
|
|
52
|
+
progress: { current: number; total: number; note?: string } | null
|
|
53
|
+
createdAt: string
|
|
54
|
+
updatedAt: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DaemonSchedule {
|
|
58
|
+
id: number
|
|
59
|
+
sessionId: string
|
|
60
|
+
cronExpr: string
|
|
61
|
+
prompt: string
|
|
62
|
+
enabled: boolean
|
|
63
|
+
lastFired: string | null
|
|
64
|
+
nextFire: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DaemonStatus {
|
|
68
|
+
pid: number
|
|
69
|
+
port: number
|
|
70
|
+
uptime: number // seconds since start
|
|
71
|
+
activeSessions: number
|
|
72
|
+
totalSessions: number
|
|
73
|
+
activeAgents: number
|
|
74
|
+
version: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MessageRecord {
|
|
78
|
+
id: number
|
|
79
|
+
sessionId: string
|
|
80
|
+
role: 'user' | 'assistant' | 'system' | 'tool'
|
|
81
|
+
content: string // JSON-serialized Message object
|
|
82
|
+
createdAt: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// HTTP API types
|
|
86
|
+
export interface ApiResponse<T> {
|
|
87
|
+
ok: boolean
|
|
88
|
+
data?: T
|
|
89
|
+
error?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface CreateSessionResponse {
|
|
93
|
+
session: DaemonSession
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ListSessionsResponse {
|
|
97
|
+
sessions: DaemonSession[]
|
|
98
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// apps/cli/src/daemon/worker-pool.ts — Phase 2: Worker Pool Management
|
|
2
|
+
//
|
|
3
|
+
// WorkerPool manages the lifecycle of SessionWorker instances across all
|
|
4
|
+
// active sessions in the daemon. It provides lazy creation, idle timeout
|
|
5
|
+
// (auto-stop inactive workers), and graceful shutdown.
|
|
6
|
+
//
|
|
7
|
+
// Architecture:
|
|
8
|
+
// WorkerPool
|
|
9
|
+
// owns Map<string, SessionWorker> (active workers)
|
|
10
|
+
// owns Map<string, Timer> (idle timeouts)
|
|
11
|
+
// references DaemonDatabase (session metadata + persistence)
|
|
12
|
+
//
|
|
13
|
+
// Lifecycle:
|
|
14
|
+
// 1. createWorker() — creates or returns cached SessionWorker, starts idle timer
|
|
15
|
+
// 2. getWorker() — lookup by sessionId
|
|
16
|
+
// 3. resetIdleTimeout() — called on each prompt to keep worker alive
|
|
17
|
+
// 4. stopWorker() — interrupt, save, remove, mark idle, callback
|
|
18
|
+
// 5. stopAll() — graceful shutdown of all workers
|
|
19
|
+
|
|
20
|
+
import { SessionWorker } from './session-worker'
|
|
21
|
+
import type { DaemonDatabase } from './database'
|
|
22
|
+
import type { QueryEngine } from '../core/engine'
|
|
23
|
+
import type { ContextManager } from '../core/context'
|
|
24
|
+
import type { ProviderRegistry } from '../providers/registry'
|
|
25
|
+
|
|
26
|
+
export class WorkerPool {
|
|
27
|
+
private workers: Map<string, SessionWorker>
|
|
28
|
+
private db: DaemonDatabase
|
|
29
|
+
private idleTimeouts: Map<string, Timer>
|
|
30
|
+
private readonly idleTimeoutMs: number
|
|
31
|
+
|
|
32
|
+
/** Called when a worker is stopped (either explicitly or by idle timeout). */
|
|
33
|
+
onSessionClosed?: (sessionId: string) => void
|
|
34
|
+
|
|
35
|
+
constructor(db: DaemonDatabase, idleTimeoutMs: number = 30 * 60 * 1000) {
|
|
36
|
+
this.workers = new Map()
|
|
37
|
+
this.db = db
|
|
38
|
+
this.idleTimeouts = new Map()
|
|
39
|
+
this.idleTimeoutMs = idleTimeoutMs
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a SessionWorker for the given session, or return an existing one.
|
|
44
|
+
*
|
|
45
|
+
* The engine, context, and registry are accepted for validation and
|
|
46
|
+
* extensibility; the SessionWorker itself is constructed from engine + db
|
|
47
|
+
* + DaemonSession (fetched from the database by sessionId).
|
|
48
|
+
*
|
|
49
|
+
* Starts the idle timeout on creation. Callers should invoke
|
|
50
|
+
* {@link resetIdleTimeout} on each prompt to keep the worker alive.
|
|
51
|
+
*/
|
|
52
|
+
createWorker(
|
|
53
|
+
sessionId: string,
|
|
54
|
+
engine: QueryEngine,
|
|
55
|
+
_context: ContextManager,
|
|
56
|
+
_registry: ProviderRegistry,
|
|
57
|
+
): SessionWorker {
|
|
58
|
+
// Return cached worker if it exists
|
|
59
|
+
const existing = this.workers.get(sessionId)
|
|
60
|
+
if (existing) {
|
|
61
|
+
return existing
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Look up session metadata from the database
|
|
65
|
+
const session = this.db.getSession(sessionId)
|
|
66
|
+
if (!session) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`WorkerPool: cannot create worker for session ${sessionId} — not found in database`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const worker = new SessionWorker(engine, this.db, session)
|
|
73
|
+
this.workers.set(sessionId, worker)
|
|
74
|
+
this.resetIdleTimeout(sessionId)
|
|
75
|
+
return worker
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Get the SessionWorker for a session, or undefined if not active. */
|
|
79
|
+
getWorker(sessionId: string): SessionWorker | undefined {
|
|
80
|
+
return this.workers.get(sessionId)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Check whether a worker is active for the given session. */
|
|
84
|
+
hasWorker(sessionId: string): boolean {
|
|
85
|
+
return this.workers.has(sessionId)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** List all currently active session IDs. */
|
|
89
|
+
getActiveSessions(): string[] {
|
|
90
|
+
return Array.from(this.workers.keys())
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Number of currently active workers. */
|
|
94
|
+
get size(): number {
|
|
95
|
+
return this.workers.size
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Stop a worker: interrupt any in-progress generation, persist state,
|
|
100
|
+
* mark the session as idle in the database, remove from pool, clear
|
|
101
|
+
* the idle timeout, and fire the onSessionClosed callback.
|
|
102
|
+
*/
|
|
103
|
+
async stopWorker(sessionId: string): Promise<void> {
|
|
104
|
+
const worker = this.workers.get(sessionId)
|
|
105
|
+
if (!worker) return
|
|
106
|
+
|
|
107
|
+
// Interrupt any in-progress prompt processing
|
|
108
|
+
worker.interrupt()
|
|
109
|
+
|
|
110
|
+
// Persist the full conversation state to the database
|
|
111
|
+
worker.saveToDatabase()
|
|
112
|
+
|
|
113
|
+
// Mark the session as idle (not closed) — the session record
|
|
114
|
+
// remains available for future re-attach or resume
|
|
115
|
+
this.db.updateSessionStatus(sessionId, 'idle')
|
|
116
|
+
|
|
117
|
+
// Remove from the active worker map
|
|
118
|
+
this.workers.delete(sessionId)
|
|
119
|
+
|
|
120
|
+
// Clear the idle timeout
|
|
121
|
+
const timeout = this.idleTimeouts.get(sessionId)
|
|
122
|
+
if (timeout) {
|
|
123
|
+
clearTimeout(timeout)
|
|
124
|
+
this.idleTimeouts.delete(sessionId)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fire the lifecycle callback
|
|
128
|
+
if (this.onSessionClosed) {
|
|
129
|
+
this.onSessionClosed(sessionId)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reset the idle timeout for a session.
|
|
135
|
+
*
|
|
136
|
+
* Call this on every user prompt so the worker stays alive as long as
|
|
137
|
+
* the user is actively interacting. If no prompt arrives within
|
|
138
|
+
* `idleTimeoutMs`, the worker is automatically stopped to free resources.
|
|
139
|
+
*/
|
|
140
|
+
resetIdleTimeout(sessionId: string): void {
|
|
141
|
+
// Clear the existing timeout if there is one
|
|
142
|
+
const existing = this.idleTimeouts.get(sessionId)
|
|
143
|
+
if (existing) {
|
|
144
|
+
clearTimeout(existing)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Set a new timeout — when it fires, auto-stop the worker
|
|
148
|
+
const timeout = setTimeout(() => {
|
|
149
|
+
this.stopWorker(sessionId).catch((err) => {
|
|
150
|
+
// Log but never crash — this runs in a Timer callback outside
|
|
151
|
+
// any request context
|
|
152
|
+
console.error(`WorkerPool: error auto-stopping idle worker for session ${sessionId}:`, err)
|
|
153
|
+
})
|
|
154
|
+
}, this.idleTimeoutMs)
|
|
155
|
+
|
|
156
|
+
// unref so the timer does not prevent the Node/Bun process from
|
|
157
|
+
// exiting when all other work is complete
|
|
158
|
+
timeout.unref()
|
|
159
|
+
|
|
160
|
+
this.idleTimeouts.set(sessionId, timeout)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Gracefully stop all workers.
|
|
165
|
+
*
|
|
166
|
+
* Iterates over all active workers, interrupts them, persists their
|
|
167
|
+
* state, and cleans up. Runs all stops in parallel since workers
|
|
168
|
+
* are independent.
|
|
169
|
+
*/
|
|
170
|
+
async stopAll(): Promise<void> {
|
|
171
|
+
const sessionIds = Array.from(this.workers.keys())
|
|
172
|
+
await Promise.all(sessionIds.map((id) => this.stopWorker(id)))
|
|
173
|
+
}
|
|
174
|
+
}
|