From a9ef0c9fa407bda4110d369657ae9377d3cec6cb Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 16 Jul 2026 15:07:42 +0000 Subject: [PATCH] fix(debug): bundled all same-day pid logs Report bundles now concatenate every same-day omp...log tail oldest-first, so a report from a later invocation still captures a crashed process's log after the shared-filename scheme was replaced with PID-qualified paths. Fixes #5716 --- .../coding-agent/src/debug/report-bundle.ts | 42 ++++++++++++- .../test/debug/report-bundle-logs.test.ts | 61 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/debug/report-bundle-logs.test.ts diff --git a/packages/coding-agent/src/debug/report-bundle.ts b/packages/coding-agent/src/debug/report-bundle.ts index 9119b70b1..c99e046d5 100644 --- a/packages/coding-agent/src/debug/report-bundle.ts +++ b/packages/coding-agent/src/debug/report-bundle.ts @@ -105,9 +105,10 @@ export async function createReportBundle(options: ReportBundleOptions): Promise< files.push("config.json"); } - // Recent logs (last 1000 lines) - const logPath = getLogPath(); - const logs = await readLastLines(logPath, 1000); + // Recent logs (last 1000 lines) across every same-day process. PID-qualified + // filenames mean a report generated from a later invocation must still gather + // the crashed process's log, so read all of today's files, not just our own. + const logs = await collectSameDayLogs(1000); if (logs) { data["logs.txt"] = logs; files.push("logs.txt"); @@ -241,6 +242,41 @@ export async function getLogText(): Promise { return readLastLines(getLogPath(), MAX_LOG_LINES); } +/** + * Concatenate the tail of every same-day process log so a report generated + * after a crash still captures the fatal PID's `omp...log`. Files + * are ordered oldest-first by mtime and separated by a filename header. + */ +async function collectSameDayLogs(linesPerFile: number): Promise { + const logsDir = getLogsDir(); + const today = new Date().toISOString().slice(0, 10); + const sameDay: Array<{ name: string; mtimeMs: number }> = []; + try { + const entries = await fs.readdir(logsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + const match = LOG_FILE_PATTERN.exec(entry.name); + if (!match || match[1] !== today) continue; + try { + const stat = await fs.stat(path.join(logsDir, entry.name)); + sameDay.push({ name: entry.name, mtimeMs: stat.mtimeMs }); + } catch { + // File may have rotated away between readdir and stat. + } + } + } catch { + return ""; + } + sameDay.sort((a, b) => a.mtimeMs - b.mtimeMs); + + const chunks: string[] = []; + for (const { name } of sameDay) { + const text = await readLastLines(path.join(logsDir, name), linesPerFile); + if (text) chunks.push(`===== ${name} =====\n${text}`); + } + return chunks.join("\n\n"); +} + const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.\\d+\\.log$`); export async function createDebugLogSource(): Promise { diff --git a/packages/coding-agent/test/debug/report-bundle-logs.test.ts b/packages/coding-agent/test/debug/report-bundle-logs.test.ts new file mode 100644 index 000000000..2f2ba3dea --- /dev/null +++ b/packages/coding-agent/test/debug/report-bundle-logs.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createReportBundle } from "@oh-my-pi/pi-coding-agent/debug/report-bundle"; +import { getConfigRootDir, getLogsDir, removeWithRetries, setAgentDir } from "@oh-my-pi/pi-utils"; + +const originalAgentDir = process.env.PI_CODING_AGENT_DIR; +const originalXdgStateHome = process.env.XDG_STATE_HOME; +const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); +let cleanupRoot: string | undefined; + +afterEach(async () => { + if (originalXdgStateHome === undefined) { + delete process.env.XDG_STATE_HOME; + } else { + process.env.XDG_STATE_HOME = originalXdgStateHome; + } + if (originalAgentDir) { + setAgentDir(originalAgentDir); + } else { + setAgentDir(fallbackAgentDir); + delete process.env.PI_CODING_AGENT_DIR; + } + if (cleanupRoot) { + await removeWithRetries(cleanupRoot); + cleanupRoot = undefined; + } +}); + +describe("report bundle logs", () => { + it("collects every same-day PID log, not only the current process", async () => { + cleanupRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-report-logs-")); + const xdgStateHome = path.join(cleanupRoot, "state"); + await fs.mkdir(path.join(xdgStateHome, "omp"), { recursive: true }); + process.env.XDG_STATE_HOME = xdgStateHome; + setAgentDir(fallbackAgentDir); + + const logsDir = getLogsDir(); + await fs.mkdir(logsDir, { recursive: true }); + const today = new Date().toISOString().slice(0, 10); + const crashedName = `omp.${today}.4242.log`; + const currentName = `omp.${today}.${process.pid}.log`; + await Bun.write(path.join(logsDir, crashedName), '{"pid":4242,"message":"fatal in crashed pid"}\n'); + await fs.utimes(path.join(logsDir, crashedName), 1, 1); + await Bun.write(path.join(logsDir, currentName), '{"pid":0,"message":"later invocation"}\n'); + await fs.utimes(path.join(logsDir, currentName), 2, 2); + + const result = await createReportBundle({ sessionFile: undefined }); + + expect(result.files).toContain("logs.txt"); + const archive = new Bun.Archive(await Bun.file(result.path).bytes()); + const files = await archive.files(); + const logsText = (await files.get("logs.txt")?.text()) ?? ""; + expect(logsText).toContain(crashedName); + expect(logsText).toContain("fatal in crashed pid"); + expect(logsText).toContain(currentName); + expect(logsText).toContain("later invocation"); + expect(logsText.indexOf(crashedName)).toBeLessThan(logsText.indexOf(currentName)); + }); +});