From 9274a74ea3e47ee4911dcba0390d12cd0904d3fc Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:24:34 +0200 Subject: [PATCH 1/4] fix: cap pm item reads at 64 MiB instead of Node's 1 MiB spawnSync default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readPmItems (export/import idempotency index) spawned the pm CLI without a maxBuffer, so Node's 1 MiB default applied. Measured against a real 443-item pm workspace, this package's exact argv returns 1054050 bytes (ENOBUFS today). Past the cap the child is killed with status null, signal SIGTERM, error.code ENOBUFS and EMPTY stderr — so the failure surfaced with nothing to diagnose. At larger sizes stdout is genuinely truncated mid-document (a 100 KiB cap yields 'Unterminated string in JSON at position 130829'). Capped at 64 MiB, matching pm-changelog / pm-context / pm-brief / pm-graph / pm-linear, which already guard this. An overrun now names itself instead of surfacing as an unexplained failure. Item: pm-beads-6a9l (closed) Co-Authored-By: Claude Opus 5 (1M context) --- .agents/pm/history/pm-beads-6a9l.jsonl | 2 ++ .agents/pm/issues/pm-beads-6a9l.toon | 16 ++++++++++++++++ CHANGELOG.md | 6 ++++++ index.ts | 25 ++++++++++++++++++++++++- 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 .agents/pm/history/pm-beads-6a9l.jsonl create mode 100644 .agents/pm/issues/pm-beads-6a9l.toon diff --git a/.agents/pm/history/pm-beads-6a9l.jsonl b/.agents/pm/history/pm-beads-6a9l.jsonl new file mode 100644 index 0000000..e0d7d66 --- /dev/null +++ b/.agents/pm/history/pm-beads-6a9l.jsonl @@ -0,0 +1,2 @@ +{"ts":"2026-07-24T20:24:33.459Z","author":"claude-opus-5","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-beads-6a9l"},{"op":"add","path":"/metadata/title","value":"pm item reads are capped at Node's 1 MiB spawnSync default, so a mature tracker fails with no diagnosis"},{"op":"add","path":"/metadata/description","value":"readPmItems (export/import idempotency index) spawns the pm CLI to read items but passes no maxBuffer, so Node's 1 MiB spawnSync default applies. Measured against a real 443-item pm workspace, this package's exact argv returns 1054050 bytes (ENOBUFS today). Past the cap the child is killed with status null, signal SIGTERM, error.code ENOBUFS and EMPTY stderr, so the failure surfaces with nothing to diagnose; at larger sizes stdout is genuinely truncated mid-document (verified: a 100 KiB cap yields 'Unterminated string in JSON at position 130829'). Sibling packages pm-changelog, pm-context, pm-brief, pm-graph and pm-linear already cap explicitly; this package was a straggler. Fix: cap at 64 MiB and name an ENOBUFS overrun explicitly instead of reporting an unexplained failure (or, for the never-throw demo/search paths, an empty result set)."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["agent-ux","maxbuffer","production-readiness","real-data"]},{"op":"add","path":"/metadata/created_at","value":"2026-07-24T20:24:33.459Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-07-24T20:24:33.459Z"},{"op":"add","path":"/metadata/author","value":"claude-opus-5"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"f5771fbd8365af7e50e34b41379eff444b4d7c6b9e739dc8fa3d4ff6d192e310","message":"File the 1 MiB pm-read ceiling found by a fleet-wide audit against a real 443-item workspace"} +{"ts":"2026-07-24T20:24:34.068Z","author":"claude-opus-5","op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-07-24T20:24:34.068Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-07-24T20:24:34.067Z"},{"op":"add","path":"/metadata/resolution","value":"Added PM_JSON_MAX_BUFFER (64 MiB) and passed it to the pm read; ENOBUFS is now reported as a buffer overrun naming the limit and the remedy."},{"op":"add","path":"/metadata/expected_result","value":"Reads of a mature tracker succeed, and any future overrun reports its real cause instead of an unexplained failure or a silent empty result."},{"op":"add","path":"/metadata/actual_result","value":"Verified with the package's exact argv against the real 443-item workspace: default cap -> status null / ENOBUFS; 64 MiB cap -> status 0 with the complete payload. Typecheck and the full test suite pass."},{"op":"add","path":"/metadata/close_reason","value":"Capped the pm read at 64 MiB and made an ENOBUFS overrun self-describing"}],"before_hash":"f5771fbd8365af7e50e34b41379eff444b4d7c6b9e739dc8fa3d4ff6d192e310","after_hash":"5f2294af7d26d3ea91e3d4d13f08da369a121802a4e20328b4a2ed969ab1b93b","message":"Close after verifying the capped read against the real workspace"} diff --git a/.agents/pm/issues/pm-beads-6a9l.toon b/.agents/pm/issues/pm-beads-6a9l.toon new file mode 100644 index 0000000..99e0dd9 --- /dev/null +++ b/.agents/pm/issues/pm-beads-6a9l.toon @@ -0,0 +1,16 @@ +id: pm-beads-6a9l +title: "pm item reads are capped at Node's 1 MiB spawnSync default, so a mature tracker fails with no diagnosis" +description: "readPmItems (export/import idempotency index) spawns the pm CLI to read items but passes no maxBuffer, so Node's 1 MiB spawnSync default applies. Measured against a real 443-item pm workspace, this package's exact argv returns 1054050 bytes (ENOBUFS today). Past the cap the child is killed with status null, signal SIGTERM, error.code ENOBUFS and EMPTY stderr, so the failure surfaces with nothing to diagnose; at larger sizes stdout is genuinely truncated mid-document (verified: a 100 KiB cap yields 'Unterminated string in JSON at position 130829'). Sibling packages pm-changelog, pm-context, pm-brief, pm-graph and pm-linear already cap explicitly; this package was a straggler. Fix: cap at 64 MiB and name an ENOBUFS overrun explicitly instead of reporting an unexplained failure (or, for the never-throw demo/search paths, an empty result set)." +type: Issue +status: closed +priority: 1 +tags[4]: agent-ux,maxbuffer,production-readiness,real-data +created_at: "2026-07-24T20:24:33.459Z" +updated_at: "2026-07-24T20:24:34.068Z" +closed_at: "2026-07-24T20:24:34.067Z" +author: claude-opus-5 +resolution: Added PM_JSON_MAX_BUFFER (64 MiB) and passed it to the pm read; ENOBUFS is now reported as a buffer overrun naming the limit and the remedy. +expected_result: "Reads of a mature tracker succeed, and any future overrun reports its real cause instead of an unexplained failure or a silent empty result." +actual_result: "Verified with the package's exact argv against the real 443-item workspace: default cap -> status null / ENOBUFS; 64 MiB cap -> status 0 with the complete payload. Typecheck and the full test suite pass." +close_reason: Capped the pm read at 64 MiB and made an ENOBUFS overrun self-describing +body: "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a1644..1176b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- pm item reads are capped at Node's 1 MiB spawnSync default, so a mature tracker fails with no diagnosis ([pm-beads-6a9l](https://github.com/unbraind/pm-beads/blob/main/.agents/pm/issues/pm-beads-6a9l.toon)) + ## 2026.7.23 - 2026-07-23 ### Fixed diff --git a/index.ts b/index.ts index e534f00..8846d8c 100644 --- a/index.ts +++ b/index.ts @@ -1361,14 +1361,37 @@ export function extractCreatedId(stdout: string): string | undefined { // Export core — serialize pm items back to Beads JSONL // --------------------------------------------------------------------------- +// Node's spawnSync defaults to a 1 MiB stdout cap, which a mature tracker's JSON +// dump passes at a few hundred items. Past that the child is killed with ENOBUFS, +// status null and EMPTY stderr, so the failure surfaces with nothing to diagnose +// (and at larger sizes stdout is genuinely truncated mid-document). +// 64 MiB matches the cap the sibling pm packages settled on. +const PM_JSON_MAX_BUFFER = 64 * 1024 * 1024; + +/** Name the real cause of a failed `pm` read. A stdout overrun kills the child + * with `status: null` and EMPTY stderr, so without this the failure surfaces as + * an unexplained error (or, worse, as an empty result set). */ +function describePmReadFailure(error: Error): string { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOBUFS") { + return `pm output exceeded the ${PM_JSON_MAX_BUFFER} byte read buffer. ` + + "The workspace is larger than this integration's read limit; narrow the " + + "operation or raise PM_JSON_MAX_BUFFER."; + } + return `pm read failed: ${error.message}`; +} + function readPmItems(pmRoot: string): PmItem[] { // `--full --include-body` so descriptions, tags and dependency edges survive // the export instead of the brief projection (which omits them). const result = spawnSync( "pm", ["--path", pmRoot, "--json", "list-all", "--full", "--include-body", "--limit", "10000"], - { encoding: "utf-8" }, + { encoding: "utf-8", maxBuffer: PM_JSON_MAX_BUFFER }, ); + if (result.error) { + throw new CommandError(describePmReadFailure(result.error)); + } if (result.status !== 0) { throw new CommandError(result.stderr || "pm list failed"); } From 7c0495b5153162c65d97fa5cf93580057e7f9aba Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:34:13 +0200 Subject: [PATCH 2/4] fix: make the read-buffer cap overridable and stop silent read failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile review findings, all confirmed: - **P2 — the ENOBUFS diagnostic prescribed an impossible remedy.** It told the reader to raise the cap, but the cap was a module-private compile-time constant with no override, so a published-package consumer could only follow that advice by patching and rebuilding. The cap now reads an env var (`PM_JSON_MAX_BUFFER` / `PM_LIST_MAX_BUFFER`, in bytes), falling back to the default on any invalid or non-positive value so the guard cannot be silently disabled. - **P2 (pm-starter) — inserting the constant between the existing JSDoc and `readPmItems` orphaned the doc comment**, dropping the "never throws / safe read pattern" contract from the consumer-facing `dist/index.d.ts`. Verified in the generated output, then moved the block above the JSDoc; the contract is back in `dist/index.d.ts`. - **P1 (starters) — a genuine nonzero `pm` exit was still silent.** Only the `result.error` branch was hardened, so an unreadable or invalid workspace still returned an empty result with stderr discarded — leaving exactly the failure mode this change set out to remove: silence that looks like "no items" / "no matches". Both never-throw paths now log the exit code and stderr while keeping their empty-result contract. Typecheck and full test suite green. Co-Authored-By: Claude Opus 5 (1M context) --- index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 8846d8c..908ba22 100644 --- a/index.ts +++ b/index.ts @@ -1366,7 +1366,15 @@ export function extractCreatedId(stdout: string): string | undefined { // status null and EMPTY stderr, so the failure surfaces with nothing to diagnose // (and at larger sizes stdout is genuinely truncated mid-document). // 64 MiB matches the cap the sibling pm packages settled on. -const PM_JSON_MAX_BUFFER = 64 * 1024 * 1024; +const PM_JSON_MAX_BUFFER = resolvePmJsonMaxBuffer(); + +/** 64 MiB by default; override with the `PM_JSON_MAX_BUFFER` env var (bytes) for + * workspaces larger than that. Invalid or non-positive values fall back to the + * default rather than silently disabling the guard. */ +function resolvePmJsonMaxBuffer(): number { + const raw = Number.parseInt(process.env.PM_JSON_MAX_BUFFER ?? "", 10); + return Number.isFinite(raw) && raw > 0 ? raw : 64 * 1024 * 1024; +} /** Name the real cause of a failed `pm` read. A stdout overrun kills the child * with `status: null` and EMPTY stderr, so without this the failure surfaces as From e3f1fae996254df043cae955c7ddf1a127a11535 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:40:17 +0200 Subject: [PATCH 3/4] fix: resolve the read-buffer cap per call and cover the overrun contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the Greptile round-2 findings. - The cap was resolved once at module load, so the `PM_JSON_MAX_BUFFER` override only applied if the env var was set before the module was imported — an import-order dependency that also made the branch untestable. Now resolved per call (one integer parse against a process spawn: free), and the ENOBUFS message reports the limit actually in effect rather than a constant's name. - Added coverage for the failure contract in the starter packages: a real pm workspace read with the cap forced to 64 bytes must return the documented empty result AND report the overrun on stderr. Without a test, "a read failure must not look like an empty workspace" was an assertion in a commit message rather than something the suite enforces. Typecheck and the full test suite green. Co-Authored-By: Claude Opus 5 (1M context) --- index.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/index.ts b/index.ts index 908ba22..4affd75 100644 --- a/index.ts +++ b/index.ts @@ -1366,12 +1366,11 @@ export function extractCreatedId(stdout: string): string | undefined { // status null and EMPTY stderr, so the failure surfaces with nothing to diagnose // (and at larger sizes stdout is genuinely truncated mid-document). // 64 MiB matches the cap the sibling pm packages settled on. -const PM_JSON_MAX_BUFFER = resolvePmJsonMaxBuffer(); - -/** 64 MiB by default; override with the `PM_JSON_MAX_BUFFER` env var (bytes) for - * workspaces larger than that. Invalid or non-positive values fall back to the - * default rather than silently disabling the guard. */ -function resolvePmJsonMaxBuffer(): number { +/** Read-buffer cap for `pm` output, in bytes. 64 MiB by default; override with the + * `PM_JSON_MAX_BUFFER` env var. Resolved per call so the override takes effect + * without an import-order dependency. Invalid or non-positive values fall back to + * the default rather than silently disabling the guard. */ +function pmJsonMaxBuffer(): number { const raw = Number.parseInt(process.env.PM_JSON_MAX_BUFFER ?? "", 10); return Number.isFinite(raw) && raw > 0 ? raw : 64 * 1024 * 1024; } @@ -1379,10 +1378,10 @@ function resolvePmJsonMaxBuffer(): number { /** Name the real cause of a failed `pm` read. A stdout overrun kills the child * with `status: null` and EMPTY stderr, so without this the failure surfaces as * an unexplained error (or, worse, as an empty result set). */ -function describePmReadFailure(error: Error): string { +function describePmReadFailure(error: Error, limitBytes: number): string { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOBUFS") { - return `pm output exceeded the ${PM_JSON_MAX_BUFFER} byte read buffer. ` + return `pm output exceeded the ${limitBytes} byte read buffer. ` + "The workspace is larger than this integration's read limit; narrow the " + "operation or raise PM_JSON_MAX_BUFFER."; } @@ -1390,15 +1389,16 @@ function describePmReadFailure(error: Error): string { } function readPmItems(pmRoot: string): PmItem[] { + const maxBuffer = pmJsonMaxBuffer(); // `--full --include-body` so descriptions, tags and dependency edges survive // the export instead of the brief projection (which omits them). const result = spawnSync( "pm", ["--path", pmRoot, "--json", "list-all", "--full", "--include-body", "--limit", "10000"], - { encoding: "utf-8", maxBuffer: PM_JSON_MAX_BUFFER }, + { encoding: "utf-8", maxBuffer }, ); if (result.error) { - throw new CommandError(describePmReadFailure(result.error)); + throw new CommandError(describePmReadFailure(result.error, maxBuffer)); } if (result.status !== 0) { throw new CommandError(result.stderr || "pm list failed"); From e7854f96898614c519a11e7f0fde6bcd1368c3c6 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:44:43 +0200 Subject: [PATCH 4/4] fix: reject malformed read-buffer overrides instead of trusting a numeric prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1: `Number.parseInt("64MiB", 10)` returns 64, so that value silently imposed a **64-BYTE** cap — breaking every ordinary read while appearing to honor the documented invalid-value fallback. The plausible-looking typo was the worst case: it turned a safety guard into a guaranteed failure. Switched to `Number()`, which rejects the whole string, plus `Number.isSafeInteger(raw) && raw > 0`. Covered in the starter suite across "64MiB", "64 MB", "abc", "-1", "0", "6.5" and "", each of which must fall back to the default, while a valid explicit value is still honored. Also narrowed the starters' overrun message: those reads are always the full workspace, so "narrow the operation" was a dead instruction — the message now names only the lever the reader has. Typecheck and full test suites green. Co-Authored-By: Claude Opus 5 (1M context) --- index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 4affd75..72e4468 100644 --- a/index.ts +++ b/index.ts @@ -1371,8 +1371,11 @@ export function extractCreatedId(stdout: string): string | undefined { * without an import-order dependency. Invalid or non-positive values fall back to * the default rather than silently disabling the guard. */ function pmJsonMaxBuffer(): number { - const raw = Number.parseInt(process.env.PM_JSON_MAX_BUFFER ?? "", 10); - return Number.isFinite(raw) && raw > 0 ? raw : 64 * 1024 * 1024; + // Number(), not parseInt(): parseInt("64MiB") silently yields 64, which would + // impose a 64-BYTE cap and break every ordinary read while appearing to honor + // the documented invalid-value fallback. Number() rejects the whole string. + const raw = Number(process.env.PM_JSON_MAX_BUFFER); + return Number.isSafeInteger(raw) && raw > 0 ? raw : 64 * 1024 * 1024; } /** Name the real cause of a failed `pm` read. A stdout overrun kills the child