ework-daemon 0.4.34 → 0.4.36

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.34",
3
+ "version": "0.4.36",
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/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
@@ -594,8 +662,8 @@ export class Engine {
594
662
  return;
595
663
  }
596
664
 
597
- if (issueData.ai_status === "halted" && (event.type === "issue_opened" || event.type === "comment_created")) {
598
- log.info(`engine: issue halted — skipping ${event.type} for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
665
+ if ((issueData.ai_status === "halted" || issueData.ai_status === "dispatch_off") && (event.type === "issue_opened" || event.type === "comment_created")) {
666
+ log.info(`engine: issue ${issueData.ai_status} — skipping ${event.type} for ${ref.trackerType}:${scopeKey}#${ref.issueId}`);
599
667
  return;
600
668
  }
601
669
 
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;