Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec97a06d2 | ||
|
|
84915fe314 | ||
|
|
5506fb9330 | ||
|
|
beebcd5851 | ||
|
|
5a1493fb4a |
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# 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
|
## 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`.
|
- 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`.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ services:
|
|||||||
bot:
|
bot:
|
||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
|
TZ: Europe/Warsaw
|
||||||
MESH_HOST: 192.168.1.4
|
MESH_HOST: 192.168.1.4
|
||||||
MESH_PORT: '4403'
|
MESH_PORT: '4403'
|
||||||
MESH_DB_PATH: ./data/mesh-bot.db
|
MESH_DB_PATH: ./data/mesh-bot.db
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mesh-bot",
|
"name": "mesh-bot",
|
||||||
"version": "0.21.9",
|
"version": "0.21.12",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -119,6 +119,47 @@ describe('BatteryAlertService check threshold', () => {
|
|||||||
expect(sendText).not.toHaveBeenCalled();
|
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 () => {
|
it('merges all qualifying nodes into exactly ONE broadcast with the broadcast target', async () => {
|
||||||
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
const sendText = vi.fn(async (..._args: unknown[]) => 1);
|
||||||
const svc = makeService(
|
const svc = makeService(
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export class BatteryAlertService {
|
|||||||
|
|
||||||
private shouldAlert(node: NodeSummary, now: number): boolean {
|
private shouldAlert(node: NodeSummary, now: number): boolean {
|
||||||
if (node.batteryLevel === undefined) return false; // no battery reported — skip
|
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)
|
// <= 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;
|
if (!(node.batteryLevel <= this.threshold)) return false;
|
||||||
const last = this.lastAlertAt.get(node.num);
|
const last = this.lastAlertAt.get(node.num);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
const [url, init] = fetchMock.mock.calls[1];
|
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 {
|
const opts = init as {
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
@@ -83,15 +83,10 @@ describe('OpencodeService.notify', () => {
|
|||||||
expect(opts.headers?.authorization).toBe(
|
expect(opts.headers?.authorization).toBe(
|
||||||
'Basic ' + Buffer.from('u:p').toString('base64'),
|
'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(
|
expect(String(opts.body)).toBe(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
parts: [
|
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
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];
|
const [, init] = fetchMock.mock.calls[1];
|
||||||
expect(String((init as { body?: string }).body)).toBe(
|
expect(String((init as { body?: string }).body)).toBe(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
parts: [
|
text: 'New mesh message (id 7): "hi" — please react to it.',
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: 'New mesh message (id 7): "hi" — please react to it.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -149,7 +139,7 @@ describe('OpencodeService.notify', () => {
|
|||||||
await svc.notify(7, 'hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
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');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
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
|
fetchMock
|
||||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
.mockResolvedValueOnce(json([WORKSPACE]))
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
json([
|
// v2 envelope {data, cursor}; the /elsewhere session is more recent but a different
|
||||||
{ id: 'ses_old', time: { updated: 100 } },
|
// directory, so the location.directory filter must exclude it
|
||||||
{ id: 'ses_new', time: { updated: 200 } },
|
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({}));
|
.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify(7, 'hi');
|
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(
|
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' });
|
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
|
||||||
fetchMock
|
fetchMock
|
||||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
.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({}));
|
.mockResolvedValueOnce(json({}));
|
||||||
|
|
||||||
await svc.notify(7, 'hi');
|
await svc.notify(7, 'hi');
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
|
||||||
expect(fetchMock.mock.calls[2][0]).toBe(
|
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');
|
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).toHaveBeenCalledTimes(2);
|
||||||
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
|
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
|
||||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
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
|
fetchMock
|
||||||
.mockResolvedValueOnce(json([WORKSPACE]))
|
.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');
|
const logSpy = vi.spyOn(Logger.prototype, 'log');
|
||||||
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
await expect(svc.notify('hi')).resolves.toBeUndefined();
|
||||||
|
|||||||
@@ -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
|
// 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];
|
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;
|
const TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
/** Entry of GET {api}/api/workspaces (only the fields we use). */
|
/** Entry of GET {api}/api/workspaces (only the fields we use). */
|
||||||
@@ -14,9 +15,11 @@ interface WorkspaceEntry {
|
|||||||
proxyPath?: string;
|
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 {
|
interface SessionEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
location?: { directory?: string };
|
||||||
time?: { updated?: number };
|
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 —
|
* Push an rx mesh message into the opencode session (v2: POST {base}/api/session/{id}/prompt with a
|
||||||
* including on a final-POST read timeout, which means DELIVERED (opencode holds the response until
|
* `{text}` body). Resolves when delivered — including on a final-POST read timeout, which still means
|
||||||
* the agent turn finishes) and is never retried. Workspace/session resolution failures are retried
|
* DELIVERED (kept as a harmless safety net; v2 normally returns 2xx immediately) and is never retried.
|
||||||
* up to RETRY_DELAYS_MS.length + 1 attempts total; the last failure logs `opencode notify dropped
|
* Workspace/session resolution failures are retried up to RETRY_DELAYS_MS.length + 1 attempts total;
|
||||||
* after N attempts`. No-op when disabled (no credentials).
|
* 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> {
|
async notify(id: number, text: string, from?: number): Promise<void> {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
@@ -112,12 +115,19 @@ export class OpencodeService {
|
|||||||
const base = this.api + (ws.proxyPath ?? '');
|
const base = this.api + (ws.proxyPath ?? '');
|
||||||
let sid = this.sessionOverride;
|
let sid = this.sessionOverride;
|
||||||
if (!sid) {
|
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)
|
if (sessions.length === 0)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`no sessions in workspace ${ws.id} (${this.workspacePath})`,
|
`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;
|
const updated = (s: SessionEntry): number => s.time?.updated ?? 0;
|
||||||
sid = sessions.some((s) => updated(s))
|
sid = sessions.some((s) => updated(s))
|
||||||
? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id
|
? sessions.reduce((a, b) => (updated(b) > updated(a) ? b : a)).id
|
||||||
@@ -148,16 +158,17 @@ export class OpencodeService {
|
|||||||
msg: string,
|
msg: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${base}/session/${sid}/message`, {
|
const res = await fetch(`${base}/api/session/${sid}/prompt`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...this.authHeaders(), 'content-type': 'application/json' },
|
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),
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures
|
if (!res.ok) throw new Error(`HTTP ${res.status}`); // HTTP errors retry like network failures
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// a read timeout here means DELIVERED, not failed — opencode accepted the message and is holding
|
// a read timeout here means DELIVERED, not failed — kept as a harmless safety net: v2 normally
|
||||||
// the response until the agent turn finishes; never retried
|
// 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') {
|
if ((err as Error).name === 'TimeoutError') {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
|
`opencode notify accepted (response still pending after ${TIMEOUT_MS / 1000} s)`,
|
||||||
|
|||||||
Reference in New Issue
Block a user