ework-daemon 0.4.29 → 0.4.30
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 +15 -0
- package/src/opencode.ts +29 -6
- 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
|
@@ -420,6 +420,21 @@ export class Store {
|
|
|
420
420
|
);
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
async getDaemonCapacity(daemonId: number): Promise<number | null> {
|
|
424
|
+
const row = await getDB().get<{ capacity: number }>(
|
|
425
|
+
"SELECT capacity FROM {{daemons}} WHERE id = ?",
|
|
426
|
+
[daemonId]
|
|
427
|
+
);
|
|
428
|
+
return row ? row.capacity : null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async updateDaemonCapacity(daemonId: number, capacity: number): Promise<void> {
|
|
432
|
+
await getDB().run(
|
|
433
|
+
"UPDATE {{daemons}} SET capacity = ? WHERE id = ?",
|
|
434
|
+
[capacity, daemonId]
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
423
438
|
async markDaemonStatus(daemonId: number, status: "active" | "drained" | "dead"): Promise<void> {
|
|
424
439
|
await getDB().run(
|
|
425
440
|
"UPDATE {{daemons}} SET status = ? WHERE id = ?",
|
package/src/opencode.ts
CHANGED
|
@@ -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);
|
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
|
}
|