ework-daemon 0.1.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.
@@ -0,0 +1,1219 @@
1
+ import { spawn, type Subprocess } from "bun";
2
+ import { mkdirSync, writeFileSync } from "fs";
3
+ import { join, resolve, isAbsolute } from "path";
4
+ import { homedir } from "os";
5
+ import { log } from "./logger";
6
+ import type { Config } from "./config";
7
+ import type { Store } from "./op";
8
+ import type { IssueTracker, TrackerRef, TrackerEvent, TrackerComment, Issue, OpSession, Message } from "./trackers/types";
9
+ import { formatKey, parseKey } from "./trackers/types";
10
+
11
+ // ─── Types ───
12
+
13
+ interface TrackerRegistry {
14
+ get(type: string): IssueTracker | undefined;
15
+ }
16
+
17
+ // ─── Engine ───
18
+
19
+ export class Engine {
20
+ private cfg: Config;
21
+ private store: Store;
22
+ private trackers: TrackerRegistry;
23
+
24
+ // Runtime state keyed by session key (trackerType:scopeKey#issueId@sessionName)
25
+ private processes = new Map<string, Subprocess<"ignore", "pipe", "pipe">>();
26
+ private running = new Set<string>();
27
+ private stopping = new Set<string>();
28
+ private processingComments = new Set<string>();
29
+ private currentMessage = new Map<string, string>();
30
+ private lastOutputAt = new Map<string, number>();
31
+ private startedAt = new Map<string, number>();
32
+ private progressCommentId = new Map<string, string>();
33
+
34
+ private nudgeRounds = new Map<string, number>();
35
+ private processExitNudgeRounds = new Map<string, number>();
36
+ private stuckNudgeRounds = new Map<string, number>();
37
+ private currentPrompt = new Map<string, string>();
38
+
39
+ // Generation counter per session key — incremented on every execProcess call.
40
+ // finishRun captures the generation at start and checks it after each await.
41
+ // If the generation changed, a new process preempted this run → bail out
42
+ // before corrupting the new run's runtime state.
43
+ private generation = new Map<string, number>();
44
+
45
+ private observedIssues = new Set<string>();
46
+ private observerTimer?: ReturnType<typeof setInterval>;
47
+
48
+ private static MAX_INLINE_SIZE = 4000;
49
+ private static MAX_NUDGE_ROUNDS = 1;
50
+ private static MAX_STUCK_NUDGE_ROUNDS = 1;
51
+ private static OBSERVER_INTERVAL_MS = 5 * 60 * 1000;
52
+ private static STUCK_THRESHOLD_MS = 30 * 60 * 1000;
53
+ private static MAX_PROCESS_EXIT_NUDGE_ROUNDS = 1;
54
+
55
+ constructor(cfg: Config, store: Store, trackers: TrackerRegistry) {
56
+ this.cfg = cfg;
57
+ this.store = store;
58
+ this.trackers = trackers;
59
+ this.startGlobalObserver();
60
+ void this.recover();
61
+ }
62
+
63
+ private get stuckThresholdMs(): number {
64
+ return this.cfg.stuck?.thresholdMs ?? Engine.STUCK_THRESHOLD_MS;
65
+ }
66
+
67
+ private get maxStuckNudges(): number {
68
+ return this.cfg.stuck?.maxNudges ?? Engine.MAX_STUCK_NUDGE_ROUNDS;
69
+ }
70
+
71
+ private getTracker(type: string): IssueTracker {
72
+ const tracker = this.trackers.get(type);
73
+ if (!tracker) throw new Error(`Unknown tracker type: ${type}`);
74
+ return tracker;
75
+ }
76
+
77
+ private sessionKey(session: OpSession, issue: Issue): string {
78
+ return formatKey(issue.trackerType, issue.trackerScopeKey, issue.trackerIssueId, session.name);
79
+ }
80
+
81
+ private sessionToRef(session: OpSession, issue: Issue): TrackerRef {
82
+ return { trackerType: issue.trackerType, scope: issue.trackerScope, issueId: issue.trackerIssueId };
83
+ }
84
+
85
+ private resolveWorkdir(session: OpSession, issue: Issue): string {
86
+ if (session.workdir) {
87
+ let dir = session.workdir;
88
+ if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
89
+ return isAbsolute(dir) ? dir : resolve(this.cfg.opencode.baseWorkdir, dir);
90
+ }
91
+ // Fallback: use repo name from scope
92
+ const repoName = issue.trackerScope["repo"] ?? issue.trackerScopeKey.split("/").pop() ?? "default";
93
+ return join(this.cfg.opencode.baseWorkdir, repoName);
94
+ }
95
+
96
+ private persistRuntimeState(sessionId: string) {
97
+ const session = this.store.getSession(sessionId);
98
+ if (!session) return;
99
+ const issue = this.store.getIssue(session.issueId);
100
+ if (!issue) return;
101
+ const k = this.sessionKey(session, issue);
102
+ this.store.updateSession(sessionId, {
103
+ startedAt: this.startedAt.get(k),
104
+ progressCommentId: this.progressCommentId.get(k),
105
+ currentPrompt: this.currentPrompt.get(k),
106
+ });
107
+ }
108
+
109
+ private extractMentionName(text: string): string | null {
110
+ // Strip fenced code blocks (```...```) and inline code (`...`) before scanning for @mentions,
111
+ // otherwise pasted terminal output / code containing "user@host" or "git@repo" creates phantom sessions.
112
+ const stripped = text
113
+ .replace(/```[\s\S]*?```/g, "")
114
+ .replace(/`[^`\n]*`/g, "");
115
+ const match = stripped.match(/(?:^|\s)@([\w\u4e00-\u9fff]+)/);
116
+ return match?.[1] ?? null;
117
+ }
118
+
119
+ private parseDirCommand(text: string): string | null {
120
+ const match = text.match(/^\/dir\s+(\S+)/m);
121
+ return match?.[1] ?? null;
122
+ }
123
+
124
+ private formatDuration(ms: number): string {
125
+ const minutes = Math.floor(ms / 60000);
126
+ if (minutes < 1) return "less than 1 minute";
127
+ if (minutes < 60) return `${minutes} min`;
128
+ const hours = Math.floor(minutes / 60);
129
+ const remainMin = minutes % 60;
130
+ return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
131
+ }
132
+
133
+ /** System comments are posted by the daemon itself (acks, progress, reports). They are NOT AI replies. */
134
+ private static SYSTEM_PREFIX = "[system]";
135
+ private static RECENT_BOT_REPLY_THRESHOLD_MS = 5 * 60_000; // 5 minutes
136
+
137
+ private isSystemComment(comment: TrackerComment): boolean {
138
+ return comment.body.startsWith(Engine.SYSTEM_PREFIX);
139
+ }
140
+
141
+ private countAIReplies(comments: TrackerComment[], tracker: IssueTracker): number {
142
+ return comments.filter(c => tracker.isBotUser(c.author) && !this.isSystemComment(c)).length;
143
+ }
144
+
145
+ private hasRecentBotReply(comments: TrackerComment[], tracker: IssueTracker): boolean {
146
+ const now = Date.now();
147
+ return comments.some(c => {
148
+ if (!tracker.isBotUser(c.author) || this.isSystemComment(c)) return false;
149
+ if (!c.createdAt) return true; // no timestamp — assume recent to avoid false nudges
150
+ const age = now - new Date(c.createdAt).getTime();
151
+ return age < Engine.RECENT_BOT_REPLY_THRESHOLD_MS;
152
+ });
153
+ }
154
+
155
+ private lastBotReply(comments: TrackerComment[], tracker: IssueTracker): TrackerComment | undefined {
156
+ return [...comments].reverse().find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c));
157
+ }
158
+
159
+ // ─── Event Dispatch ───
160
+
161
+ async handleEvent(event: TrackerEvent) {
162
+ const { ref, issue: issueData } = event;
163
+ const tracker = this.getTracker(ref.trackerType);
164
+ const scopeKey = tracker.formatScopeKey(ref.scope);
165
+
166
+ switch (event.type) {
167
+ case "issue_opened":
168
+ return this.handleOpened(ref, scopeKey, issueData, tracker);
169
+ case "comment_created":
170
+ return this.handleCommented(ref, scopeKey, issueData, event.comment!, tracker);
171
+ case "issue_closed":
172
+ return this.handleClosed(ref, scopeKey, tracker);
173
+ }
174
+ }
175
+
176
+ private async handleOpened(
177
+ ref: TrackerRef,
178
+ scopeKey: string,
179
+ issueData: TrackerEvent["issue"],
180
+ tracker: IssueTracker
181
+ ) {
182
+ // Create or find issue
183
+ const issue = this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
184
+ if (issue.state === "closed") {
185
+ // Issue was closed before, now reopened
186
+ this.store.updateIssueState(issue.id, "active");
187
+ issue.state = "active";
188
+ } else if (issue.state === "created") {
189
+ this.store.updateIssueState(issue.id, "active");
190
+ issue.state = "active";
191
+ }
192
+
193
+ // Start observer for this issue
194
+ this.startObserver(issue);
195
+
196
+ // Create default session for bot user
197
+ const defaultSessionName = this.cfg.bot.username;
198
+ let session = this.store.getSessionByName(issue.id, defaultSessionName);
199
+ if (session && session.state === "running") {
200
+ log.info(`engine: duplicate issue_opened — session already running for ${scopeKey}#${ref.issueId}`);
201
+ this.startObserver(issue);
202
+ return;
203
+ }
204
+ if (!session) {
205
+ session = this.store.createSession(issue.id, defaultSessionName);
206
+ }
207
+
208
+ const k = this.sessionKey(session, issue);
209
+ const workdir = this.resolveWorkdir(session, issue);
210
+ log.info(`engine: session "${session.name}" created for ${k}, workdir=${workdir}`);
211
+
212
+ await tracker.createComment(ref, `[system] 🔄 **${session.name}** picked up this issue.\n> session: \`${session.id}\` | workdir: \`${workdir}\``);
213
+
214
+ const instructions = tracker.getTrackerInstructions(ref);
215
+ const prompt = this.buildInitialPrompt(
216
+ session.name, issueData.title,
217
+ this.handleLargeContent(workdir, issueData.body, "issue-body.txt"),
218
+ issueData.author, workdir, instructions
219
+ );
220
+ this.enqueueOrRun(session, issue, prompt);
221
+ }
222
+
223
+ private async handleCommented(
224
+ ref: TrackerRef,
225
+ scopeKey: string,
226
+ issueData: TrackerEvent["issue"],
227
+ comment: NonNullable<TrackerEvent["comment"]>,
228
+ tracker: IssueTracker
229
+ ) {
230
+ if (!comment) return;
231
+
232
+ if (tracker.isBotUser(comment.author)) {
233
+ log.info(`engine: ignoring own comment on ${scopeKey}#${ref.issueId}`);
234
+ return;
235
+ }
236
+
237
+ if (issueData.state !== "open") return;
238
+
239
+ if (comment.id) {
240
+ if (this.processingComments.has(comment.id) || this.store.findMessageByCommentId(comment.id)) {
241
+ log.info(`engine: duplicate comment ${comment.id}, skipping`);
242
+ return;
243
+ }
244
+ this.processingComments.add(comment.id);
245
+ }
246
+
247
+ try {
248
+ // Find issue
249
+ let issue = this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
250
+ if (!issue) {
251
+ // Issue not tracked yet — auto-track it
252
+ issue = this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
253
+ this.store.updateIssueState(issue.id, "active");
254
+ issue.state = "active";
255
+ this.startObserver(issue);
256
+ } else if (issue.state === "closed") {
257
+ log.info(`engine: issue ${scopeKey}#${ref.issueId} is closed in DB, skipping comment`);
258
+ return;
259
+ }
260
+
261
+ const dirPath = this.parseDirCommand(comment.body);
262
+ const mentionName = this.extractMentionName(comment.body);
263
+
264
+ if (mentionName) {
265
+ // @mention → targeted delivery
266
+ let session = this.store.getSessionByName(issue.id, mentionName);
267
+
268
+ if (session) {
269
+ // Forward to existing session
270
+ if (dirPath) {
271
+ this.store.updateSession(session.id, { workdir: dirPath });
272
+ session.workdir = dirPath;
273
+ }
274
+ const workdir = this.resolveWorkdir(session, issue);
275
+ const instructions = tracker.getTrackerInstructions(ref);
276
+ const prompt = this.buildForwardPrompt(
277
+ session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
278
+ comment.author, issueData.title, workdir, instructions
279
+ );
280
+
281
+ // Immediate ack
282
+ await tracker.createComment(ref, `[system] ✓ Message forwarded to **${session.name}**${this.running.has(this.sessionKey(session, issue)) ? " (running)" : ""}.\n> session: \`${session.id}\` | workdir: \`${workdir}\``);
283
+
284
+ this.enqueueOrRun(session, issue, prompt, comment.id);
285
+ } else {
286
+ // Create new session
287
+ session = this.store.createSession(issue.id, mentionName);
288
+ if (dirPath) {
289
+ this.store.updateSession(session.id, { workdir: dirPath });
290
+ session.workdir = dirPath;
291
+ }
292
+ const workdir = this.resolveWorkdir(session, issue);
293
+
294
+ await tracker.createComment(ref, `[system] 🔄 **${session.name}** joined the conversation.`);
295
+
296
+ const instructions = tracker.getTrackerInstructions(ref);
297
+ const prompt = this.buildInitialPrompt(
298
+ session.name, issueData.title,
299
+ this.handleLargeContent(workdir, issueData.body, "issue-body.txt"),
300
+ issueData.author, workdir, instructions
301
+ );
302
+ this.enqueueOrRun(session, issue, prompt, comment.id);
303
+ }
304
+ } else {
305
+ // No @mention → broadcast to all sessions on this issue
306
+ const sessions = this.store.getSessionsForIssue(issue.id);
307
+ if (sessions.length === 0) return;
308
+
309
+ for (const session of sessions) {
310
+ if (dirPath) {
311
+ this.store.updateSession(session.id, { workdir: dirPath });
312
+ session.workdir = dirPath;
313
+ }
314
+ const workdir = this.resolveWorkdir(session, issue);
315
+ const instructions = tracker.getTrackerInstructions(ref);
316
+ const prompt = this.buildForwardPrompt(
317
+ session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
318
+ comment.author, issueData.title, workdir, instructions
319
+ );
320
+ this.enqueueOrRun(session, issue, prompt, comment.id);
321
+ }
322
+
323
+ // Immediate ack
324
+ const names = sessions.map(s => `**${s.name}** (\`${s.opencodeSessionId ?? s.id}\`, \`${this.resolveWorkdir(s, issue)}\`)`).join(", ");
325
+ await tracker.createComment(ref, `[system] ✓ Message broadcasted to: ${names}.`);
326
+ }
327
+ } finally {
328
+ if (comment.id) this.processingComments.delete(comment.id);
329
+ }
330
+ }
331
+
332
+ private async handleClosed(
333
+ ref: TrackerRef,
334
+ scopeKey: string,
335
+ tracker: IssueTracker
336
+ ) {
337
+ const issue = this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
338
+ if (!issue) return;
339
+
340
+ this.store.updateIssueState(issue.id, "closed");
341
+ this.stopObserver(issue.id);
342
+
343
+ // Kill all running processes for this issue's sessions
344
+ const sessions = this.store.getSessionsForIssue(issue.id);
345
+ for (const session of sessions) {
346
+ const k = this.sessionKey(session, issue);
347
+ const proc = this.processes.get(k);
348
+ if (proc) {
349
+ this.stopping.add(k);
350
+ try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
351
+ }
352
+ // Clear runtime state
353
+ this.clearRuntimeState(k);
354
+ // Mark pending/running messages as interrupted
355
+ const msgs = this.store.getMessagesForSession(session.id);
356
+ for (const msg of msgs) {
357
+ if (msg.status === "pending" || msg.status === "running") {
358
+ this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
359
+ }
360
+ }
361
+ // Update session state
362
+ this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
363
+ }
364
+
365
+ log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
366
+ }
367
+
368
+ private clearRuntimeState(k: string) {
369
+ this.processes.delete(k);
370
+ this.running.delete(k);
371
+ this.stopping.delete(k);
372
+ this.currentMessage.delete(k);
373
+ this.lastOutputAt.delete(k);
374
+ this.startedAt.delete(k);
375
+ this.progressCommentId.delete(k);
376
+ this.nudgeRounds.delete(k);
377
+ this.processExitNudgeRounds.delete(k);
378
+ this.stuckNudgeRounds.delete(k);
379
+ this.currentPrompt.delete(k);
380
+ this.generation.delete(k);
381
+ }
382
+
383
+ // ─── Preemptive Scheduler ───
384
+
385
+ private enqueueOrRun(session: OpSession, issue: Issue, prompt: string, sourceCommentId?: string) {
386
+ const k = this.sessionKey(session, issue);
387
+
388
+ this.stuckNudgeRounds.delete(k);
389
+ this.processExitNudgeRounds.delete(k);
390
+
391
+ const msg = this.store.createMessage(session.id, prompt, sourceCommentId);
392
+
393
+ if (this.running.has(k)) {
394
+ // PREEMPTIVE: Kill running process, new message takes priority
395
+ log.info(`engine: preempting ${k} with new message ${msg.id.slice(0, 8)}`);
396
+ this.preemptSession(k, session, issue, msg);
397
+ return;
398
+ }
399
+
400
+ // Not running — execute directly
401
+ this.executeMessage(k, session, issue, msg);
402
+ }
403
+
404
+ private preemptSession(k: string, session: OpSession, issue: Issue, newMsg: Message) {
405
+ const proc = this.processes.get(k);
406
+ const oldMsgId = this.currentMessage.get(k);
407
+
408
+ // Mark old message as interrupted
409
+ if (oldMsgId) {
410
+ this.store.updateMessageStatus(oldMsgId, "interrupted", "preempted by new message");
411
+ }
412
+
413
+ // Kill running process
414
+ if (proc) {
415
+ this.stopping.add(k);
416
+ try { this.killProcessTree(proc.pid); } catch { /* already dead */ }
417
+ this.processes.delete(k);
418
+ this.lastOutputAt.delete(k);
419
+ }
420
+
421
+ // Don't clear stopping — let old execProcess detect preemption via process reference mismatch
422
+ this.running.delete(k);
423
+ this.currentMessage.delete(k);
424
+ this.startedAt.delete(k);
425
+
426
+ // Execute new message
427
+ this.executeMessage(k, session, issue, newMsg);
428
+ }
429
+
430
+ private executeMessage(k: string, session: OpSession, issue: Issue, msg: Message) {
431
+ log.info(`engine: executing msg ${msg.id.slice(0, 8)} for ${k}`);
432
+ this.running.add(k);
433
+ this.store.updateMessageStatus(msg.id, "running");
434
+ this.currentMessage.set(k, msg.id);
435
+
436
+ // Update session state
437
+ this.store.updateSession(session.id, { state: "running" });
438
+
439
+ this.execProcess(k, session, issue, msg);
440
+ }
441
+
442
+ // ─── Process Manager ───
443
+
444
+ private async execProcess(k: string, session: OpSession, issue: Issue, msg: Message) {
445
+ const gen = (this.generation.get(k) ?? 0) + 1;
446
+ this.generation.set(k, gen);
447
+
448
+ const workdir = this.resolveWorkdir(session, issue);
449
+ mkdirSync(workdir, { recursive: true });
450
+
451
+ const ref = this.sessionToRef(session, issue);
452
+ const tracker = this.getTracker(issue.trackerType);
453
+
454
+ const args = [this.cfg.opencode.binary, "run", "--format", "json", "--dir", workdir];
455
+ if (session.opencodeSessionId) {
456
+ args.push("--session", session.opencodeSessionId);
457
+ }
458
+ args.push(msg.content);
459
+
460
+ // Set eyes reaction on the source comment
461
+ if (msg.sourceCommentId) {
462
+ try {
463
+ await tracker.setReaction(ref, msg.sourceCommentId, "eyes");
464
+ } catch { /* non-critical */ }
465
+ }
466
+
467
+ let exitCode: number | null = null;
468
+
469
+ try {
470
+ const proc = spawn({
471
+ cmd: args,
472
+ cwd: workdir,
473
+ stdout: "pipe",
474
+ stderr: "pipe",
475
+ stdin: "ignore",
476
+ });
477
+
478
+ this.processes.set(k, proc);
479
+ this.lastOutputAt.set(k, Date.now());
480
+ if (!this.startedAt.has(k)) {
481
+ this.startedAt.set(k, Date.now());
482
+ }
483
+ this.currentPrompt.set(k, msg.content);
484
+
485
+ // Persist PID for crash recovery
486
+ this.store.updateSession(session.id, { opencodePid: proc.pid });
487
+ this.persistRuntimeState(session.id);
488
+
489
+ log.info(`engine: spawned pid=${proc.pid} for ${k}`);
490
+
491
+ // Read stdout to capture session ID
492
+ let opencodeSessionId: string | null = null;
493
+ const stderrPromise = new Response(proc.stderr).text();
494
+ const reader = proc.stdout.getReader();
495
+ const decoder = new TextDecoder();
496
+ let lineBuf = "";
497
+
498
+ while (true) {
499
+ const { done, value } = await reader.read();
500
+ if (done) break;
501
+
502
+ this.lastOutputAt.set(k, Date.now());
503
+
504
+ if (!opencodeSessionId) {
505
+ lineBuf += decoder.decode(value, { stream: true });
506
+ const lines = lineBuf.split("\n");
507
+ lineBuf = lines.pop()!;
508
+ for (const line of lines) {
509
+ if (!line.trim()) continue;
510
+ try {
511
+ const ev = JSON.parse(line);
512
+ if (ev.sessionID) {
513
+ const sid: string = ev.sessionID;
514
+ opencodeSessionId = sid;
515
+ // Persist now, not at exit: a preempt/crash before exit must
516
+ // not lose the ID, otherwise the re-run opens a fresh session.
517
+ if (!session.opencodeSessionId) {
518
+ this.store.updateSession(session.id, { opencodeSessionId: sid });
519
+ session.opencodeSessionId = sid;
520
+ log.info(`engine: captured sessionID=${sid.slice(0, 8)} for ${k} (early persist)`);
521
+ }
522
+ break;
523
+ }
524
+ } catch { /* not json */ }
525
+ }
526
+ }
527
+ }
528
+
529
+ exitCode = await proc.exited;
530
+ const stderr = await stderrPromise;
531
+
532
+ // Detect preemption or force-stop: if our process is no longer in the map, another took over
533
+ if (this.processes.get(k) !== proc) {
534
+ log.info(`engine: process replaced, skipping finishRun for ${k}`);
535
+ this.stopping.delete(k);
536
+ return;
537
+ }
538
+
539
+ this.processes.delete(k);
540
+ this.lastOutputAt.delete(k);
541
+ this.store.updateSession(session.id, { opencodePid: undefined });
542
+
543
+ if (exitCode !== 0) {
544
+ log.error(`engine: pid=${proc.pid} exited ${exitCode} for ${k}`);
545
+ log.error(` stderr: ${stderr.slice(0, 2000)}`);
546
+ this.store.updateMessageStatus(msg.id, "failed", `exit ${exitCode}: ${stderr.slice(0, 500)}`);
547
+ } else {
548
+ log.info(`engine: pid=${proc.pid} completed for ${k}`);
549
+ if (stderr) log.warn(`engine: pid=${proc.pid} stderr on exit 0: ${stderr.slice(0, 500)}`);
550
+ if (!opencodeSessionId) log.warn(`engine: pid=${proc.pid} produced NO sessionID (no stdout output)`);
551
+ this.store.updateMessageStatus(msg.id, "done");
552
+ }
553
+
554
+ // Save opencode session ID for continuity
555
+ if (opencodeSessionId && !session.opencodeSessionId) {
556
+ this.store.updateSession(session.id, { opencodeSessionId });
557
+ }
558
+ } catch (err) {
559
+ log.error(`engine: exec failed for ${k}:`, err);
560
+ this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
561
+ }
562
+
563
+ await this.finishRun(k, session, issue, exitCode, gen);
564
+ }
565
+
566
+ private async finishRun(k: string, session: OpSession, issue: Issue, exitCode: number | null, gen: number) {
567
+ if (this.stopping.delete(k)) {
568
+ log.info(`engine: finishRun skipped (force-stopped) for ${k}`);
569
+ return;
570
+ }
571
+
572
+ const superseded = () => this.generation.get(k) !== gen;
573
+
574
+ // running.delete deferred to dequeuePending
575
+ this.currentMessage.delete(k);
576
+
577
+ const started = this.startedAt.get(k);
578
+ this.startedAt.delete(k);
579
+
580
+ const progressId = this.progressCommentId.get(k);
581
+ const ref = this.sessionToRef(session, issue);
582
+ const tracker = this.getTracker(issue.trackerType);
583
+
584
+ // Edit the progress comment to show final state instead of deleting it,
585
+ // so users can always see whether a run completed, failed, or crashed.
586
+ // For short runs with no progress comment, only post if >3 min.
587
+ const duration = started ? this.formatDuration(Date.now() - started) : "unknown";
588
+ const emoji = exitCode === null ? "💥" : exitCode === 0 ? "✅" : "❌";
589
+ const label = exitCode === null ? "spawn failed" : exitCode === 0 ? "completed" : "failed";
590
+ const finalText = `[system] ${emoji} **${session.name}** ${label} (${duration})`;
591
+
592
+ if (progressId) {
593
+ try {
594
+ await tracker.editComment(ref, progressId, finalText);
595
+ } catch (err) {
596
+ log.error(`engine: failed to update progress comment for ${k}:`, (err as Error).message);
597
+ }
598
+ } else if (started && Date.now() - started > 180_000) {
599
+ await tracker.createComment(ref, finalText).catch(
600
+ err => log.error("engine: completion report failed:", (err as Error).message)
601
+ );
602
+ }
603
+
604
+ if (superseded()) {
605
+ log.info(`engine: finishRun aborted (superseded) for ${k}`);
606
+ return;
607
+ }
608
+ this.progressCommentId.delete(k);
609
+ this.currentPrompt.delete(k);
610
+ this.persistRuntimeState(session.id);
611
+
612
+
613
+ // spawn failed (exitCode === null) → skip completion check
614
+ if (exitCode === null) {
615
+ log.info(`engine: spawn failed for ${k}, skipping completion check`);
616
+ this.store.updateSession(session.id, { opencodePid: undefined });
617
+ this.deactivateIfIdle(k, session, issue);
618
+ return;
619
+ }
620
+ if (superseded()) {
621
+ log.info(`engine: finishRun aborted (superseded) for ${k}`);
622
+ return;
623
+ }
624
+
625
+ // Completion check: did AI post a recent [bot] reply?
626
+ const commentsNow = await tracker.listComments(ref).catch((): TrackerComment[] => []);
627
+ if (superseded()) {
628
+ log.info(`engine: finishRun aborted (superseded) for ${k}`);
629
+ return;
630
+ }
631
+ const hasRecent = this.hasRecentBotReply(commentsNow, tracker);
632
+
633
+ if (hasRecent) {
634
+ log.info(`engine: recent [bot] reply found for ${k}, marking done`);
635
+ this.nudgeRounds.delete(k);
636
+ this.persistRuntimeState(session.id);
637
+ } else {
638
+ const nudgeRound = this.nudgeRounds.get(k) ?? 0;
639
+ if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
640
+ log.info(`engine: no recent [bot] reply for ${k}, nudging (round ${nudgeRound + 1}/${Engine.MAX_NUDGE_ROUNDS})`);
641
+ this.nudgeRounds.set(k, nudgeRound + 1);
642
+ this.currentPrompt.delete(k);
643
+ this.persistRuntimeState(session.id);
644
+
645
+ const instructions = tracker.getTrackerInstructions(ref);
646
+ const nudgePrompt = this.buildNudgePrompt(session, issue, instructions);
647
+ const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
648
+ this.dequeueOrIdle(k, session, issue, nudgeMsg);
649
+ return;
650
+ }
651
+ log.info(`engine: no recent [bot] reply for ${k}, marking done (nudge exhausted or process failed)`);
652
+ this.nudgeRounds.delete(k);
653
+ const detail = exitCode === 0 ? "ran but did not post a reply" : `crashed (exit ${exitCode})`;
654
+ await tracker.createComment(ref, `[system] ❌ **${session.name}** ${detail}. Try posting again or @${session.name} to retry.`).catch(() => {});
655
+ }
656
+
657
+ // Remove eyes on source comment; react +1/-1 on the bot's last reply (fallback: source)
658
+ const recentMsgs = this.store.getRecentMessages(session.id, 1);
659
+ const lastMsg = recentMsgs[0];
660
+ if (lastMsg?.sourceCommentId) {
661
+ // Check if any other session is still running on this issue
662
+ const prefix = `${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}@`;
663
+ const stillRunning = [...this.running].some(rk => rk.startsWith(prefix) && rk !== k);
664
+ if (!stillRunning) {
665
+ try {
666
+ await tracker.setReaction(ref, lastMsg.sourceCommentId, "eyes", true);
667
+ const reaction = exitCode === 0 ? "+1" : "-1";
668
+ const botReply = hasRecent ? this.lastBotReply(commentsNow, tracker) : undefined;
669
+ const targetId = botReply?.id ?? lastMsg.sourceCommentId;
670
+ await tracker.setReaction(ref, targetId, reaction);
671
+ } catch { /* non-critical */ }
672
+ }
673
+ }
674
+
675
+ if (superseded()) {
676
+ log.info(`engine: finishRun aborted (superseded) for ${k}`);
677
+ return;
678
+ }
679
+ this.deactivateIfIdle(k, session, issue);
680
+ }
681
+
682
+ private deactivateIfIdle(k: string, session: OpSession, issue: Issue) {
683
+ const nextMsg = this.store.getNextPendingMessage(session.id);
684
+ if (nextMsg) {
685
+ const current = this.store.getSession(session.id);
686
+ if (current && current.state !== "idle") {
687
+ this.dequeueOrIdle(k, current, issue, nextMsg);
688
+ return;
689
+ }
690
+ }
691
+
692
+ this.clearRuntimeState(k);
693
+ this.store.updateSession(session.id, { state: "idle" });
694
+ }
695
+
696
+ private dequeueOrIdle(k: string, session: OpSession, issue: Issue, msg: Message) {
697
+ log.info(`engine: running dequeued msg ${msg.id.slice(0, 8)} for ${k}`);
698
+ this.store.updateMessageStatus(msg.id, "running");
699
+ this.running.add(k);
700
+ this.currentMessage.set(k, msg.id);
701
+ this.store.updateSession(session.id, { state: "running" });
702
+ this.execProcess(k, session, issue, msg);
703
+ }
704
+
705
+ // ─── Prompts ───
706
+
707
+ private buildInitialPrompt(
708
+ opName: string,
709
+ title: string,
710
+ body: string,
711
+ author: string,
712
+ workdir: string,
713
+ instructions: { clone: string; issueRef: string; closeIssue?: string }
714
+ ): string {
715
+ return [
716
+ `You are ${opName}, an AI development assistant.`,
717
+ `Your identity is "${opName}" — this name was assigned when you were initialized.`,
718
+ ``,
719
+ `A new issue needs your attention:`,
720
+ `- Issue: "${title}" (${instructions.issueRef})`,
721
+ `- Author: @${author}`,
722
+ `- Working directory: ${workdir}`,
723
+ ``,
724
+ `### Issue Body`,
725
+ body,
726
+ ``,
727
+ `### Repository`,
728
+ `Working directory: \`${workdir}\``,
729
+ `If the directory is empty or doesn't contain the code, clone it:`,
730
+ `\`${instructions.clone}\``,
731
+ ``,
732
+ `Read the issue, understand what's needed, work on it, and reply to the user.`,
733
+ ``,
734
+ `**IMPORTANT**: After finishing your work, you MUST post a reply using the \`reply\` tool. Do NOT skip this step. Users are waiting for your response.`,
735
+ `**IMPORTANT**: Reply as soon as possible, then continue working. Don't make users wait.`,
736
+ `**IMPORTANT**: Every reply MUST start with \`[bot]\` prefix.`,
737
+ ].filter(Boolean).join("\n");
738
+ }
739
+
740
+ private buildForwardPrompt(
741
+ opName: string,
742
+ commentBody: string,
743
+ commentUser: string,
744
+ issueTitle: string,
745
+ workdir: string,
746
+ instructions: { issueRef: string }
747
+ ): string {
748
+ return [
749
+ `[SYSTEM FORWARD] User @${commentUser} posted a new comment on ${instructions.issueRef} "${issueTitle}":`,
750
+ ``,
751
+ `---`,
752
+ commentBody,
753
+ `---`,
754
+ ``,
755
+ `Working directory: ${workdir}`,
756
+ ``,
757
+ `Reply using the \`reply\` tool with \`[bot]\` prefix.`,
758
+ ].join("\n");
759
+ }
760
+
761
+ private buildNudgePrompt(
762
+ session: OpSession,
763
+ issue: Issue,
764
+ instructions: { issueRef: string }
765
+ ): string {
766
+ return [
767
+ `[SYSTEM NUDGE] You completed a task on ${instructions.issueRef} but did not post a reply.`,
768
+ ``,
769
+ `Post a reply now using the \`reply\` tool. Summarize what you did and the outcome.`,
770
+ `Every reply MUST start with \`[bot]\` prefix.`,
771
+ ].join("\n");
772
+ }
773
+
774
+ private buildProcessExitNudgePrompt(
775
+ session: OpSession,
776
+ issue: Issue,
777
+ instructions: { issueRef: string }
778
+ ): string {
779
+ return [
780
+ `[SYSTEM] 检测到你的进程已经退出,可能意味着您已经完成了阶段性工作或者因为某些原因中断。`,
781
+ ``,
782
+ `- 如果您确认完成了阶段性工作,您应该向用户报告结果。因为用户只能在 issue 上查看结果或者听取汇报。`,
783
+ `- 如果您是因为某些原因中断而不需要向用户汇报中间结果,请继续您未完成的工作。`,
784
+ `- 如果您因为某些不确定,必须向用户请教,请务必向用户汇报后再继续。`,
785
+ ``,
786
+ `使用 \`reply\` 工具向 ${instructions.issueRef} 给出回复(以 \`[bot]\` 开头)。其他方式的回复会被忽略。`,
787
+ ].join("\n");
788
+ }
789
+
790
+ private buildStuckNudgePrompt(
791
+ session: OpSession,
792
+ issue: Issue,
793
+ instructions: { issueRef: string },
794
+ stuckMinutes: number
795
+ ): string {
796
+ return [
797
+ `[SYSTEM] 检测到您的进程已经卡住 ${stuckMinutes} 分钟没有输出了,已经被强制重启。`,
798
+ ``,
799
+ `可能的原因:等待输入、死循环、网络请求挂起、长时间无响应的工具调用等。`,
800
+ ``,
801
+ `- 如果您之前的工作有阶段性成果,请立即向用户汇报当前进度和遇到的问题。`,
802
+ `- 如果您遇到了阻塞(权限不足、依赖缺失、不确定的方向等),请向用户说明并请求指导。`,
803
+ `- 如果您可以继续,请避开导致卡住的操作,换一种方式继续工作。`,
804
+ ``,
805
+ `使用 \`reply\` 工具向 ${instructions.issueRef} 给出回复(以 \`[bot]\` 开头)。其他方式的回复会被忽略。`,
806
+ ].join("\n");
807
+ }
808
+
809
+ private handleLargeContent(workdir: string, content: string, filename: string): string {
810
+ if (content.length <= Engine.MAX_INLINE_SIZE) return content;
811
+
812
+ const dir = join(workdir, ".ework-daemon");
813
+ mkdirSync(dir, { recursive: true });
814
+ const absPath = join(dir, filename);
815
+ writeFileSync(absPath, content);
816
+
817
+ return [
818
+ `[Large content: ${content.length} chars, saved to \`${absPath}\`]`,
819
+ `Read: \`cat '${absPath}'\``,
820
+ `Search: \`grep "pattern" '${absPath}'\``,
821
+ ].join("\n");
822
+ }
823
+
824
+ // ─── IssueObserver (Global Polling) ───
825
+
826
+ private startGlobalObserver() {
827
+ // Single global timer that checks all active issues
828
+ this.observerTimer = setInterval(() => this.runObserverCycle(), Engine.OBSERVER_INTERVAL_MS);
829
+ }
830
+
831
+ private startObserver(issue: Issue) {
832
+ if (this.observedIssues.has(issue.id)) return;
833
+ this.observedIssues.add(issue.id);
834
+ log.info(`engine: observer started for issue ${issue.trackerScopeKey}#${issue.trackerIssueId}`);
835
+ }
836
+
837
+ private stopObserver(issueId: string) {
838
+ this.observedIssues.delete(issueId);
839
+ }
840
+
841
+ private async runObserverCycle() {
842
+ const activeIssues = this.store.listActiveIssues();
843
+
844
+ for (const issue of activeIssues) {
845
+ if (!this.observedIssues.has(issue.id)) continue;
846
+ try {
847
+ await this.observeIssue(issue);
848
+ } catch (err) {
849
+ log.error(`engine: observer error for ${issue.trackerScopeKey}#${issue.trackerIssueId}:`, (err as Error).message);
850
+ }
851
+ }
852
+ }
853
+
854
+ private async observeIssue(issue: Issue) {
855
+ const tracker = this.getTracker(issue.trackerType);
856
+ const sessions = this.store.getSessionsForIssue(issue.id);
857
+
858
+ for (const session of sessions) {
859
+ if (session.state !== "running") continue;
860
+
861
+ const k = this.sessionKey(session, issue);
862
+ const proc = this.processes.get(k);
863
+ const lastTs = this.lastOutputAt.get(k);
864
+
865
+ if (proc) {
866
+ // Process exists — check if alive
867
+ try { process.kill(proc.pid, 0); } catch {
868
+ // Stale reference: execProcess may have already cleaned up
869
+ if (this.processes.get(k) !== proc) continue;
870
+
871
+ // Process died unexpectedly
872
+ log.warn(`engine: observer detected dead process for ${k}`);
873
+ this.processes.delete(k);
874
+ this.lastOutputAt.delete(k);
875
+ this.running.delete(k);
876
+ this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
877
+
878
+ // Mark running message as failed
879
+ const msgId = this.currentMessage.get(k);
880
+ if (msgId) {
881
+ this.store.updateMessageStatus(msgId, "failed", "process died unexpectedly");
882
+ this.currentMessage.delete(k);
883
+ }
884
+
885
+ const ref = this.sessionToRef(session, issue);
886
+ const exitNudgeRound = this.processExitNudgeRounds.get(k) ?? 0;
887
+ if (exitNudgeRound < Engine.MAX_PROCESS_EXIT_NUDGE_ROUNDS) {
888
+ const comments = await tracker.listComments(ref).catch(() => []);
889
+ const aiReplies = this.countAIReplies(comments, tracker);
890
+
891
+ if (aiReplies === 0) {
892
+ log.info(`engine: process died and no bot reply for ${k}, sending process-exit nudge (round ${exitNudgeRound + 1}/${Engine.MAX_PROCESS_EXIT_NUDGE_ROUNDS})`);
893
+ await tracker.createComment(ref, `[system] 💀 **${session.name}** process exited unexpectedly, restarting...`).catch(
894
+ err => log.error(`engine: process-exit comment failed for ${k}:`, (err as Error).message)
895
+ );
896
+ this.processExitNudgeRounds.set(k, exitNudgeRound + 1);
897
+ this.currentPrompt.delete(k);
898
+
899
+ const instructions = tracker.getTrackerInstructions(ref);
900
+ const nudgePrompt = this.buildProcessExitNudgePrompt(session, issue, instructions);
901
+ const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
902
+ this.dequeueOrIdle(k, session, issue, nudgeMsg);
903
+ continue;
904
+ } else {
905
+ // AI already replied before dying — no need to nudge, but user must be
906
+ // notified the process terminated so they know the run is over.
907
+ log.info(`engine: process died for ${k} but AI had posted ${aiReplies} reply(ies), not nudging`);
908
+ await tracker.createComment(ref, `[system] 💀 **${session.name}** process exited unexpectedly.`).catch(
909
+ err => log.error(`engine: process-exit comment failed for ${k}:`, (err as Error).message)
910
+ );
911
+ }
912
+ } else {
913
+ log.warn(`engine: process died for ${k}, process-exit nudge exhausted (${exitNudgeRound}/${Engine.MAX_PROCESS_EXIT_NUDGE_ROUNDS}), giving up`);
914
+ await tracker.createComment(ref, `[system] ⛔ **${session.name}** process exited, gave up after ${Engine.MAX_PROCESS_EXIT_NUDGE_ROUNDS} restart attempt(s).`).catch(
915
+ err => log.error(`engine: process-exit comment failed for ${k}:`, (err as Error).message)
916
+ );
917
+ this.processExitNudgeRounds.delete(k);
918
+ }
919
+
920
+ // Try to dequeue next message
921
+ const nextMsg = this.store.getNextPendingMessage(session.id);
922
+ if (nextMsg) {
923
+ this.dequeueOrIdle(k, session, issue, nextMsg);
924
+ }
925
+ continue;
926
+ }
927
+
928
+ // Process alive — check stuck
929
+ if (lastTs && Date.now() - lastTs >= this.stuckThresholdMs) {
930
+ const minutes = Math.round((Date.now() - lastTs) / 60000);
931
+ const ref = this.sessionToRef(session, issue);
932
+
933
+ const stuckNudgeRound = this.stuckNudgeRounds.get(k) ?? 0;
934
+ if (stuckNudgeRound < this.maxStuckNudges) {
935
+ log.warn(`engine: stuck — no output for ${minutes}min on ${k}, killing + sending stuck nudge (round ${stuckNudgeRound + 1}/${this.maxStuckNudges})`);
936
+ await tracker.createComment(ref, `[system] ⏰ **${session.name}** no output for ${minutes} min, restarting...`).catch(
937
+ err => log.error(`engine: stuck-nudge comment failed for ${k}:`, (err as Error).message)
938
+ );
939
+
940
+ this.forceStop(k);
941
+ this.stuckNudgeRounds.set(k, stuckNudgeRound + 1);
942
+
943
+ const instructions = tracker.getTrackerInstructions(ref);
944
+ const nudgePrompt = this.buildStuckNudgePrompt(session, issue, instructions, minutes);
945
+ const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
946
+ this.dequeueOrIdle(k, session, issue, nudgeMsg);
947
+ } else {
948
+ log.warn(`engine: stuck for ${minutes}min on ${k}, stuck nudge exhausted (${stuckNudgeRound}/${this.maxStuckNudges}), giving up`);
949
+ await tracker.createComment(ref, `[system] ⛔ **${session.name}** stuck for ${minutes} min, gave up after ${this.maxStuckNudges} restart(s).`).catch(
950
+ err => log.error(`engine: stuck-giveup comment failed for ${k}:`, (err as Error).message)
951
+ );
952
+ this.forceStop(k);
953
+ this.stuckNudgeRounds.delete(k);
954
+ }
955
+ }
956
+ } else if (session.state === "running" && !this.running.has(k)) {
957
+ log.warn(`engine: observer fixing orphaned running state for ${k}`);
958
+ this.running.delete(k);
959
+ this.store.updateSession(session.id, { state: "idle" });
960
+ }
961
+ }
962
+
963
+ // Progress reports for running sessions
964
+ const now = Date.now();
965
+ for (const session of sessions) {
966
+ const k = this.sessionKey(session, issue);
967
+ const started = this.startedAt.get(k);
968
+ if (!started || !this.running.has(k)) continue;
969
+
970
+ const ref = this.sessionToRef(session, issue);
971
+ const duration = this.formatDuration(now - started);
972
+ const body = `[system] ⏳ **${session.name}** processing, running for ${duration}...`;
973
+
974
+ const existingId = this.progressCommentId.get(k);
975
+ try {
976
+ if (existingId) {
977
+ await tracker.editComment(ref, existingId, body);
978
+ } else {
979
+ const result = await tracker.createComment(ref, body);
980
+ this.progressCommentId.set(k, result.id);
981
+ this.persistRuntimeState(session.id);
982
+ }
983
+ } catch (err) {
984
+ log.error(`engine: progress report failed for ${k}:`, (err as Error).message);
985
+ if (existingId) {
986
+ this.progressCommentId.delete(k);
987
+ }
988
+ }
989
+ }
990
+ }
991
+
992
+ // ─── Recovery ───
993
+
994
+ private async recover() {
995
+ const allSessions = this.store.listAllSessions();
996
+ for (const session of allSessions) {
997
+ if (session.opencodePid) {
998
+ try {
999
+ process.kill(session.opencodePid, 0);
1000
+ log.info(`engine: SIGTERM to orphaned pid=${session.opencodePid} for session ${session.id}`);
1001
+ process.kill(session.opencodePid, "SIGTERM");
1002
+ let exited = false;
1003
+ for (let i = 0; i < 30; i++) {
1004
+ Bun.sleepSync(100);
1005
+ try { process.kill(session.opencodePid, 0); } catch { exited = true; break; }
1006
+ }
1007
+ if (!exited) {
1008
+ log.info(`engine: SIGTERM timeout, SIGKILL pid=${session.opencodePid}`);
1009
+ try { process.kill(session.opencodePid, "SIGKILL"); } catch { /* dead */ }
1010
+ }
1011
+ } catch { /* already dead */ }
1012
+ this.store.updateSession(session.id, { opencodePid: undefined });
1013
+ }
1014
+ }
1015
+
1016
+ // Restore runtime state from DB
1017
+ for (const session of allSessions) {
1018
+ const issue = this.store.getIssue(session.issueId);
1019
+ if (!issue || issue.state === "closed") continue;
1020
+ const k = this.sessionKey(session, issue);
1021
+ if (session.startedAt != null) this.startedAt.set(k, session.startedAt);
1022
+ if (session.progressCommentId) this.progressCommentId.set(k, session.progressCommentId);
1023
+ if (session.currentPrompt) this.currentPrompt.set(k, session.currentPrompt);
1024
+ // Start observer for active issues
1025
+ this.startObserver(issue);
1026
+ }
1027
+
1028
+ const restored = this.startedAt.size;
1029
+ if (restored > 0) {
1030
+ log.info(`engine: restored runtime state for ${restored} sessions from DB`);
1031
+ }
1032
+
1033
+ // Recover stuck messages
1034
+ const stuck = this.store.getPendingOrRunningMessages();
1035
+ if (stuck.length === 0) return;
1036
+
1037
+ log.info(`engine: recovering ${stuck.length} stuck messages`);
1038
+
1039
+ // Reset running messages to pending
1040
+ for (const msg of stuck) {
1041
+ if (msg.status === "running") {
1042
+ this.store.updateMessageStatus(msg.id, "pending");
1043
+ }
1044
+ }
1045
+
1046
+ // Group by session and re-run earliest pending
1047
+ const bySession = new Map<string, Message[]>();
1048
+ for (const msg of stuck) {
1049
+ const arr = bySession.get(msg.sessionId) ?? [];
1050
+ arr.push(msg);
1051
+ bySession.set(msg.sessionId, arr);
1052
+ }
1053
+
1054
+ for (const [sessionId, msgs] of bySession) {
1055
+ const session = this.store.getSession(sessionId);
1056
+ if (!session) continue;
1057
+ const issue = this.store.getIssue(session.issueId);
1058
+ if (!issue || issue.state === "closed") continue;
1059
+
1060
+ const k = this.sessionKey(session, issue);
1061
+ if (this.running.has(k)) continue;
1062
+
1063
+ const first = msgs.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())[0]!;
1064
+
1065
+ // Check if AI already replied before the crash — skip re-running if so
1066
+ const ref = this.sessionToRef(session, issue);
1067
+ const tracker = this.getTracker(issue.trackerType);
1068
+ const comments = await tracker.listComments(ref).catch((): TrackerComment[] => []);
1069
+ if (this.hasRecentBotReply(comments, tracker)) {
1070
+ log.info(`engine: recovered msg ${first.id.slice(0, 8)} for ${k} — bot reply detected, marking done`);
1071
+ this.store.updateMessageStatus(first.id, "done");
1072
+ const next = this.store.getNextPendingMessage(session.id);
1073
+ if (next) { this.dequeueOrIdle(k, session, issue, next); }
1074
+ continue;
1075
+ }
1076
+
1077
+ log.info(`engine: recovering msg ${first.id.slice(0, 8)} for ${k}`);
1078
+ this.dequeueOrIdle(k, session, issue, first);
1079
+ }
1080
+ }
1081
+
1082
+ // ─── API Methods ───
1083
+
1084
+ retryMessage(messageId: string): boolean {
1085
+ const msg = this.store.getMessage(messageId);
1086
+ if (!msg || msg.status !== "failed") return false;
1087
+ const session = this.store.getSession(msg.sessionId);
1088
+ if (!session) return false;
1089
+ const issue = this.store.getIssue(session.issueId);
1090
+ if (!issue || issue.state === "closed") return false;
1091
+
1092
+ this.store.updateMessageStatus(messageId, "pending");
1093
+ const k = this.sessionKey(session, issue);
1094
+ if (!this.running.has(k)) {
1095
+ this.dequeueOrIdle(k, session, issue, msg);
1096
+ }
1097
+ return true;
1098
+ }
1099
+
1100
+ getStatus() {
1101
+ const pendingCount = this.store.getPendingOrRunningMessages().filter(m => m.status === "pending").length;
1102
+ return {
1103
+ runningCount: this.running.size,
1104
+ runningKeys: [...this.running],
1105
+ pendingCount,
1106
+ processCount: this.processes.size,
1107
+ observedIssues: this.observedIssues.size,
1108
+ };
1109
+ }
1110
+
1111
+ getQueue(): Record<string, number> {
1112
+ const result: Record<string, number> = {};
1113
+ const allPending = this.store.getPendingOrRunningMessages().filter(m => m.status === "pending");
1114
+ for (const msg of allPending) {
1115
+ const session = this.store.getSession(msg.sessionId);
1116
+ if (!session) continue;
1117
+ const issue = this.store.getIssue(session.issueId);
1118
+ if (!issue) continue;
1119
+ const k = this.sessionKey(session, issue);
1120
+ result[k] = (result[k] ?? 0) + 1;
1121
+ }
1122
+ return result;
1123
+ }
1124
+
1125
+ getProcesses(): Array<{ key: string; pid: number; lastOutputAt: number | null }> {
1126
+ const result: Array<{ key: string; pid: number; lastOutputAt: number | null }> = [];
1127
+ for (const [k, proc] of this.processes) {
1128
+ result.push({
1129
+ key: k,
1130
+ pid: proc.pid,
1131
+ lastOutputAt: this.lastOutputAt.get(k) ?? null,
1132
+ });
1133
+ }
1134
+ return result;
1135
+ }
1136
+
1137
+ private killProcessTree(pid: number, signal: NodeJS.Signals | number = 9): void {
1138
+ try {
1139
+ const result = Bun.spawnSync(["pgrep", "-P", String(pid)]);
1140
+ const childPids = result.stdout.toString().trim().split("\n").filter(Boolean);
1141
+ for (const childPid of childPids) {
1142
+ this.killProcessTree(Number(childPid), signal);
1143
+ }
1144
+ } catch { /* pgrep failed */ }
1145
+ try { process.kill(pid, signal); } catch { /* already dead */ }
1146
+ }
1147
+
1148
+ forceStop(key: string): boolean {
1149
+ const proc = this.processes.get(key);
1150
+ this.stopping.add(key);
1151
+ log.warn(`engine: forceStop ${key}, pid=${proc?.pid ?? "none"}`);
1152
+
1153
+ if (proc) {
1154
+ try { process.kill(proc.pid, 9); } catch { /* dead */ }
1155
+ this.processes.delete(key);
1156
+ }
1157
+
1158
+ const progressId = this.progressCommentId.get(key);
1159
+
1160
+ this.running.delete(key);
1161
+ this.currentMessage.delete(key);
1162
+ this.lastOutputAt.delete(key);
1163
+ this.startedAt.delete(key);
1164
+ this.progressCommentId.delete(key);
1165
+ this.currentPrompt.delete(key);
1166
+ this.processExitNudgeRounds.delete(key);
1167
+ this.stuckNudgeRounds.delete(key);
1168
+ this.generation.delete(key);
1169
+ // Update session and messages
1170
+ const parsed = parseKey(key);
1171
+ if (parsed) {
1172
+ const issue = this.store.findIssue(parsed.trackerType, parsed.scopeKey, parsed.issueId);
1173
+ if (issue) {
1174
+ const session = this.store.getSessionByName(issue.id, parsed.sessionName);
1175
+ if (session) {
1176
+ if (progressId) {
1177
+ const ref = this.sessionToRef(session, issue);
1178
+ const tracker = this.getTracker(issue.trackerType);
1179
+ void tracker.editComment(ref, progressId, `[system] ⛔ **${session.name}** force-stopped.`).catch(() => {});
1180
+ }
1181
+ const msgs = this.store.getMessagesForSession(session.id);
1182
+ for (const msg of msgs) {
1183
+ if (msg.status === "pending" || msg.status === "running") {
1184
+ this.store.updateMessageStatus(msg.id, "failed", "force stopped");
1185
+ }
1186
+ }
1187
+ this.store.updateSession(session.id, {
1188
+ state: "idle",
1189
+ opencodePid: undefined,
1190
+ startedAt: undefined,
1191
+ progressCommentId: undefined,
1192
+ currentPrompt: undefined,
1193
+ });
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ return !!proc;
1199
+ }
1200
+
1201
+ destroy() {
1202
+ if (this.observerTimer) clearInterval(this.observerTimer);
1203
+ this.observedIssues.clear();
1204
+ for (const [, proc] of this.processes) {
1205
+ try { process.kill(proc.pid, "SIGKILL"); } catch { /* dead */ }
1206
+ }
1207
+ this.processes.clear();
1208
+ this.running.clear();
1209
+ this.stopping.clear();
1210
+ this.currentMessage.clear();
1211
+ this.lastOutputAt.clear();
1212
+ this.startedAt.clear();
1213
+ this.progressCommentId.clear();
1214
+ this.processExitNudgeRounds.clear();
1215
+ this.stuckNudgeRounds.clear();
1216
+ this.currentPrompt.clear();
1217
+ this.generation.clear();
1218
+ }
1219
+ }