fix(debug): scope report bundle to current session subtree
createReportBundle's addSubagentSessions scanned path.dirname(sessionFile) - the sessions root holding every session in history - and bundled the 10 newest .jsonl files, leaking unrelated sessions' transcripts and bloating archives to tens of MB. Real subagent sessions live inside the current session's own artifacts dir, so recurse that subtree and drop the sessions-root scan entirely. Fixes #8648
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the `/debug` report bundle including unrelated historic sessions: subagent transcripts were collected by scanning the entire sessions directory, leaking other sessions' `.jsonl` files and bloating archives to tens of MB. The bundle now captures only the current session's own artifacts subtree ([#8648](https://github.com/can1357/oh-my-pi/issues/8648)).
|
||||
|
||||
## [17.3.4] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*
|
||||
* Creates a .tar.gz archive with session data, logs, system info, and optional profiling data.
|
||||
*/
|
||||
|
||||
import type { Dirent } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { WorkProfile } from "@oh-my-pi/pi-natives";
|
||||
@@ -66,8 +68,8 @@ export interface DebugLogSource {
|
||||
*
|
||||
* Bundle contents:
|
||||
* - session.jsonl: Current session transcript
|
||||
* - artifacts/: Session artifacts directory
|
||||
* - subagents/: Subagent sessions + artifacts
|
||||
* - artifacts/: Current session's artifacts subtree (recursive), including any
|
||||
* subagent session transcripts nested under it
|
||||
* - logs.txt: Recent log entries
|
||||
* - system.json: OS, arch, CPU, memory, versions
|
||||
* - env.json: Sanitized environment variables
|
||||
@@ -130,14 +132,12 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
|
||||
// Session file might not exist yet
|
||||
}
|
||||
|
||||
// Artifacts directory (same path without .jsonl)
|
||||
// Artifacts subtree (same path without .jsonl). Recursing captures the
|
||||
// current session's nested subagent transcripts and their artifacts while
|
||||
// staying inside this session's own directory — unrelated co-located
|
||||
// sessions in the sessions root are never touched (#8648).
|
||||
const artifactsDir = options.sessionFile.slice(0, -6);
|
||||
await addDirectoryToArchive(data, files, artifactsDir, "artifacts");
|
||||
|
||||
// Look for subagent sessions in the same directory
|
||||
const sessionDir = path.dirname(options.sessionFile);
|
||||
const sessionBasename = path.basename(options.sessionFile, ".jsonl");
|
||||
await addSubagentSessions(data, files, sessionDir, sessionBasename);
|
||||
}
|
||||
|
||||
// CPU profile
|
||||
@@ -172,68 +172,34 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
|
||||
return { path: outputPath, files };
|
||||
}
|
||||
|
||||
/** Add all files from a directory to the archive */
|
||||
/** Recursively add every file under a directory to the archive. */
|
||||
async function addDirectoryToArchive(
|
||||
data: Record<string, string>,
|
||||
files: string[],
|
||||
dirPath: string,
|
||||
archivePrefix: string,
|
||||
): Promise<void> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(dirPath, entry.name);
|
||||
const archivePath = `${archivePrefix}/${entry.name}`;
|
||||
try {
|
||||
const content = await Bun.file(filePath).text();
|
||||
data[archivePath] = content;
|
||||
files.push(archivePath);
|
||||
} catch {
|
||||
// Skip files we can't read
|
||||
}
|
||||
}
|
||||
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Find and add subagent session files */
|
||||
async function addSubagentSessions(
|
||||
data: Record<string, string>,
|
||||
files: string[],
|
||||
sessionDir: string,
|
||||
parentBasename: string,
|
||||
): Promise<void> {
|
||||
// Subagent sessions are named with task IDs in the same directory
|
||||
// They follow the pattern: {timestamp}_{sessionId}.jsonl
|
||||
// We look for any sessions created after the parent session
|
||||
try {
|
||||
const entries = await fs.readdir(sessionDir, { withFileTypes: true });
|
||||
const sessionFiles = entries
|
||||
.filter(e => e.isFile() && e.name.endsWith(".jsonl") && e.name !== `${parentBasename}.jsonl`)
|
||||
.map(e => e.name);
|
||||
|
||||
// Limit to most recent 10 subagent sessions
|
||||
const sortedFiles = sessionFiles.sort().slice(-10);
|
||||
|
||||
for (const filename of sortedFiles) {
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
const archivePath = `subagents/${filename}`;
|
||||
try {
|
||||
const content = await Bun.file(filePath).text();
|
||||
data[archivePath] = content;
|
||||
files.push(archivePath);
|
||||
|
||||
// Also add artifacts for this subagent session
|
||||
const artifactsDir = filePath.slice(0, -6);
|
||||
await addDirectoryToArchive(data, files, artifactsDir, `subagents/${filename.slice(0, -6)}`);
|
||||
} catch {
|
||||
// Skip files we can't read
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dirPath, entry.name);
|
||||
const archivePath = `${archivePrefix}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
await addDirectoryToArchive(data, files, entryPath, archivePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
try {
|
||||
data[archivePath] = await Bun.file(entryPath).text();
|
||||
files.push(archivePath);
|
||||
} catch {
|
||||
// Skip files we can't read
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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, 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;
|
||||
}
|
||||
});
|
||||
|
||||
async function archiveMembers(archivePath: string): Promise<string[]> {
|
||||
const archive = new Bun.Archive(await Bun.file(archivePath).bytes());
|
||||
return [...(await archive.files()).keys()].sort();
|
||||
}
|
||||
|
||||
describe("report bundle sessions", () => {
|
||||
it("bundles only the current session's subtree, not unrelated co-located sessions", async () => {
|
||||
cleanupRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-report-sessions-"));
|
||||
const xdgStateHome = path.join(cleanupRoot, "state");
|
||||
await fs.mkdir(path.join(xdgStateHome, "omp"), { recursive: true });
|
||||
process.env.XDG_STATE_HOME = xdgStateHome;
|
||||
setAgentDir(fallbackAgentDir);
|
||||
|
||||
const sessionsDir = path.join(cleanupRoot, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
// Current session and its artifacts subtree: a genuine subagent transcript
|
||||
// plus a nested sub-subagent transcript one level deeper.
|
||||
const sessionFile = path.join(sessionsDir, "2026-08-15T00-00-00_CURRENT.jsonl");
|
||||
await Bun.write(sessionFile, '{"type":"session","id":"CURRENT"}\n');
|
||||
const artifactsDir = sessionFile.slice(0, -6);
|
||||
await fs.mkdir(path.join(artifactsDir, "SubTask"), { recursive: true });
|
||||
await Bun.write(path.join(artifactsDir, "SubTask.jsonl"), '{"type":"session","id":"SubTask"}\n');
|
||||
await Bun.write(path.join(artifactsDir, "SubTask", "NestedTask.jsonl"), '{"type":"session","id":"NestedTask"}\n');
|
||||
|
||||
// Unrelated top-level sessions co-located in the sessions root.
|
||||
await Bun.write(
|
||||
path.join(sessionsDir, "2026-08-10T00-00-00_OTHERA.jsonl"),
|
||||
'{"type":"session","secret":"private-a"}\n',
|
||||
);
|
||||
await Bun.write(
|
||||
path.join(sessionsDir, "2026-08-12T00-00-00_OTHERB.jsonl"),
|
||||
'{"type":"session","secret":"private-b"}\n',
|
||||
);
|
||||
|
||||
const result = await createReportBundle({ sessionFile });
|
||||
const members = await archiveMembers(result.path);
|
||||
await fs.rm(result.path, { force: true });
|
||||
|
||||
// Genuine subtree is captured recursively.
|
||||
expect(members).toContain("artifacts/SubTask.jsonl");
|
||||
expect(members).toContain("artifacts/SubTask/NestedTask.jsonl");
|
||||
// Unrelated sessions never appear anywhere in the archive.
|
||||
expect(members.some(name => name.includes("OTHERA") || name.includes("OTHERB"))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user