niahere 0.4.4 → 0.4.6

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": "niahere",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -6,6 +6,7 @@ import type { Attachment } from "../../types/attachment";
6
6
  import type { McpSourceContext } from "../../mcp";
7
7
  import { CodexNormalizer } from "./codex-normalize";
8
8
  import { mintRun, revokeRun } from "../mcp-endpoint";
9
+ import { isCliProviderDownError } from "../../utils/retry";
9
10
 
10
11
  /**
11
12
  * Resolve the codex binary's absolute path. The daemon runs under launchd with a
@@ -188,7 +189,7 @@ class CodexSession implements AgentSession {
188
189
  type: "error",
189
190
  message: stderr.trim() || `codex exited ${exit}`,
190
191
  retryable: false,
191
- providerDown: false,
192
+ providerDown: isCliProviderDownError(stderr),
192
193
  };
193
194
  }
194
195
  } finally {
@@ -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
 
@@ -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, getConfiguredChannelNames } from "../channels";
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
- const result = await startChannels();
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(channels);
371
+ await stopChannels(getStarted());
398
372
 
399
373
  try {
400
374
  if (force) {
@@ -3,9 +3,25 @@ import { runJob } from "./runner";
3
3
  import { getConfig } from "../utils/config";
4
4
  import { log } from "../utils/log";
5
5
  import { computeInitialNextRun, computeNextRun } from "../utils/schedule";
6
+ import type { JobResult } from "../types";
6
7
 
7
8
  export { computeInitialNextRun, computeNextRun };
8
9
 
10
+ /**
11
+ * Log a finished job at a level matching its outcome. `runJob` resolves with
12
+ * `status: "error"` instead of throwing, so the caller's `.catch` only ever sees
13
+ * an infrastructure fault — a job that ran and failed must be branched on here
14
+ * or it is indistinguishable from success in the log.
15
+ */
16
+ export function logJobOutcome(result: JobResult): void {
17
+ const fields = { job: result.job, status: result.status, duration: result.duration_ms };
18
+ if (result.status === "error") {
19
+ log.error({ ...fields, error: result.error, terminal_reason: result.terminal_reason }, "scheduler: job failed");
20
+ return;
21
+ }
22
+ log.info(fields, "scheduler: job completed");
23
+ }
24
+
9
25
  function isWithinActiveHours(): boolean {
10
26
  const config = getConfig();
11
27
  const { start, end } = config.activeHours;
@@ -57,11 +73,9 @@ async function tick(): Promise<void> {
57
73
  runningJobs.add(job.name);
58
74
 
59
75
  runJob(job)
60
- .then((result) => {
61
- log.info({ job: job.name, status: result.status, duration: result.duration_ms }, "scheduler: job completed");
62
- })
76
+ .then(logJobOutcome)
63
77
  .catch((err) => {
64
- log.error({ err, job: job.name }, "scheduler: job failed");
78
+ log.error({ err, job: job.name }, "scheduler: job crashed");
65
79
  })
66
80
  .finally(() => {
67
81
  runningJobs.delete(job.name);
@@ -31,6 +31,34 @@ export function isProviderDownError(error: string | null | undefined): boolean {
31
31
  return !trimmed || trimmed.toLowerCase() === "unknown error";
32
32
  }
33
33
 
34
+ const CLI_PROVIDER_DOWN_PATTERNS = [
35
+ /authenticat/i,
36
+ /unauthoriz/i,
37
+ /\b(401|403)\b/,
38
+ /not logged in/i,
39
+ /\blogin\b/i,
40
+ /\bcredentials?\b/i,
41
+ /\bapi key\b/i,
42
+ /(session|token) expired/i,
43
+ /\b(econnrefused|enotfound|etimedout|econnreset)\b/i,
44
+ /connection (refused|reset|timed out)/i,
45
+ /network (error|unreachable)/i,
46
+ /failed to refresh available models/i,
47
+ ];
48
+
49
+ /**
50
+ * Provider-down classification for CLI-subprocess backends (codex), whose
51
+ * failures surface as free-form stderr rather than the Claude SDK's structured
52
+ * error. Matches the ways a CLI reports "I could not reach or authenticate with
53
+ * the provider" — as opposed to a task that ran and genuinely failed, which must
54
+ * stay a real error so the chain does not burn a second backend replaying it.
55
+ */
56
+ export function isCliProviderDownError(stderr: string | null | undefined): boolean {
57
+ const trimmed = stderr?.trim();
58
+ if (!trimmed) return true;
59
+ return CLI_PROVIDER_DOWN_PATTERNS.some((p) => p.test(trimmed));
60
+ }
61
+
34
62
  /** Sleep for ms milliseconds. */
35
63
  export function sleep(ms: number): Promise<void> {
36
64
  return new Promise((r) => setTimeout(r, ms));