heyio 0.24.2 → 0.26.0

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/dist/config.js CHANGED
@@ -7,6 +7,7 @@ const DEFAULT_CONFIG = {
7
7
  backgroundNotifyMode: "meaningful",
8
8
  backgroundNotifyTelegram: true,
9
9
  backgroundNotifyTui: true,
10
+ watchdogEnabled: true,
10
11
  };
11
12
  function loadConfig() {
12
13
  mkdirSync(IO_HOME, { recursive: true });
package/dist/daemon.js CHANGED
@@ -13,6 +13,7 @@ import { backfillReviewVerdicts } from "./copilot/review-backfill.js";
13
13
  import { startScheduler, stopScheduler } from "./copilot/scheduler.js";
14
14
  import { startIoScheduler, stopIoScheduler } from "./copilot/io-scheduler.js";
15
15
  import { config } from "./config.js";
16
+ import { startWatchdog } from "./watchdog.js";
16
17
  import { ensureWikiStructure } from "./wiki/fs.js";
17
18
  import { autoUpdate } from "./update.js";
18
19
  import { readdirSync, statSync, rmSync } from "fs";
@@ -129,6 +130,12 @@ export async function startDaemon() {
129
130
  // Daily cleanup — prune schedule runs and notifications older than 30 days
130
131
  const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
131
132
  const PRUNE_RETENTION_DAYS = 30;
133
+ // Start event loop watchdog
134
+ let stopWatchdog;
135
+ if (config.watchdogEnabled) {
136
+ stopWatchdog = startWatchdog();
137
+ console.error("[io] Event loop watchdog started");
138
+ }
132
139
  const pruneTimer = setInterval(() => {
133
140
  try {
134
141
  const runsDeleted = pruneOldScheduleRuns(PRUNE_RETENTION_DAYS);
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Daemon liveness watchdog.
3
+ *
4
+ * Uses a periodic setInterval to detect event loop stalls. If the interval
5
+ * fires significantly late, the event loop was blocked for that duration.
6
+ *
7
+ * - Warning logged if stall exceeds WARN_THRESHOLD_MS (30s)
8
+ * - Process exits if stall exceeds FATAL_THRESHOLD_MS (60s), relying on
9
+ * the process supervisor to restart
10
+ */
11
+ const DEFAULT_CHECK_INTERVAL_MS = 30_000;
12
+ const DEFAULT_WARN_THRESHOLD_MS = 30_000;
13
+ const DEFAULT_FATAL_THRESHOLD_MS = 60_000;
14
+ let timer = null;
15
+ let lastTick = 0;
16
+ /**
17
+ * Start the event loop watchdog. Returns a stop function.
18
+ */
19
+ export function startWatchdog(opts = {}) {
20
+ const checkInterval = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
21
+ const warnThreshold = opts.warnThresholdMs ?? DEFAULT_WARN_THRESHOLD_MS;
22
+ const fatalThreshold = opts.fatalThresholdMs ?? DEFAULT_FATAL_THRESHOLD_MS;
23
+ lastTick = Date.now();
24
+ timer = setInterval(() => {
25
+ const now = Date.now();
26
+ const elapsed = now - lastTick;
27
+ const stallMs = elapsed - checkInterval;
28
+ if (stallMs >= fatalThreshold) {
29
+ console.error(`[watchdog] FATAL: event loop stalled for ${Math.round(stallMs / 1000)}s (threshold: ${Math.round(fatalThreshold / 1000)}s) at ${new Date(now).toISOString()}`);
30
+ if (opts.onFatal) {
31
+ opts.onFatal(stallMs);
32
+ }
33
+ else {
34
+ process.exit(1);
35
+ }
36
+ }
37
+ else if (stallMs >= warnThreshold) {
38
+ console.error(`[watchdog] WARNING: event loop stalled for ${Math.round(stallMs / 1000)}s at ${new Date(now).toISOString()}`);
39
+ opts.onStall?.(stallMs);
40
+ }
41
+ lastTick = now;
42
+ }, checkInterval);
43
+ timer.unref();
44
+ return () => {
45
+ if (timer) {
46
+ clearInterval(timer);
47
+ timer = null;
48
+ }
49
+ };
50
+ }
51
+ //# sourceMappingURL=watchdog.js.map
@@ -0,0 +1,83 @@
1
+ import { describe, it, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { startWatchdog } from "./watchdog.js";
4
+ describe("watchdog", () => {
5
+ let stop;
6
+ beforeEach(() => {
7
+ if (stop) {
8
+ stop();
9
+ stop = undefined;
10
+ }
11
+ });
12
+ it("calls onStall when stall exceeds warn threshold", async () => {
13
+ const stalls = [];
14
+ stop = startWatchdog({
15
+ checkIntervalMs: 30,
16
+ warnThresholdMs: 40,
17
+ fatalThresholdMs: 5000,
18
+ onStall: (ms) => stalls.push(ms),
19
+ });
20
+ // Block event loop longer than checkInterval + warnThreshold
21
+ const start = Date.now();
22
+ while (Date.now() - start < 120) {
23
+ // busy-wait
24
+ }
25
+ // Let the interval fire
26
+ await new Promise((resolve) => setTimeout(resolve, 50));
27
+ assert.ok(stalls.length > 0, "onStall should have been called");
28
+ assert.ok(stalls[0] >= 40, `stall duration ${stalls[0]} should be >= 40ms`);
29
+ stop();
30
+ stop = undefined;
31
+ });
32
+ it("calls onFatal when stall exceeds fatal threshold", async () => {
33
+ const fatals = [];
34
+ stop = startWatchdog({
35
+ checkIntervalMs: 30,
36
+ warnThresholdMs: 20,
37
+ fatalThresholdMs: 50,
38
+ onStall: () => { },
39
+ onFatal: (ms) => fatals.push(ms),
40
+ });
41
+ // Block event loop longer than checkInterval + fatalThreshold
42
+ const start = Date.now();
43
+ while (Date.now() - start < 150) {
44
+ // busy-wait
45
+ }
46
+ await new Promise((resolve) => setTimeout(resolve, 50));
47
+ assert.ok(fatals.length > 0, "onFatal should have been called");
48
+ assert.ok(fatals[0] >= 50, `fatal duration ${fatals[0]} should be >= 50ms`);
49
+ stop();
50
+ stop = undefined;
51
+ });
52
+ it("does not fire when event loop is healthy", async () => {
53
+ const stalls = [];
54
+ const fatals = [];
55
+ stop = startWatchdog({
56
+ checkIntervalMs: 30,
57
+ warnThresholdMs: 500,
58
+ fatalThresholdMs: 1000,
59
+ onStall: (ms) => stalls.push(ms),
60
+ onFatal: (ms) => fatals.push(ms),
61
+ });
62
+ // Wait without blocking — interval fires on time
63
+ await new Promise((resolve) => setTimeout(resolve, 100));
64
+ assert.equal(stalls.length, 0, "onStall should not fire for healthy loop");
65
+ assert.equal(fatals.length, 0, "onFatal should not fire for healthy loop");
66
+ stop();
67
+ stop = undefined;
68
+ });
69
+ it("stop function cleans up the interval", () => {
70
+ stop = startWatchdog({
71
+ checkIntervalMs: 30,
72
+ warnThresholdMs: 10,
73
+ fatalThresholdMs: 20,
74
+ onStall: () => { },
75
+ onFatal: () => { },
76
+ });
77
+ // Should not throw on double-stop
78
+ stop();
79
+ stop();
80
+ stop = undefined;
81
+ });
82
+ });
83
+ //# sourceMappingURL=watchdog.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.24.2",
3
+ "version": "0.26.0",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"