ework-daemon 0.4.29 → 0.4.31
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 +1 -1
- package/src/db.ts +41 -9
- package/src/op.ts +22 -0
- package/src/opencode.ts +59 -7
- package/src/server.ts +9 -0
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -18,6 +18,30 @@ import { createPool, type Pool, type PoolConnection, type ResultSetHeader } from
|
|
|
18
18
|
import { mkdirSync, readFileSync, existsSync } from "fs";
|
|
19
19
|
import { dirname, join } from "path";
|
|
20
20
|
import { homedir } from "os";
|
|
21
|
+
import { log } from "./logger";
|
|
22
|
+
|
|
23
|
+
const RETRYABLE_CODES = ["ETIMEDOUT", "ECONNRESET", "PROTOCOL_CONNECTION_LOST", "PROTOCOL_SEQUENCE_TIMEOUT", "EPIPE"];
|
|
24
|
+
const MAX_DB_RETRIES = 2;
|
|
25
|
+
const DB_RETRY_BASE_MS = 300;
|
|
26
|
+
|
|
27
|
+
async function withDbRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
|
|
28
|
+
let lastErr: unknown;
|
|
29
|
+
for (let attempt = 0; attempt <= MAX_DB_RETRIES; attempt++) {
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
} catch (e) {
|
|
33
|
+
lastErr = e;
|
|
34
|
+
const code = (e as { code?: string })?.code;
|
|
35
|
+
if (!code || !RETRYABLE_CODES.includes(code)) throw e;
|
|
36
|
+
if (attempt < MAX_DB_RETRIES) {
|
|
37
|
+
const delay = DB_RETRY_BASE_MS * Math.pow(2, attempt);
|
|
38
|
+
log.warn(`db: ${label} failed (${code}), retry ${attempt + 1}/${MAX_DB_RETRIES} in ${delay}ms`);
|
|
39
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw lastErr;
|
|
44
|
+
}
|
|
21
45
|
|
|
22
46
|
// ---- public async interface (driver-agnostic) ----
|
|
23
47
|
export interface DbRunResult {
|
|
@@ -224,21 +248,29 @@ class MysqlDriver implements AsyncDatabase {
|
|
|
224
248
|
}
|
|
225
249
|
|
|
226
250
|
async all<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
|
227
|
-
|
|
228
|
-
|
|
251
|
+
return withDbRetry(async () => {
|
|
252
|
+
const [rows] = await this.conn.query(this.prepare(sql), params);
|
|
253
|
+
return rows as T[];
|
|
254
|
+
}, "all");
|
|
229
255
|
}
|
|
230
256
|
async get<T = unknown>(sql: string, params: unknown[] = []): Promise<T | null> {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
257
|
+
return withDbRetry(async () => {
|
|
258
|
+
const [rows] = await this.conn.query(this.prepare(sql), params);
|
|
259
|
+
const arr = rows as T[];
|
|
260
|
+
return arr[0] ?? null;
|
|
261
|
+
}, "get");
|
|
234
262
|
}
|
|
235
263
|
async run(sql: string, params: unknown[] = []): Promise<DbRunResult> {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
264
|
+
return withDbRetry(async () => {
|
|
265
|
+
const [result] = await this.conn.query(this.prepare(sql), params);
|
|
266
|
+
const r = result as ResultSetHeader;
|
|
267
|
+
return { insertId: Number(r.insertId), changes: r.affectedRows };
|
|
268
|
+
}, "run");
|
|
239
269
|
}
|
|
240
270
|
async exec(sql: string): Promise<void> {
|
|
241
|
-
|
|
271
|
+
return withDbRetry(async () => {
|
|
272
|
+
await this.conn.query(this.prepare(sql));
|
|
273
|
+
}, "exec");
|
|
242
274
|
}
|
|
243
275
|
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
244
276
|
if (this.txConn) return fn();
|
package/src/op.ts
CHANGED
|
@@ -260,6 +260,13 @@ export class Store {
|
|
|
260
260
|
return rows.map(rowToSession);
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
async listSessionsWithPid(): Promise<Array<{ id: string; opencodePid: number }>> {
|
|
264
|
+
const rows = await getDB().all<{ uid: string; opencode_pid: number }>(
|
|
265
|
+
"SELECT uid, opencode_pid FROM {{op_sessions}} WHERE opencode_pid IS NOT NULL"
|
|
266
|
+
);
|
|
267
|
+
return rows.map((r) => ({ id: r.uid, opencodePid: r.opencode_pid }));
|
|
268
|
+
}
|
|
269
|
+
|
|
263
270
|
async listNonIdleSessions(): Promise<OpSession[]> {
|
|
264
271
|
const rows = await getDB().all<SessionRow>("SELECT * FROM {{op_sessions}} WHERE state != 'idle'");
|
|
265
272
|
return rows.map(rowToSession);
|
|
@@ -420,6 +427,21 @@ export class Store {
|
|
|
420
427
|
);
|
|
421
428
|
}
|
|
422
429
|
|
|
430
|
+
async getDaemonCapacity(daemonId: number): Promise<number | null> {
|
|
431
|
+
const row = await getDB().get<{ capacity: number }>(
|
|
432
|
+
"SELECT capacity FROM {{daemons}} WHERE id = ?",
|
|
433
|
+
[daemonId]
|
|
434
|
+
);
|
|
435
|
+
return row ? row.capacity : null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async updateDaemonCapacity(daemonId: number, capacity: number): Promise<void> {
|
|
439
|
+
await getDB().run(
|
|
440
|
+
"UPDATE {{daemons}} SET capacity = ? WHERE id = ?",
|
|
441
|
+
[capacity, daemonId]
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
423
445
|
async markDaemonStatus(daemonId: number, status: "active" | "drained" | "dead"): Promise<void> {
|
|
424
446
|
await getDB().run(
|
|
425
447
|
"UPDATE {{daemons}} SET status = ? WHERE id = ?",
|
package/src/opencode.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, readdirSync, existsSync, readFileSync } from "fs";
|
|
3
3
|
import { join, resolve, isAbsolute } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
5
|
import { log } from "./logger";
|
|
@@ -300,6 +300,7 @@ export class Engine {
|
|
|
300
300
|
private readonly takeover: TakeoverStrategy;
|
|
301
301
|
private readonly backend: RuntimeBackend;
|
|
302
302
|
private heartbeatTimer?: ReturnType<typeof setInterval>;
|
|
303
|
+
private maxConcurrent: number;
|
|
303
304
|
|
|
304
305
|
// Runtime state keyed by session key (trackerType:scopeKey#issueId@sessionName)
|
|
305
306
|
private processes = new Map<string, RuntimeHandle>();
|
|
@@ -348,6 +349,7 @@ export class Engine {
|
|
|
348
349
|
this.daemonId = opts.daemonId;
|
|
349
350
|
this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
|
|
350
351
|
this.backend = opts.backend ?? createDefaultBackend(cfg);
|
|
352
|
+
this.maxConcurrent = cfg.work.maxConcurrent;
|
|
351
353
|
this.startGlobalObserver();
|
|
352
354
|
void this.recover();
|
|
353
355
|
}
|
|
@@ -369,9 +371,30 @@ export class Engine {
|
|
|
369
371
|
this.store.heartbeat(this.daemonId).catch((e) => {
|
|
370
372
|
log.error(`engine: heartbeat failed for daemon ${this.daemonId}:`, (e as Error).message);
|
|
371
373
|
});
|
|
374
|
+
void this.syncMaxConcurrent();
|
|
372
375
|
}, intervalMs);
|
|
373
376
|
}
|
|
374
377
|
|
|
378
|
+
private async syncMaxConcurrent(): Promise<void> {
|
|
379
|
+
try {
|
|
380
|
+
const cap = await this.store.getDaemonCapacity(this.daemonId);
|
|
381
|
+
if (cap != null && cap > 0 && cap !== this.maxConcurrent) {
|
|
382
|
+
log.info(`engine: maxConcurrent updated ${this.maxConcurrent} → ${cap} (DB sync)`);
|
|
383
|
+
this.maxConcurrent = cap;
|
|
384
|
+
}
|
|
385
|
+
} catch { /* non-critical */ }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
setMaxConcurrent(n: number): void {
|
|
389
|
+
if (!Number.isFinite(n) || n < 1) return;
|
|
390
|
+
this.maxConcurrent = Math.floor(n);
|
|
391
|
+
log.info(`engine: maxConcurrent set to ${this.maxConcurrent}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
getMaxConcurrent(): number {
|
|
395
|
+
return this.maxConcurrent;
|
|
396
|
+
}
|
|
397
|
+
|
|
375
398
|
stopHeartbeat(): void {
|
|
376
399
|
if (this.heartbeatTimer) {
|
|
377
400
|
clearInterval(this.heartbeatTimer);
|
|
@@ -921,8 +944,8 @@ export class Engine {
|
|
|
921
944
|
return;
|
|
922
945
|
}
|
|
923
946
|
|
|
924
|
-
if (this.running.size >= this.
|
|
925
|
-
log.info(`engine: concurrency limit reached (${this.running.size}/${this.
|
|
947
|
+
if (this.running.size >= this.maxConcurrent) {
|
|
948
|
+
log.info(`engine: concurrency limit reached (${this.running.size}/${this.maxConcurrent}), message ${msg.id.slice(0, 8)} queued for ${k}`);
|
|
926
949
|
return;
|
|
927
950
|
}
|
|
928
951
|
|
|
@@ -1239,8 +1262,8 @@ export class Engine {
|
|
|
1239
1262
|
if (nextMsg) {
|
|
1240
1263
|
const current = await this.store.getSession(session.id);
|
|
1241
1264
|
if (current && current.state !== "idle") {
|
|
1242
|
-
if (this.running.size >= this.
|
|
1243
|
-
log.info(`engine: concurrency limit (${this.running.size}/${this.
|
|
1265
|
+
if (this.running.size >= this.maxConcurrent) {
|
|
1266
|
+
log.info(`engine: concurrency limit (${this.running.size}/${this.maxConcurrent}), keeping msg ${nextMsg.id.slice(0, 8)} pending for ${k}`);
|
|
1244
1267
|
} else {
|
|
1245
1268
|
await this.dequeueOrIdle(k, current, issue, nextMsg);
|
|
1246
1269
|
return;
|
|
@@ -1255,11 +1278,11 @@ export class Engine {
|
|
|
1255
1278
|
}
|
|
1256
1279
|
|
|
1257
1280
|
private async drainGlobalPending(): Promise<void> {
|
|
1258
|
-
const slotsAvailable = this.
|
|
1281
|
+
const slotsAvailable = this.maxConcurrent - this.running.size;
|
|
1259
1282
|
if (slotsAvailable <= 0) return;
|
|
1260
1283
|
const pending = await this.store.getGlobalPendingMessages(slotsAvailable);
|
|
1261
1284
|
for (const msg of pending) {
|
|
1262
|
-
if (this.running.size >= this.
|
|
1285
|
+
if (this.running.size >= this.maxConcurrent) break;
|
|
1263
1286
|
const session = await this.store.getSession(msg.sessionId);
|
|
1264
1287
|
if (!session || session.state === "running") continue;
|
|
1265
1288
|
const issue = await this.store.getIssue(session.issueId);
|
|
@@ -1594,6 +1617,8 @@ export class Engine {
|
|
|
1594
1617
|
log.error("engine: releaseDeadOwners at boot failed:", (err as Error).message);
|
|
1595
1618
|
}
|
|
1596
1619
|
|
|
1620
|
+
await this.cleanupGlobalOrphans();
|
|
1621
|
+
|
|
1597
1622
|
// Multi-machine: recover ONLY this daemon's sessions. Other daemons own
|
|
1598
1623
|
// the rest; touching their state would race them.
|
|
1599
1624
|
const ownedSessions = await this.store.listOwnedSessions(this.daemonId);
|
|
@@ -1688,6 +1713,33 @@ export class Engine {
|
|
|
1688
1713
|
}
|
|
1689
1714
|
}
|
|
1690
1715
|
|
|
1716
|
+
private async cleanupGlobalOrphans(): Promise<void> {
|
|
1717
|
+
let sessions: Array<{ id: string; opencodePid: number }>;
|
|
1718
|
+
try {
|
|
1719
|
+
sessions = await this.store.listSessionsWithPid();
|
|
1720
|
+
} catch { return; }
|
|
1721
|
+
let killed = 0;
|
|
1722
|
+
for (const s of sessions) {
|
|
1723
|
+
let alive = false;
|
|
1724
|
+
try { process.kill(s.opencodePid, 0); alive = true; } catch { /* dead */ }
|
|
1725
|
+
if (!alive) continue;
|
|
1726
|
+
|
|
1727
|
+
let ppid = -1;
|
|
1728
|
+
try {
|
|
1729
|
+
const stat = readFileSync(`/proc/${s.opencodePid}/stat`, "utf8");
|
|
1730
|
+
const m = stat.match(/\)\s+\S+\s+(\d+)/);
|
|
1731
|
+
ppid = m ? Number(m[1]) : -1;
|
|
1732
|
+
} catch { continue; }
|
|
1733
|
+
|
|
1734
|
+
if (ppid === 1) {
|
|
1735
|
+
log.info(`engine: killing global orphan pid=${s.opencodePid} (PPID=1, session ${s.id})`);
|
|
1736
|
+
try { this.killProcessTree(s.opencodePid); } catch { /* dead */ }
|
|
1737
|
+
killed++;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
if (killed > 0) log.info(`engine: cleaned up ${killed} orphaned processes (PPID=1)`);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1691
1743
|
// ─── API Methods ───
|
|
1692
1744
|
|
|
1693
1745
|
async retryMessage(messageId: string): Promise<boolean> {
|
package/src/server.ts
CHANGED
|
@@ -213,6 +213,15 @@ export function createServer(
|
|
|
213
213
|
await engine.resume();
|
|
214
214
|
return json({ ok: true, paused: false });
|
|
215
215
|
}
|
|
216
|
+
if (pathname === "/api/admin/max-concurrent" && req.method === "POST") {
|
|
217
|
+
const body = await req.json().catch(() => ({} as unknown));
|
|
218
|
+
const value = (body as { value?: unknown })?.value;
|
|
219
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
|
|
220
|
+
return json({ error: "value must be a positive number" }, 400);
|
|
221
|
+
}
|
|
222
|
+
engine.setMaxConcurrent(Math.floor(value));
|
|
223
|
+
return json({ ok: true, maxConcurrent: engine.getMaxConcurrent() });
|
|
224
|
+
}
|
|
216
225
|
|
|
217
226
|
return json({ error: "not found" }, 404);
|
|
218
227
|
}
|