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