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 +1 -1
- package/src/db.ts +41 -9
- package/src/index.ts +7 -0
- package/src/op.ts +15 -0
- package/src/opencode.ts +77 -24
- 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/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
|
|
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
|
|
802
|
-
if (
|
|
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
|
|
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
|
|
848
|
-
if (
|
|
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.
|
|
899
|
-
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}`);
|
|
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.
|
|
1217
|
-
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}`);
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1405
|
-
|
|
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
|
}
|