ework-daemon 0.4.35 → 0.4.37
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/config.ts +6 -0
- package/src/index.ts +1 -0
- package/src/opencode.ts +81 -2
- package/src/server.ts +6 -0
- package/src/trackers/gitea-tracker.ts +1 -0
- package/src/trackers/types.ts +1 -0
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -26,6 +26,9 @@ export const configSchema = z.object({
|
|
|
26
26
|
host: z.string().default("0.0.0.0"),
|
|
27
27
|
endpoint: z.string().default(""),
|
|
28
28
|
nonWakingAuthors: z.array(z.string()).default([]),
|
|
29
|
+
wakeKinds: z.array(z.string()).default(["human"]),
|
|
30
|
+
wakeLogins: z.array(z.string()).default([]),
|
|
31
|
+
noWakeLogins: z.array(z.string()).default([]),
|
|
29
32
|
}),
|
|
30
33
|
opencode: z.object({
|
|
31
34
|
binary: z.string().default("opencode"),
|
|
@@ -144,6 +147,9 @@ export function loadConfig(): Config {
|
|
|
144
147
|
host: process.env.DAEMON_HOST ?? TEST_DEFAULTS.daemon.host,
|
|
145
148
|
endpoint: process.env.DAEMON_ENDPOINT ?? "",
|
|
146
149
|
nonWakingAuthors: (process.env.WORK_NON_WAKING_AUTHORS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
150
|
+
wakeKinds: (process.env.WORK_WAKE_KINDS ?? "human").split(",").map((s) => s.trim()).filter(Boolean),
|
|
151
|
+
wakeLogins: (process.env.WORK_WAKE_LOGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
152
|
+
noWakeLogins: (process.env.WORK_NO_WAKE_LOGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
147
153
|
},
|
|
148
154
|
opencode: {
|
|
149
155
|
binary: process.env.OPENCODE_BINARY ?? TEST_DEFAULTS.opencode.binary,
|
package/src/index.ts
CHANGED
|
@@ -73,6 +73,7 @@ async function boot() {
|
|
|
73
73
|
if (claimed > 0) log.info(` first-boot migration: claimed ${claimed} previously-ownerless issue(s)`);
|
|
74
74
|
|
|
75
75
|
const engine = new Engine(config, store, trackers, { daemonId });
|
|
76
|
+
engine.restorePausedState();
|
|
76
77
|
engine.startHeartbeat(config.work.heartbeatMs);
|
|
77
78
|
const server = createServer(config, store, engine, trackers);
|
|
78
79
|
|
package/src/opencode.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { mkdirSync, writeFileSync, readdirSync, existsSync, readFileSync } from "fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, unlinkSync, 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";
|
|
@@ -413,12 +413,14 @@ export class Engine {
|
|
|
413
413
|
async pause(): Promise<void> {
|
|
414
414
|
this.paused = true;
|
|
415
415
|
await this.store.markDaemonStatus(this.daemonId, "drained");
|
|
416
|
+
this.persistPaused(true);
|
|
416
417
|
log.info(`engine: daemon ${this.daemonId} paused (drained) — new issues rejected, existing sessions continue`);
|
|
417
418
|
}
|
|
418
419
|
|
|
419
420
|
async resume(): Promise<void> {
|
|
420
421
|
this.paused = false;
|
|
421
422
|
await this.store.markDaemonStatus(this.daemonId, "active");
|
|
423
|
+
this.persistPaused(false);
|
|
422
424
|
log.info(`engine: daemon ${this.daemonId} resumed (active)`);
|
|
423
425
|
}
|
|
424
426
|
|
|
@@ -426,6 +428,72 @@ export class Engine {
|
|
|
426
428
|
return this.paused;
|
|
427
429
|
}
|
|
428
430
|
|
|
431
|
+
getRunningCount(): number {
|
|
432
|
+
return this.running.size;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Force-terminate ALL running sessions on this daemon. Returns kill count.
|
|
437
|
+
* Unlike pause() (which only rejects new work), this actively kills
|
|
438
|
+
* in-progress opencode/Pi processes.
|
|
439
|
+
*/
|
|
440
|
+
async haltAll(): Promise<number> {
|
|
441
|
+
let killed = 0;
|
|
442
|
+
const issues = await this.store.listOwnedIssues(this.daemonId);
|
|
443
|
+
for (const issue of issues) {
|
|
444
|
+
const sessions = await this.store.getSessionsForIssue(issue.id);
|
|
445
|
+
for (const session of sessions) {
|
|
446
|
+
if (session.state !== "running") continue;
|
|
447
|
+
const k = this.sessionKey(session, issue);
|
|
448
|
+
const wasKilled = await this.killSessionProcess(session, k);
|
|
449
|
+
if (wasKilled) killed++;
|
|
450
|
+
this.clearRuntimeState(k);
|
|
451
|
+
const msgs = await this.store.getMessagesForSession(session.id);
|
|
452
|
+
for (const msg of msgs) {
|
|
453
|
+
if (msg.status === "pending" || msg.status === "running") {
|
|
454
|
+
await this.store.updateMessageStatus(msg.id, "interrupted", "halted by admin");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
|
|
458
|
+
const tracker = this.trackers.get(issue.trackerType);
|
|
459
|
+
if (tracker) {
|
|
460
|
+
try {
|
|
461
|
+
await tracker.createComment(
|
|
462
|
+
{ trackerType: issue.trackerType, scope: issue.trackerScope, issueId: issue.trackerIssueId },
|
|
463
|
+
`[system] ⏹️ Session **${session.name}** force-stopped by admin (halt-all).`
|
|
464
|
+
);
|
|
465
|
+
} catch { /* tracker unavailable */ }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
this.paused = true;
|
|
470
|
+
await this.store.markDaemonStatus(this.daemonId, "drained");
|
|
471
|
+
this.persistPaused(true);
|
|
472
|
+
log.info(`engine: haltAll complete — ${killed} sessions killed, daemon ${this.daemonId} now paused`);
|
|
473
|
+
return killed;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private pausedFilePath(): string {
|
|
477
|
+
return join(this.cfg.opencode.baseWorkdir, "..", ".ework-paused.flag");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private persistPaused(paused: boolean): void {
|
|
481
|
+
try {
|
|
482
|
+
const p = this.pausedFilePath();
|
|
483
|
+
if (paused) writeFileSync(p, String(Date.now()));
|
|
484
|
+
else if (existsSync(p)) unlinkSync(p);
|
|
485
|
+
} catch { /* best-effort persistence */ }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
restorePausedState(): void {
|
|
489
|
+
try {
|
|
490
|
+
if (existsSync(this.pausedFilePath())) {
|
|
491
|
+
this.paused = true;
|
|
492
|
+
log.info(`engine: daemon ${this.daemonId} restored paused state from flag file`);
|
|
493
|
+
}
|
|
494
|
+
} catch { /* best-effort */ }
|
|
495
|
+
}
|
|
496
|
+
|
|
429
497
|
/**
|
|
430
498
|
* Ensure this engine owns the issue before doing work on it. Returns true
|
|
431
499
|
* if we own it (either already, or just claimed). Returns false if another
|
|
@@ -601,10 +669,21 @@ export class Engine {
|
|
|
601
669
|
|
|
602
670
|
if (event.type === "comment_created" && event.comment?.author) {
|
|
603
671
|
const author = event.comment.author;
|
|
604
|
-
|
|
672
|
+
const authorKind = event.comment.author_kind ?? "human";
|
|
673
|
+
const d = this.cfg.daemon;
|
|
674
|
+
const noWake = [...d.nonWakingAuthors, ...d.noWakeLogins];
|
|
675
|
+
if (noWake.includes(author)) {
|
|
605
676
|
log.info(`engine: non-waking author ${author} — skipping comment_created for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
606
677
|
return;
|
|
607
678
|
}
|
|
679
|
+
if (d.wakeLogins.length > 0 && !d.wakeLogins.includes(author)) {
|
|
680
|
+
log.info(`engine: author ${author} not in wakeLogins — skipping comment_created for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (!d.wakeKinds.includes(authorKind)) {
|
|
684
|
+
log.info(`engine: author kind ${authorKind} not in wakeKinds [${d.wakeKinds.join(",")}] — skipping comment_created for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
608
687
|
}
|
|
609
688
|
|
|
610
689
|
const issueMapKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
|
package/src/server.ts
CHANGED
|
@@ -82,6 +82,8 @@ export function createServer(
|
|
|
82
82
|
pending: status.pendingCount,
|
|
83
83
|
processes: status.processCount,
|
|
84
84
|
maxConcurrent: engine.getMaxConcurrent(),
|
|
85
|
+
paused: engine.isPaused(),
|
|
86
|
+
runningCount: engine.getRunningCount(),
|
|
85
87
|
observedIssues: status.observedIssues,
|
|
86
88
|
issues: (await store.listAllIssues()).length,
|
|
87
89
|
sessions: (await store.listAllSessions()).length,
|
|
@@ -214,6 +216,10 @@ export function createServer(
|
|
|
214
216
|
await engine.resume();
|
|
215
217
|
return json({ ok: true, paused: false });
|
|
216
218
|
}
|
|
219
|
+
if (pathname === "/api/admin/halt-all" && req.method === "POST") {
|
|
220
|
+
const killed = await engine.haltAll();
|
|
221
|
+
return json({ ok: true, killed, paused: true });
|
|
222
|
+
}
|
|
217
223
|
if (pathname === "/api/admin/max-concurrent" && req.method === "POST") {
|
|
218
224
|
const body = await req.json().catch(() => ({} as unknown));
|
|
219
225
|
const value = (body as { value?: unknown })?.value;
|
|
@@ -175,6 +175,7 @@ export class GiteaTracker implements IssueTracker {
|
|
|
175
175
|
id: String(comment.id),
|
|
176
176
|
body: comment.body as string,
|
|
177
177
|
author: commentUser?.login ?? "",
|
|
178
|
+
author_kind: (comment as Record<string, unknown>).author_kind as string | undefined,
|
|
178
179
|
},
|
|
179
180
|
model,
|
|
180
181
|
cloneUrl,
|
package/src/trackers/types.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface TrackerEvent {
|
|
|
29
29
|
id: string;
|
|
30
30
|
body: string;
|
|
31
31
|
author: string;
|
|
32
|
+
author_kind?: string;
|
|
32
33
|
};
|
|
33
34
|
// Resolved "provider/model" string from ework-web (project override or
|
|
34
35
|
// global default). Empty/undefined = no override; engine omits --model
|