ework-daemon 0.1.3 → 0.2.1

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,6 +14,64 @@ 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
+
17
75
  /**
18
76
  * Extract the target name of an @mention from comment text.
19
77
  *
@@ -61,10 +119,20 @@ export function pickLastActive(sessions: OpSession[]): OpSession | undefined {
61
119
 
62
120
  // ─── Engine ───
63
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
+
64
129
  export class Engine {
65
130
  private cfg: Config;
66
131
  private store: Store;
67
132
  private trackers: TrackerRegistry;
133
+ private readonly daemonId: number;
134
+ private readonly takeover: TakeoverStrategy;
135
+ private heartbeatTimer?: ReturnType<typeof setInterval>;
68
136
 
69
137
  // Runtime state keyed by session key (trackerType:scopeKey#issueId@sessionName)
70
138
  private processes = new Map<string, Subprocess<"ignore", "pipe", "pipe">>();
@@ -97,14 +165,52 @@ export class Engine {
97
165
  private static STUCK_THRESHOLD_MS = 30 * 60 * 1000;
98
166
  private static MAX_PROCESS_EXIT_NUDGE_ROUNDS = 1;
99
167
 
100
- constructor(cfg: Config, store: Store, trackers: TrackerRegistry) {
168
+ constructor(cfg: Config, store: Store, trackers: TrackerRegistry, opts: EngineOptions) {
101
169
  this.cfg = cfg;
102
170
  this.store = store;
103
171
  this.trackers = trackers;
172
+ this.daemonId = opts.daemonId;
173
+ this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
104
174
  this.startGlobalObserver();
105
175
  void this.recover();
106
176
  }
107
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
+
108
214
  private get stuckThresholdMs(): number {
109
215
  return this.cfg.stuck?.thresholdMs ?? Engine.STUCK_THRESHOLD_MS;
110
216
  }
@@ -127,27 +233,24 @@ export class Engine {
127
233
  return { trackerType: issue.trackerType, scope: issue.trackerScope, issueId: issue.trackerIssueId };
128
234
  }
129
235
 
130
- private resolveWorkdir(session: OpSession, issue: Issue): string {
131
- if (session.workdir) {
132
- let dir = session.workdir;
133
- if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
134
- return isAbsolute(dir) ? dir : resolve(this.cfg.opencode.baseWorkdir, dir);
135
- }
136
- // Fallback: use repo name from scope
137
- const repoName = issue.trackerScope["repo"] ?? issue.trackerScopeKey.split("/").pop() ?? "default";
138
- return join(this.cfg.opencode.baseWorkdir, repoName);
236
+ private async resolveWorkdir(session: OpSession, issue: Issue): Promise<string> {
237
+ return this.takeover.acquireWorkdir(session, issue);
139
238
  }
140
239
 
141
- private persistRuntimeState(sessionId: string) {
142
- const session = this.store.getSession(sessionId);
240
+ private async persistRuntimeState(sessionId: string) {
241
+ const session = await this.store.getSession(sessionId);
143
242
  if (!session) return;
144
- const issue = this.store.getIssue(session.issueId);
243
+ const issue = await this.store.getIssue(session.issueId);
145
244
  if (!issue) return;
146
245
  const k = this.sessionKey(session, issue);
147
- this.store.updateSession(sessionId, {
246
+ await this.store.updateSession(sessionId, {
148
247
  startedAt: this.startedAt.get(k),
149
248
  progressCommentId: this.progressCommentId.get(k),
150
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,
151
254
  });
152
255
  }
153
256
 
@@ -234,33 +337,38 @@ export class Engine {
234
337
  model?: string,
235
338
  ) {
236
339
  // Create or find issue
237
- const issue = this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
340
+ const issue = await this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
238
341
  if (issue.state === "closed") {
239
342
  // Issue was closed before, now reopened
240
- this.store.updateIssueState(issue.id, "active");
343
+ await this.store.updateIssueState(issue.id, "active");
241
344
  issue.state = "active";
242
345
  } else if (issue.state === "created") {
243
- this.store.updateIssueState(issue.id, "active");
346
+ await this.store.updateIssueState(issue.id, "active");
244
347
  issue.state = "active";
245
348
  }
246
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
+
247
355
  // Start observer for this issue
248
356
  this.startObserver(issue);
249
357
 
250
358
  // Create default session for bot user
251
359
  const defaultSessionName = this.cfg.bot.username;
252
- let session = this.store.getSessionByName(issue.id, defaultSessionName);
360
+ let session = await this.store.getSessionByName(issue.id, defaultSessionName);
253
361
  if (session && session.state === "running") {
254
362
  log.info(`engine: duplicate issue_opened — session already running for ${scopeKey}#${ref.issueId}`);
255
363
  this.startObserver(issue);
256
364
  return;
257
365
  }
258
366
  if (!session) {
259
- session = this.store.createSession(issue.id, defaultSessionName);
367
+ session = await this.store.createSession(issue.id, defaultSessionName);
260
368
  }
261
369
 
262
370
  const k = this.sessionKey(session, issue);
263
- const workdir = this.resolveWorkdir(session, issue);
371
+ const workdir = await this.resolveWorkdir(session, issue);
264
372
  log.info(`engine: session "${session.name}" created for ${k}, workdir=${workdir}`);
265
373
 
266
374
  await tracker.createComment(ref, `[system] 🔄 **${session.name}** picked up this issue.\n> session: \`${session.id}\` | workdir: \`${workdir}\``);
@@ -271,7 +379,7 @@ export class Engine {
271
379
  this.handleLargeContent(workdir, issueData.body, "issue-body.txt"),
272
380
  issueData.author, workdir, instructions
273
381
  );
274
- this.enqueueOrRun(session, issue, prompt, undefined, model);
382
+ await this.enqueueOrRun(session, issue, prompt, undefined, model);
275
383
  }
276
384
 
277
385
  private async handleCommented(
@@ -292,7 +400,7 @@ export class Engine {
292
400
  if (issueData.state !== "open") return;
293
401
 
294
402
  if (comment.id) {
295
- if (this.processingComments.has(comment.id) || this.store.findMessageByCommentId(comment.id)) {
403
+ if (this.processingComments.has(comment.id) || await this.store.findMessageByCommentId(comment.id)) {
296
404
  log.info(`engine: duplicate comment ${comment.id}, skipping`);
297
405
  return;
298
406
  }
@@ -301,11 +409,11 @@ export class Engine {
301
409
 
302
410
  try {
303
411
  // Find issue
304
- let issue = this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
412
+ let issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
305
413
  if (!issue) {
306
414
  // Issue not tracked yet — auto-track it
307
- issue = this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
308
- this.store.updateIssueState(issue.id, "active");
415
+ issue = await this.store.findOrCreateIssue(ref, scopeKey, issueData.title);
416
+ await this.store.updateIssueState(issue.id, "active");
309
417
  issue.state = "active";
310
418
  this.startObserver(issue);
311
419
  } else if (issue.state === "closed") {
@@ -313,6 +421,10 @@ export class Engine {
313
421
  return;
314
422
  }
315
423
 
424
+ // Multi-machine: claim before doing work.
425
+ if (!(await this.ensureOwned(issue))) return;
426
+ issue.ownerDaemonId = this.daemonId;
427
+
316
428
  const dirPath = this.parseDirCommand(comment.body);
317
429
  const rawMention = this.extractMentionName(comment.body);
318
430
  // Gate extracted @mentions through the agent whitelist. An unknown name
@@ -325,15 +437,15 @@ export class Engine {
325
437
 
326
438
  if (mentionName) {
327
439
  // @mention → targeted delivery
328
- let session = this.store.getSessionByName(issue.id, mentionName);
440
+ let session = await this.store.getSessionByName(issue.id, mentionName);
329
441
 
330
442
  if (session) {
331
443
  // Forward to existing session
332
444
  if (dirPath) {
333
- this.store.updateSession(session.id, { workdir: dirPath });
445
+ await this.store.updateSession(session.id, { workdir: dirPath });
334
446
  session.workdir = dirPath;
335
447
  }
336
- const workdir = this.resolveWorkdir(session, issue);
448
+ const workdir = await this.resolveWorkdir(session, issue);
337
449
  const instructions = tracker.getTrackerInstructions(ref);
338
450
  const prompt = this.buildForwardPrompt(
339
451
  session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
@@ -343,15 +455,15 @@ export class Engine {
343
455
  // Immediate ack
344
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}\``);
345
457
 
346
- this.enqueueOrRun(session, issue, prompt, comment.id, model);
458
+ await this.enqueueOrRun(session, issue, prompt, comment.id, model);
347
459
  } else {
348
460
  // Create new session
349
- session = this.store.createSession(issue.id, mentionName);
461
+ session = await this.store.createSession(issue.id, mentionName);
350
462
  if (dirPath) {
351
- this.store.updateSession(session.id, { workdir: dirPath });
463
+ await this.store.updateSession(session.id, { workdir: dirPath });
352
464
  session.workdir = dirPath;
353
465
  }
354
- const workdir = this.resolveWorkdir(session, issue);
466
+ const workdir = await this.resolveWorkdir(session, issue);
355
467
 
356
468
  await tracker.createComment(ref, `[system] 🔄 **${session.name}** joined the conversation.`);
357
469
 
@@ -361,30 +473,35 @@ export class Engine {
361
473
  this.handleLargeContent(workdir, issueData.body, "issue-body.txt"),
362
474
  issueData.author, workdir, instructions
363
475
  );
364
- this.enqueueOrRun(session, issue, prompt, comment.id, model);
476
+ await this.enqueueOrRun(session, issue, prompt, comment.id, model);
365
477
  }
366
478
  } else {
367
479
  // No valid @mention → forward to the most recently active session only.
368
480
  // Broadcasting to all sessions makes multiple AIs race on the same request;
369
481
  // routing to the last-active lets the user continue without re-@mentioning,
370
482
  // while @mention switches to a different AI.
371
- const sessions = this.store.getSessionsForIssue(issue.id);
372
- if (sessions.length === 0) return;
373
-
374
- const session = pickLastActive(sessions);
375
- if (!session) return;
483
+ const sessions = await this.store.getSessionsForIssue(issue.id);
484
+ let session: OpSession;
485
+ if (sessions.length === 0) {
486
+ log.info(`engine: no session for ${scopeKey}#${ref.issueId} — creating default "${this.cfg.bot.username}"`);
487
+ session = await this.store.createSession(issue.id, this.cfg.bot.username);
488
+ } else {
489
+ const picked = pickLastActive(sessions);
490
+ if (!picked) return;
491
+ session = picked;
492
+ }
376
493
  if (dirPath) {
377
- this.store.updateSession(session.id, { workdir: dirPath });
494
+ await this.store.updateSession(session.id, { workdir: dirPath });
378
495
  session.workdir = dirPath;
379
496
  }
380
- const workdir = this.resolveWorkdir(session, issue);
497
+ const workdir = await this.resolveWorkdir(session, issue);
381
498
  const instructions = tracker.getTrackerInstructions(ref);
382
499
  const prompt = this.buildForwardPrompt(
383
500
  session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
384
501
  comment.author, issueData.title, workdir, instructions
385
502
  );
386
503
  await tracker.createComment(ref, `[system] ✓ Message forwarded to **${session.name}**${this.running.has(this.sessionKey(session, issue)) ? " (running)" : ""}.\n> session: \`${session.id}\` | workdir: \`${workdir}\``);
387
- this.enqueueOrRun(session, issue, prompt, comment.id, model);
504
+ await this.enqueueOrRun(session, issue, prompt, comment.id, model);
388
505
  }
389
506
  } finally {
390
507
  if (comment.id) this.processingComments.delete(comment.id);
@@ -396,14 +513,14 @@ export class Engine {
396
513
  scopeKey: string,
397
514
  tracker: IssueTracker
398
515
  ) {
399
- const issue = this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
516
+ const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
400
517
  if (!issue) return;
401
518
 
402
- this.store.updateIssueState(issue.id, "closed");
519
+ await this.store.updateIssueState(issue.id, "closed");
403
520
  this.stopObserver(issue.id);
404
521
 
405
522
  // Kill all running processes for this issue's sessions
406
- const sessions = this.store.getSessionsForIssue(issue.id);
523
+ const sessions = await this.store.getSessionsForIssue(issue.id);
407
524
  for (const session of sessions) {
408
525
  const k = this.sessionKey(session, issue);
409
526
  const proc = this.processes.get(k);
@@ -414,14 +531,14 @@ export class Engine {
414
531
  // Clear runtime state
415
532
  this.clearRuntimeState(k);
416
533
  // Mark pending/running messages as interrupted
417
- const msgs = this.store.getMessagesForSession(session.id);
534
+ const msgs = await this.store.getMessagesForSession(session.id);
418
535
  for (const msg of msgs) {
419
536
  if (msg.status === "pending" || msg.status === "running") {
420
- this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
537
+ await this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
421
538
  }
422
539
  }
423
540
  // Update session state
424
- this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
541
+ await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
425
542
  }
426
543
 
427
544
  log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
@@ -444,32 +561,32 @@ export class Engine {
444
561
 
445
562
  // ─── Preemptive Scheduler ───
446
563
 
447
- private enqueueOrRun(session: OpSession, issue: Issue, prompt: string, sourceCommentId?: string, model?: string) {
564
+ private async enqueueOrRun(session: OpSession, issue: Issue, prompt: string, sourceCommentId?: string, model?: string) {
448
565
  const k = this.sessionKey(session, issue);
449
566
 
450
567
  this.stuckNudgeRounds.delete(k);
451
568
  this.processExitNudgeRounds.delete(k);
452
569
 
453
- const msg = this.store.createMessage(session.id, prompt, sourceCommentId, undefined, model);
570
+ const msg = await this.store.createMessage(session.id, prompt, sourceCommentId, undefined, model);
454
571
 
455
572
  if (this.running.has(k)) {
456
573
  // PREEMPTIVE: Kill running process, new message takes priority
457
574
  log.info(`engine: preempting ${k} with new message ${msg.id.slice(0, 8)}`);
458
- this.preemptSession(k, session, issue, msg);
575
+ await this.preemptSession(k, session, issue, msg);
459
576
  return;
460
577
  }
461
578
 
462
579
  // Not running — execute directly
463
- this.executeMessage(k, session, issue, msg);
580
+ await this.executeMessage(k, session, issue, msg);
464
581
  }
465
582
 
466
- private preemptSession(k: string, session: OpSession, issue: Issue, newMsg: Message) {
583
+ private async preemptSession(k: string, session: OpSession, issue: Issue, newMsg: Message) {
467
584
  const proc = this.processes.get(k);
468
585
  const oldMsgId = this.currentMessage.get(k);
469
586
 
470
587
  // Mark old message as interrupted
471
588
  if (oldMsgId) {
472
- this.store.updateMessageStatus(oldMsgId, "interrupted", "preempted by new message");
589
+ await this.store.updateMessageStatus(oldMsgId, "interrupted", "preempted by new message");
473
590
  }
474
591
 
475
592
  // Kill running process
@@ -486,19 +603,28 @@ export class Engine {
486
603
  this.startedAt.delete(k);
487
604
 
488
605
  // Execute new message
489
- this.executeMessage(k, session, issue, newMsg);
606
+ await this.executeMessage(k, session, issue, newMsg);
490
607
  }
491
608
 
492
- private executeMessage(k: string, session: OpSession, issue: Issue, msg: Message) {
609
+ private async executeMessage(k: string, session: OpSession, issue: Issue, msg: Message) {
493
610
  log.info(`engine: executing msg ${msg.id.slice(0, 8)} for ${k}`);
611
+
612
+ // Multi-machine: atomic message claim. Pending → running, only if we win.
613
+ // Locally-created messages always succeed (no contention); this gates the
614
+ // cross-daemon race when peer daemons share the session.
615
+ const won = await this.store.claimMessage(msg.id);
616
+ if (!won) {
617
+ log.info(`engine: lost message claim for ${msg.id.slice(0, 8)}, another daemon took it`);
618
+ return;
619
+ }
620
+
494
621
  this.running.add(k);
495
- this.store.updateMessageStatus(msg.id, "running");
496
622
  this.currentMessage.set(k, msg.id);
497
623
 
498
624
  // Update session state
499
- this.store.updateSession(session.id, { state: "running" });
625
+ await this.store.updateSession(session.id, { state: "running" });
500
626
 
501
- this.execProcess(k, session, issue, msg);
627
+ void this.execProcess(k, session, issue, msg);
502
628
  }
503
629
 
504
630
  // ─── Process Manager ───
@@ -507,15 +633,22 @@ export class Engine {
507
633
  const gen = (this.generation.get(k) ?? 0) + 1;
508
634
  this.generation.set(k, gen);
509
635
 
510
- const workdir = this.resolveWorkdir(session, issue);
511
- mkdirSync(workdir, { recursive: true });
636
+ const workdir = await this.resolveWorkdir(session, issue);
512
637
 
513
638
  const ref = this.sessionToRef(session, issue);
514
639
  const tracker = this.getTracker(issue.trackerType);
515
640
 
516
641
  const args = [this.cfg.opencode.binary, "run", "--format", "json", "--dir", workdir];
517
- if (session.opencodeSessionId) {
518
- args.push("--session", session.opencodeSessionId);
642
+ // Prefer the captured session id (resume our own previous run). Otherwise
643
+ // ask the takeover strategy whether a resumable session exists elsewhere
644
+ // (e.g. NAS-backed). Default strategy returns null → fresh session.
645
+ let resumeSessionId = session.opencodeSessionId;
646
+ if (!resumeSessionId) {
647
+ const fromStrategy = await this.takeover.resumeOpenCodeSession(session);
648
+ if (fromStrategy) resumeSessionId = fromStrategy;
649
+ }
650
+ if (resumeSessionId) {
651
+ args.push("--session", resumeSessionId);
519
652
  }
520
653
  // Push --model BEFORE the message content. Defends against env-var-
521
654
  // registered providers stealing the slot (the original bug). Empty/
@@ -562,8 +695,8 @@ export class Engine {
562
695
  this.currentPrompt.set(k, msg.content);
563
696
 
564
697
  // Persist PID for crash recovery
565
- this.store.updateSession(session.id, { opencodePid: proc.pid });
566
- this.persistRuntimeState(session.id);
698
+ await this.store.updateSession(session.id, { opencodePid: proc.pid });
699
+ await this.persistRuntimeState(session.id);
567
700
 
568
701
  log.info(`engine: spawned pid=${proc.pid} for ${k}`);
569
702
 
@@ -594,7 +727,7 @@ export class Engine {
594
727
  // Persist now, not at exit: a preempt/crash before exit must
595
728
  // not lose the ID, otherwise the re-run opens a fresh session.
596
729
  if (!session.opencodeSessionId) {
597
- this.store.updateSession(session.id, { opencodeSessionId: sid });
730
+ await this.store.updateSession(session.id, { opencodeSessionId: sid });
598
731
  session.opencodeSessionId = sid;
599
732
  log.info(`engine: captured sessionID=${sid.slice(0, 8)} for ${k} (early persist)`);
600
733
  }
@@ -617,26 +750,26 @@ export class Engine {
617
750
 
618
751
  this.processes.delete(k);
619
752
  this.lastOutputAt.delete(k);
620
- this.store.updateSession(session.id, { opencodePid: undefined });
753
+ await this.store.updateSession(session.id, { opencodePid: undefined });
621
754
 
622
755
  if (exitCode !== 0) {
623
756
  log.error(`engine: pid=${proc.pid} exited ${exitCode} for ${k}`);
624
757
  log.error(` stderr: ${stderr.slice(0, 2000)}`);
625
- this.store.updateMessageStatus(msg.id, "failed", `exit ${exitCode}: ${stderr.slice(0, 500)}`);
758
+ await this.store.updateMessageStatus(msg.id, "failed", `exit ${exitCode}: ${stderr.slice(0, 500)}`);
626
759
  } else {
627
760
  log.info(`engine: pid=${proc.pid} completed for ${k}`);
628
761
  if (stderr) log.warn(`engine: pid=${proc.pid} stderr on exit 0: ${stderr.slice(0, 500)}`);
629
762
  if (!opencodeSessionId) log.warn(`engine: pid=${proc.pid} produced NO sessionID (no stdout output)`);
630
- this.store.updateMessageStatus(msg.id, "done");
763
+ await this.store.updateMessageStatus(msg.id, "done");
631
764
  }
632
765
 
633
766
  // Save opencode session ID for continuity
634
767
  if (opencodeSessionId && !session.opencodeSessionId) {
635
- this.store.updateSession(session.id, { opencodeSessionId });
768
+ await this.store.updateSession(session.id, { opencodeSessionId });
636
769
  }
637
770
  } catch (err) {
638
771
  log.error(`engine: exec failed for ${k}:`, err);
639
- this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
772
+ await this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
640
773
  }
641
774
 
642
775
  await this.finishRun(k, session, issue, exitCode, gen);
@@ -686,14 +819,14 @@ export class Engine {
686
819
  }
687
820
  this.progressCommentId.delete(k);
688
821
  this.currentPrompt.delete(k);
689
- this.persistRuntimeState(session.id);
822
+ await this.persistRuntimeState(session.id);
690
823
 
691
824
 
692
825
  // spawn failed (exitCode === null) → skip completion check
693
826
  if (exitCode === null) {
694
827
  log.info(`engine: spawn failed for ${k}, skipping completion check`);
695
- this.store.updateSession(session.id, { opencodePid: undefined });
696
- this.deactivateIfIdle(k, session, issue);
828
+ await this.store.updateSession(session.id, { opencodePid: undefined });
829
+ await this.deactivateIfIdle(k, session, issue);
697
830
  return;
698
831
  }
699
832
  if (superseded()) {
@@ -712,19 +845,19 @@ export class Engine {
712
845
  if (hasRecent) {
713
846
  log.info(`engine: recent [bot] reply found for ${k}, marking done`);
714
847
  this.nudgeRounds.delete(k);
715
- this.persistRuntimeState(session.id);
848
+ await this.persistRuntimeState(session.id);
716
849
  } else {
717
850
  const nudgeRound = this.nudgeRounds.get(k) ?? 0;
718
851
  if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
719
852
  log.info(`engine: no recent [bot] reply for ${k}, nudging (round ${nudgeRound + 1}/${Engine.MAX_NUDGE_ROUNDS})`);
720
853
  this.nudgeRounds.set(k, nudgeRound + 1);
721
854
  this.currentPrompt.delete(k);
722
- this.persistRuntimeState(session.id);
855
+ await this.persistRuntimeState(session.id);
723
856
 
724
857
  const instructions = tracker.getTrackerInstructions(ref);
725
858
  const nudgePrompt = this.buildNudgePrompt(session, issue, instructions);
726
- const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
727
- this.dequeueOrIdle(k, session, issue, nudgeMsg);
859
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt);
860
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
728
861
  return;
729
862
  }
730
863
  log.info(`engine: no recent [bot] reply for ${k}, marking done (nudge exhausted or process failed)`);
@@ -734,7 +867,7 @@ export class Engine {
734
867
  }
735
868
 
736
869
  // Remove eyes on source comment; react +1/-1 on the bot's last reply (fallback: source)
737
- const recentMsgs = this.store.getRecentMessages(session.id, 1);
870
+ const recentMsgs = await this.store.getRecentMessages(session.id, 1);
738
871
  const lastMsg = recentMsgs[0];
739
872
  if (lastMsg?.sourceCommentId) {
740
873
  // Check if any other session is still running on this issue
@@ -755,30 +888,30 @@ export class Engine {
755
888
  log.info(`engine: finishRun aborted (superseded) for ${k}`);
756
889
  return;
757
890
  }
758
- this.deactivateIfIdle(k, session, issue);
891
+ await this.deactivateIfIdle(k, session, issue);
759
892
  }
760
893
 
761
- private deactivateIfIdle(k: string, session: OpSession, issue: Issue) {
762
- const nextMsg = this.store.getNextPendingMessage(session.id);
894
+ private async deactivateIfIdle(k: string, session: OpSession, issue: Issue) {
895
+ const nextMsg = await this.store.getNextPendingMessage(session.id);
763
896
  if (nextMsg) {
764
- const current = this.store.getSession(session.id);
897
+ const current = await this.store.getSession(session.id);
765
898
  if (current && current.state !== "idle") {
766
- this.dequeueOrIdle(k, current, issue, nextMsg);
899
+ await this.dequeueOrIdle(k, current, issue, nextMsg);
767
900
  return;
768
901
  }
769
902
  }
770
903
 
771
904
  this.clearRuntimeState(k);
772
- this.store.updateSession(session.id, { state: "idle" });
905
+ await this.store.updateSession(session.id, { state: "idle" });
773
906
  }
774
907
 
775
- private dequeueOrIdle(k: string, session: OpSession, issue: Issue, msg: Message) {
908
+ private async dequeueOrIdle(k: string, session: OpSession, issue: Issue, msg: Message) {
776
909
  log.info(`engine: running dequeued msg ${msg.id.slice(0, 8)} for ${k}`);
777
- this.store.updateMessageStatus(msg.id, "running");
910
+ await this.store.updateMessageStatus(msg.id, "running");
778
911
  this.running.add(k);
779
912
  this.currentMessage.set(k, msg.id);
780
- this.store.updateSession(session.id, { state: "running" });
781
- this.execProcess(k, session, issue, msg);
913
+ await this.store.updateSession(session.id, { state: "running" });
914
+ void this.execProcess(k, session, issue, msg);
782
915
  }
783
916
 
784
917
  // ─── Prompts ───
@@ -918,10 +1051,18 @@ export class Engine {
918
1051
  }
919
1052
 
920
1053
  private async runObserverCycle() {
921
- const activeIssues = this.store.listActiveIssues();
1054
+ // Multi-machine: periodically release stale owners so we can adopt their
1055
+ // work, and only iterate issues/sessions this daemon owns.
1056
+ try {
1057
+ await this.store.releaseDeadOwners(this.cfg.work.leaseTtlMs);
1058
+ } catch (err) {
1059
+ log.error("engine: releaseDeadOwners failed:", (err as Error).message);
1060
+ }
1061
+
1062
+ const ownedIssues = (await this.store.listOwnedIssues(this.daemonId))
1063
+ .filter((i) => this.observedIssues.has(i.id));
922
1064
 
923
- for (const issue of activeIssues) {
924
- if (!this.observedIssues.has(issue.id)) continue;
1065
+ for (const issue of ownedIssues) {
925
1066
  try {
926
1067
  await this.observeIssue(issue);
927
1068
  } catch (err) {
@@ -932,7 +1073,7 @@ export class Engine {
932
1073
 
933
1074
  private async observeIssue(issue: Issue) {
934
1075
  const tracker = this.getTracker(issue.trackerType);
935
- const sessions = this.store.getSessionsForIssue(issue.id);
1076
+ const sessions = await this.store.getSessionsForIssue(issue.id);
936
1077
 
937
1078
  for (const session of sessions) {
938
1079
  if (session.state !== "running") continue;
@@ -952,12 +1093,12 @@ export class Engine {
952
1093
  this.processes.delete(k);
953
1094
  this.lastOutputAt.delete(k);
954
1095
  this.running.delete(k);
955
- this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
1096
+ await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
956
1097
 
957
1098
  // Mark running message as failed
958
1099
  const msgId = this.currentMessage.get(k);
959
1100
  if (msgId) {
960
- this.store.updateMessageStatus(msgId, "failed", "process died unexpectedly");
1101
+ await this.store.updateMessageStatus(msgId, "failed", "process died unexpectedly");
961
1102
  this.currentMessage.delete(k);
962
1103
  }
963
1104
 
@@ -977,8 +1118,8 @@ export class Engine {
977
1118
 
978
1119
  const instructions = tracker.getTrackerInstructions(ref);
979
1120
  const nudgePrompt = this.buildProcessExitNudgePrompt(session, issue, instructions);
980
- const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
981
- this.dequeueOrIdle(k, session, issue, nudgeMsg);
1121
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt);
1122
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
982
1123
  continue;
983
1124
  } else {
984
1125
  // AI already replied before dying — no need to nudge, but user must be
@@ -997,9 +1138,9 @@ export class Engine {
997
1138
  }
998
1139
 
999
1140
  // Try to dequeue next message
1000
- const nextMsg = this.store.getNextPendingMessage(session.id);
1141
+ const nextMsg = await this.store.getNextPendingMessage(session.id);
1001
1142
  if (nextMsg) {
1002
- this.dequeueOrIdle(k, session, issue, nextMsg);
1143
+ await this.dequeueOrIdle(k, session, issue, nextMsg);
1003
1144
  }
1004
1145
  continue;
1005
1146
  }
@@ -1021,8 +1162,8 @@ export class Engine {
1021
1162
 
1022
1163
  const instructions = tracker.getTrackerInstructions(ref);
1023
1164
  const nudgePrompt = this.buildStuckNudgePrompt(session, issue, instructions, minutes);
1024
- const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
1025
- this.dequeueOrIdle(k, session, issue, nudgeMsg);
1165
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt);
1166
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
1026
1167
  } else {
1027
1168
  log.warn(`engine: stuck for ${minutes}min on ${k}, stuck nudge exhausted (${stuckNudgeRound}/${this.maxStuckNudges}), giving up`);
1028
1169
  await tracker.createComment(ref, `[system] ⛔ **${session.name}** stuck for ${minutes} min, gave up after ${this.maxStuckNudges} restart(s).`).catch(
@@ -1035,7 +1176,7 @@ export class Engine {
1035
1176
  } else if (session.state === "running" && !this.running.has(k)) {
1036
1177
  log.warn(`engine: observer fixing orphaned running state for ${k}`);
1037
1178
  this.running.delete(k);
1038
- this.store.updateSession(session.id, { state: "idle" });
1179
+ await this.store.updateSession(session.id, { state: "idle" });
1039
1180
  }
1040
1181
  }
1041
1182
 
@@ -1057,7 +1198,7 @@ export class Engine {
1057
1198
  } else {
1058
1199
  const result = await tracker.createComment(ref, body);
1059
1200
  this.progressCommentId.set(k, result.id);
1060
- this.persistRuntimeState(session.id);
1201
+ await this.persistRuntimeState(session.id);
1061
1202
  }
1062
1203
  } catch (err) {
1063
1204
  log.error(`engine: progress report failed for ${k}:`, (err as Error).message);
@@ -1071,8 +1212,20 @@ export class Engine {
1071
1212
  // ─── Recovery ───
1072
1213
 
1073
1214
  private async recover() {
1074
- const allSessions = this.store.listAllSessions();
1075
- for (const session of allSessions) {
1215
+ // Release stale owners first so we can adopt orphaned issues that just
1216
+ // became available (this daemon is fresh; any dead daemon's slots are now
1217
+ // reclaimable).
1218
+ try {
1219
+ await this.store.releaseDeadOwners(this.cfg.work.leaseTtlMs);
1220
+ } catch (err) {
1221
+ log.error("engine: releaseDeadOwners at boot failed:", (err as Error).message);
1222
+ }
1223
+
1224
+ // Multi-machine: recover ONLY this daemon's sessions. Other daemons own
1225
+ // the rest; touching their state would race them.
1226
+ const ownedSessions = await this.store.listOwnedSessions(this.daemonId);
1227
+
1228
+ for (const session of ownedSessions) {
1076
1229
  if (session.opencodePid) {
1077
1230
  try {
1078
1231
  process.kill(session.opencodePid, 0);
@@ -1088,19 +1241,23 @@ export class Engine {
1088
1241
  try { process.kill(session.opencodePid, "SIGKILL"); } catch { /* dead */ }
