Compare commits

..
16 Commits
Author SHA1 Message Date
kamilkrzywda b54f909fbc Merge pull request 'Ignore 0% battery readings in low-battery alerting' (#55) from fix/battery-zero-ignore into main
CI / build + unit (push) Successful in 1m10s
2026-10-06 12:53:41 +02:00
kamilkrzywda 7ec97a06d2 Ignore 0% battery readings in low-battery alerting
CI / build + unit (pull_request) Successful in 1m18s
A 0% batteryLevel is an invalid/no-data value (no battery wired or
misconfigured node), not a genuinely critical battery, so it must not
produce a "⚠️ battery low" alert. Add a non-positive guard to
shouldAlert after the undefined check: 0 (and any non-positive) now
skips, while 1-20 still alert and >20 still doesn't. Threshold/cooldown/
batching unchanged.

Tests: 3 new specs in the check-threshold block (0% excluded from the
batch, lone 0% sends nothing, 1% still alerts). 180 -> 183.
2026-10-06 08:02:37 +00:00
kamilkrzywda 84915fe314 Merge pull request 'fix(notifier): migrate opencode notifier to v2 routes (v0.21.11)' (#54) from fix/opencode-v2-notifier into main
CI / build + unit (push) Successful in 22s
2026-10-05 15:25:38 +02:00
kamilkrzywda 5506fb9330 fix(notifier): migrate opencode notifier to v2 routes (v0.21.11)
CI / build + unit (pull_request) Successful in 46s
CodeNomad's per-workspace instance proxy was upgraded and now only
serves opencode v2 /api/* routes; the v1 /session/... routes 403, which
broke all notifications.

- Discovery: GET {api}{proxyPath}/api/session (v2 {data,cursor} envelope)
  instead of GET {base}/session; sessions filtered by location.directory
  and the max time.updated entry is picked.
- Send: POST {api}{proxyPath}/api/session/{sid}/prompt with a {text}
  body instead of POST {base}/session/{id}/message with {parts:[...]}.
- v2 returns 2xx immediately, so the 15 s read-timeout-as-delivered
  branch is kept only as a harmless safety net.
- Updated the spec to the v2 routes/body/envelope (incl. a
  directory-filter assertion) and kept AGENTS.md in sync.
2026-10-05 12:48:47 +00:00
kamilkrzywda beebcd5851 Merge pull request 'Container TZ: Europe/Warsaw so cron hours are local (v0.21.10)' (#53) from fix/tz-warsaw into main
CI / build + unit (push) Successful in 1m16s
2026-09-30 07:49:50 +02:00
kamilkrzywda 5a1493fb4a Run container in Europe/Warsaw so cron hours and logs use local Polish time (v0.21.10)
CI / build + unit (pull_request) Successful in 50s
2026-09-30 05:47:10 +00:00
kamilkrzywda 8fb16217f7 Merge pull request 'Add #loser command — worst packet delivery record (v0.21.9)' (#52) from fix/loser-command into main
CI / build + unit (push) Successful in 1m36s
2026-09-29 23:01:29 +02:00
kamilkrzywda 3b1f5de101 Add #loser command — worst packet delivery record over directed sends (v0.21.9)
CI / build + unit (pull_request) Successful in 48s
2026-09-29 20:58:43 +00:00
kamilkrzywda 46d32b3553 Merge pull request 'Fix unhandled ECONNRESET in fake-bridge teardown — npm test exits 0 again (v0.21.8)' (#51) from fix/spec-teardown-econnreset into main
CI / build + unit (push) Successful in 1m11s
2026-09-27 09:18:09 +02:00
kamilkrzywda e20e11e2d5 Fix npm test exit 1 — uncaught ECONNRESET from fake-bridge sockets without error handlers (v0.21.8)
CI / build + unit (pull_request) Successful in 46s
2026-09-27 07:13:56 +00:00
kamilkrzywda 0b20df242c Merge pull request 'Fix double ScheduleModule.forRoot() — all cron jobs fired twice (v0.21.7)' (#50) from fix/schedule-double-registration into main
CI / build + unit (push) Failing after 34s
2026-09-27 08:52:35 +02:00
kamilkrzywda a16b6c19d4 Fix double ScheduleModule.forRoot() registration doubling every cron/interval job (v0.21.7)
CI / build + unit (pull_request) Failing after 22s
2026-09-27 06:51:50 +00:00
kamilkrzywda 9e558fd95e Merge pull request 'Add #help command with short list of all commands (v0.21.6)' (#49) from fix/help-command into main
CI / build + unit (push) Failing after 34s
2026-09-26 12:54:06 +02:00
kamilkrzywda 70be8bdaa7 Add #help command — short single-message list of all commands (v0.21.6)
CI / build + unit (pull_request) Failing after 21s
Owner request: 'now add #help with short list of all command'. Registered in
the CommandService constructor next to #ping; returns a SINGLE multi-line
string (one packet, ~203 chars) — not an array, which would drip-feed one
spaced packet per line through the v0.21.5 MESH_MULTI_SEND_DELAY_MS loop.

Also hardened the v0.21.5 multi-send-spacing spec: its wall-clock assertion
('first send < 8 ms after t0') flaked under full-suite load — replaced with
fake timers, which assert deterministically that line 1 is out before any time
elapses and lines 2-3 land exactly one delay apart.
2026-09-26 10:52:18 +00:00
kamilkrzywda 847c3d47d0 Merge pull request 'Space multi-line command replies 5 s apart (v0.21.5)' (#48) from fix/multi-send-spacing into main
CI / build + unit (push) Failing after 46s
2026-09-26 12:31:22 +02:00
kamilkrzywda 1b768b14a7 Space multi-line command replies MESH_MULTI_SEND_DELAY_MS apart (v0.21.5)
CI / build + unit (pull_request) Failing after 21s
Production incident: '#rss get lublin' returned an array of lines and
CommandService sent each one back-to-back within the same second — on the real
mesh 3 of 4 items were dropped in RF (the owner saw only the last one).

CommandService.onRx now waits MESH_MULTI_SEND_DELAY_MS (new env, default 5000 =
5 s, read at construction like the other timing knobs) between consecutive
lines of an array reply — no delay before the first line. The delay sits in the
shared per-line loop, so both the respond() path and the legacy sendText
fallback are covered by one await.
2026-09-26 10:29:21 +00:00
13 changed files with 423 additions and 48 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -1,5 +1,41 @@
# Changelog
## v0.21.12 — 2026-10-06 08:01
- Battery monitoring now ignores a 0% reading: a 0 (or any non-positive) `batteryLevel` is treated as an invalid/no-data value (node has no battery wired or is misconfigured) and no longer produces a "⚠️ battery low" alert, while genuinely-low readings (1–20%) still alert exactly as before. One-line guard added to `BatteryAlertService.shouldAlert` right after the `undefined` check. Tests: 3 new specs in the `check threshold` block — a 0% node is excluded from the batch (and a lone 0% sends nothing) while a 1% node still alerts.
## v0.21.11 — 2026-10-05 14:47
- Migrated the opencode notifier to the v2 API routes: discovery is now `GET {api}{proxyPath}/api/session` (a `{ data, cursor }` envelope; sessions filtered by `location.directory`, most-recent `time.updated` wins) and send is now `POST {api}{proxyPath}/api/session/{sid}/prompt` with a `{text}` body, instead of the v1 `GET /session` + `POST /session/{id}/message` (`{parts:[{type:"text",text}]}`) routes. CodeNomad's per-workspace instance proxy was upgraded and now only serves opencode v2 `/api/*` — every v1 `/session/…` call 403s, which was breaking all notifications. v2 returns 2xx immediately (no longer holds the response until the turn finishes), so the 15 s read-timeout-as-delivered branch is kept only as a harmless safety net.
## v0.21.10 — 2026-09-30 05:46
- Container now runs with `TZ=Europe/Warsaw` (new line in the bot service's `environment:` block, hardcoded like MESH_HOST/MESH_PORT): scheduled jobs and log timestamps use local Polish time instead of UTC — the weather cron (`WEATHER_CRON=0 6,12,18 * * *`) previously fired at 06/12/18 UTC = 08/14/20 CEST. DST-aware via the IANA zone (CEST +2 / CET +1). Deployment-config only — no application code changed; takes effect on the next `docker compose up -d --build`.
## v0.21.9 — 2026-09-29 20:56
- New `#loser` command (owner request): replies with which user/node has the worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide loss stats, so it groups our own tx history by destination and counts delivered vs failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and the broadcast addr is never a real destination even if a NACK lands on it). Loser = most failures, tie-break worse failure ratio. Node resolved to `longName`/`shortName`, falling back to the `@!xxxxxxxx` mention when unknown. Three reply shapes: worst delivery line (`worst delivery: <name> (@!hex) - N failed / M delivered`), a "no failed sends yet" line, and a "not enough data yet — only directed sends count (broadcasts have no acks)" line. Registered in the CommandService constructor next to `#ping`; listed in `#help`.
- Tests: 175 → 180 — new `#loser` specs cover all three reply cases, broadcast/pending exclusion, the unknown-node `@!hex` fallback, and the failure-ratio tie-break; the `#help` spec now also asserts `#loser` is listed.
## v0.21.8 — 2026-09-27 07:13
- Fixed `npm test` exiting 1 with "Errors 2" even though all tests pass (every CI run red since v0.21.2): the two watchdog specs that force a reconnect destroy the service's socket while data is still pending in its receive buffer (the overflow spec floods 300 KB and destroys on the first ~64 KB chunk; the garbage spec destroys right after its last write), so the kernel sends RST instead of FIN and the fake bridge's accepted server-side sockets emit `read ECONNRESET`. Those test-owned sockets had no `'error'` handler, so Node threw it as an uncaught exception that vitest counts as a run-level failure. `startBridge()` now attaches a no-op `'error'` handler to every accepted socket (single registration point, covers all four spec files using the fake bridge). Test-only change — `fake-bridge.ts` is imported by specs only; no runtime diff, no deploy needed.
## v0.21.7 — 2026-09-27 06:50
- Fixed double registration of scheduled jobs: `ScheduleModule.forRoot()` was called from both `WeatherModule` and `BatteryAlertModule`, so Nest created two ScheduleExplorers, each scanning all providers — every @Cron/@Interval job (hourly weather broadcast, per-minute battery check) fired twice. Removed the call from `battery.module.ts`; the single registration in `weather.module.ts` remains.
- Tests: 174 → 175 — new regression spec (`src/schedule-singleton.spec.ts`) asserts `ScheduleModule.forRoot(` appears exactly once across all `src/**/*.module.ts`.
## v0.21.6 — 2026-09-26 10:37
- New `#help` command (owner request): replies with a short list of all available commands as ONE multi-line message (single packet, not one spaced packet per line) — `#ping`, the `#rss help/add/remove/get/sources/tags` family. Registered in the CommandService constructor next to `#ping`.
- Tests: 173 → 174 — `#help` dispatches through respond() exactly once with text containing both `#ping` and `#rss`. (Also hardened the v0.21.5 multi-send-spacing spec, which flaked under full-suite load: it now uses fake timers to assert deterministically that line 1 goes out before any time elapses and lines 2-3 land exactly one delay apart.)
## v0.21.5 — 2026-09-26 10:26
- Multi-line command replies are now spaced apart: `CommandService.onRx` waits `MESH_MULTI_SEND_DELAY_MS` (new env, default **5000** = 5 s, read at construction) between consecutive lines of an array reply — no delay before the first line. Production incident: `#rss get lublin` sent its items back-to-back within the same second and 3 of 4 were dropped in RF (the owner saw only the last one); each packet now gets time to land.
- Tests: 172 → 173 — a 3-line reply with an overridden short delay asserts all lines are sent, the first goes out immediately (no pre-delay), and each subsequent line lands ≥ the configured gap after the previous one; existing specs pin `MESH_MULTI_SEND_DELAY_MS=0` in `beforeEach` so they stay fast.
## v0.21.4 — 2026-09-26 09:51
- `LLM_TIMEOUT_MS` default raised from 60000 (60 s) to **180000** (3 min): production incident — `#rss get lublin` at 9:41 AM failed with "The operation was aborted due to timeout" because the local OpenAI-compatible server (qwen3.8-27b, uncontrollable reasoning tokens) was slow/busy on that call; the same endpoint answers a trivial prompt in ~1 s when idle, so this is latency variance, not a dead endpoint. Still env-overridable (`LLM_TIMEOUT_MS`), only the fallback changed.
+1
View File
@@ -2,6 +2,7 @@ services:
bot:
build: .
environment:
TZ: Europe/Warsaw
MESH_HOST: 192.168.1.4
MESH_PORT: '4403'
MESH_DB_PATH: ./data/mesh-bot.db
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mesh-bot",
"version": "0.21.4",
"version": "0.21.12",
"description": "",
"author": "",
"private": true,
+1 -2
View File
@@ -1,10 +1,9 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { MeshModule } from '../mesh/mesh.module.js';
import { BatteryAlertService } from './battery.service.js';
@Module({
imports: [ScheduleModule.forRoot(), MeshModule],
imports: [MeshModule],
providers: [BatteryAlertService],
exports: [BatteryAlertService],
})
+41
View File
@@ -119,6 +119,47 @@ describe('BatteryAlertService check threshold', () => {
expect(sendText).not.toHaveBeenCalled();
});
it('ignores a 0% reading (invalid/no battery) even though 0 <= threshold', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
// 0% must not alert, but a genuinely-low 5% node in the same batch must
const svc = makeService([node(1, 0), node(2, 5)], sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1);
// only the 5% node — the 0% node is excluded entirely
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ battery low: @!00000002 5%',
undefined,
2,
]);
});
it('ignores a lone 0% reading (no broadcast at all)', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService([node(1, 0)], sendText); // only an invalid 0% reading
await svc.check();
expect(sendText).not.toHaveBeenCalled();
});
it('still alerts a genuine 1% reading (lowest valid value)', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService([node(1, 1)], sendText);
await svc.check();
expect(sendText).toHaveBeenCalledTimes(1);
expect(sendText.mock.calls[0]).toEqual([
BROADCAST_ADDR,
'⚠️ battery low: @!00000001 1%',
undefined,
2,
]);
});
it('merges all qualifying nodes into exactly ONE broadcast with the broadcast target', async () => {
const sendText = vi.fn(async (..._args: unknown[]) => 1);
const svc = makeService(
+1
View File
@@ -60,6 +60,7 @@ export class BatteryAlertService {
private shouldAlert(node: NodeSummary, now: number): boolean {
if (node.batteryLevel === undefined) return false; // no battery reported — skip
if (node.batteryLevel <= 0) return false; // 0/invalid reading — no battery wired or misconfigured node, not a real low-battery alert
// <= on purpose: a node at exactly the threshold is already "low" (spec allowed either < or <=; tests pin this choice)
if (!(node.batteryLevel <= this.threshold)) return false;
const last = this.lastAlertAt.get(node.num);
+166 -1
View File
@@ -13,7 +13,11 @@ import {
KAMIL_NUM,
} from '../mesh/fake-bridge.js';
import { BROADCAST_ADDR } from '../mesh/codec.js';
import type { MeshService } from '../mesh/mesh.service.js';
import type {
MessageRecord,
MeshService,
NodeSummary,
} from '../mesh/mesh.service.js';
import { MeshService as RealMeshService } from '../mesh/mesh.service.js';
import { CommandService } from './command.service.js';
@@ -30,6 +34,7 @@ function makeService(
beforeEach(() => {
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
process.env.MESH_MULTI_SEND_DELAY_MS = '0'; // no spacing under test unless a test opts in — the real default (5000) would slow the suite down
});
describe('CommandService.onRx', () => {
@@ -40,6 +45,16 @@ describe('CommandService.onRx', () => {
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('replies #help with a short single-message list of all commands', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#help', 42, 7);
expect(mesh.respond).toHaveBeenCalledTimes(1); // one packet — not one per line
const text = mesh.respond.mock.calls[0][1];
expect(text).toContain('#ping');
expect(text).toContain('#loser');
expect(text).toContain('#rss');
});
it('falls back to a directed sendText on COMMAND_CHANNEL when the trigger has no packet id (DM)', async () => {
const [svc, mesh] = makeService();
await svc.onRx(2, '#ping', 42); // from present but no id — defensive path
@@ -124,6 +139,37 @@ describe('CommandService.onRx', () => {
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('spaces consecutive multi-line sends by MESH_MULTI_SEND_DELAY_MS — no delay before the first line', async () => {
process.env.MESH_MULTI_SEND_DELAY_MS = '50'; // short so the test stays fast
let sends = 0;
const respond = vi.fn(async () => {
sends++;
return { id: 1 };
});
vi.useFakeTimers();
try {
const [svc, mesh] = makeService(undefined, respond);
svc.register('multi3', () => ['a: one', 'b: two', 'c: three']);
const p = svc.onRx(2, '#multi3', 42, 9);
// flush microtasks without advancing time — the first line must already be out (no delay before it)
await vi.advanceTimersByTimeAsync(0);
expect(sends).toBe(1);
// each subsequent line waits exactly MESH_MULTI_SEND_DELAY_MS after the previous one landed
await vi.advanceTimersByTimeAsync(50);
expect(sends).toBe(2);
await vi.advanceTimersByTimeAsync(50);
expect(sends).toBe(3);
await p;
expect(mesh.respond).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
delete process.env.MESH_MULTI_SEND_DELAY_MS;
}
});
it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => {
const [svc, mesh] = makeService();
svc.register('multi', () => ['one: first item', 'two: second item']);
@@ -164,6 +210,125 @@ describe('CommandService.onRx', () => {
});
});
describe('#loser', () => {
const tx = (
id: number,
to: number,
status: MessageRecord['status'],
): MessageRecord => ({
id,
dir: 'tx',
to,
text: 'hi',
channel: 3,
at: Date.now(),
status,
});
const makeLoserMesh = (
messages: MessageRecord[],
nodes: NodeSummary[],
): [CommandService, MeshService] => {
const mesh = {
sendText: vi.fn(),
respond: vi.fn().mockResolvedValue({ id: 1 }),
messages: () => messages,
nodes: async () => nodes,
} as unknown as MeshService;
return [new CommandService(mesh), mesh];
};
it('reports the node with the most failures — named via longName, broadcasts and pending sends excluded', async () => {
const messages = [
tx(10, BROADCAST_ADDR, 'failed'), // firmware quirk: a broadcast NACK is never a real destination
tx(9, 222, 'failed'), // Alice: 3 failed / 0 delivered → loser
tx(8, 111, 'delivered'), // Bob: 1 failed / 2 delivered
tx(7, 222, 'failed'),
tx(6, 111, 'failed'),
tx(5, 333, 'pending'), // no final status yet — ignored
tx(4, 222, 'failed'),
{
id: 3,
dir: 'rx',
from: 999,
text: 'hello',
channel: 0,
at: Date.now(),
},
];
const nodes = [
{ num: 111, shortName: 'Bob', lastHeard: null },
{ num: 222, longName: 'Alice', lastHeard: null },
];
const [svc, mesh] = makeLoserMesh(messages, nodes);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'worst delivery: Alice (@!000000de) - 3 failed / 0 delivered',
);
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('says no failed sends when the directed history is all delivered', async () => {
const messages = [tx(2, 5, 'delivered'), tx(1, 6, 'delivered')];
const [svc, mesh] = makeLoserMesh(messages, []);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'no failed sends yet - everyone delivers :)',
);
});
it('explains the directed-only rule when there is no final-status data at all', async () => {
const messages = [tx(2, BROADCAST_ADDR, 'pending'), tx(1, 5, 'pending')];
const [svc, mesh] = makeLoserMesh(messages, []);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'not enough data yet - only directed sends count (broadcasts have no acks)',
);
});
it('falls back to the @!hex mention when the worst node has no known name', async () => {
const messages = [tx(1, 600, 'failed')];
const nodes = [{ num: 5, longName: 'Someone Else', lastHeard: null }];
const [svc, mesh] = makeLoserMesh(messages, nodes);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'worst delivery: @!00000258 - 1 failed / 0 delivered',
);
});
it('tie-breaks equal failure counts by the worse failure ratio', async () => {
const messages = [
// node 7: 1 failed / 9 delivered (ratio 0.1) — not the loser
tx(16, 8, 'failed'), // node 8: 1 failed / 1 delivered (ratio 0.5) → loser
tx(15, 8, 'delivered'),
tx(14, 7, 'failed'),
...Array.from({ length: 9 }, (_, i) =>
tx(i + 1, 7, 'delivered' as const),
),
];
const [svc, mesh] = makeLoserMesh(messages, []);
await svc.onRx(2, '#loser', 42, 7);
expect(mesh.respond).toHaveBeenCalledWith(
7,
'worst delivery: @!00000008 - 1 failed / 1 delivered',
);
});
});
describe('#rss prefix dispatch', () => {
let dir: string | undefined;
+75 -2
View File
@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
// pure formatting helper (no cycle: battery → mesh only)
import { mention } from '../battery/battery.service.js';
import { BROADCAST_ADDR } from '../mesh/codec.js';
// type-only: the instances come from DI (command.module.ts factory) or direct construction in tests — no runtime import cycles with src/mesh / src/rss
import type { MeshService } from '../mesh/mesh.service.js';
@@ -27,6 +29,11 @@ export class CommandService {
process.env.COMMAND_CHANNEL ?? '2',
10,
);
// spacing between consecutive multi-line reply messages — back-to-back packets get dropped in RF on a real mesh; each packet needs time to land. Read at construction like the other env config.
private readonly multiSendDelayMs = parseInt(
process.env.MESH_MULTI_SEND_DELAY_MS ?? '5000',
10,
);
// single registration point: the constructor registers every built-in command — add the next one there (or via register())
private readonly commands = new Map<string, CommandHandler>();
// prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name
@@ -38,9 +45,68 @@ export class CommandService {
rss?: RssService,
) {
this.register('ping', () => '#pong!');
this.register('loser', () => this.loserReply());
// single multi-line string = ONE packet — not an array, which would drip-feed one spaced packet per line
this.register('help', () =>
[
'#ping - alive check',
'#loser - worst delivery record',
'#rss help - rss subcommands',
'#rss add <url> <name> - add feed with AI tags',
'#rss remove <name> - drop feed',
'#rss get <name|#tag> - news summary',
'#rss sources / #rss tags - list feeds/tags',
].join('\n'),
);
if (rss) this.registerPrefix('rss', (ctx) => rss.handle(ctx));
}
/**
* #loser — worst packet delivery record, over directed sends only. Meshtastic shares no mesh-wide
* loss stats, so the only real data is our own tx history: group by destination, count delivered vs
* failed per node; broadcasts are excluded (firmware strips want_ack pre-TX, they stay pending — and
* the broadcast addr is never a real destination even if a NACK ever lands on it). Loser = most
* failures, tie-break worse failure ratio. One reply line, ≤ ~180 chars.
*/
private async loserReply(): Promise<string> {
const mesh = this.mesh;
if (!mesh)
return 'not enough data yet - only directed sends count (broadcasts have no acks)';
const stats = new Map<number, { failed: number; delivered: number }>();
for (const m of mesh.messages()) {
if (m.dir !== 'tx' || m.to === undefined || m.to === BROADCAST_ADDR)
continue; // directed sends only
if (m.status !== 'delivered' && m.status !== 'failed') continue; // pending = no final status yet
const s = stats.get(m.to) ?? { failed: 0, delivered: 0 };
if (m.status === 'failed') s.failed += 1;
else s.delivered += 1;
stats.set(m.to, s);
}
if (stats.size === 0)
return 'not enough data yet - only directed sends count (broadcasts have no acks)';
let worst: { num: number; failed: number; total: number } | undefined;
for (const [num, s] of stats) {
if (s.failed === 0) continue; // zero-failure nodes can't be the loser
const total = s.failed + s.delivered;
// most failures first, then worse failure ratio on a tie
if (
!worst ||
s.failed > worst.failed ||
(s.failed === worst.failed &&
s.failed / total > worst.failed / worst.total)
) {
worst = { num, failed: s.failed, total };
}
}
if (!worst) return 'no failed sends yet - everyone delivers :)';
const names = new Map(
(await mesh.nodes()).map((n) => [n.num, n.longName ?? n.shortName]),
);
const name = names.get(worst.num);
const who = name ? `${name} (${mention(worst.num)})` : mention(worst.num);
return `worst delivery: ${who} - ${worst.failed} failed / ${worst.total - worst.failed} delivered`;
}
/** Register a command handler (names are case-sensitive; the leading `#` is stripped before lookup). */
register(name: string, fn: CommandHandler): void {
this.commands.set(name, fn);
@@ -83,8 +149,15 @@ export class CommandService {
try {
const reply = await handler({ text: trimmed, from, id });
if (reply === undefined || reply === null) return; // void = no reply
// an array reply sends one message per line, all threaded to the same trigger packet id
for (const line of Array.isArray(reply) ? reply : [reply]) {
// an array reply sends one message per line, all threaded to the same trigger packet id — spaced so each packet has time to land in RF (no delay before the first line)
const lines = Array.isArray(reply) ? reply : [reply];
for (let i = 0; i < lines.length; i++) {
if (i > 0 && this.multiSendDelayMs > 0) {
await new Promise((resolve) =>
setTimeout(resolve, this.multiSendDelayMs),
);
}
const line = lines[i];
if (id !== undefined && this.mesh) {
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
await this.mesh.respond(id, line);
+2
View File
@@ -10,6 +10,8 @@ export function startBridge(
): Promise<{ port: number; sockets: net.Socket[]; close: () => void }> {
const sockets: net.Socket[] = [];
const server = net.createServer((socket) => {
// Test-owned socket: the service destroys its end mid-test with unread data pending (overflow/garbage tests), which makes the kernel send RST and this side emit ECONNRESET. Expected teardown noise — without a handler Node throws it as an uncaught exception and vitest fails the whole run.
socket.on('error', () => {});
onSocket?.(socket); // attached at accept time, before any data can arrive
sockets.push(socket);
});
+45 -27
View File
@@ -72,7 +72,7 @@ describe('OpencodeService.notify', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
const [url, init] = fetchMock.mock.calls[1];
expect(url).toBe(`${API}/proxy/abc/session/ses_pinned/message`);
expect(url).toBe(`${API}/proxy/abc/api/session/ses_pinned/prompt`);
const opts = init as {
method?: string;
headers?: Record<string, string>;
@@ -83,15 +83,10 @@ describe('OpencodeService.notify', () => {
expect(opts.headers?.authorization).toBe(
'Basic ' + Buffer.from('u:p').toString('base64'),
);
// body is EXACTLY {"parts":[{"type":"text","text":msg}]} — em-dash, not a hyphen
// body is EXACTLY {"text": msg} — em-dash, not a hyphen — and a 2xx resolves notify as delivered
expect(String(opts.body)).toBe(
JSON.stringify({
parts: [
{
type: 'text',
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
},
],
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
}),
);
});
@@ -111,12 +106,7 @@ describe('OpencodeService.notify', () => {
const [, init] = fetchMock.mock.calls[1];
expect(String((init as { body?: string }).body)).toBe(
JSON.stringify({
parts: [
{
type: 'text',
text: 'New mesh message (id 7): "hi" — please react to it.',
},
],
text: 'New mesh message (id 7): "hi" — please react to it.',
}),
);
});
@@ -149,7 +139,7 @@ describe('OpencodeService.notify', () => {
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/abc/session/ses_pinned/message`,
`${API}/proxy/abc/api/session/ses_pinned/prompt`,
);
});
@@ -182,7 +172,7 @@ describe('OpencodeService.notify', () => {
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/b/session/ses_pinned/message`,
`${API}/proxy/b/api/session/ses_pinned/prompt`,
);
});
@@ -191,18 +181,37 @@ describe('OpencodeService.notify', () => {
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(
json([
{ id: 'ses_old', time: { updated: 100 } },
{ id: 'ses_new', time: { updated: 200 } },
]),
// v2 envelope {data, cursor}; the /elsewhere session is more recent but a different
// directory, so the location.directory filter must exclude it
json({
data: [
{
id: 'ses_old',
location: { directory: WS_PATH },
time: { updated: 100 },
},
{
id: 'ses_other_ws',
location: { directory: '/elsewhere' },
time: { updated: 300 },
},
{
id: 'ses_new',
location: { directory: WS_PATH },
time: { updated: 200 },
},
],
cursor: { previous: null, next: null },
}),
)
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`);
// discovery GET hits the v2 route; the prompt POST goes to the most recently updated session of THIS directory
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/session/ses_new/message`,
`${API}/proxy/abc/api/session/ses_new/prompt`,
);
});
@@ -210,13 +219,22 @@ describe('OpencodeService.notify', () => {
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }]))
.mockResolvedValueOnce(
json({
data: [
{ id: 'ses_a', location: { directory: WS_PATH } },
{ id: 'ses_b', location: { directory: WS_PATH }, time: {} },
],
cursor: { previous: null, next: null },
}),
)
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/session/ses_a/message`,
`${API}/proxy/abc/api/session/ses_a/prompt`,
);
});
@@ -232,11 +250,11 @@ describe('OpencodeService.notify', () => {
await svc.notify(7, 'hi');
// workspaces GET + message POST only — no /session discovery GET
// workspaces GET + prompt POST only — no /api/session discovery GET
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/abc/session/ses_pinned/message`,
`${API}/proxy/abc/api/session/ses_pinned/prompt`,
);
});
@@ -248,7 +266,7 @@ describe('OpencodeService.notify', () => {
});
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockRejectedValueOnce(timeoutError()); // the message POST times out — opencode holds the response until the turn finishes
.mockRejectedValueOnce(timeoutError()); // the prompt POST times out — a read timeout is still treated as delivered (safety net)
const logSpy = vi.spyOn(Logger.prototype, 'log');
await expect(svc.notify('hi')).resolves.toBeUndefined();
+24 -13
View File
@@ -2,7 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
// ponytail: hardcoded backoff [2s, 10s, 30s] (~45 s window) — no env knob; tune here if outages run longer
const RETRY_DELAYS_MS = [2_000, 10_000, 30_000];
// opencode accepts a session message immediately but holds the HTTP response until the agent turn finishes
// opencode v2 returns 2xx immediately (it no longer holds the response until the agent turn finishes);
// the 15 s timeout only guards a stuck connection — a read timeout is still treated as delivered
const TIMEOUT_MS = 15_000;
/** Entry of GET {api}/api/workspaces (only the fields we use). */
@@ -14,9 +15,11 @@ interface WorkspaceEntry {
proxyPath?: string;
}
/** Entry of GET {base}/session (only the fields we use). */
/** Entry of GET {base}/api/session (v2; only the fields we use — directory lives under location, time is epoch ms). */
interface SessionEntry {
id: string;
title?: string;
location?: { directory?: string };
time?: { updated?: number };
}
@@ -43,11 +46,11 @@ export class OpencodeService {
}
/**
* Push an rx mesh message into the opencode session as a text part. Resolves when delivered —
* including on a final-POST read timeout, which means DELIVERED (opencode holds the response until
* the agent turn finishes) and is never retried. Workspace/session resolution failures are retried
* up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped
* after N attempts`. No-op when disabled (no credentials).
* Push an rx mesh message into the opencode session (v2: POST {base}/api/session/{id}/prompt with a
* `{text}` body). Resolves when delivered — including on a final-POST read timeout, which still means
* DELIVERED (kept as a harmless safety net; v2 normally returns 2xx immediately) and is never retried.
* Workspace/session resolution failures are retried up to RETRY_DELAYS_MS.length + 1 attempts total;
* the last failure logs `opencode notify dropped after N attempts`. No-op when disabled (no credentials).
*/
async notify(id: number, text: string, from?: number): Promise<void> {
if (!this.enabled) return;
@@ -112,12 +115,19 @@ export class OpencodeService {
const base = this.api + (ws.proxyPath ?? '');
let sid = this.sessionOverride;
if (!sid) {
const sessions = await this.getJson<SessionEntry[]>(`${base}/session`);
// v2 discovery: GET {base}/api/session returns an envelope { data, cursor } — NOT a bare array
const envelope = await this.getJson<{ data?: SessionEntry[] }>(
`${base}/api/session`,
);
// only sessions of THIS workspace directory — the instance proxy may serve other sessions too
const sessions = (envelope.data ?? []).filter(
(s) => s.location?.directory === this.workspacePath,
);
if (sessions.length === 0)
throw new Error(
`no sessions in workspace ${ws.id} (${this.workspacePath})`,
);
// most recently updated session; first entry when none carry time.updated
// most recently updated session (epoch ms); first entry when none carry time.updated
const updated = (s: SessionEntry): number => s.time?.updated ?? 0;
sid = sessions.some((s) => updated(s))
? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id
@@ -148,16 +158,17 @@ export class OpencodeService {
msg: string,
): Promise<void> {
try {
const res = await fetch(`${base}/session/${sid}/message`, {
const res = await fetch(`${base}/api/session/${sid}/prompt`, {
method: 'POST',
headers: { ...this.authHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ parts: [{ type: 'text', text: msg }] }),
body: JSON.stringify({ text: msg }),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures
} catch (err) {
// a read timeout here means DELIVERED, not failed — opencode accepted the message and is holding
// the response until the agent turn finishes; never retried
// a read timeout here means DELIVERED, not failed — kept as a harmless safety net: v2 normally
// returns 2xx immediately, but if the response is still pending past the timeout we treat the
// message as accepted and never retry
if ((err as Error).name === 'TimeoutError') {
this.logger.log(
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
+28
View File
@@ -0,0 +1,28 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const srcDir = fileURLToPath(new URL('.', import.meta.url));
function moduleFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? moduleFiles(join(dir, entry.name))
: entry.name.endsWith('.module.ts')
? [join(dir, entry.name)]
: [],
);
}
describe('ScheduleModule.forRoot single registration', () => {
// Regression guard (v0.21.7): each forRoot() call creates its own ScheduleExplorer that scans all providers, so a second call made every @Cron/@Interval job fire twice.
it('appears exactly once across all src/**/*.module.ts', () => {
const hits = moduleFiles(srcDir).flatMap((file) =>
(
readFileSync(file, 'utf8').match(/ScheduleModule\.forRoot\(/g) ?? []
).map(() => file.slice(srcDir.length)),
);
expect(hits).toHaveLength(1);
});
});