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/package.json +3 -1
- package/src/config.ts +41 -6
- package/src/db.ts +375 -0
- package/src/index.ts +43 -15
- package/src/op.ts +267 -278
- package/src/opencode.ts +296 -137
- package/src/schema-mysql.sql +74 -0
- package/src/schema-sqlite.sql +68 -0
- package/src/server.ts +14 -14
- package/src/trackers/types.ts +8 -0
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1075
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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) {
|