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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
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/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
- if (this.cfg.daemon.nonWakingAuthors.includes(author)) {
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,
@@ -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