niahere 0.4.4 → 0.4.5
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/channels/index.ts +41 -3
- package/src/core/alive.ts +22 -0
- package/src/core/daemon.ts +7 -33
package/package.json
CHANGED
package/src/channels/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Channel } from "../types";
|
|
2
|
-
import { registerChannel, getFactories, trackStarted, clearStarted } from "./registry";
|
|
2
|
+
import { registerChannel, getFactories, trackStarted, clearStarted, getStarted } from "./registry";
|
|
3
3
|
import { log } from "../utils/log";
|
|
4
4
|
import { getConfig } from "../utils/config";
|
|
5
5
|
import { createTelegramChannel } from "./telegram";
|
|
@@ -25,10 +25,12 @@ export interface StartResult {
|
|
|
25
25
|
failed: string[];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export async function startChannels(): Promise<StartResult> {
|
|
28
|
+
export async function startChannels(only?: readonly string[]): Promise<StartResult> {
|
|
29
|
+
const onlySet = only ? new Set(only) : null;
|
|
29
30
|
const pending = getFactories()
|
|
30
31
|
.map((factory) => factory())
|
|
31
|
-
.filter((ch): ch is Channel => ch !== null)
|
|
32
|
+
.filter((ch): ch is Channel => ch !== null)
|
|
33
|
+
.filter((ch) => onlySet === null || onlySet.has(ch.name));
|
|
32
34
|
|
|
33
35
|
if (pending.length === 0) return { started: [], failed: [] };
|
|
34
36
|
|
|
@@ -60,6 +62,42 @@ export async function startChannels(): Promise<StartResult> {
|
|
|
60
62
|
return { started, failed };
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Bring the running channel set back in line with configuration. Starts any
|
|
67
|
+
* configured-but-not-running channels and stops any running-but-unconfigured
|
|
68
|
+
* ones. A no-op when already in sync, so it is safe to call on a timer.
|
|
69
|
+
*
|
|
70
|
+
* This is the recovery path for the boot-time race where channels fail to
|
|
71
|
+
* start because Postgres isn't ready yet (channel `.start()` reads the DB):
|
|
72
|
+
* `startChannels` abandons the failed channels with no retry, so without
|
|
73
|
+
* reconciliation Nia stays alive but deaf on every channel until a manual
|
|
74
|
+
* restart. The alive monitor calls this every healthy heartbeat.
|
|
75
|
+
*/
|
|
76
|
+
export async function reconcileChannels(): Promise<StartResult> {
|
|
77
|
+
const wanted = getConfiguredChannelNames();
|
|
78
|
+
const running = getStarted();
|
|
79
|
+
const runningNames = new Set(running.map((ch) => ch.name));
|
|
80
|
+
const wantedSet = new Set(wanted);
|
|
81
|
+
|
|
82
|
+
const missing = wanted.filter((name) => !runningNames.has(name));
|
|
83
|
+
const extra = running.filter((ch) => !wantedSet.has(ch.name));
|
|
84
|
+
if (missing.length === 0 && extra.length === 0) return { started: [], failed: [] };
|
|
85
|
+
|
|
86
|
+
log.warn({ missing, extra: extra.map((ch) => ch.name) }, "channels out of sync, reconciling");
|
|
87
|
+
|
|
88
|
+
// A channel was removed from config. stopChannels() tears down the whole
|
|
89
|
+
// registry (it stops the shared Twilio server and clears all tracking), so a
|
|
90
|
+
// partial stop isn't possible — rebuild the full configured set instead.
|
|
91
|
+
if (extra.length > 0) {
|
|
92
|
+
await stopChannels(running);
|
|
93
|
+
return wanted.length > 0 ? startChannels() : { started: [], failed: [] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Only additions: start just the missing channels so healthy ones stay
|
|
97
|
+
// connected and one persistently-failing channel can't thrash the rest.
|
|
98
|
+
return startChannels(missing);
|
|
99
|
+
}
|
|
100
|
+
|
|
63
101
|
export function getConfiguredChannelNames(): string[] {
|
|
64
102
|
const { channels } = getConfig();
|
|
65
103
|
if (!channels.enabled) return [];
|
package/src/core/alive.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { log } from "../utils/log";
|
|
2
2
|
import { getConfig } from "../utils/config";
|
|
3
3
|
import { getSql, closeDb } from "../db/connection";
|
|
4
|
+
import { reconcileChannels } from "../channels";
|
|
4
5
|
import { getFailures, type Check } from "./health";
|
|
5
6
|
|
|
6
7
|
const HEARTBEAT_INTERVAL = 60_000; // 60s
|
|
@@ -164,6 +165,23 @@ async function runRecoveryAgent(failures: Check[]): Promise<{ recovered: boolean
|
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Restart any configured channel that isn't currently running. Only acts when
|
|
170
|
+
* the running set drifts from config (the boot-time postgres race is the common
|
|
171
|
+
* cause), and notifies the user once when it recovers channels.
|
|
172
|
+
*/
|
|
173
|
+
async function reconcileDeadChannels(): Promise<void> {
|
|
174
|
+
try {
|
|
175
|
+
const { started } = await reconcileChannels();
|
|
176
|
+
if (started.length > 0) {
|
|
177
|
+
log.warn({ started }, "alive: restarted dead channels");
|
|
178
|
+
await notifyUser(`Channels were down and have been restarted: ${started.join(", ")}.`);
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
log.warn({ err }, "alive: channel reconcile failed");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
167
185
|
async function heartbeat(): Promise<void> {
|
|
168
186
|
const failures = await getFailures();
|
|
169
187
|
const failureNames = failures.map((f) => f.name);
|
|
@@ -176,6 +194,10 @@ async function heartbeat(): Promise<void> {
|
|
|
176
194
|
}
|
|
177
195
|
lastFailures = [];
|
|
178
196
|
recoveryAttempted = false;
|
|
197
|
+
// The DB is healthy, so any channel that failed to start (e.g. it lost the
|
|
198
|
+
// boot-time race against Postgres) can be brought back now. No-op when the
|
|
199
|
+
// running channels already match config.
|
|
200
|
+
await reconcileDeadChannels();
|
|
179
201
|
return;
|
|
180
202
|
}
|
|
181
203
|
|
package/src/core/daemon.ts
CHANGED
|
@@ -8,8 +8,7 @@ import { isRunning, readPid, removePid, writePid } from "../utils/pid";
|
|
|
8
8
|
import { ActiveEngine, Job } from "../db/models";
|
|
9
9
|
import { runMigrations } from "../db/migrate";
|
|
10
10
|
import { closeDb, getSql } from "../db/connection";
|
|
11
|
-
import { registerAllChannels, startChannels, stopChannels, getStarted,
|
|
12
|
-
import type { Channel } from "../types";
|
|
11
|
+
import { registerAllChannels, startChannels, stopChannels, getStarted, reconcileChannels } from "../channels";
|
|
13
12
|
import { startScheduler, stopScheduler, recomputeAllNextRuns } from "./scheduler";
|
|
14
13
|
import { startAlive, stopAlive } from "./alive";
|
|
15
14
|
import { createNiaMcpServer } from "../mcp/server";
|
|
@@ -282,13 +281,13 @@ export async function runDaemon(): Promise<void> {
|
|
|
282
281
|
// composition root) so the endpoint module stays free of the handler chain.
|
|
283
282
|
await startMcpEndpoint(NIA_TOOLS);
|
|
284
283
|
|
|
285
|
-
// Register and start channels
|
|
284
|
+
// Register and start channels. The running set is tracked in the channel
|
|
285
|
+
// registry (getStarted()); the alive monitor reconciles it if any channel
|
|
286
|
+
// fails to start (e.g. it lost the boot-time race against Postgres).
|
|
286
287
|
registerAllChannels();
|
|
287
|
-
let channels: Channel[] = [];
|
|
288
288
|
const config = getConfig();
|
|
289
289
|
if (config.channels.enabled) {
|
|
290
|
-
|
|
291
|
-
channels = result.started;
|
|
290
|
+
await startChannels();
|
|
292
291
|
} else {
|
|
293
292
|
log.info("channels disabled (channels_enabled: false)");
|
|
294
293
|
}
|
|
@@ -353,32 +352,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
353
352
|
process.on("SIGHUP", async () => {
|
|
354
353
|
log.info("received SIGHUP, reloading config");
|
|
355
354
|
resetConfig();
|
|
356
|
-
|
|
357
|
-
const running = getStarted();
|
|
358
|
-
const wantedNames = getConfiguredChannelNames();
|
|
359
|
-
const runningNames = running.map((channel) => channel.name).sort();
|
|
360
|
-
const wantChannels = wantedNames.length > 0;
|
|
361
|
-
const haveChannels = running.length > 0;
|
|
362
|
-
const needsReconcile =
|
|
363
|
-
wantChannels &&
|
|
364
|
-
haveChannels &&
|
|
365
|
-
(wantedNames.length !== runningNames.length || wantedNames.sort().some((name, i) => name !== runningNames[i]));
|
|
366
|
-
|
|
367
|
-
if (wantChannels && !haveChannels) {
|
|
368
|
-
log.info("SIGHUP: starting channels");
|
|
369
|
-
const result = await startChannels();
|
|
370
|
-
channels = result.started;
|
|
371
|
-
} else if (!wantChannels && haveChannels) {
|
|
372
|
-
log.info("SIGHUP: stopping channels");
|
|
373
|
-
await stopChannels(running);
|
|
374
|
-
channels = [];
|
|
375
|
-
} else if (needsReconcile) {
|
|
376
|
-
log.info({ wantedNames, runningNames }, "SIGHUP: reconciling channels");
|
|
377
|
-
await stopChannels(running);
|
|
378
|
-
const result = await startChannels();
|
|
379
|
-
channels = result.started;
|
|
380
|
-
}
|
|
381
|
-
|
|
355
|
+
await reconcileChannels();
|
|
382
356
|
await recomputeAllNextRuns().catch(() => {});
|
|
383
357
|
});
|
|
384
358
|
|
|
@@ -394,7 +368,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
394
368
|
stopAlive();
|
|
395
369
|
stopScheduler();
|
|
396
370
|
stopMcpEndpoint();
|
|
397
|
-
await stopChannels(
|
|
371
|
+
await stopChannels(getStarted());
|
|
398
372
|
|
|
399
373
|
try {
|
|
400
374
|
if (force) {
|