ework-daemon 0.1.2 → 0.2.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/src/opencode.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn, type Subprocess } from "bun";
2
- import { mkdirSync, writeFileSync } from "fs";
2
+ import { mkdirSync, writeFileSync, readdirSync } from "fs";
3
3
  import { join, resolve, isAbsolute } from "path";
4
4
  import { homedir } from "os";
5
5
  import { log } from "./logger";
@@ -14,12 +14,125 @@ interface TrackerRegistry {
14
14
  get(type: string): IssueTracker | undefined;
15
15
  }
16
16
 
17
+ /**
18
+ * Pluggable strategy for taking over a session's workdir + opencode session.
19
+ * Phase 1 ships only RecloneStrategy (fresh clone + fresh opencode session).
20
+ * Phase 2 swaps in NAS-backed / OpenCode-server strategies without touching
21
+ * the coordination layer.
22
+ */
23
+ export interface TakeoverStrategy {
24
+ /** Resolve (and ensure exists) the workdir for this session+issue. */
25
+ acquireWorkdir(session: OpSession, issue: Issue): Promise<string>;
26
+ /** Return an opencode session id to resume, or null for a fresh session. */
27
+ resumeOpenCodeSession(session: OpSession): Promise<string | null>;
28
+ }
29
+
30
+ /**
31
+ * Default TakeoverStrategy: deterministic per-issue workdir under
32
+ * `<baseWorkdir>/<owner>--<repo>/<issueId>/<sessionName>`, with a best-effort
33
+ * `git clone` when the directory is empty. Resume always returns null
34
+ * (fresh opencode session — accepts memory loss on takeover).
35
+ */
36
+ export class RecloneStrategy implements TakeoverStrategy {
37
+ constructor(private cfg: Config) {}
38
+
39
+ async acquireWorkdir(session: OpSession, issue: Issue): Promise<string> {
40
+ if (session.workdir) {
41
+ let dir = session.workdir;
42
+ if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
43
+ dir = isAbsolute(dir) ? dir : resolve(this.cfg.opencode.baseWorkdir, dir);
44
+ mkdirSync(dir, { recursive: true });
45
+ return dir;
46
+ }
47
+ const parts = issue.trackerScopeKey.split("/");
48
+ const owner = issue.trackerScope["owner"] ?? parts[0] ?? "default";
49
+ const repo = issue.trackerScope["repo"] ?? parts[parts.length - 1] ?? "default";
50
+ const dir = join(
51
+ this.cfg.opencode.baseWorkdir,
52
+ `${owner}--${repo}`,
53
+ String(issue.trackerIssueId),
54
+ session.name,
55
+ );
56
+ mkdirSync(dir, { recursive: true });
57
+ try {
58
+ const entries = readdirSync(dir);
59
+ if (entries.length === 0) {
60
+ const base = this.cfg.gitea.url.replace(/\/$/, "");
61
+ const url = `${base}/${owner}/${repo}.git`;
62
+ Bun.spawnSync({ cmd: ["git", "clone", url, dir], stdout: "ignore", stderr: "ignore" });
63
+ }
64
+ } catch {
65
+ // directory access failed — leave it; the agent's own tools can clone
66
+ }
67
+ return dir;
68
+ }
69
+
70
+ async resumeOpenCodeSession(_session: OpSession): Promise<string | null> {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Extract the target name of an @mention from comment text.
77
+ *
78
+ * Strips fenced + inline code first (terminal pastes with "user@host" / "git@repo"),
79
+ * then matches `@name`. Rejects two phantom-mention shapes that previously spawned
80
+ * stray agent sessions (ework-daemon#2):
81
+ * - scoped package refs (`@types/node`, `@babel/core` — the trailing `/` means an
82
+ * npm path, not a person);
83
+ * - version-like `@<digits>` (`@123`).
84
+ *
85
+ * Exported so tests can pin the exact accept/reject behavior (regression coverage).
86
+ */
87
+ export function detectMention(text: string): string | null {
88
+ const stripped = text
89
+ .replace(/```[\s\S]*?```/g, "")
90
+ .replace(/`[^`\n]*`/g, "");
91
+ const re = /(?:^|\s)@([\w\u4e00-\u9fff]+)/g;
92
+ let m: RegExpExecArray | null;
93
+ while ((m = re.exec(stripped)) !== null) {
94
+ const name = m[1];
95
+ if (!name) continue;
96
+ if (/^\d+$/.test(name)) continue; // @<digits> → version ref, skip
97
+ if (stripped[re.lastIndex] === "/") continue; // @scope/pkg → scoped package, skip
98
+ return name;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Pick the most recently active session from a list: the one whose process last
105
+ * started (`startedAt`), falling back to creation time when a session has never
106
+ * run yet. Returns `undefined` for an empty list.
107
+ *
108
+ * Used by the no-mention dispatch path to route a comment to a single session
109
+ * (the "last AI") instead of broadcasting to all of them.
110
+ */
111
+ export function pickLastActive(sessions: OpSession[]): OpSession | undefined {
112
+ if (sessions.length === 0) return undefined;
113
+ return sessions.reduce((a, b) => {
114
+ const aT = a.startedAt ?? a.createdAt.getTime();
115
+ const bT = b.startedAt ?? b.createdAt.getTime();
116
+ return bT > aT ? b : a;
117
+ });
118
+ }
119
+
17
120
  // ─── Engine ───
18
121
 
122
+ export interface EngineOptions {
123
+ /** DB-allocated logical daemon id (from Store.registerDaemon). */
124
+ daemonId: number;
125
+ /** Workdir + session-resume strategy; defaults to RecloneStrategy. */
126
+ takeover?: TakeoverStrategy;
127
+ }
128
+
19
129
  export class Engine {
20
130
  private cfg: Config;
21
131
  private store: Store;
22
132
  private trackers: TrackerRegistry;
133
+ private readonly daemonId: number;
134
+ private readonly takeover: TakeoverStrategy;
135
+ private heartbeatTimer?: ReturnType<typeof setInterval>;
23
136
 
24
137
  // Runtime state keyed by session key (trackerType:scopeKey#issueId@sessionName)
25
138
  private processes = new Map<string, Subprocess<"ignore", "pipe", "pipe">>();
@@ -52,14 +165,52 @@ export class Engine {
52
165
  private static STUCK_THRESHOLD_MS = 30 * 60 * 1000;
53
166
  private static MAX_PROCESS_EXIT_NUDGE_ROUNDS = 1;
54
167
 
55
- constructor(cfg: Config, store: Store, trackers: TrackerRegistry) {
168
+ constructor(cfg: Config, store: Store, trackers: TrackerRegistry, opts: EngineOptions) {
56
169
  this.cfg = cfg;
57
170
  this.store = store;
58
171
  this.trackers = trackers;
172
+ this.daemonId = opts.daemonId;
173
+ this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
59
174
  this.startGlobalObserver();
60
175
  void this.recover();
61
176
  }
62
177
 
178
+ /** Start the lease heartbeat. Must be called once after registerDaemon. */
179
+ startHeartbeat(intervalMs: number): void {
180
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
181
+ this.heartbeatTimer = setInterval(() => {
182
+ this.store.heartbeat(this.daemonId).catch((e) => {
183
+ log.error(`engine: heartbeat failed for daemon ${this.daemonId}:`, (e as Error).message);
184
+ });
185
+ }, intervalMs);
186
+ }
187
+
188
+ stopHeartbeat(): void {
189
+ if (this.heartbeatTimer) {
190
+ clearInterval(this.heartbeatTimer);
191
+ this.heartbeatTimer = undefined;
192
+ }
193
+ }
194
+
195
+ getDaemonId(): number {
196
+ return this.daemonId;
197
+ }
198
+
199
+ /**
200
+ * Ensure this engine owns the issue before doing work on it. Returns true
201
+ * if we own it (either already, or just claimed). Returns false if another
202
+ * daemon won the claim — caller must skip.
203
+ */
204
+ private async ensureOwned(issue: Issue): Promise<boolean> {
205
+ if (issue.ownerDaemonId === this.daemonId) return true;
206
+ const won = await this.store.claimIssue(issue.id, this.daemonId);
207
+ if (!won) {
208
+ log.info(`engine: lost claim on issue ${issue.id} to another daemon (owner=${issue.ownerDaemonId})`);
209
+ return false;
210
+ }
211
+ return true;
212
+ }
213
+
63
214
  private get stuckThresholdMs(): number {
64
215
  return this.cfg.stuck?.thresholdMs ?? Engine.STUCK_THRESHOLD_MS;
65
216
  }
@@ -82,38 +233,43 @@ export class Engine {
82
233
  return { trackerType: issue.trackerType, scope: issue.trackerScope, issueId: issue.trackerIssueId };
83
234
  }
84
235
 
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);
236
+ private async resolveWorkdir(session: OpSession, issue: Issue): Promise<string> {
237
+ return this.takeover.acquireWorkdir(session, issue);
94
238
  }
95
239
 
96
- private persistRuntimeState(sessionId: string) {
97
- const session = this.store.getSession(sessionId);
240
+ private async persistRuntimeState(sessionId: string) {
241
+ const session = await this.store.getSession(sessionId);
98
242
  if (!session) return;
99
- const issue = this.store.getIssue(session.issueId);
243
+ const issue = await this.store.getIssue(session.issueId);
100
244
  if (!issue) return;
101
245
  const k = this.sessionKey(session, issue);
102
- this.store.updateSession(sessionId, {
246
+ await this.store.updateSession(sessionId, {
103
247
  startedAt: this.startedAt.get(k),
104
248
  progressCommentId: this.progressCommentId.get(k),
105
249
  currentPrompt: this.currentPrompt.get(k),
250
+ lastOutputAt: this.lastOutputAt.get(k),
251
+ nudgeRounds: this.nudgeRounds.get(k) ?? 0,
252
+ stuckNudgeRounds: this.stuckNudgeRounds.get(k) ?? 0,
253
+ generation: this.generation.get(k) ?? 0,
106
254
  });
107
255
  }
108
256
 
109
257
  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;
258
+ return detectMention(text);
259
+ }
260
+
261
+ /**
262
+ * Whitelist gate: default only the bot itself is a valid @mention target.
263
+ * Unknown names (misread from plain text — `@types/node`, etc.) don't spawn a
264
+ * new session; they fall back to broadcast. To run multiple named agents, set
265
+ * `DAEMON_ALLOWED_AGENTS=ework,tester,...`.
266
+ */
267
+ private isAllowedAgent(name: string): boolean {
268
+ const env = process.env.DAEMON_ALLOWED_AGENTS;
269
+ const allowed = env && env.trim()
270
+ ? env.split(",").map(s => s.trim()).filter(Boolean)
271
+ : [this.cfg.bot.username];
272
+ return allowed.includes(name);
117
273
  }
118
274
 
119
275
  private parseDirCommand(text: string): string | null {
@@ -181,33 +337,38 @@ export class Engine {
181
337
  model?: string,
182
338
  ) {
183
339
  // Create or find issue
184
- const issue = this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
340
+ const issue = await this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
185
341
  if (issue.state === "closed") {
186
342
  // Issue was closed before, now reopened
187
- this.store.updateIssueState(issue.id, "active");
343
+ await this.store.updateIssueState(issue.id, "active");
188
344
  issue.state = "active";
189
345
  } else if (issue.state === "created") {
190
- this.store.updateIssueState(issue.id, "active");
346
+ await this.store.updateIssueState(issue.id, "active");
191
347
  issue.state = "active";
192
348
  }
193
349
 
350
+ // Multi-machine: claim before doing work. If another daemon already owns
351
+ // this issue, skip — they will handle it.
352
+ if (!(await this.ensureOwned(issue))) return;
353
+ issue.ownerDaemonId = this.daemonId;
354
+
194
355
  // Start observer for this issue
195
356
  this.startObserver(issue);
196
357
 
197
358
  // Create default session for bot user
198
359
  const defaultSessionName = this.cfg.bot.username;
199
- let session = this.store.getSessionByName(issue.id, defaultSessionName);
360
+ let session = await this.store.getSessionByName(issue.id, defaultSessionName);
200
361
  if (session && session.state === "running") {
201
362
  log.info(`engine: duplicate issue_opened — session already running for ${scopeKey}#${ref.issueId}`);
202
363
  this.startObserver(issue);
203
364
  return;
204
365
  }
205
366
  if (!session) {
206
- session = this.store.createSession(issue.id, defaultSessionName);
367
+ session = await this.store.createSession(issue.id, defaultSessionName);
207
368
  }
208
369
 
209
370
  const k = this.sessionKey(session, issue);
210
- const workdir = this.resolveWorkdir(session, issue);
371
+ const workdir = await this.resolveWorkdir(session, issue);
211
372
  log.info(`engine: session "${session.name}" created for ${k}, workdir=${workdir}`);
212
373
 
213
374
  await tracker.createComment(ref, `[system] 🔄 **${session.name}** picked up this issue.\n> session: \`${session.id}\` | workdir: \`${workdir}\``);
@@ -218,7 +379,7 @@ export class Engine {
218
379
  this.handleLargeContent(workdir, issueData.body, "issue-body.txt"),
219
380
  issueData.author, workdir, instructions
220
381
  );
221
- this.enqueueOrRun(session, issue, prompt, undefined, model);
382
+ await this.enqueueOrRun(session, issue, prompt, undefined, model);
222
383
  }
223
384
 
224
385
  private async handleCommented(
@@ -239,7 +400,7 @@ export class Engine {
239
400
  if (issueData.state !== "open") return;
240
401
 
241
402
  if (comment.id) {
242
- if (this.processingComments.has(comment.id) || this.store.findMessageByCommentId(comment.id)) {
403
+ if (this.processingComments.has(comment.id) || await this.store.findMessageByCommentId(comment.id)) {
243
404
  log.info(`engine: duplicate comment ${comment.id}, skipping`);
244
405
  return;
245
406
  }
@@ -248,11 +409,11 @@ export class Engine {
248
409
 
249
410
  try {
250
411
  // Find issue
251
- let issue = this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
412
+ let issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
252
413
  if (!issue) {
253
414
  // Issue not tracked yet — auto-track it
254
- issue = this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
255
- this.store.updateIssueState(issue.id, "active");
415
+ issue = await this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
416
+ await this.store.updateIssueState(issue.id, "active");
256
417
  issue.state = "active";
257
418
  this.startObserver(issue);
258
419
  } else if (issue.state === "closed") {
@@ -260,20 +421,31 @@ export class Engine {
260
421
  return;
261
422
  }
262
423
 
424
+ // Multi-machine: claim before doing work.
425
+ if (!(await this.ensureOwned(issue))) return;
426
+ issue.ownerDaemonId = this.daemonId;
427
+
263
428
  const dirPath = this.parseDirCommand(comment.body);
264
- const mentionName = this.extractMentionName(comment.body);
429
+ const rawMention = this.extractMentionName(comment.body);
430
+ // Gate extracted @mentions through the agent whitelist. An unknown name
431
+ // (e.g. `@types` misread from `@types/node`) is treated as no mention and
432
+ // falls through to broadcast instead of spawning a phantom session (ework-daemon#2).
433
+ const mentionName = rawMention && this.isAllowedAgent(rawMention) ? rawMention : null;
434
+ if (rawMention && !mentionName) {
435
+ log.info(`engine: @${rawMention} is not an allowed agent — routing to last session instead of spawning`);
436
+ }
265
437
 
266
438
  if (mentionName) {
267
439
  // @mention → targeted delivery
268
- let session = this.store.getSessionByName(issue.id, mentionName);
440
+ let session = await this.store.getSessionByName(issue.id, mentionName);
269
441
 
270
442
  if (session) {
271
443
  // Forward to existing session
272
444
  if (dirPath) {
273
- this.store.updateSession(session.id, { workdir: dirPath });
445
+ await this.store.updateSession(session.id, { workdir: dirPath });
274
446
  session.workdir = dirPath;
275
447
  }
276
- const workdir = this.resolveWorkdir(session, issue);
448
+ const workdir = await this.resolveWorkdir(session, issue);
277
449
  const instructions = tracker.getTrackerInstructions(ref);
278
450
  const prompt = this.buildForwardPrompt(
279
451
  session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
@@ -283,15 +455,15 @@ export class Engine {
283
455
  // Immediate ack
284
456
  await tracker.createComment(ref, `[system] ✓ Message forwarded to **${session.name}**${this.running.has(this.sessionKey(session, issue)) ? " (running)" : ""}.\n> session: \`${session.id}\` | workdir: \`${workdir}\``);
285
457
 
286
- this.enqueueOrRun(session, issue, prompt, comment.id, model);
458
+ await this.enqueueOrRun(session, issue, prompt, comment.id, model);
287
459
  } else {
288
460
  // Create new session
289
- session = this.store.createSession(issue.id, mentionName);
461
+ session = await this.store.createSession(issue.id, mentionName);
290
462
  if (dirPath) {
291
- this.store.updateSession(session.id, { workdir: dirPath });
463
+ await this.store.updateSession(session.id, { workdir: dirPath });
292
464
  session.workdir = dirPath;
293
465
  }
294
- const workdir = this.resolveWorkdir(session, issue);
466
+ const workdir = await this.resolveWorkdir(session, issue);
295
467
 
296
468
  await tracker.createComment(ref, `[system] 🔄 **${session.name}** joined the conversation.`);
297
469
 
@@ -301,30 +473,30 @@ export class Engine {
301
473
  this.handleLargeContent(workdir, issueData.body, "issue-body.txt"),
302
474
  issueData.author, workdir, instructions
303
475
  );
304
- this.enqueueOrRun(session, issue, prompt, comment.id, model);
476
+ await this.enqueueOrRun(session, issue, prompt, comment.id, model);
305
477
  }
306
478
  } else {
307
- // No @mention → broadcast to all sessions on this issue
308
- const sessions = this.store.getSessionsForIssue(issue.id);
479
+ // No valid @mention → forward to the most recently active session only.
480
+ // Broadcasting to all sessions makes multiple AIs race on the same request;
481
+ // routing to the last-active lets the user continue without re-@mentioning,
482
+ // while @mention switches to a different AI.
483
+ const sessions = await this.store.getSessionsForIssue(issue.id);
309
484
  if (sessions.length === 0) return;
310
485
 
311
- for (const session of sessions) {
312
- if (dirPath) {
313
- this.store.updateSession(session.id, { workdir: dirPath });
314
- session.workdir = dirPath;
315
- }
316
- const workdir = this.resolveWorkdir(session, issue);
317
- const instructions = tracker.getTrackerInstructions(ref);
318
- const prompt = this.buildForwardPrompt(
319
- session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
320
- comment.author, issueData.title, workdir, instructions
321
- );
322
- this.enqueueOrRun(session, issue, prompt, comment.id, model);
486
+ const session = pickLastActive(sessions);
487
+ if (!session) return;
488
+ if (dirPath) {
489
+ await this.store.updateSession(session.id, { workdir: dirPath });
490
+ session.workdir = dirPath;
323
491
  }
324
-
325
- // Immediate ack
326
- const names = sessions.map(s => `**${s.name}** (\`${s.opencodeSessionId ?? s.id}\`, \`${this.resolveWorkdir(s, issue)}\`)`).join(", ");
327
- await tracker.createComment(ref, `[system] ✓ Message broadcasted to: ${names}.`);
492
+ const workdir = await this.resolveWorkdir(session, issue);
493
+ const instructions = tracker.getTrackerInstructions(ref);
494
+ const prompt = this.buildForwardPrompt(
495
+ session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
496
+ comment.author, issueData.title, workdir, instructions
497
+ );
498
+ await tracker.createComment(ref, `[system] ✓ Message forwarded to **${session.name}**${this.running.has(this.sessionKey(session, issue)) ? " (running)" : ""}.\n> session: \`${session.id}\` | workdir: \`${workdir}\``);
499
+ await this.enqueueOrRun(session, issue, prompt, comment.id, model);
328
500
  }
329
501
  } finally {
330
502
  if (comment.id) this.processingComments.delete(comment.id);
@@ -336,14 +508,14 @@ export class Engine {
336
508
  scopeKey: string,
337
509
  tracker: IssueTracker
338
510
  ) {
339
- const issue = this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
511
+ const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
340
512
  if (!issue) return;
341
513
 
342
- this.store.updateIssueState(issue.id, "closed");
514
+ await this.store.updateIssueState(issue.id, "closed");
343
515
  this.stopObserver(issue.id);
344
516
 
345
517
  // Kill all running processes for this issue's sessions
346
- const sessions = this.store.getSessionsForIssue(issue.id);
518
+ const sessions = await this.store.getSessionsForIssue(issue.id);
347
519
  for (const session of sessions) {
348
520
  const k = this.sessionKey(session, issue);
349
521
  const proc = this.processes.get(k);
@@ -354,14 +526,14 @@ export class Engine {
354
526
  // Clear runtime state
355
527
  this.clearRuntimeState(k);
356
528
  // Mark pending/running messages as interrupted
357
- const msgs = this.store.getMessagesForSession(session.id);
529
+ const msgs = await this.store.getMessagesForSession(session.id);
358
530
  for (const msg of msgs) {
359
531
  if (msg.status === "pending" || msg.status === "running") {
360
- this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
532
+ await this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
361
533
  }
362
534
  }
363
535
  // Update session state
364
- this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
536
+ await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
365
537
  }
366
538
 
367
539
  log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
@@ -384,32 +556,32 @@ export class Engine {
384
556
 
385
557
  // ─── Preemptive Scheduler ───
386
558
 
387
- private enqueueOrRun(session: OpSession, issue: Issue, prompt: string, sourceCommentId?: string, model?: string) {
559
+ private async enqueueOrRun(session: OpSession, issue: Issue, prompt: string, sourceCommentId?: string, model?: string) {
388
560
  const k = this.sessionKey(session, issue);
389
561
 
390
562
  this.stuckNudgeRounds.delete(k);
391
563
  this.processExitNudgeRounds.delete(k);
392
564
 
393
- const msg = this.store.createMessage(session.id, prompt, sourceCommentId, undefined, model);
565
+ const msg = await this.store.createMessage(session.id, prompt, sourceCommentId, undefined, model);
394
566
 
395
567
  if (this.running.has(k)) {
396
568
  // PREEMPTIVE: Kill running process, new message takes priority
397
569
  log.info(`engine: preempting ${k} with new message ${msg.id.slice(0, 8)}`);
398
- this.preemptSession(k, session, issue, msg);
570
+ await this.preemptSession(k, session, issue, msg);
399
571
  return;
400
572
  }
401
573
 
402
574
  // Not running — execute directly
403
- this.executeMessage(k, session, issue, msg);
575
+ await this.executeMessage(k, session, issue, msg);
404
576
  }
405
577
 
406
- private preemptSession(k: string, session: OpSession, issue: Issue, newMsg: Message) {
578
+ private async preemptSession(k: string, session: OpSession, issue: Issue, newMsg: Message) {
407
579
  const proc = this.processes.get(k);
408
580
  const oldMsgId = this.currentMessage.get(k);
409
581
 
410
582
  // Mark old message as interrupted
411
583
  if (oldMsgId) {
412
- this.store.updateMessageStatus(oldMsgId, "interrupted", "preempted by new message");
584
+ await this.store.updateMessageStatus(oldMsgId, "interrupted", "preempted by new message");
413
585
  }
414
586
 
415
587
  // Kill running process
@@ -426,19 +598,28 @@ export class Engine {
426
598
  this.startedAt.delete(k);
427
599
 
428
600
  // Execute new message
429
- this.executeMessage(k, session, issue, newMsg);
601
+ await this.executeMessage(k, session, issue, newMsg);
430
602
  }
431
603
 
432
- private executeMessage(k: string, session: OpSession, issue: Issue, msg: Message) {
604
+ private async executeMessage(k: string, session: OpSession, issue: Issue, msg: Message) {
433
605
  log.info(`engine: executing msg ${msg.id.slice(0, 8)} for ${k}`);
606
+
607
+ // Multi-machine: atomic message claim. Pending → running, only if we win.
608
+ // Locally-created messages always succeed (no contention); this gates the
609
+ // cross-daemon race when peer daemons share the session.
610
+ const won = await this.store.claimMessage(msg.id);
611
+ if (!won) {
612
+ log.info(`engine: lost message claim for ${msg.id.slice(0, 8)}, another daemon took it`);
613
+ return;
614
+ }
615
+
434
616
  this.running.add(k);
435
- this.store.updateMessageStatus(msg.id, "running");
436
617
  this.currentMessage.set(k, msg.id);
437
618
 
438
619
  // Update session state
439
- this.store.updateSession(session.id, { state: "running" });
620
+ await this.store.updateSession(session.id, { state: "running" });
440
621
 
441
- this.execProcess(k, session, issue, msg);
622
+ void this.execProcess(k, session, issue, msg);
442
623
  }
443
624
 
444
625
  // ─── Process Manager ───
@@ -447,15 +628,22 @@ export class Engine {
447
628
  const gen = (this.generation.get(k) ?? 0) + 1;
448
629
  this.generation.set(k, gen);
449
630
 
450
- const workdir = this.resolveWorkdir(session, issue);
451
- mkdirSync(workdir, { recursive: true });
631
+ const workdir = await this.resolveWorkdir(session, issue);
452
632
 
453
633
  const ref = this.sessionToRef(session, issue);
454
634
  const tracker = this.getTracker(issue.trackerType);
455
635
 
456
636
  const args = [this.cfg.opencode.binary, "run", "--format", "json", "--dir", workdir];
457
- if (session.opencodeSessionId) {
458
- args.push("--session", session.opencodeSessionId);
637
+ // Prefer the captured session id (resume our own previous run). Otherwise
638
+ // ask the takeover strategy whether a resumable session exists elsewhere
639
+ // (e.g. NAS-backed). Default strategy returns null → fresh session.
640
+ let resumeSessionId = session.opencodeSessionId;
641
+ if (!resumeSessionId) {
642
+ const fromStrategy = await this.takeover.resumeOpenCodeSession(session);
643
+ if (fromStrategy) resumeSessionId = fromStrategy;
644
+ }
645
+ if (resumeSessionId) {
646
+ args.push("--session", resumeSessionId);
459
647
  }
460
648
  // Push --model BEFORE the message content. Defends against env-var-
461
649
  // registered providers stealing the slot (the original bug). Empty/
@@ -502,8 +690,8 @@ export class Engine {
502
690
  this.currentPrompt.set(k, msg.content);
503
691
 
504
692
  // Persist PID for crash recovery
505
- this.store.updateSession(session.id, { opencodePid: proc.pid });
506
- this.persistRuntimeState(session.id);
693
+ await this.store.updateSession(session.id, { opencodePid: proc.pid });
694
+ await this.persistRuntimeState(session.id);
507
695
 
508
696
  log.info(`engine: spawned pid=${proc.pid} for ${k}`);
509
697
 
@@ -534,7 +722,7 @@ export class Engine {
534
722
  // Persist now, not at exit: a preempt/crash before exit must
535
723
  // not lose the ID, otherwise the re-run opens a fresh session.
536
724
  if (!session.opencodeSessionId) {
537
- this.store.updateSession(session.id, { opencodeSessionId: sid });
725
+ await this.store.updateSession(session.id, { opencodeSessionId: sid });
538
726
  session.opencodeSessionId = sid;
539
727
  log.info(`engine: captured sessionID=${sid.slice(0, 8)} for ${k} (early persist)`);
540
728
  }
@@ -557,26 +745,26 @@ export class Engine {
557
745
 
558
746
  this.processes.delete(k);
559
747
  this.lastOutputAt.delete(k);
560
- this.store.updateSession(session.id, { opencodePid: undefined });
748
+ await this.store.updateSession(session.id, { opencodePid: undefined });
561
749
 
562
750
  if (exitCode !== 0) {
563
751
  log.error(`engine: pid=${proc.pid} exited ${exitCode} for ${k}`);
564
752
  log.error(` stderr: ${stderr.slice(0, 2000)}`);
565
- this.store.updateMessageStatus(msg.id, "failed", `exit ${exitCode}: ${stderr.slice(0, 500)}`);
753
+ await this.store.updateMessageStatus(msg.id, "failed", `exit ${exitCode}: ${stderr.slice(0, 500)}`);
566
754
  } else {
567
755
  log.info(`engine: pid=${proc.pid} completed for ${k}`);
568
756
  if (stderr) log.warn(`engine: pid=${proc.pid} stderr on exit 0: ${stderr.slice(0, 500)}`);
569
757
  if (!opencodeSessionId) log.warn(`engine: pid=${proc.pid} produced NO sessionID (no stdout output)`);
570
- this.store.updateMessageStatus(msg.id, "done");
758
+ await this.store.updateMessageStatus(msg.id, "done");
571
759
  }
572
760
 
573
761
  // Save opencode session ID for continuity
574
762
  if (opencodeSessionId && !session.opencodeSessionId) {
575
- this.store.updateSession(session.id, { opencodeSessionId });
763
+ await this.store.updateSession(session.id, { opencodeSessionId });
576
764
  }
577
765
  } catch (err) {
578
766
  log.error(`engine: exec failed for ${k}:`, err);
579
- this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
767
+ await this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
580
768
  }
581
769
 
582
770
  await this.finishRun(k, session, issue, exitCode, gen);
@@ -626,14 +814,14 @@ export class Engine {
626
814
  }
627
815
  this.progressCommentId.delete(k);
628
816
  this.currentPrompt.delete(k);
629
- this.persistRuntimeState(session.id);
817
+ await this.persistRuntimeState(session.id);
630
818
 
631
819
 
632
820
  // spawn failed (exitCode === null) → skip completion check
633
821
  if (exitCode === null) {
634
822
  log.info(`engine: spawn failed for ${k}, skipping completion check`);
635
- this.store.updateSession(session.id, { opencodePid: undefined });
636
- this.deactivateIfIdle(k, session, issue);
823
+ await this.store.updateSession(session.id, { opencodePid: undefined });
824
+ await this.deactivateIfIdle(k, session, issue);
637
825
  return;
638
826
  }
639
827
  if (superseded()) {
@@ -652,19 +840,19 @@ export class Engine {
652
840
  if (hasRecent) {
653
841
  log.info(`engine: recent [bot] reply found for ${k}, marking done`);
654
842
  this.nudgeRounds.delete(k);
655
- this.persistRuntimeState(session.id);
843
+ await this.persistRuntimeState(session.id);
656
844
  } else {
657
845
  const nudgeRound = this.nudgeRounds.get(k) ?? 0;
658
846
  if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
659
847
  log.info(`engine: no recent [bot] reply for ${k}, nudging (round ${nudgeRound + 1}/${Engine.MAX_NUDGE_ROUNDS})`);
660
848
  this.nudgeRounds.set(k, nudgeRound + 1);
661
849
  this.currentPrompt.delete(k);
662
- this.persistRuntimeState(session.id);
850
+ await this.persistRuntimeState(session.id);
663
851
 
664
852
  const instructions = tracker.getTrackerInstructions(ref);
665
853
  const nudgePrompt = this.buildNudgePrompt(session, issue, instructions);
666
- const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
667
- this.dequeueOrIdle(k, session, issue, nudgeMsg);
854
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt);
855
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
668
856
  return;
669
857
  }
670
858
  log.info(`engine: no recent [bot] reply for ${k}, marking done (nudge exhausted or process failed)`);
@@ -674,7 +862,7 @@ export class Engine {
674
862
  }
675
863
 
676
864
  // Remove eyes on source comment; react +1/-1 on the bot's last reply (fallback: source)
677
- const recentMsgs = this.store.getRecentMessages(session.id, 1);
865
+ const recentMsgs = await this.store.getRecentMessages(session.id, 1);
678
866
  const lastMsg = recentMsgs[0];
679
867
  if (lastMsg?.sourceCommentId) {
680
868
  // Check if any other session is still running on this issue
@@ -695,30 +883,30 @@ export class Engine {
695
883
  log.info(`engine: finishRun aborted (superseded) for ${k}`);
696
884
  return;
697
885
  }
698
- this.deactivateIfIdle(k, session, issue);
886
+ await this.deactivateIfIdle(k, session, issue);
699
887
  }
700
888
 
701
- private deactivateIfIdle(k: string, session: OpSession, issue: Issue) {
702
- const nextMsg = this.store.getNextPendingMessage(session.id);
889
+ private async deactivateIfIdle(k: string, session: OpSession, issue: Issue) {
890
+ const nextMsg = await this.store.getNextPendingMessage(session.id);
703
891
  if (nextMsg) {
704
- const current = this.store.getSession(session.id);
892
+ const current = await this.store.getSession(session.id);
705
893
  if (current && current.state !== "idle") {
706
- this.dequeueOrIdle(k, current, issue, nextMsg);
894
+ await this.dequeueOrIdle(k, current, issue, nextMsg);
707
895
  return;
708
896
  }
709
897
  }
710
898
 
711
899
  this.clearRuntimeState(k);
712
- this.store.updateSession(session.id, { state: "idle" });
900
+ await this.store.updateSession(session.id, { state: "idle" });
713
901
  }
714
902
 
715
- private dequeueOrIdle(k: string, session: OpSession, issue: Issue, msg: Message) {
903
+ private async dequeueOrIdle(k: string, session: OpSession, issue: Issue, msg: Message) {
716
904
  log.info(`engine: running dequeued msg ${msg.id.slice(0, 8)} for ${k}`);
717
- this.store.updateMessageStatus(msg.id, "running");
905
+ await this.store.updateMessageStatus(msg.id, "running");
718
906
  this.running.add(k);
719
907
  this.currentMessage.set(k, msg.id);
720
- this.store.updateSession(session.id, { state: "running" });
721
- this.execProcess(k, session, issue, msg);
908
+ await this.store.updateSession(session.id, { state: "running" });
909
+ void this.execProcess(k, session, issue, msg);
722
910
  }
723
911
 
724
912
  // ─── Prompts ───
@@ -858,10 +1046,18 @@ export class Engine {
858
1046
  }
859
1047
 
860
1048
  private async runObserverCycle() {
861
- const activeIssues = this.store.listActiveIssues();
1049
+ // Multi-machine: periodically release stale owners so we can adopt their
1050
+ // work, and only iterate issues/sessions this daemon owns.
1051
+ try {
1052
+ await this.store.releaseDeadOwners(this.cfg.work.leaseTtlMs);
1053
+ } catch (err) {
1054
+ log.error("engine: releaseDeadOwners failed:", (err as Error).message);
1055
+ }
862
1056
 
863
- for (const issue of activeIssues) {
864
- if (!this.observedIssues.has(issue.id)) continue;
1057
+ const ownedIssues = (await this.store.listOwnedIssues(this.daemonId))
1058
+ .filter((i) => this.observedIssues.has(i.id));
1059
+
1060
+ for (const issue of ownedIssues) {
865
1061
  try {
866
1062
  await this.observeIssue(issue);
867
1063
  } catch (err) {
@@ -872,7 +1068,7 @@ export class Engine {
872
1068
 
873
1069
  private async observeIssue(issue: Issue) {
874
1070
  const tracker = this.getTracker(issue.trackerType);
875
- const sessions = this.store.getSessionsForIssue(issue.id);
1071
+ const sessions = await this.store.getSessionsForIssue(issue.id);
876
1072
 
877
1073
  for (const session of sessions) {
878
1074
  if (session.state !== "running") continue;
@@ -892,12 +1088,12 @@ export class Engine {
892
1088
  this.processes.delete(k);
893
1089
  this.lastOutputAt.delete(k);
894
1090
  this.running.delete(k);
895
- this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
1091
+ await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
896
1092
 
897
1093
  // Mark running message as failed
898
1094
  const msgId = this.currentMessage.get(k);
899
1095
  if (msgId) {
900
- this.store.updateMessageStatus(msgId, "failed", "process died unexpectedly");
1096
+ await this.store.updateMessageStatus(msgId, "failed", "process died unexpectedly");
901
1097
  this.currentMessage.delete(k);
902
1098
  }
903
1099
 
@@ -917,8 +1113,8 @@ export class Engine {
917
1113
 
918
1114
  const instructions = tracker.getTrackerInstructions(ref);
919
1115
  const nudgePrompt = this.buildProcessExitNudgePrompt(session, issue, instructions);
920
- const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
921
- this.dequeueOrIdle(k, session, issue, nudgeMsg);
1116
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt);
1117
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
922
1118
  continue;
923
1119
  } else {
924
1120
  // AI already replied before dying — no need to nudge, but user must be
@@ -937,9 +1133,9 @@ export class Engine {
937
1133
  }
938
1134
 
939
1135
  // Try to dequeue next message
940
- const nextMsg = this.store.getNextPendingMessage(session.id);
1136
+ const nextMsg = await this.store.getNextPendingMessage(session.id);
941
1137
  if (nextMsg) {
942
- this.dequeueOrIdle(k, session, issue, nextMsg);
1138
+ await this.dequeueOrIdle(k, session, issue, nextMsg);
943
1139
  }
944
1140
  continue;
945
1141
  }
@@ -961,8 +1157,8 @@ export class Engine {
961
1157
 
962
1158
  const instructions = tracker.getTrackerInstructions(ref);
963
1159
  const nudgePrompt = this.buildStuckNudgePrompt(session, issue, instructions, minutes);
964
- const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
965
- this.dequeueOrIdle(k, session, issue, nudgeMsg);
1160
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt);
1161
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
966
1162
  } else {
967
1163
  log.warn(`engine: stuck for ${minutes}min on ${k}, stuck nudge exhausted (${stuckNudgeRound}/${this.maxStuckNudges}), giving up`);
968
1164
  await tracker.createComment(ref, `[system] ⛔ **${session.name}** stuck for ${minutes} min, gave up after ${this.maxStuckNudges} restart(s).`).catch(
@@ -975,7 +1171,7 @@ export class Engine {
975
1171
  } else if (session.state === "running" && !this.running.has(k)) {
976
1172
  log.warn(`engine: observer fixing orphaned running state for ${k}`);
977
1173
  this.running.delete(k);
978
- this.store.updateSession(session.id, { state: "idle" });
1174
+ await this.store.updateSession(session.id, { state: "idle" });
979
1175
  }
980
1176
  }
981
1177
 
@@ -997,7 +1193,7 @@ export class Engine {
997
1193
  } else {
998
1194
  const result = await tracker.createComment(ref, body);
999
1195
  this.progressCommentId.set(k, result.id);
1000
- this.persistRuntimeState(session.id);
1196
+ await this.persistRuntimeState(session.id);
1001
1197
  }
1002
1198
  } catch (err) {
1003
1199
  log.error(`engine: progress report failed for ${k}:`, (err as Error).message);
@@ -1011,8 +1207,20 @@ export class Engine {
1011
1207
  // ─── Recovery ───
1012
1208
 
1013
1209
  private async recover() {
1014
- const allSessions = this.store.listAllSessions();
1015
- for (const session of allSessions) {
1210
+ // Release stale owners first so we can adopt orphaned issues that just
1211
+ // became available (this daemon is fresh; any dead daemon's slots are now
1212
+ // reclaimable).
1213
+ try {
1214
+ await this.store.releaseDeadOwners(this.cfg.work.leaseTtlMs);
1215
+ } catch (err) {
1216
+ log.error("engine: releaseDeadOwners at boot failed:", (err as Error).message);
1217
+ }
1218
+
1219
+ // Multi-machine: recover ONLY this daemon's sessions. Other daemons own
1220
+ // the rest; touching their state would race them.
1221
+ const ownedSessions = await this.store.listOwnedSessions(this.daemonId);
1222
+
1223
+ for (const session of ownedSessions) {
1016
1224
  if (session.opencodePid) {
1017
1225
  try {
1018
1226
  process.kill(session.opencodePid, 0);
@@ -1028,19 +1236,23 @@ export class Engine {
1028
1236
  try { process.kill(session.opencodePid, "SIGKILL"); } catch { /* dead */ }
1029
1237
  }
1030
1238
  } catch { /* already dead */ }
1031
- this.store.updateSession(session.id, { opencodePid: undefined });
1239
+ await this.store.updateSession(session.id, { opencodePid: undefined });
1032
1240
  }
1033
1241
  }
1034
1242
 
1035
- // Restore runtime state from DB
1036
- for (const session of allSessions) {
1037
- const issue = this.store.getIssue(session.issueId);
1243
+ // Restore runtime state (now persisted in op_sessions) from DB.
1244
+ for (const session of ownedSessions) {
1245
+ const issue = await this.store.getIssue(session.issueId);
1038
1246
  if (!issue || issue.state === "closed") continue;
1039
1247
  const k = this.sessionKey(session, issue);
1040
1248
  if (session.startedAt != null) this.startedAt.set(k, session.startedAt);
1041
1249
  if (session.progressCommentId) this.progressCommentId.set(k, session.progressCommentId);
1042
1250
  if (session.currentPrompt) this.currentPrompt.set(k, session.currentPrompt);
1043
- // Start observer for active issues
1251
+ if (session.lastOutputAt != null) this.lastOutputAt.set(k, session.lastOutputAt);
1252
+ if (session.nudgeRounds != null) this.nudgeRounds.set(k, session.nudgeRounds);
1253
+ if (session.stuckNudgeRounds != null) this.stuckNudgeRounds.set(k, session.stuckNudgeRounds);
1254
+ if (session.generation != null) this.generation.set(k, session.generation);
1255
+ // Start observer for active issues we own
1044
1256
  this.startObserver(issue);
1045
1257
  }
1046
1258
 
@@ -1049,8 +1261,8 @@ export class Engine {
1049
1261
  log.info(`engine: restored runtime state for ${restored} sessions from DB`);
1050
1262
  }
1051
1263
 
1052
- // Recover stuck messages
1053
- const stuck = this.store.getPendingOrRunningMessages();
1264
+ // Recover stuck messages scoped to this daemon's issues.
1265
+ const stuck = await this.store.getOwnedPendingOrRunningMessages(this.daemonId);
1054
1266
  if (stuck.length === 0) return;
1055
1267
 
1056
1268
  log.info(`engine: recovering ${stuck.length} stuck messages`);
@@ -1058,7 +1270,7 @@ export class Engine {
1058
1270
  // Reset running messages to pending
1059
1271
  for (const msg of stuck) {
1060
1272
  if (msg.status === "running") {
1061
- this.store.updateMessageStatus(msg.id, "pending");
1273
+ await this.store.updateMessageStatus(msg.id, "pending");
1062
1274
  }
1063
1275
  }
1064
1276
 
@@ -1071,9 +1283,9 @@ export class Engine {
1071
1283
  }
1072
1284
 
1073
1285
  for (const [sessionId, msgs] of bySession) {
1074
- const session = this.store.getSession(sessionId);
1286
+ const session = await this.store.getSession(sessionId);
1075
1287
  if (!session) continue;
1076
- const issue = this.store.getIssue(session.issueId);
1288
+ const issue = await this.store.getIssue(session.issueId);
1077
1289
  if (!issue || issue.state === "closed") continue;
1078
1290
 
1079
1291
  const k = this.sessionKey(session, issue);
@@ -1087,53 +1299,54 @@ export class Engine {
1087
1299
  const comments = await tracker.listComments(ref).catch((): TrackerComment[] => []);
1088
1300
  if (this.hasRecentBotReply(comments, tracker)) {
1089
1301
  log.info(`engine: recovered msg ${first.id.slice(0, 8)} for ${k} — bot reply detected, marking done`);
1090
- this.store.updateMessageStatus(first.id, "done");
1091
- const next = this.store.getNextPendingMessage(session.id);
1092
- if (next) { this.dequeueOrIdle(k, session, issue, next); }
1302
+ await this.store.updateMessageStatus(first.id, "done");
1303
+ const next = await this.store.getNextPendingMessage(session.id);
1304
+ if (next) { await this.dequeueOrIdle(k, session, issue, next); }
1093
1305
  continue;
1094
1306
  }
1095
1307
 
1096
1308
  log.info(`engine: recovering msg ${first.id.slice(0, 8)} for ${k}`);
1097
- this.dequeueOrIdle(k, session, issue, first);
1309
+ await this.dequeueOrIdle(k, session, issue, first);
1098
1310
  }
1099
1311
  }
1100
1312
 
1101
1313
  // ─── API Methods ───
1102
1314
 
1103
- retryMessage(messageId: string): boolean {
1104
- const msg = this.store.getMessage(messageId);
1315
+ async retryMessage(messageId: string): Promise<boolean> {
1316
+ const msg = await this.store.getMessage(messageId);
1105
1317
  if (!msg || msg.status !== "failed") return false;
1106
- const session = this.store.getSession(msg.sessionId);
1318
+ const session = await this.store.getSession(msg.sessionId);
1107
1319
  if (!session) return false;
1108
- const issue = this.store.getIssue(session.issueId);
1320
+ const issue = await this.store.getIssue(session.issueId);
1109
1321
  if (!issue || issue.state === "closed") return false;
1110
1322
 
1111
- this.store.updateMessageStatus(messageId, "pending");
1323
+ await this.store.updateMessageStatus(messageId, "pending");
1112
1324
  const k = this.sessionKey(session, issue);
1113
1325
  if (!this.running.has(k)) {
1114
- this.dequeueOrIdle(k, session, issue, msg);
1326
+ await this.dequeueOrIdle(k, session, issue, msg);
1115
1327
  }
1116
1328
  return true;
1117
1329
  }
1118
1330
 
1119
- getStatus() {
1120
- const pendingCount = this.store.getPendingOrRunningMessages().filter(m => m.status === "pending").length;
1331
+ async getStatus() {
1332
+ const pendingCount = (await this.store.getOwnedPendingOrRunningMessages(this.daemonId)).filter(m => m.status === "pending").length;
1121
1333
  return {
1122
1334
  runningCount: this.running.size,
1123
1335
  runningKeys: [...this.running],
1124
1336
  pendingCount,
1125
1337
  processCount: this.processes.size,
1126
1338
  observedIssues: this.observedIssues.size,
1339
+ daemonId: this.daemonId,
1127
1340
  };
1128
1341
  }
1129
1342
 
1130
- getQueue(): Record<string, number> {
1343
+ async getQueue(): Promise<Record<string, number>> {
1131
1344
  const result: Record<string, number> = {};
1132
- const allPending = this.store.getPendingOrRunningMessages().filter(m => m.status === "pending");
1345
+ const allPending = (await this.store.getOwnedPendingOrRunningMessages(this.daemonId)).filter(m => m.status === "pending");
1133
1346
  for (const msg of allPending) {
1134
- const session = this.store.getSession(msg.sessionId);
1347
+ const session = await this.store.getSession(msg.sessionId);
1135
1348
  if (!session) continue;
1136
- const issue = this.store.getIssue(session.issueId);
1349
+ const issue = await this.store.getIssue(session.issueId);
1137
1350
  if (!issue) continue;
1138
1351
  const k = this.sessionKey(session, issue);
1139
1352
  result[k] = (result[k] ?? 0) + 1;
@@ -1164,7 +1377,7 @@ export class Engine {
1164
1377
  try { process.kill(pid, signal); } catch { /* already dead */ }
1165
1378
  }
1166
1379
 
1167
- forceStop(key: string): boolean {
1380
+ async forceStop(key: string): Promise<boolean> {
1168
1381
  const proc = this.processes.get(key);
1169
1382
  this.stopping.add(key);
1170
1383
  log.warn(`engine: forceStop ${key}, pid=${proc?.pid ?? "none"}`);
@@ -1188,22 +1401,22 @@ export class Engine {
1188
1401
  // Update session and messages
1189
1402
  const parsed = parseKey(key);
1190
1403
  if (parsed) {
1191
- const issue = this.store.findIssue(parsed.trackerType, parsed.scopeKey, parsed.issueId);
1404
+ const issue = await this.store.findIssue(parsed.trackerType, parsed.scopeKey, parsed.issueId);
1192
1405
  if (issue) {
1193
- const session = this.store.getSessionByName(issue.id, parsed.sessionName);
1406
+ const session = await this.store.getSessionByName(issue.id, parsed.sessionName);
1194
1407
  if (session) {
1195
1408
  if (progressId) {
1196
1409
  const ref = this.sessionToRef(session, issue);
1197
1410
  const tracker = this.getTracker(issue.trackerType);
1198
1411
  void tracker.editComment(ref, progressId, `[system] ⛔ **${session.name}** force-stopped.`).catch(() => {});
1199
1412
  }
1200
- const msgs = this.store.getMessagesForSession(session.id);
1413
+ const msgs = await this.store.getMessagesForSession(session.id);
1201
1414
  for (const msg of msgs) {
1202
1415
  if (msg.status === "pending" || msg.status === "running") {
1203
- this.store.updateMessageStatus(msg.id, "failed", "force stopped");
1416
+ await this.store.updateMessageStatus(msg.id, "failed", "force stopped");
1204
1417
  }
1205
1418
  }
1206
- this.store.updateSession(session.id, {
1419
+ await this.store.updateSession(session.id, {
1207
1420
  state: "idle",
1208
1421
  opencodePid: undefined,
1209
1422
  startedAt: undefined,
@@ -1218,6 +1431,7 @@ export class Engine {
1218
1431
  }
1219
1432
 
1220
1433
  destroy() {
1434
+ this.stopHeartbeat();
1221
1435
  if (this.observerTimer) clearInterval(this.observerTimer);
1222
1436
  this.observedIssues.clear();
1223
1437
  for (const [, proc] of this.processes) {