ework-daemon 0.4.28 β†’ 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.28",
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/index.ts CHANGED
@@ -88,6 +88,13 @@ async function boot() {
88
88
  process.on("SIGTERM", () => shutdown("SIGTERM"));
89
89
  process.on("SIGINT", () => shutdown("SIGINT"));
90
90
 
91
+ process.on("unhandledRejection", (reason) => {
92
+ log.error("unhandledRejection (continuing):", reason);
93
+ });
94
+ process.on("uncaughtException", (err) => {
95
+ log.error("uncaughtException (continuing):", err);
96
+ });
97
+
91
98
  const activeCount = (await store.listActiveIssues()).length;
92
99
  log.info(`\n${isTest ? "πŸ§ͺ" : "βœ…"} ework-daemon ready at http://${server.hostname}:${server.port}/webhook`);
93
100
  log.info(` Configure Gitea webhook to POST to /webhook/gitea`);
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);
@@ -562,8 +585,8 @@ export class Engine {
562
585
  const tracker = this.getTracker(ref.trackerType);
563
586
  const scopeKey = tracker.formatScopeKey(ref.scope);
564
587
 
565
- if (this.paused && event.type === "issue_opened") {
566
- log.info(`engine: paused β€” skipping issue_opened for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
588
+ if (this.paused && (event.type === "issue_opened" || event.type === "comment_created")) {
589
+ log.info(`engine: paused β€” skipping ${event.type} for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
567
590
  return;
568
591
  }
569
592
 
@@ -796,13 +819,11 @@ export class Engine {
796
819
 
797
820
  // Kill all running processes for this issue's sessions
798
821
  const sessions = await this.store.getSessionsForIssue(issue.id);
822
+ let killedCount = 0;
799
823
  for (const session of sessions) {
800
824
  const k = this.sessionKey(session, issue);
801
- const proc = this.processes.get(k);
802
- if (proc) {
803
- this.stopping.add(k);
804
- try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
805
- }
825
+ const killed = await this.killSessionProcess(session, k);
826
+ if (killed) killedCount++;
806
827
  this.clearRuntimeState(k);
807
828
  const msgs = await this.store.getMessagesForSession(session.id);
808
829
  for (const msg of msgs) {
@@ -829,7 +850,7 @@ export class Engine {
829
850
  this.cloneUrls.delete(gcKey);
830
851
  this.senders.delete(gcKey);
831
852
 
832
- log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
853
+ log.info(`engine: issue closed, ${killedCount}/${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
833
854
  void tracker.updateStatus(ref, "completed");
834
855
  }
835
856
 
@@ -842,13 +863,11 @@ export class Engine {
842
863
  if (!issue) return;
843
864
  this.stopObserver(issue.id);
844
865
  const sessions = await this.store.getSessionsForIssue(issue.id);
866
+ let killedCount = 0;
845
867
  for (const session of sessions) {
846
868
  const k = this.sessionKey(session, issue);
847
- const proc = this.processes.get(k);
848
- if (proc) {
849
- this.stopping.add(k);
850
- try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
851
- }
869
+ const killed = await this.killSessionProcess(session, k);
870
+ if (killed) killedCount++;
852
871
  this.clearRuntimeState(k);
853
872
  const msgs = await this.store.getMessagesForSession(session.id);
854
873
  for (const msg of msgs) {
@@ -858,7 +877,7 @@ export class Engine {
858
877
  }
859
878
  await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
860
879
  }
861
- log.info(`engine: issue halted, ${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
880
+ log.info(`engine: issue halted, ${killedCount}/${sessions.length} sessions killed for ${scopeKey}#${ref.issueId}`);
862
881
  try { await tracker.createComment(ref, "[system] ⏸️ AI processing halted by user."); } catch { /* tracker unavailable */ }
863
882
  }
864
883
 
@@ -878,6 +897,36 @@ export class Engine {
878
897
  this.generation.delete(k);
879
898
  }
880
899
 
900
+ private async killSessionProcess(session: OpSession, k: string): Promise<boolean> {
901
+ const handle = this.processes.get(k);
902
+ if (handle) {
903
+ this.stopping.add(k);
904
+ try { this.killProcessTree(handle.pid, "SIGTERM"); } catch { /* already dead */ }
905
+ this.processes.delete(k);
906
+ return true;
907
+ }
908
+
909
+ const pid = session.opencodePid;
910
+ if (!pid) return false;
911
+ try {
912
+ process.kill(pid, 0);
913
+ } catch {
914
+ return false;
915
+ }
916
+
917
+ log.info(`engine: killing orphaned pid=${pid} for ${k} (cross-restart)`);
918
+ this.stopping.add(k);
919
+ try {
920
+ this.killProcessTree(pid, "SIGTERM");
921
+ for (let i = 0; i < 30; i++) {
922
+ await new Promise(r => setTimeout(r, 100));
923
+ try { process.kill(pid, 0); } catch { break; }
924
+ }
925
+ try { process.kill(pid, "SIGKILL"); } catch { /* dead */ }
926
+ } catch { /* already dead */ }
927
+ return true;
928
+ }
929
+
881
930
  // ─── Preemptive Scheduler ───
882
931
 
883
932
  private async enqueueOrRun(session: OpSession, issue: Issue, prompt: string, sourceCommentId?: string, model?: string) {
@@ -895,8 +944,8 @@ export class Engine {
895
944
  return;
896
945
  }
897
946
 
898
- if (this.running.size >= this.cfg.work.maxConcurrent) {
899
- 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}`);
900
949
  return;
901
950
  }
902
951
 
@@ -1213,8 +1262,8 @@ export class Engine {
1213
1262
  if (nextMsg) {
1214
1263
  const current = await this.store.getSession(session.id);
1215
1264
  if (current && current.state !== "idle") {
1216
- if (this.running.size >= this.cfg.work.maxConcurrent) {
1217
- 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}`);
1218
1267
  } else {
1219
1268
  await this.dequeueOrIdle(k, current, issue, nextMsg);
1220
1269
  return;
@@ -1229,11 +1278,11 @@ export class Engine {
1229
1278
  }
1230
1279
 
1231
1280
  private async drainGlobalPending(): Promise<void> {
1232
- const slotsAvailable = this.cfg.work.maxConcurrent - this.running.size;
1281
+ const slotsAvailable = this.maxConcurrent - this.running.size;
1233
1282
  if (slotsAvailable <= 0) return;
1234
1283
  const pending = await this.store.getGlobalPendingMessages(slotsAvailable);
1235
1284
  for (const msg of pending) {
1236
- if (this.running.size >= this.cfg.work.maxConcurrent) break;
1285
+ if (this.running.size >= this.maxConcurrent) break;
1237
1286
  const session = await this.store.getSession(msg.sessionId);
1238
1287
  if (!session || session.state === "running") continue;
1239
1288
  const issue = await this.store.getIssue(session.issueId);
@@ -1393,16 +1442,20 @@ export class Engine {
1393
1442
  }
1394
1443
 
1395
1444
  private async runObserverCycle() {
1396
- // Multi-machine: periodically release stale owners so we can adopt their
1397
- // work, and only iterate issues/sessions this daemon owns.
1398
1445
  try {
1399
1446
  await this.store.releaseDeadOwners(this.cfg.work.leaseTtlMs);
1400
1447
  } catch (err) {
1401
1448
  log.error("engine: releaseDeadOwners failed:", (err as Error).message);
1402
1449
  }
1403
1450
 
1404
- const ownedIssues = (await this.store.listOwnedIssues(this.daemonId))
1405
- .filter((i) => this.observedIssues.has(i.id));
1451
+ let ownedIssues;
1452
+ try {
1453
+ ownedIssues = (await this.store.listOwnedIssues(this.daemonId))
1454
+ .filter((i) => this.observedIssues.has(i.id));
1455
+ } catch (err) {
1456
+ log.error("engine: listOwnedIssues failed:", (err as Error).message);
1457
+ return;
1458
+ }
1406
1459
 
1407
1460
  for (const issue of ownedIssues) {
1408
1461
  try {
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
  }