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/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 +287 -133
- 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,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
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1075
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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) {
|