ework-daemon 0.1.3 → 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,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,30 @@ 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);
483
+ const sessions = await this.store.getSessionsForIssue(issue.id);
372
484
  if (sessions.length === 0) return;
373
485
 
374
486
  const session = pickLastActive(sessions);
375
487
  if (!session) return;
376
488
  if (dirPath) {
377
- this.store.updateSession(session.id, { workdir: dirPath });
489
+ await this.store.updateSession(session.id, { workdir: dirPath });
378
490
  session.workdir = dirPath;
379
491
  }
380
- const workdir = this.resolveWorkdir(session, issue);
492
+ const workdir = await this.resolveWorkdir(session, issue);
381
493
  const instructions = tracker.getTrackerInstructions(ref);
382
494
  const prompt = this.buildForwardPrompt(
383
495
  session.name, this.handleLargeContent(workdir, comment.body, `comment-${comment.id}.txt`),
384
496
  comment.author, issueData.title, workdir, instructions
385
497
  );
386
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}\``);
387
- this.enqueueOrRun(session, issue, prompt, comment.id, model);
499
+ await this.enqueueOrRun(session, issue, prompt, comment.id, model);
388
500
  }
389
501
  } finally {
390
502
  if (comment.id) this.processingComments.delete(comment.id);
@@ -396,14 +508,14 @@ export class Engine {
396
508
  scopeKey: string,
397
509
  tracker: IssueTracker
398
510
  ) {
399
- const issue = this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
511
+ const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
400
512
  if (!issue) return;
401
513
 
402
- this.store.updateIssueState(issue.id, "closed");
514
+ await this.store.updateIssueState(issue.id, "closed");
403
515
  this.stopObserver(issue.id);
404
516
 
405
517
  // Kill all running processes for this issue's sessions
406
- const sessions = this.store.getSessionsForIssue(issue.id);
518
+ const sessions = await this.store.getSessionsForIssue(issue.id);
407
519
  for (const session of sessions) {
408
520
  const k = this.sessionKey(session, issue);
409
521
  const proc = this.processes.get(k);
@@ -414,14 +526,14 @@ export class Engine {
414
526
  // Clear runtime state
415
527
  this.clearRuntimeState(k);
416
528
  // Mark pending/running messages as interrupted
417
- const msgs = this.store.getMessagesForSession(session.id);
529
+ const msgs = await this.store.getMessagesForSession(session.id);
418
530
  for (const msg of msgs) {
419
531
  if (msg.status === "pending" || msg.status === "running") {
420
- this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
532
+ await this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
421
533
  }
422
534
  }
423
535
  // Update session state
424
- this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
536
+ await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
425
537
  }
426
538
 
427
539
  log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
@@ -444,32 +556,32 @@ export class Engine {
444
556
 
445
557
  // ─── Preemptive Scheduler ───
446
558
 
447
- 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) {
448
560
  const k = this.sessionKey(session, issue);
449
561
 
450
562
  this.stuckNudgeRounds.delete(k);
451
563
  this.processExitNudgeRounds.delete(k);
452
564
 
453
- const msg = this.store.createMessage(session.id, prompt, sourceCommentId, undefined, model);
565
+ const msg = await this.store.createMessage(session.id, prompt, sourceCommentId, undefined, model);
454
566
 
455
567
  if (this.running.has(k)) {
456
568
  // PREEMPTIVE: Kill running process, new message takes priority
457
569
  log.info(`engine: preempting ${k} with new message ${msg.id.slice(0, 8)}`);
458
- this.preemptSession(k, session, issue, msg);
570
+ await this.preemptSession(k, session, issue, msg);
459
571
  return;
460
572
  }
461
573
 
462
574
  // Not running — execute directly
463
- this.executeMessage(k, session, issue, msg);
575
+ await this.executeMessage(k, session, issue, msg);
464
576
  }
465
577
 
466
- private preemptSession(k: string, session: OpSession, issue: Issue, newMsg: Message) {
578
+ private async preemptSession(k: string, session: OpSession, issue: Issue, newMsg: Message) {
467
579
  const proc = this.processes.get(k);
468
580
  const oldMsgId = this.currentMessage.get(k);
469
581
 
470
582
  // Mark old message as interrupted
471
583
  if (oldMsgId) {
472
- this.store.updateMessageStatus(oldMsgId, "interrupted", "preempted by new message");
584
+ await this.store.updateMessageStatus(oldMsgId, "interrupted", "preempted by new message");
473
585
  }
474
586
 
475
587
  // Kill running process
@@ -486,19 +598,28 @@ export class Engine {
486
598
  this.startedAt.delete(k);
487
599
 
488
600
  // Execute new message
489
- this.executeMessage(k, session, issue, newMsg);
601
+ await this.executeMessage(k, session, issue, newMsg);
490
602
  }
491
603
 
492
- private executeMessage(k: string, session: OpSession, issue: Issue, msg: Message) {
604
+ private async executeMessage(k: string, session: OpSession, issue: Issue, msg: Message) {
493
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
+
494
616
  this.running.add(k);
495
- this.store.updateMessageStatus(msg.id, "running");
496
617
  this.currentMessage.set(k, msg.id);
497
618
 
498
619
  // Update session state
499
- this.store.updateSession(session.id, { state: "running" });
620
+ await this.store.updateSession(session.id, { state: "running" });
500
621
 
501
- this.execProcess(k, session, issue, msg);
622
+ void this.execProcess(k, session, issue, msg);
502
623
  }
503
624
 
504
625
  // ─── Process Manager ───
@@ -507,15 +628,22 @@ export class Engine {
507
628
  const gen = (this.generation.get(k) ?? 0) + 1;
508
629
  this.generation.set(k, gen);
509
630
 
510
- const workdir = this.resolveWorkdir(session, issue);
511
- mkdirSync(workdir, { recursive: true });
631
+ const workdir = await this.resolveWorkdir(session, issue);
512
632
 
513
633
  const ref = this.sessionToRef(session, issue);
514
634
  const tracker = this.getTracker(issue.trackerType);
515
635
 
516
636
  const args = [this.cfg.opencode.binary, "run", "--format", "json", "--dir", workdir];
517
- if (session.opencodeSessionId) {
518
- 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);
519
647
  }
520
648
  // Push --model BEFORE the message content. Defends against env-var-
521
649
  // registered providers stealing the slot (the original bug). Empty/
@@ -562,8 +690,8 @@ export class Engine {
562
690
  this.currentPrompt.set(k, msg.content);
563
691
 
564
692
  // Persist PID for crash recovery
565
- this.store.updateSession(session.id, { opencodePid: proc.pid });
566
- this.persistRuntimeState(session.id);
693
+ await this.store.updateSession(session.id, { opencodePid: proc.pid });
694
+ await this.persistRuntimeState(session.id);
567
695
 
568
696
  log.info(`engine: spawned pid=${proc.pid} for ${k}`);
569
697
 
@@ -594,7 +722,7 @@ export class Engine {
594
722
  // Persist now, not at exit: a preempt/crash before exit must
595
723
  // not lose the ID, otherwise the re-run opens a fresh session.
596
724
  if (!session.opencodeSessionId) {
597
- this.store.updateSession(session.id, { opencodeSessionId: sid });
725
+ await this.store.updateSession(session.id, { opencodeSessionId: sid });
598
726
  session.opencodeSessionId = sid;
599
727
  log.info(`engine: captured sessionID=${sid.slice(0, 8)} for ${k} (early persist)`);
600
728
  }
@@ -617,26 +745,26 @@ export class Engine {
617
745
 
618
746
  this.processes.delete(k);
619
747
  this.lastOutputAt.delete(k);
620
- this.store.updateSession(session.id, { opencodePid: undefined });
748
+ await this.store.updateSession(session.id, { opencodePid: undefined });
621
749
 
622
750
  if (exitCode !== 0) {
623
751
  log.error(`engine: pid=${proc.pid} exited ${exitCode} for ${k}`);
624
752
  log.error(` stderr: ${stderr.slice(0, 2000)}`);
625
- 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)}`);
626
754
  } else {
627
755
  log.info(`engine: pid=${proc.pid} completed for ${k}`);
628
756
  if (stderr) log.warn(`engine: pid=${proc.pid} stderr on exit 0: ${stderr.slice(0, 500)}`);
629
757
  if (!opencodeSessionId) log.warn(`engine: pid=${proc.pid} produced NO sessionID (no stdout output)`);
630
- this.store.updateMessageStatus(msg.id, "done");
758
+ await this.store.updateMessageStatus(msg.id, "done");
631
759
  }
632
760
 
633
761
  // Save opencode session ID for continuity
634
762
  if (opencodeSessionId && !session.opencodeSessionId) {
635
- this.store.updateSession(session.id, { opencodeSessionId });
763
+ await this.store.updateSession(session.id, { opencodeSessionId });
636
764
  }
637
765
  } catch (err) {
638
766
  log.error(`engine: exec failed for ${k}:`, err);
639
- this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
767
+ await this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
640
768
  }
641
769
 
642
770
  await this.finishRun(k, session, issue, exitCode, gen);
@@ -686,14 +814,14 @@ export class Engine {
686
814
  }
687
815
  this.progressCommentId.delete(k);
688
816
  this.currentPrompt.delete(k);
689
- this.persistRuntimeState(session.id);
817
+ await this.persistRuntimeState(session.id);
690
818
 
691
819
 
692
820
  // spawn failed (exitCode === null) → skip completion check
693
821
  if (exitCode === null) {
694
822
  log.info(`engine: spawn failed for ${k}, skipping completion check`);
695
- this.store.updateSession(session.id, { opencodePid: undefined });
696
- this.deactivateIfIdle(k, session, issue);
823
+ await this.store.updateSession(session.id, { opencodePid: undefined });
824
+ await this.deactivateIfIdle(k, session, issue);
697
825
  return;
698
826
  }
699
827
  if (superseded()) {
@@ -712,19 +840,19 @@ export class Engine {
712
840
  if (hasRecent) {
713
841
  log.info(`engine: recent [bot] reply found for ${k}, marking done`);
714
842
  this.nudgeRounds.delete(k);
715
- this.persistRuntimeState(session.id);
843
+ await this.persistRuntimeState(session.id);
716
844
  } else {
717
845
  const nudgeRound = this.nudgeRounds.get(k) ?? 0;
718
846
  if (exitCode === 0 && nudgeRound < Engine.MAX_NUDGE_ROUNDS) {
719
847
  log.info(`engine: no recent [bot] reply for ${k}, nudging (round ${nudgeRound + 1}/${Engine.MAX_NUDGE_ROUNDS})`);
720
848
  this.nudgeRounds.set(k, nudgeRound + 1);
721
849
  this.currentPrompt.delete(k);
722
- this.persistRuntimeState(session.id);
850
+ await this.persistRuntimeState(session.id);
723
851
 
724
852
  const instructions = tracker.getTrackerInstructions(ref);
725
853
  const nudgePrompt = this.buildNudgePrompt(session, issue, instructions);
726
- const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
727
- this.dequeueOrIdle(k, session, issue, nudgeMsg);
854
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt);
855
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
728
856
  return;
729
857
  }
730
858
  log.info(`engine: no recent [bot] reply for ${k}, marking done (nudge exhausted or process failed)`);
@@ -734,7 +862,7 @@ export class Engine {
734
862
  }
735
863
 
736
864
  // 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);
865
+ const recentMsgs = await this.store.getRecentMessages(session.id, 1);
738
866
  const lastMsg = recentMsgs[0];
739
867
  if (lastMsg?.sourceCommentId) {
740
868
  // Check if any other session is still running on this issue
@@ -755,30 +883,30 @@ export class Engine {
755
883
  log.info(`engine: finishRun aborted (superseded) for ${k}`);
756
884
  return;
757
885
  }
758
- this.deactivateIfIdle(k, session, issue);
886
+ await this.deactivateIfIdle(k, session, issue);
759
887
  }
760
888
 
761
- private deactivateIfIdle(k: string, session: OpSession, issue: Issue) {
762
- 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);
763
891
  if (nextMsg) {
764
- const current = this.store.getSession(session.id);
892
+ const current = await this.store.getSession(session.id);
765
893
  if (current && current.state !== "idle") {
766
- this.dequeueOrIdle(k, current, issue, nextMsg);
894
+ await this.dequeueOrIdle(k, current, issue, nextMsg);
767
895
  return;
768
896
  }
769
897
  }
770
898
 
771
899
  this.clearRuntimeState(k);
772
- this.store.updateSession(session.id, { state: "idle" });
900
+ await this.store.updateSession(session.id, { state: "idle" });
773
901
  }
774
902
 
775
- private dequeueOrIdle(k: string, session: OpSession, issue: Issue, msg: Message) {
903
+ private async dequeueOrIdle(k: string, session: OpSession, issue: Issue, msg: Message) {
776
904
  log.info(`engine: running dequeued msg ${msg.id.slice(0, 8)} for ${k}`);
777
- this.store.updateMessageStatus(msg.id, "running");
905
+ await this.store.updateMessageStatus(msg.id, "running");
778
906
  this.running.add(k);
779
907
  this.currentMessage.set(k, msg.id);
780
- this.store.updateSession(session.id, { state: "running" });
781
- this.execProcess(k, session, issue, msg);
908
+ await this.store.updateSession(session.id, { state: "running" });
909
+ void this.execProcess(k, session, issue, msg);
782
910
  }
783
911
 
784
912
  // ─── Prompts ───
@@ -918,10 +1046,18 @@ export class Engine {
918
1046
  }
919
1047
 
920
1048
  private async runObserverCycle() {
921
- 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
+ }
1056
+
1057
+ const ownedIssues = (await this.store.listOwnedIssues(this.daemonId))
1058
+ .filter((i) => this.observedIssues.has(i.id));
922
1059
 
923
- for (const issue of activeIssues) {
924
- if (!this.observedIssues.has(issue.id)) continue;
1060
+ for (const issue of ownedIssues) {
925
1061
  try {
926
1062
  await this.observeIssue(issue);
927
1063
  } catch (err) {
@@ -932,7 +1068,7 @@ export class Engine {
932
1068
 
933
1069
  private async observeIssue(issue: Issue) {
934
1070
  const tracker = this.getTracker(issue.trackerType);
935
- const sessions = this.store.getSessionsForIssue(issue.id);
1071
+ const sessions = await this.store.getSessionsForIssue(issue.id);
936
1072
 
937
1073
  for (const session of sessions) {
938
1074
  if (session.state !== "running") continue;
@@ -952,12 +1088,12 @@ export class Engine {
952
1088
  this.processes.delete(k);
953
1089
  this.lastOutputAt.delete(k);
954
1090
  this.running.delete(k);
955
- this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
1091
+ await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
956
1092
 
957
1093
  // Mark running message as failed
958
1094
  const msgId = this.currentMessage.get(k);
959
1095
  if (msgId) {
960
- this.store.updateMessageStatus(msgId, "failed", "process died unexpectedly");
1096
+ await this.store.updateMessageStatus(msgId, "failed", "process died unexpectedly");
961
1097
  this.currentMessage.delete(k);
962
1098
  }
963
1099
 
@@ -977,8 +1113,8 @@ export class Engine {
977
1113
 
978
1114
  const instructions = tracker.getTrackerInstructions(ref);
979
1115
  const nudgePrompt = this.buildProcessExitNudgePrompt(session, issue, instructions);
980
- const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
981
- this.dequeueOrIdle(k, session, issue, nudgeMsg);
1116
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt);
1117
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
982
1118
  continue;
983
1119
  } else {
984
1120
  // AI already replied before dying — no need to nudge, but user must be
@@ -997,9 +1133,9 @@ export class Engine {
997
1133
  }
998
1134
 
999
1135
  // Try to dequeue next message
1000
- const nextMsg = this.store.getNextPendingMessage(session.id);
1136
+ const nextMsg = await this.store.getNextPendingMessage(session.id);
1001
1137
  if (nextMsg) {
1002
- this.dequeueOrIdle(k, session, issue, nextMsg);
1138
+ await this.dequeueOrIdle(k, session, issue, nextMsg);
1003
1139
  }
1004
1140
  continue;
1005
1141
  }
@@ -1021,8 +1157,8 @@ export class Engine {
1021
1157
 
1022
1158
  const instructions = tracker.getTrackerInstructions(ref);
1023
1159
  const nudgePrompt = this.buildStuckNudgePrompt(session, issue, instructions, minutes);
1024
- const nudgeMsg = this.store.createMessage(session.id, nudgePrompt);
1025
- this.dequeueOrIdle(k, session, issue, nudgeMsg);
1160
+ const nudgeMsg = await this.store.createMessage(session.id, nudgePrompt);
1161
+ await this.dequeueOrIdle(k, session, issue, nudgeMsg);
1026
1162
  } else {
1027
1163
  log.warn(`engine: stuck for ${minutes}min on ${k}, stuck nudge exhausted (${stuckNudgeRound}/${this.maxStuckNudges}), giving up`);
1028
1164
  await tracker.createComment(ref, `[system] ⛔ **${session.name}** stuck for ${minutes} min, gave up after ${this.maxStuckNudges} restart(s).`).catch(
@@ -1035,7 +1171,7 @@ export class Engine {
1035
1171
  } else if (session.state === "running" && !this.running.has(k)) {
1036
1172
  log.warn(`engine: observer fixing orphaned running state for ${k}`);
1037
1173
  this.running.delete(k);
1038
- this.store.updateSession(session.id, { state: "idle" });
1174
+ await this.store.updateSession(session.id, { state: "idle" });
1039
1175
  }
1040
1176
  }
1041
1177
 
@@ -1057,7 +1193,7 @@ export class Engine {
1057
1193
  } else {
1058
1194
  const result = await tracker.createComment(ref, body);
1059
1195
  this.progressCommentId.set(k, result.id);
1060
- this.persistRuntimeState(session.id);
1196
+ await this.persistRuntimeState(session.id);
1061
1197
  }
1062
1198
  } catch (err) {
1063
1199
  log.error(`engine: progress report failed for ${k}:`, (err as Error).message);
@@ -1071,8 +1207,20 @@ export class Engine {
1071
1207
  // ─── Recovery ───
1072
1208
 
1073
1209
  private async recover() {
1074
- const allSessions = this.store.listAllSessions();
1075
- 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) {
1076
1224
  if (session.opencodePid) {
1077
1225
  try {
1078
1226
  process.kill(session.opencodePid, 0);
@@ -1088,19 +1236,23 @@ export class Engine {
1088
1236
  try { process.kill(session.opencodePid, "SIGKILL"); } catch { /* dead */ }
1089
1237
  }
1090
1238
  } catch { /* already dead */ }
1091
- this.store.updateSession(session.id, { opencodePid: undefined });
1239
+ await this.store.updateSession(session.id, { opencodePid: undefined });
1092
1240
  }
1093
1241
  }
1094
1242
 
1095
- // Restore runtime state from DB
1096
- for (const session of allSessions) {
1097
- 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);
1098
1246
  if (!issue || issue.state === "closed") continue;
1099
1247
  const k = this.sessionKey(session, issue);
1100
1248
  if (session.startedAt != null) this.startedAt.set(k, session.startedAt);
1101
1249
  if (session.progressCommentId) this.progressCommentId.set(k, session.progressCommentId);
1102
1250
  if (session.currentPrompt) this.currentPrompt.set(k, session.currentPrompt);
1103
- // 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
1104
1256
  this.startObserver(issue);
1105
1257
  }
1106
1258
 
@@ -1109,8 +1261,8 @@ export class Engine {
1109
1261
  log.info(`engine: restored runtime state for ${restored} sessions from DB`);
1110
1262
  }
1111
1263
 
1112
- // Recover stuck messages
1113
- const stuck = this.store.getPendingOrRunningMessages();
1264
+ // Recover stuck messages scoped to this daemon's issues.
1265
+ const stuck = await this.store.getOwnedPendingOrRunningMessages(this.daemonId);
1114
1266
  if (stuck.length === 0) return;
1115
1267
 
1116
1268
  log.info(`engine: recovering ${stuck.length} stuck messages`);
@@ -1118,7 +1270,7 @@ export class Engine {
1118
1270
  // Reset running messages to pending
1119
1271
  for (const msg of stuck) {
1120
1272
  if (msg.status === "running") {
1121
- this.store.updateMessageStatus(msg.id, "pending");
1273
+ await this.store.updateMessageStatus(msg.id, "pending");
1122
1274
  }
1123
1275
  }
1124
1276
 
@@ -1131,9 +1283,9 @@ export class Engine {
1131
1283
  }
1132
1284
 
1133
1285
  for (const [sessionId, msgs] of bySession) {
1134
- const session = this.store.getSession(sessionId);
1286
+ const session = await this.store.getSession(sessionId);
1135
1287
  if (!session) continue;
1136
- const issue = this.store.getIssue(session.issueId);
1288
+ const issue = await this.store.getIssue(session.issueId);
1137
1289
  if (!issue || issue.state === "closed") continue;
1138
1290
 
1139
1291
  const k = this.sessionKey(session, issue);
@@ -1147,53 +1299,54 @@ export class Engine {
1147
1299
  const comments = await tracker.listComments(ref).catch((): TrackerComment[] => []);
1148
1300
  if (this.hasRecentBotReply(comments, tracker)) {
1149
1301
  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); }
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); }
1153
1305
  continue;
1154
1306
  }
1155
1307
 
1156
1308
  log.info(`engine: recovering msg ${first.id.slice(0, 8)} for ${k}`);
1157
- this.dequeueOrIdle(k, session, issue, first);
1309
+ await this.dequeueOrIdle(k, session, issue, first);
1158
1310
  }
1159
1311
  }
1160
1312
 
1161
1313
  // ─── API Methods ───
1162
1314
 
1163
- retryMessage(messageId: string): boolean {
1164
- const msg = this.store.getMessage(messageId);
1315
+ async retryMessage(messageId: string): Promise<boolean> {
1316
+ const msg = await this.store.getMessage(messageId);
1165
1317
  if (!msg || msg.status !== "failed") return false;
1166
- const session = this.store.getSession(msg.sessionId);
1318
+ const session = await this.store.getSession(msg.sessionId);
1167
1319
  if (!session) return false;
1168
- const issue = this.store.getIssue(session.issueId);
1320
+ const issue = await this.store.getIssue(session.issueId);
1169
1321
  if (!issue || issue.state === "closed") return false;
1170
1322
 
1171
- this.store.updateMessageStatus(messageId, "pending");
1323
+ await this.store.updateMessageStatus(messageId, "pending");
1172
1324
  const k = this.sessionKey(session, issue);
1173
1325
  if (!this.running.has(k)) {
1174
- this.dequeueOrIdle(k, session, issue, msg);
1326
+ await this.dequeueOrIdle(k, session, issue, msg);
1175
1327
  }
1176
1328
  return true;
1177
1329
  }
1178
1330
 
1179
- getStatus() {
1180
- 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;
1181
1333
  return {
1182
1334
  runningCount: this.running.size,
1183
1335
  runningKeys: [...this.running],
1184
1336
  pendingCount,
1185
1337
  processCount: this.processes.size,
1186
1338
  observedIssues: this.observedIssues.size,
1339
+ daemonId: this.daemonId,
1187
1340
  };
1188
1341
  }
1189
1342
 
1190
- getQueue(): Record<string, number> {
1343
+ async getQueue(): Promise<Record<string, number>> {
1191
1344
  const result: Record<string, number> = {};
1192
- const allPending = this.store.getPendingOrRunningMessages().filter(m => m.status === "pending");
1345
+ const allPending = (await this.store.getOwnedPendingOrRunningMessages(this.daemonId)).filter(m => m.status === "pending");
1193
1346
  for (const msg of allPending) {
1194
- const session = this.store.getSession(msg.sessionId);
1347
+ const session = await this.store.getSession(msg.sessionId);
1195
1348
  if (!session) continue;
1196
- const issue = this.store.getIssue(session.issueId);
1349
+ const issue = await this.store.getIssue(session.issueId);
1197
1350
  if (!issue) continue;
1198
1351
  const k = this.sessionKey(session, issue);
1199
1352
  result[k] = (result[k] ?? 0) + 1;
@@ -1224,7 +1377,7 @@ export class Engine {
1224
1377
  try { process.kill(pid, signal); } catch { /* already dead */ }
1225
1378
  }
1226
1379
 
1227
- forceStop(key: string): boolean {
1380
+ async forceStop(key: string): Promise<boolean> {
1228
1381
  const proc = this.processes.get(key);
1229
1382
  this.stopping.add(key);
1230
1383
  log.warn(`engine: forceStop ${key}, pid=${proc?.pid ?? "none"}`);
@@ -1248,22 +1401,22 @@ export class Engine {
1248
1401
  // Update session and messages
1249
1402
  const parsed = parseKey(key);
1250
1403
  if (parsed) {
1251
- const issue = this.store.findIssue(parsed.trackerType, parsed.scopeKey, parsed.issueId);
1404
+ const issue = await this.store.findIssue(parsed.trackerType, parsed.scopeKey, parsed.issueId);
1252
1405
  if (issue) {
1253
- const session = this.store.getSessionByName(issue.id, parsed.sessionName);
1406
+ const session = await this.store.getSessionByName(issue.id, parsed.sessionName);
1254
1407
  if (session) {
1255
1408
  if (progressId) {
1256
1409
  const ref = this.sessionToRef(session, issue);
1257
1410
  const tracker = this.getTracker(issue.trackerType);
1258
1411
  void tracker.editComment(ref, progressId, `[system] ⛔ **${session.name}** force-stopped.`).catch(() => {});
1259
1412
  }
1260
- const msgs = this.store.getMessagesForSession(session.id);
1413
+ const msgs = await this.store.getMessagesForSession(session.id);
1261
1414
  for (const msg of msgs) {
1262
1415
  if (msg.status === "pending" || msg.status === "running") {
1263
- this.store.updateMessageStatus(msg.id, "failed", "force stopped");
1416
+ await this.store.updateMessageStatus(msg.id, "failed", "force stopped");
1264
1417
  }
1265
1418
  }
1266
- this.store.updateSession(session.id, {
1419
+ await this.store.updateSession(session.id, {
1267
1420
  state: "idle",
1268
1421
  opencodePid: undefined,
1269
1422
  startedAt: undefined,
@@ -1278,6 +1431,7 @@ export class Engine {
1278
1431
  }
1279
1432
 
1280
1433
  destroy() {
1434
+ this.stopHeartbeat();
1281
1435
  if (this.observerTimer) clearInterval(this.observerTimer);
1282
1436
  this.observedIssues.clear();
1283
1437
  for (const [, proc] of this.processes) {