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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.29",
3
+ "version": "0.4.30",
4
4
  "description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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
- const [rows] = await this.conn.query(this.prepare(sql), params);
228
- return rows as T[];
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
- const [rows] = await this.conn.query(this.prepare(sql), params);
232
- const arr = rows as T[];
233
- return arr[0] ?? null;
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
- const [result] = await this.conn.query(this.prepare(sql), params);
237
- const r = result as ResultSetHeader;
238
- return { insertId: Number(r.insertId), changes: r.affectedRows };
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
- await this.conn.query(this.prepare(sql));
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.cfg.work.maxConcurrent) {
925
- log.info(`engine: concurrency limit reached (${this.running.size}/${this.cfg.work.maxConcurrent}), message ${msg.id.slice(0, 8)} queued for ${k}`);
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.cfg.work.maxConcurrent) {
1243
- log.info(`engine: concurrency limit (${this.running.size}/${this.cfg.work.maxConcurrent}), keeping msg ${nextMsg.id.slice(0, 8)} pending for ${k}`);
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.cfg.work.maxConcurrent - this.running.size;
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.cfg.work.maxConcurrent) break;
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
  }