1089
1242
  }
1090
1243
  } catch { /* already dead */ }
1091
- this.store.updateSession(session.id, { opencodePid: undefined });
1244
+ await this.store.updateSession(session.id, { opencodePid: undefined });
1092
1245
  }
1093
1246
  }
1094
1247
 
1095
- // Restore runtime state from DB
1096
- for (const session of allSessions) {
1097
- const issue = this.store.getIssue(session.issueId);
1248
+ // Restore runtime state (now persisted in op_sessions) from DB.
1249
+ for (const session of ownedSessions) {
1250
+ const issue = await this.store.getIssue(session.issueId);
1098
1251
  if (!issue || issue.state === "closed") continue;
1099
1252
  const k = this.sessionKey(session, issue);
1100
1253
  if (session.startedAt != null) this.startedAt.set(k, session.startedAt);
1101
1254
  if (session.progressCommentId) this.progressCommentId.set(k, session.progressCommentId);
1102
1255
  if (session.currentPrompt) this.currentPrompt.set(k, session.currentPrompt);
1103
- // Start observer for active issues
1256
+ if (session.lastOutputAt != null) this.lastOutputAt.set(k, session.lastOutputAt);
1257
+ if (session.nudgeRounds != null) this.nudgeRounds.set(k, session.nudgeRounds);
1258
+ if (session.stuckNudgeRounds != null) this.stuckNudgeRounds.set(k, session.stuckNudgeRounds);
1259
+ if (session.generation != null) this.generation.set(k, session.generation);
1260
+ // Start observer for active issues we own
1104
1261
  this.startObserver(issue);
1105
1262
  }
1106
1263
 
@@ -1109,8 +1266,8 @@ export class Engine {
1109
1266
  log.info(`engine: restored runtime state for ${restored} sessions from DB`);
1110
1267
  }
1111
1268
 
1112
- // Recover stuck messages
1113
- const stuck = this.store.getPendingOrRunningMessages();
1269
+ // Recover stuck messages scoped to this daemon's issues.
1270
+ const stuck = await this.store.getOwnedPendingOrRunningMessages(this.daemonId);
1114
1271
  if (stuck.length === 0) return;
1115
1272
 
1116
1273
  log.info(`engine: recovering ${stuck.length} stuck messages`);
@@ -1118,7 +1275,7 @@ export class Engine {
1118
1275
  // Reset running messages to pending
1119
1276
  for (const msg of stuck) {
1120
1277
  if (msg.status === "running") {
1121
- this.store.updateMessageStatus(msg.id, "pending");
1278
+ await this.store.updateMessageStatus(msg.id, "pending");
1122
1279
  }
1123
1280
  }
1124
1281
 
@@ -1131,9 +1288,9 @@ export class Engine {
1131
1288
  }
1132
1289
 
1133
1290
  for (const [sessionId, msgs] of bySession) {
1134
- const session = this.store.getSession(sessionId);
1291
+ const session = await this.store.getSession(sessionId);
1135
1292
  if (!session) continue;
1136
- const issue = this.store.getIssue(session.issueId);
1293
+ const issue = await this.store.getIssue(session.issueId);
1137
1294
  if (!issue || issue.state === "closed") continue;
1138
1295
 
1139
1296
  const k = this.sessionKey(session, issue);
@@ -1147,53 +1304,54 @@ export class Engine {
1147
1304
  const comments = await tracker.listComments(ref).catch((): TrackerComment[] => []);
1148
1305
  if (this.hasRecentBotReply(comments, tracker)) {
1149
1306
  log.info(`engine: recovered msg ${first.id.slice(0, 8)} for ${k} — bot reply detected, marking done`);
1150
- this.store.updateMessageStatus(first.id, "done");
1151
- const next = this.store.getNextPendingMessage(session.id);
1152
- if (next) { this.dequeueOrIdle(k, session, issue, next); }
1307
+ await this.store.updateMessageStatus(first.id, "done");
1308
+ const next = await this.store.getNextPendingMessage(session.id);
1309
+ if (next) { await this.dequeueOrIdle(k, session, issue, next); }
1153
1310
  continue;
1154
1311
  }
1155
1312
 
1156
1313
  log.info(`engine: recovering msg ${first.id.slice(0, 8)} for ${k}`);
1157
- this.dequeueOrIdle(k, session, issue, first);
1314
+ await this.dequeueOrIdle(k, session, issue, first);
1158
1315
  }
1159
1316
  }
1160
1317
 
1161
1318
  // ─── API Methods ───
1162
1319
 
1163
- retryMessage(messageId: string): boolean {
1164
- const msg = this.store.getMessage(messageId);
1320
+ async retryMessage(messageId: string): Promise<boolean> {
1321
+ const msg = await this.store.getMessage(messageId);
1165
1322
  if (!msg || msg.status !== "failed") return false;
1166
- const session = this.store.getSession(msg.sessionId);
1323
+ const session = await this.store.getSession(msg.sessionId);
1167
1324
  if (!session) return false;
1168
- const issue = this.store.getIssue(session.issueId);
1325
+ const issue = await this.store.getIssue(session.issueId);
1169
1326
  if (!issue || issue.state === "closed") return false;
1170
1327
 
1171
- this.store.updateMessageStatus(messageId, "pending");
1328
+ await this.store.updateMessageStatus(messageId, "pending");
1172
1329
  const k = this.sessionKey(session, issue);
1173
1330
  if (!this.running.has(k)) {
1174
- this.dequeueOrIdle(k, session, issue, msg);
1331
+ await this.dequeueOrIdle(k, session, issue, msg);
1175
1332
  }
1176
1333
  return true;
1177
1334
  }
1178
1335
 
1179
- getStatus() {
1180
- const pendingCount = this.store.getPendingOrRunningMessages().filter(m => m.status === "pending").length;
1336
+ async getStatus() {
1337
+ const pendingCount = (await this.store.getOwnedPendingOrRunningMessages(this.daemonId)).filter(m => m.status === "pending").length;
1181
1338
  return {
1182
1339
  runningCount: this.running.size,
1183
1340
  runningKeys: [...this.running],
1184
1341
  pendingCount,
1185
1342
  processCount: this.processes.size,
1186
1343
  observedIssues: this.observedIssues.size,
1344
+ daemonId: this.daemonId,
1187
1345
  };
1188
1346
  }
1189
1347
 
1190
- getQueue(): Record<string, number> {
1348
+ async getQueue(): Promise<Record<string, number>> {
1191
1349
  const result: Record<string, number> = {};
1192
- const allPending = this.store.getPendingOrRunningMessages().filter(m => m.status === "pending");
1350
+ const allPending = (await this.store.getOwnedPendingOrRunningMessages(this.daemonId)).filter(m => m.status === "pending");
1193
1351
  for (const msg of allPending) {
1194
- const session = this.store.getSession(msg.sessionId);
1352
+ const session = await this.store.getSession(msg.sessionId);
1195
1353
  if (!session) continue;
1196
- const issue = this.store.getIssue(session.issueId);
1354
+ const issue = await this.store.getIssue(session.issueId);
1197
1355
  if (!issue) continue;
1198
1356
  const k = this.sessionKey(session, issue);
1199
1357
  result[k] = (result[k] ?? 0) + 1;
@@ -1224,7 +1382,7 @@ export class Engine {
1224
1382
  try { process.kill(pid, signal); } catch { /* already dead */ }
1225
1383
  }
1226
1384
 
1227
- forceStop(key: string): boolean {
1385
+ async forceStop(key: string): Promise<boolean> {
1228
1386
  const proc = this.processes.get(key);
1229
1387
  this.stopping.add(key);
1230
1388
  log.warn(`engine: forceStop ${key}, pid=${proc?.pid ?? "none"}`);
@@ -1248,22 +1406,22 @@ export class Engine {
1248
1406
  // Update session and messages
1249
1407
  const parsed = parseKey(key);
1250
1408
  if (parsed) {
1251
- const issue = this.store.findIssue(parsed.trackerType, parsed.scopeKey, parsed.issueId);
1409
+ const issue = await this.store.findIssue(parsed.trackerType, parsed.scopeKey, parsed.issueId);
1252
1410
  if (issue) {
1253
- const session = this.store.getSessionByName(issue.id, parsed.sessionName);
1411
+ const session = await this.store.getSessionByName(issue.id, parsed.sessionName);
1254
1412
  if (session) {
1255
1413
  if (progressId) {
1256
1414
  const ref = this.sessionToRef(session, issue);
1257
1415
  const tracker = this.getTracker(issue.trackerType);
1258
1416
  void tracker.editComment(ref, progressId, `[system] ⛔ **${session.name}** force-stopped.`).catch(() => {});
1259
1417
  }
1260
- const msgs = this.store.getMessagesForSession(session.id);
1418
+ const msgs = await this.store.getMessagesForSession(session.id);
1261
1419
  for (const msg of msgs) {
1262
1420
  if (msg.status === "pending" || msg.status === "running") {
1263
- this.store.updateMessageStatus(msg.id, "failed", "force stopped");
1421
+ await this.store.updateMessageStatus(msg.id, "failed", "force stopped");
1264
1422
  }
1265
1423
  }
1266
- this.store.updateSession(session.id, {
1424
+ await this.store.updateSession(session.id, {
1267
1425
  state: "idle",
1268
1426
  opencodePid: undefined,
1269
1427
  startedAt: undefined,
@@ -1278,6 +1436,7 @@ export class Engine {
1278
1436
  }
1279
1437
 
1280
1438
  destroy() {
1439
+ this.stopHeartbeat();
1281
1440
  if (this.observerTimer) clearInterval(this.observerTimer);
1282
1441
  this.observedIssues.clear();
1283
1442
  for (const [, proc] of this.processes) {