From b2be34bb084f97501157bf95206da15d930291da Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:58:47 +0000 Subject: [PATCH 1/3] fix(discord): preserve complete alert traces Split oversized ops alerts into ordered Discord-safe messages instead of truncating fatal and bridge details. Add focused coverage proving long session-error stacks survive delivery intact. --- apps/discord-bot/src/features/Alerts.test.ts | 32 +++++++++ apps/discord-bot/src/features/Alerts.ts | 71 ++++++++++++++++---- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/apps/discord-bot/src/features/Alerts.test.ts b/apps/discord-bot/src/features/Alerts.test.ts index 4e5e0dabe71..9c6a7cf74b6 100644 --- a/apps/discord-bot/src/features/Alerts.test.ts +++ b/apps/discord-bot/src/features/Alerts.test.ts @@ -2,8 +2,11 @@ import * as Cause from "effect/Cause"; import { describe, expect, it } from "vite-plus/test"; import { + chunkAlertContent, classifySessionLastError, formatAlertCause, + formatBridgeAlertContent, + formatFatalAlertContent, isExpectedSessionLastError, selectSessionErrorsForAlert, sessionErrorAlertKey, @@ -64,6 +67,35 @@ describe("formatAlertCause", () => { }); }); +describe("Discord alert content", () => { + it("preserves a full stack trace across ordered Discord-sized chunks", () => { + const trace = [ + "Error: Invalid params", + ...Array.from( + { length: 80 }, + (_, index) => + ` at decodeFrame${index} (file:///var/lib/t3/src/t3code/node_modules/effect/frame-${index}.js:877:8)`, + ), + ].join("\n"); + const content = formatFatalAlertContent( + "T3 session error", + `thread=\`2d9ccf35-a36a-41bc-a762-523f5e423f41\`\n${trace}`, + ); + const chunks = chunkAlertContent(content); + + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= 1900)).toBe(true); + expect(chunks.join("")).toBe(content); + expect(chunks.at(-1)).toContain("decodeFrame79"); + }); + + it("does not truncate fatal or bridge details before delivery", () => { + const detail = `start\n${"x".repeat(4_000)}\nend`; + expect(formatFatalAlertContent("failure", detail)).toContain(detail); + expect(formatBridgeAlertContent("failure", detail)).toContain(detail); + }); +}); + describe("session last_error alert classification", () => { it("treats orphan / server-restart recover text as expected (not fatal)", () => { expect( diff --git a/apps/discord-bot/src/features/Alerts.ts b/apps/discord-bot/src/features/Alerts.ts index 2e398a1532c..9892a9676b5 100644 --- a/apps/discord-bot/src/features/Alerts.ts +++ b/apps/discord-bot/src/features/Alerts.ts @@ -28,6 +28,8 @@ const FATAL_COOLDOWN_MS = 2 * 60 * 1000; const SESSION_ERROR_FATAL_COOLDOWN_MS = 30 * 60 * 1000; /** Cap distinct session-error posts per watchdog tick. */ const SESSION_ERROR_ALERT_MAX = 5; +/** Leave headroom below Discord's 2,000-character message-content limit. */ +const DISCORD_ALERT_MESSAGE_LIMIT = 1900; const LOAD_RATIO = 0.75; const CPU_PERCENT_ALERT = 85; @@ -583,6 +585,44 @@ let poster: Poster | null = null; /** Bridge snapshot handler failures: short enough to notice, long enough to avoid spam. */ const BRIDGE_ALERT_COOLDOWN_MS = 3 * 60 * 1000; +/** + * Split an alert without discarding any of its contents. Prefer line or word + * boundaries so stack frames remain readable, while preserving the separators + * verbatim across the resulting messages. + */ +export function chunkAlertContent( + content: string, + limit = DISCORD_ALERT_MESSAGE_LIMIT, +): ReadonlyArray { + if (!Number.isInteger(limit) || limit < 1) { + throw new RangeError("Discord alert chunk limit must be a positive integer"); + } + if (content.length <= limit) return [content]; + + const chunks: string[] = []; + let offset = 0; + while (content.length - offset > limit) { + const hardEnd = offset + limit; + const preferredStart = offset + Math.floor(limit * 0.5); + const newlineEnd = content.lastIndexOf("\n", hardEnd - 1) + 1; + const spaceEnd = content.lastIndexOf(" ", hardEnd - 1) + 1; + const end = + newlineEnd > preferredStart ? newlineEnd : spaceEnd > preferredStart ? spaceEnd : hardEnd; + chunks.push(content.slice(offset, end)); + offset = end; + } + chunks.push(content.slice(offset)); + return chunks; +} + +export function formatFatalAlertContent(title: string, detail: string): string { + return [`**FATAL: ${title}**`, detail].join("\n"); +} + +export function formatBridgeAlertContent(title: string, detail: string): string { + return [`**BRIDGE: ${title}**`, detail].join("\n"); +} + /** * Render an Effect `Cause` (or any thrown value) for Discord / logs. * Logging `{ cause }` alone shows `{ _id: 'Cause', failures: [ [Object] ] }`. @@ -619,11 +659,7 @@ export const postFatalAlert = (key: string, title: string, detail: string) => yield* Effect.logError(`Fatal (no alerts channel): ${title}`, { detail }); return; } - yield* p( - `fatal:${key}`, - [`**FATAL: ${title}**`, detail.slice(0, 1500)].join("\n"), - FATAL_COOLDOWN_MS, - ); + yield* p(`fatal:${key}`, formatFatalAlertContent(title, detail), FATAL_COOLDOWN_MS); }); /** @@ -638,11 +674,7 @@ export const postBridgeAlert = (key: string, title: string, detail: string) => yield* Effect.logError(`Bridge alert (no alerts channel): ${title}`, { detail }); return; } - yield* p( - `bridge:${key}`, - [`**BRIDGE: ${title}**`, detail.slice(0, 1500)].join("\n"), - BRIDGE_ALERT_COOLDOWN_MS, - ); + yield* p(`bridge:${key}`, formatBridgeAlertContent(title, detail), BRIDGE_ALERT_COOLDOWN_MS); }); // --- Watchdog ---------------------------------------------------------------- @@ -670,9 +702,22 @@ export const runAlertWatchdog = (botConfig: DiscordBotConfig) => const prev = lastSent.get(key) ?? 0; if (now - prev < cooldownMs) return; lastSent.set(key, now); - const body = content.length > 1900 ? `${content.slice(0, 1900)}…` : content; - yield* rest.createMessage(channelId, { content: body }).pipe( - Effect.tap(() => Effect.logInfo("Posted Discord ops alert", { key, channelId })), + const chunks = chunkAlertContent(content); + yield* Effect.forEach( + chunks, + (body, index) => + rest.createMessage(channelId, { content: body }).pipe( + Effect.tap(() => + Effect.logInfo("Posted Discord ops alert", { + key, + channelId, + chunk: index + 1, + chunkCount: chunks.length, + }), + ), + ), + { concurrency: 1, discard: true }, + ).pipe( Effect.catchCause((cause) => Effect.logError("Failed to post Discord ops alert").pipe( Effect.andThen(Effect.logError(cause)), From b4939dbce5faf88058cbc1a4772fa2827ce84887 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:53:49 +0000 Subject: [PATCH 2/3] fix(discord): attach complete alert traces Send fatal, bridge, and T3 session traces as text-file attachments through the existing native multipart upload path. Keep alert messages concise and preserve complete causes by default. Co-authored-by: Enrico Polanski <16064771+enricopolanski@users.noreply.github.com> Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- apps/discord-bot/src/features/Alerts.test.ts | 58 ++++++--- apps/discord-bot/src/features/Alerts.ts | 130 +++++++++++-------- 2 files changed, 114 insertions(+), 74 deletions(-) diff --git a/apps/discord-bot/src/features/Alerts.test.ts b/apps/discord-bot/src/features/Alerts.test.ts index 9c6a7cf74b6..b5cdafadd36 100644 --- a/apps/discord-bot/src/features/Alerts.test.ts +++ b/apps/discord-bot/src/features/Alerts.test.ts @@ -1,14 +1,19 @@ import * as Cause from "effect/Cause"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("dfx", () => ({ + DiscordConfig: { DiscordConfig: {} }, + DiscordREST: {}, +})); import { - chunkAlertContent, + bridgeAlertDelivery, classifySessionLastError, + fatalAlertDelivery, formatAlertCause, - formatBridgeAlertContent, - formatFatalAlertContent, isExpectedSessionLastError, selectSessionErrorsForAlert, + sessionErrorAlertDelivery, sessionErrorAlertKey, trackSustainedHotProcesses, type ProcInfo, @@ -65,10 +70,16 @@ describe("formatAlertCause", () => { expect(formatAlertCause(new Error("boom"))).toContain("boom"); expect(formatAlertCause("plain")).toBe("plain"); }); + + it("keeps complete causes by default while honoring explicit preview limits", () => { + const cause = `start\n${"x".repeat(4_000)}\nend`; + expect(formatAlertCause(cause)).toBe(cause); + expect(formatAlertCause(cause, 20)).toBe(`${cause.slice(0, 20)}…`); + }); }); describe("Discord alert content", () => { - it("preserves a full stack trace across ordered Discord-sized chunks", () => { + it("attaches the complete T3 session stack as one text file", () => { const trace = [ "Error: Invalid params", ...Array.from( @@ -77,22 +88,29 @@ describe("Discord alert content", () => { ` at decodeFrame${index} (file:///var/lib/t3/src/t3code/node_modules/effect/frame-${index}.js:877:8)`, ), ].join("\n"); - const content = formatFatalAlertContent( - "T3 session error", - `thread=\`2d9ccf35-a36a-41bc-a762-523f5e423f41\`\n${trace}`, - ); - const chunks = chunkAlertContent(content); + const delivery = sessionErrorAlertDelivery("2d9ccf35-a36a-41bc-a762-523f5e423f41", trace); - expect(chunks.length).toBeGreaterThan(1); - expect(chunks.every((chunk) => chunk.length <= 1900)).toBe(true); - expect(chunks.join("")).toBe(content); - expect(chunks.at(-1)).toContain("decodeFrame79"); - }); - - it("does not truncate fatal or bridge details before delivery", () => { - const detail = `start\n${"x".repeat(4_000)}\nend`; - expect(formatFatalAlertContent("failure", detail)).toContain(detail); - expect(formatBridgeAlertContent("failure", detail)).toContain(detail); + expect(delivery.content).toContain("thread=`2d9ccf35-a36a-41bc-a762-523f5e423f41`"); + expect(delivery.content).not.toContain("Invalid params"); + expect(delivery.files).toHaveLength(1); + expect(delivery.files[0]?.name).toBe( + "t3-session-error-2d9ccf35-a36a-41bc-a762-523f5e423f41.txt", + ); + expect(delivery.files[0]?.mimeType).toBe("text/plain;charset=utf-8"); + expect(new TextDecoder().decode(delivery.files[0]?.data)).toBe(trace); + }); + + it("keeps fatal and bridge traces out of message content and intact in attachments", () => { + const trace = `start\n${"x".repeat(4_000)}\nend`; + for (const delivery of [ + fatalAlertDelivery("failure", trace), + bridgeAlertDelivery("failure", trace), + ]) { + expect(delivery.content).not.toContain(trace); + expect(delivery.files).toHaveLength(1); + expect(delivery.files[0]?.name.endsWith(".txt")).toBe(true); + expect(new TextDecoder().decode(delivery.files[0]?.data)).toBe(trace); + } }); }); diff --git a/apps/discord-bot/src/features/Alerts.ts b/apps/discord-bot/src/features/Alerts.ts index 9892a9676b5..16b29a8eb21 100644 --- a/apps/discord-bot/src/features/Alerts.ts +++ b/apps/discord-bot/src/features/Alerts.ts @@ -9,13 +9,20 @@ */ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; -import { DiscordREST } from "dfx"; +import { DiscordConfig, DiscordREST } from "dfx"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; import type { DiscordBotConfig } from "../config.ts"; +import { + createMessageWithAttachments, + DiscordUploadError, + textFile, + type DiscordUploadFile, +} from "../presentation/discordFiles.ts"; const POLL = "60 seconds"; const COOLDOWN_MS = 10 * 60 * 1000; @@ -578,56 +585,53 @@ export function collectHostSnapshot(input: { // --- Fatal / bridge alert bus (callable from bridge / main) ------------------ -type Poster = (key: string, content: string, cooldownMs?: number) => Effect.Effect; +type Poster = ( + key: string, + content: string, + cooldownMs?: number, + files?: ReadonlyArray, +) => Effect.Effect; let poster: Poster | null = null; /** Bridge snapshot handler failures: short enough to notice, long enough to avoid spam. */ const BRIDGE_ALERT_COOLDOWN_MS = 3 * 60 * 1000; +const TRACE_MIME_TYPE = "text/plain;charset=utf-8"; -/** - * Split an alert without discarding any of its contents. Prefer line or word - * boundaries so stack frames remain readable, while preserving the separators - * verbatim across the resulting messages. - */ -export function chunkAlertContent( - content: string, - limit = DISCORD_ALERT_MESSAGE_LIMIT, -): ReadonlyArray { - if (!Number.isInteger(limit) || limit < 1) { - throw new RangeError("Discord alert chunk limit must be a positive integer"); - } - if (content.length <= limit) return [content]; - - const chunks: string[] = []; - let offset = 0; - while (content.length - offset > limit) { - const hardEnd = offset + limit; - const preferredStart = offset + Math.floor(limit * 0.5); - const newlineEnd = content.lastIndexOf("\n", hardEnd - 1) + 1; - const spaceEnd = content.lastIndexOf(" ", hardEnd - 1) + 1; - const end = - newlineEnd > preferredStart ? newlineEnd : spaceEnd > preferredStart ? spaceEnd : hardEnd; - chunks.push(content.slice(offset, end)); - offset = end; - } - chunks.push(content.slice(offset)); - return chunks; +export interface AlertTraceDelivery { + readonly content: string; + readonly files: ReadonlyArray; } -export function formatFatalAlertContent(title: string, detail: string): string { - return [`**FATAL: ${title}**`, detail].join("\n"); +function alertTraceDelivery(content: string, filename: string, trace: string): AlertTraceDelivery { + return { + content: `${content}\n_Complete trace attached as \`${filename}\`._`, + files: [textFile(filename, trace, TRACE_MIME_TYPE)], + }; } -export function formatBridgeAlertContent(title: string, detail: string): string { - return [`**BRIDGE: ${title}**`, detail].join("\n"); +export function fatalAlertDelivery(title: string, trace: string): AlertTraceDelivery { + return alertTraceDelivery(`**FATAL: ${title}**`, "fatal-trace.txt", trace); +} + +export function bridgeAlertDelivery(title: string, trace: string): AlertTraceDelivery { + return alertTraceDelivery(`**BRIDGE: ${title}**`, "bridge-trace.txt", trace); +} + +export function sessionErrorAlertDelivery(threadId: string, trace: string): AlertTraceDelivery { + const filename = `t3-session-error-${threadId}.txt`; + return alertTraceDelivery( + ["**FATAL: T3 session error**", `thread=\`${threadId}\``].join("\n"), + filename, + trace, + ); } /** * Render an Effect `Cause` (or any thrown value) for Discord / logs. * Logging `{ cause }` alone shows `{ _id: 'Cause', failures: [ [Object] ] }`. */ -export function formatAlertCause(cause: unknown, maxLen = 1200): string { +export function formatAlertCause(cause: unknown, maxLen?: number): string { let text: string; try { if (Cause.isCause(cause)) { @@ -644,7 +648,7 @@ export function formatAlertCause(cause: unknown, maxLen = 1200): string { } const trimmed = text.replace(/\s+$/u, "").trim(); if (trimmed === "") return "(empty cause)"; - return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}…` : trimmed; + return maxLen !== undefined && trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}…` : trimmed; } /** @@ -659,7 +663,8 @@ export const postFatalAlert = (key: string, title: string, detail: string) => yield* Effect.logError(`Fatal (no alerts channel): ${title}`, { detail }); return; } - yield* p(`fatal:${key}`, formatFatalAlertContent(title, detail), FATAL_COOLDOWN_MS); + const delivery = fatalAlertDelivery(title, detail); + yield* p(`fatal:${key}`, delivery.content, FATAL_COOLDOWN_MS, delivery.files); }); /** @@ -674,7 +679,8 @@ export const postBridgeAlert = (key: string, title: string, detail: string) => yield* Effect.logError(`Bridge alert (no alerts channel): ${title}`, { detail }); return; } - yield* p(`bridge:${key}`, formatBridgeAlertContent(title, detail), BRIDGE_ALERT_COOLDOWN_MS); + const delivery = bridgeAlertDelivery(title, detail); + yield* p(`bridge:${key}`, delivery.content, BRIDGE_ALERT_COOLDOWN_MS, delivery.files); }); // --- Watchdog ---------------------------------------------------------------- @@ -694,30 +700,44 @@ export const runAlertWatchdog = (botConfig: DiscordBotConfig) => } const rest = yield* DiscordREST; + const discordConfig = yield* DiscordConfig.DiscordConfig; const lastSent = new Map(); - const postAlert: Poster = (key, content, cooldownMs = COOLDOWN_MS) => + const postAlert: Poster = (key, content, cooldownMs = COOLDOWN_MS, files = []) => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; const prev = lastSent.get(key) ?? 0; if (now - prev < cooldownMs) return; lastSent.set(key, now); - const chunks = chunkAlertContent(content); - yield* Effect.forEach( - chunks, - (body, index) => - rest.createMessage(channelId, { content: body }).pipe( - Effect.tap(() => - Effect.logInfo("Posted Discord ops alert", { - key, + const body = + content.length > DISCORD_ALERT_MESSAGE_LIMIT + ? `${content.slice(0, DISCORD_ALERT_MESSAGE_LIMIT)}…` + : content; + yield* Effect.gen(function* () { + if (files.length === 0) { + yield* rest.createMessage(channelId, { content: body }); + } else { + yield* Effect.tryPromise({ + try: () => + createMessageWithAttachments({ + baseUrl: discordConfig.rest.baseUrl, + botToken: Redacted.value(discordConfig.token), channelId, - chunk: index + 1, - chunkCount: chunks.length, + content: body, + files, }), - ), - ), - { concurrency: 1, discard: true }, - ).pipe( + catch: (cause) => + cause instanceof DiscordUploadError + ? cause + : new DiscordUploadError(cause instanceof Error ? cause.message : String(cause)), + }); + } + yield* Effect.logInfo("Posted Discord ops alert", { + key, + channelId, + fileCount: files.length, + }); + }).pipe( Effect.catchCause((cause) => Effect.logError("Failed to post Discord ops alert").pipe( Effect.andThen(Effect.logError(cause)), @@ -875,10 +895,12 @@ export const runAlertWatchdog = (botConfig: DiscordBotConfig) => // --- session last_error (real fatals only; skip orphan-restart recover spam) --- const sessionSelection = selectSessionErrorsForAlert(snap.sessionErrors); for (const err of sessionSelection.fatals) { + const delivery = sessionErrorAlertDelivery(err.threadId, err.lastError); yield* postAlert( sessionErrorAlertKey(err.threadId, err.lastError), - ["**FATAL: T3 session error**", `thread=\`${err.threadId}\``, err.lastError].join("\n"), + delivery.content, SESSION_ERROR_FATAL_COOLDOWN_MS, + delivery.files, ); } // Expected recoveries are intentionally not posted — high volume after restarts From 77e3c7d286fcf8a5656a08fb87a463e884910738 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:56:56 +0000 Subject: [PATCH 3/3] fix(discord): satisfy overlay checks Co-authored-by: Enrico Polanski <16064771+enricopolanski@users.noreply.github.com> Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- apps/discord-bot/src/identityMap.ts | 1 + .../src/presentation/discordPrAttribution.ts | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/discord-bot/src/identityMap.ts b/apps/discord-bot/src/identityMap.ts index 362cf4c5f86..3255f2d22d0 100644 --- a/apps/discord-bot/src/identityMap.ts +++ b/apps/discord-bot/src/identityMap.ts @@ -626,6 +626,7 @@ export function makeRefreshingIdentityMapStore(input: { readonly onReload?: (people: ReadonlyArray) => void; }): IdentityMapStoreService { const ttlMs = input.ttlMs ?? IDENTITY_MAP_CACHE_TTL_MS; + // @effect-diagnostics-next-line globalDate:off const now = input.now ?? (() => Date.now()); const load = input.load ?? loadIdentityMapFromFileSync; const path = input.filePath.trim(); diff --git a/apps/discord-bot/src/presentation/discordPrAttribution.ts b/apps/discord-bot/src/presentation/discordPrAttribution.ts index 2a13d6a6e5e..dcfbb1ef3a9 100644 --- a/apps/discord-bot/src/presentation/discordPrAttribution.ts +++ b/apps/discord-bot/src/presentation/discordPrAttribution.ts @@ -7,8 +7,8 @@ */ // @effect-diagnostics nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; -import * as NodeFs from "node:fs/promises"; -import * as NodeOs from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; @@ -216,10 +216,10 @@ async function writePullRequestBody( body: string, execImpl: ExecFileLike, ): Promise { - const tempDir = await NodeFs.mkdtemp(NodePath.join(NodeOs.tmpdir(), "t3-discord-pr-body-")); + const tempDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-discord-pr-body-")); const bodyFile = NodePath.join(tempDir, "body.json"); try { - await NodeFs.writeFile(bodyFile, JSON.stringify({ body }), "utf8"); + await NodeFSP.writeFile(bodyFile, JSON.stringify({ body }), "utf8"); await execImpl( "gh", [ @@ -233,6 +233,6 @@ async function writePullRequestBody( { env: gitCommandEnv(), maxBuffer: 4 * 1024 * 1024 }, ); } finally { - await NodeFs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + await NodeFSP.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); } }