diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f749018..c815011 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Polycli host adapters for Claude Code and related agent CLIs", - "version": "0.6.26" + "version": "0.6.27" }, "plugins": [ { "name": "polycli", "description": "Claude Code adapter for the shared polycli companion", - "version": "0.6.26", + "version": "0.6.27", "source": "./plugins/polycli" } ] diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 6dab3b2..185bb70 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Polycli marketplace for GitHub Copilot CLI", - "version": "0.6.26" + "version": "0.6.27" }, "plugins": [ { "name": "polycli-copilot", "description": "Run the shared polycli companion from GitHub Copilot CLI", - "version": "0.6.26", + "version": "0.6.27", "source": "./plugins/polycli-copilot" } ] diff --git a/README.ja.md b/README.ja.md index b41987f..9736a59 100644 --- a/README.ja.md +++ b/README.ja.md @@ -27,9 +27,9 @@ これは **ユーティリティ専用の Path B モノレポ** です。プロバイダ間の差異を偽の抽象化で覆い隠したり、ランタイム基底クラスを発明したりはしません。公式の上流 CLI をサブプロセスとして組み合わせ、単一のコマンド面を公開し、4 状態の timing スキーマで能力の違いを正直に表現します。 -## 最新リリース: v0.6.26 +## 最新リリース: v0.6.27 -v0.6.25 の cc-X エンドポイントレシピを土台に、review で見つかった Grok のバグとリリース文書のドリフトを修正しました。Grok のネストされた error オブジェクト(`{error:{message:...}}`、可視テキストあり)は `ok:true` ではなく正しく失敗として扱われます。cc-X バリデータは `status` を `verified` / `marketplace-unstable` に限定し、文書では構造 + source の裏付けのみを保証し現時点の真正性は保証しないことを明記しました。v0.6.24 を指したままだった README 三言語と roadmap スナップショットも同期しました。Claude `ask` / `review` は引き続き headless `claude -p` 既定経路です。詳細は英語の release notes を参照してください: [`docs/release-notes-v0.6.26.md`](./docs/release-notes-v0.6.26.md)。 +v0.6.26 の Grok ネストエラー修正を土台に、残りの review 残件を解消しました。バックグラウンドジョブのディスクリークを修正(`saveState` は `MAX_JOBS` を超えて削除される terminal ジョブの result/config/log を回収します)。fixture-metadata バリデータは path/meta 契約(`provider` がディレクトリ、`name` がファイル stem に一致)を強制し、cc-X バリデータと OpenCode の exit-2 ソフトシグナルに実行経路テストを追加しました。最新リリースを v0.6.24 と書いたままだった `docs/roadmap.md` の Current-state も同期しました。Claude `ask` / `review` は引き続き headless `claude -p` 既定経路です。詳細は英語の release notes を参照してください: [`docs/release-notes-v0.6.27.md`](./docs/release-notes-v0.6.27.md)。 ## なぜ polycli を使うのか? diff --git a/README.md b/README.md index d2dfe7d..a827786 100644 --- a/README.md +++ b/README.md @@ -29,17 +29,16 @@ It is a **utility-only Path B monorepo**: it does not unify provider differences behind fake abstractions, and it does not invent a runtime base class. It composes the official upstream CLIs as subprocesses, exposes one command surface, and surfaces honest capability differences in a four-state timing schema. -## Latest release: v0.6.26 +## Latest release: v0.6.27 -The latest patch fixes a Grok review-found bug and release-doc drift on top of v0.6.25's cc-X endpoint recipes: +The latest patch clears the remaining review residuals on top of v0.6.26's Grok nested-error fix: -- Grok: a NESTED error object (`{error:{message:...}}`) with visible text is now correctly reported as failed instead of `ok:true` — the previous recursion missed an error payload that carried no `type`/`is_error` marker. -- cc-X recipe validator now constrains `status` to `verified` / `marketplace-unstable` (rejecting unlabeled entries), and the docs state plainly that it guards structure + source-anchoring, not current-truth. -- Synced the release-state docs (README in three languages, roadmap snapshot) that still pointed at v0.6.24. -- Builds on v0.6.25: the re-verified workflow-review remediation, the tmux test stabilization, and the cc-X domestic-model endpoint recipes (`docs/cc-x-endpoints.md` / `docs/cc-x-recipes.json`) that ride the existing `claude`/`opencode` runtimes — cc-X is documented config, **not** a new provider adapter. -- Claude `ask` and `review` still use headless `claude -p` by default with plan/no-tools/no-MCP constraints; utility packages stay on their independent v1.x cadence. +- Fixed a background-job disk leak: `saveState` now reclaims the result/config/log artifacts of terminal jobs pruned past `MAX_JOBS` instead of leaving them in the jobs dir forever. +- The fixture-metadata validator enforces the documented path/meta contract (`provider` matches the directory, `name` matches the file stem); the cc-X validator and OpenCode exit-2 soft-signal now have execution-path test coverage. +- Synced the `docs/roadmap.md` Current-state section, which still said the latest release was v0.6.24. +- Builds on v0.6.26 (Grok nested-error fix) and v0.6.25 (re-verified remediation + cc-X endpoint recipes). Claude `ask` and `review` still use headless `claude -p` by default with plan/no-tools/no-MCP constraints; utility packages stay on their independent v1.x cadence. -See [`docs/release-notes-v0.6.26.md`](./docs/release-notes-v0.6.26.md). +See [`docs/release-notes-v0.6.27.md`](./docs/release-notes-v0.6.27.md). ## Why polycli? diff --git a/README.zh-CN.md b/README.zh-CN.md index 38144a1..2d270a0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,9 +27,9 @@ 这是一个 **utility-only 的 Path B monorepo**:不假装能抹平 provider 之间的差异,也不引入 runtime 基类。它把官方上游 CLI 作为子进程组合起来,统一命令面,并通过四态 timing schema 如实暴露能力差异。 -## 最新版本:v0.6.26 +## 最新版本:v0.6.27 -在 v0.6.25 的 cc-X 端点 recipe 基础上,修复了 review 发现的 Grok bug 与发布文档漂移:Grok 嵌套 error 对象(`{error:{message:...}}`,带可见文本)现在正确判为失败而非 `ok:true`;cc-X validator 将 `status` 限定为 `verified` / `marketplace-unstable`,文档明确它只保结构 + source 锚定、不保当前真值;同步了仍指向 v0.6.24 的 README 三语与 roadmap 快照。Claude `ask` / `review` 仍走 headless `claude -p` 默认路径。详情见英文 release notes:[`docs/release-notes-v0.6.26.md`](./docs/release-notes-v0.6.26.md)。 +在 v0.6.26 的 Grok 嵌套 error 修复基础上,清理剩余 review 残留:修复后台 job 磁盘泄漏(`saveState` 现在回收被 `MAX_JOBS` 裁掉的 terminal job 的 result/config/log,不再永久留存);fixture-metadata validator 强制 path/meta 契约(`provider` 配目录、`name` 配文件 stem),cc-X validator 与 OpenCode exit-2 软信号补了执行面测试覆盖;同步了 `docs/roadmap.md` Current-state 段(仍写最新版是 v0.6.24)。Claude `ask` / `review` 仍走 headless `claude -p` 默认路径。详情见英文 release notes:[`docs/release-notes-v0.6.27.md`](./docs/release-notes-v0.6.27.md)。 ## 为什么要用 polycli? diff --git a/docs/release-notes-v0.6.27.md b/docs/release-notes-v0.6.27.md new file mode 100644 index 0000000..68e12b7 --- /dev/null +++ b/docs/release-notes-v0.6.27.md @@ -0,0 +1,36 @@ +# polycli v0.6.27 + +Patch on top of `v0.6.26` that clears the remaining review residuals: release-state doc drift, two validator path/meta contracts, OpenCode exit-2 execution-path coverage, and the MAX_JOBS terminal-job disk leak. + +The v0.6.22 Claude behavior remains unchanged: ordinary Claude `ask` / `review` calls use headless `claude -p` with plan/no-tools/no-MCP constraints, while explicit/internal tmux TUI runtime support remains available. + +## What changed + +### Background job disk-leak (residual) + +- `saveState` pruned terminal jobs past `MAX_JOBS` out of the persisted list but never deleted their on-disk artifacts, so the jobs dir grew unbounded as terminal history aged. It now reclaims the result envelope, config file, and log file for every terminal job dropped from the persisted set (active jobs are never pruned). Added `removeJobLogFile` and wired the previously-dead `removeJobFile` export. + +### Validator path/meta contracts + +- `scripts/validate-fixture-metadata.mjs` now enforces the `docs/capture-fixtures.md` consistency rules: `provider` must match the fixture directory and `name` must match the file stem. Previously a `qwen/stream-success.meta.json` declaring `provider:"claude", name:"wrong-success"` still passed. + +### OpenCode exit-2 execution-path coverage + +- `runCompanion` is now exported and accepts an injectable spawn, so the exit-2 soft-signal contract is tested at the execution layer (returns the stdout envelope on exit 2; throws with `error.stdout` attached on a hard exit 1), not only via the `isHardCompanionFailure` predicate. + +### Release-state doc drift + +- `docs/roadmap.md` still said the latest public release was v0.6.24 and that post-v0.6.24 work was "not yet published" in its **Current state** section (only the Snapshot line had been updated). Both the Snapshot line and the Current state section now reflect v0.6.27 with nothing unreleased pending. + +## Verification + +- Focused RED/GREEN: state disk-reclaim test (dropped terminal job's result/config/log removed, kept + active retained), fixture path/meta mismatch rejections, OpenCode `runCompanion` execution-path return/throw tests. +- `node scripts/validate-fixture-metadata.mjs` ok (17 checked), `node scripts/validate-cc-x-recipes.mjs` ok (9 entries). +- `npm test` and `npm run release:check` green. + +## Release artifacts + +- GitHub release `v0.6.27`: https://github.com/bbingz/polycli/releases/tag/v0.6.27 +- npm `@bbingz/polycli@0.6.27` and `@bbingz/polycli-opencode@0.6.27` (`latest`). + +Utility packages stay on the independent v1.x cadence (`@bbingz/polycli-utils@1.0.2`, `@bbingz/polycli-timing@1.0.1`). diff --git a/docs/roadmap.md b/docs/roadmap.md index 96cf95e..d4ce842 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -Snapshot: 2026-06-19 (v0.6.26 is the latest public release; it fixes a review-found Grok nested-error bug, tightens the cc-X recipe validator, and syncs release-state docs, on top of v0.6.25's re-verified remediation + cc-X endpoint recipes, while keeping Claude ask/review on headless `claude -p` defaults). +Snapshot: 2026-06-19 (v0.6.27 is the latest public release; it cleans up review residuals — roadmap release-state drift, the cc-X/fixture validator path/meta + status contracts, OpenCode exit-2 execution-path coverage, and the MAX_JOBS terminal-job disk-leak — on top of v0.6.26's Grok nested-error fix and v0.6.25's re-verified remediation + cc-X endpoint recipes, while keeping Claude ask/review on headless `claude -p` defaults). This file lives next to `docs/release.md` (what's shipped) and `CHANGELOG.md` (what happened). It answers the complementary question: **what's open, how it's prioritized, and what we're deliberately not doing.** @@ -10,9 +10,9 @@ Living document — update when items land, when priorities shift, or when a def ## Current state -- Latest public release: **v0.6.24** — see `docs/release-notes-v0.6.24.md`. It keeps default Claude ask/review on `claude -p` and hardens status wait timeout behavior for JSON, text, and invalid timeout values. +- Latest public release: **v0.6.27** — see `docs/release-notes-v0.6.27.md`. It keeps default Claude ask/review on `claude -p` and is the latest in the post-v0.6.24 patch line (status-wait hardening → re-verified remediation + cc-X recipes → Grok nested-error fix → these review-residual cleanups). - 11 providers ship in the latest release (claude / gemini / kimi / qwen / minimax / copilot / opencode / pi / cmd / agy / grok). v0.6.21 shipped Claude detached tmux TUI defaults and the third-party review remediation set. -- Current unreleased workspace work: post-v0.6.24 status wait compatibility/test hardening after latest-package multi-provider review; not yet published. +- No unreleased workspace work pending: everything through v0.6.27 (status-wait compatibility, the re-verified remediation, the cc-X endpoint recipes, the Grok nested-error fix, and the validator/doc/disk-leak review residuals) is published. - 4 host plugins (polycli / polycli-codex / polycli-copilot / polycli-opencode) plus the optional `@bbingz/polycli` terminal CLI, each with an independent release manifest. - Path B architectural stance is intact: `@bbingz/polycli-utils` / `@bbingz/polycli-timing` are public v1 npm packages; `@bbingz/polycli` is the public terminal CLI surface; `@bbingz/polycli-runtime` remains an internal bundler input (`private: true`); provider modules are flat, not inherited; timing four-state semantics preserved. diff --git a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs index a501ec0..f6d4f84 100755 --- a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs +++ b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs @@ -5079,6 +5079,14 @@ function loadState(workspaceRoot) { function saveState(workspaceRoot, state) { ensureStateDir(workspaceRoot); const jobs = pruneJobsForSave(state.jobs); + const keptIds = new Set(jobs.map((job) => job.jobId)); + for (const job of state.jobs) { + if (job && job.jobId && !keptIds.has(job.jobId)) { + removeJobFile(workspaceRoot, job.jobId); + removeJobConfigFile(workspaceRoot, job.jobId); + removeJobLogFile(workspaceRoot, job.jobId); + } + } const config = state.config && typeof state.config === "object" ? state.config : {}; writeJsonAtomic(resolveStateFile(workspaceRoot), { version: STATE_VERSION, config, jobs }, { mode: PRIVATE_FILE_MODE }); return { version: STATE_VERSION, config, jobs }; @@ -5179,6 +5187,12 @@ function readJobFile(jobFile) { return null; } } +function removeJobFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobFile(workspaceRoot, jobId)); + } catch { + } +} function writeJobConfigFile(workspaceRoot, jobId, payload) { ensureStateDir(workspaceRoot); writeJsonAtomic(resolveJobConfigFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); @@ -5197,6 +5211,12 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } +function removeJobLogFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { + } +} // plugins/polycli/scripts/lib/run-ledger.mjs import { randomUUID as randomUUID2 } from "node:crypto"; diff --git a/packages/polycli-terminal/package.json b/packages/polycli-terminal/package.json index c17da77..ffd1bb5 100644 --- a/packages/polycli-terminal/package.json +++ b/packages/polycli-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@bbingz/polycli", - "version": "0.6.26", + "version": "0.6.27", "description": "Terminal CLI for Polycli provider diagnostics and host-compatible commands.", "type": "module", "bin": { diff --git a/plugins/polycli-codex/.codex-plugin/plugin.json b/plugins/polycli-codex/.codex-plugin/plugin.json index 17e14ce..ddca17b 100644 --- a/plugins/polycli-codex/.codex-plugin/plugin.json +++ b/plugins/polycli-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "polycli-codex", - "version": "0.6.26", + "version": "0.6.27", "description": "Codex skill adapter that routes provider CLI work through the shared polycli companion.", "author": { "name": "bing" diff --git a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs index a501ec0..f6d4f84 100755 --- a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs @@ -5079,6 +5079,14 @@ function loadState(workspaceRoot) { function saveState(workspaceRoot, state) { ensureStateDir(workspaceRoot); const jobs = pruneJobsForSave(state.jobs); + const keptIds = new Set(jobs.map((job) => job.jobId)); + for (const job of state.jobs) { + if (job && job.jobId && !keptIds.has(job.jobId)) { + removeJobFile(workspaceRoot, job.jobId); + removeJobConfigFile(workspaceRoot, job.jobId); + removeJobLogFile(workspaceRoot, job.jobId); + } + } const config = state.config && typeof state.config === "object" ? state.config : {}; writeJsonAtomic(resolveStateFile(workspaceRoot), { version: STATE_VERSION, config, jobs }, { mode: PRIVATE_FILE_MODE }); return { version: STATE_VERSION, config, jobs }; @@ -5179,6 +5187,12 @@ function readJobFile(jobFile) { return null; } } +function removeJobFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobFile(workspaceRoot, jobId)); + } catch { + } +} function writeJobConfigFile(workspaceRoot, jobId, payload) { ensureStateDir(workspaceRoot); writeJsonAtomic(resolveJobConfigFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); @@ -5197,6 +5211,12 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } +function removeJobLogFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { + } +} // plugins/polycli/scripts/lib/run-ledger.mjs import { randomUUID as randomUUID2 } from "node:crypto"; diff --git a/plugins/polycli-copilot/plugin.json b/plugins/polycli-copilot/plugin.json index 6a07854..7dd2c2c 100644 --- a/plugins/polycli-copilot/plugin.json +++ b/plugins/polycli-copilot/plugin.json @@ -1,7 +1,7 @@ { "name": "polycli-copilot", "description": "GitHub Copilot CLI adapter for the shared polycli companion.", - "version": "0.6.26", + "version": "0.6.27", "author": { "name": "bing" }, diff --git a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs index a501ec0..f6d4f84 100755 --- a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs @@ -5079,6 +5079,14 @@ function loadState(workspaceRoot) { function saveState(workspaceRoot, state) { ensureStateDir(workspaceRoot); const jobs = pruneJobsForSave(state.jobs); + const keptIds = new Set(jobs.map((job) => job.jobId)); + for (const job of state.jobs) { + if (job && job.jobId && !keptIds.has(job.jobId)) { + removeJobFile(workspaceRoot, job.jobId); + removeJobConfigFile(workspaceRoot, job.jobId); + removeJobLogFile(workspaceRoot, job.jobId); + } + } const config = state.config && typeof state.config === "object" ? state.config : {}; writeJsonAtomic(resolveStateFile(workspaceRoot), { version: STATE_VERSION, config, jobs }, { mode: PRIVATE_FILE_MODE }); return { version: STATE_VERSION, config, jobs }; @@ -5179,6 +5187,12 @@ function readJobFile(jobFile) { return null; } } +function removeJobFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobFile(workspaceRoot, jobId)); + } catch { + } +} function writeJobConfigFile(workspaceRoot, jobId, payload) { ensureStateDir(workspaceRoot); writeJsonAtomic(resolveJobConfigFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); @@ -5197,6 +5211,12 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } +function removeJobLogFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { + } +} // plugins/polycli/scripts/lib/run-ledger.mjs import { randomUUID as randomUUID2 } from "node:crypto"; diff --git a/plugins/polycli-opencode/index.mjs b/plugins/polycli-opencode/index.mjs index 306301c..f1bb950 100644 --- a/plugins/polycli-opencode/index.mjs +++ b/plugins/polycli-opencode/index.mjs @@ -22,8 +22,8 @@ export function isHardCompanionFailure(status) { return status !== 0 && status !== 2; } -function runCompanion(argv) { - const result = spawnSync(process.execPath, [COMPANION, ...argv], { +export function runCompanion(argv, { spawn = spawnSync } = {}) { + const result = spawn(process.execPath, [COMPANION, ...argv], { cwd: process.cwd(), encoding: "utf8", }); diff --git a/plugins/polycli-opencode/package.json b/plugins/polycli-opencode/package.json index 5a96d3d..539513c 100644 --- a/plugins/polycli-opencode/package.json +++ b/plugins/polycli-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@bbingz/polycli-opencode", - "version": "0.6.26", + "version": "0.6.27", "type": "module", "main": "./index.mjs", "exports": { diff --git a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs index a501ec0..f6d4f84 100755 --- a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs @@ -5079,6 +5079,14 @@ function loadState(workspaceRoot) { function saveState(workspaceRoot, state) { ensureStateDir(workspaceRoot); const jobs = pruneJobsForSave(state.jobs); + const keptIds = new Set(jobs.map((job) => job.jobId)); + for (const job of state.jobs) { + if (job && job.jobId && !keptIds.has(job.jobId)) { + removeJobFile(workspaceRoot, job.jobId); + removeJobConfigFile(workspaceRoot, job.jobId); + removeJobLogFile(workspaceRoot, job.jobId); + } + } const config = state.config && typeof state.config === "object" ? state.config : {}; writeJsonAtomic(resolveStateFile(workspaceRoot), { version: STATE_VERSION, config, jobs }, { mode: PRIVATE_FILE_MODE }); return { version: STATE_VERSION, config, jobs }; @@ -5179,6 +5187,12 @@ function readJobFile(jobFile) { return null; } } +function removeJobFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobFile(workspaceRoot, jobId)); + } catch { + } +} function writeJobConfigFile(workspaceRoot, jobId, payload) { ensureStateDir(workspaceRoot); writeJsonAtomic(resolveJobConfigFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); @@ -5197,6 +5211,12 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } +function removeJobLogFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { + } +} // plugins/polycli/scripts/lib/run-ledger.mjs import { randomUUID as randomUUID2 } from "node:crypto"; diff --git a/plugins/polycli/.claude-plugin/plugin.json b/plugins/polycli/.claude-plugin/plugin.json index 2267333..c362e60 100644 --- a/plugins/polycli/.claude-plugin/plugin.json +++ b/plugins/polycli/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "polycli", - "version": "0.6.26", + "version": "0.6.27", "description": "Use multiple provider CLIs from one Claude Code plugin, including background job lifecycle.", "author": { "name": "bing" diff --git a/plugins/polycli/scripts/lib/state.mjs b/plugins/polycli/scripts/lib/state.mjs index d48b6b6..4e77ae6 100644 --- a/plugins/polycli/scripts/lib/state.mjs +++ b/plugins/polycli/scripts/lib/state.mjs @@ -186,6 +186,17 @@ export function loadState(workspaceRoot) { export function saveState(workspaceRoot, state) { ensureStateDir(workspaceRoot); const jobs = pruneJobsForSave(state.jobs); + const keptIds = new Set(jobs.map((job) => job.jobId)); + // Reclaim on-disk artifacts for terminal jobs pruned out of the persisted list (active jobs are + // never pruned, so this only ever removes aged-out terminal history). Without this the jobs dir + // grows unbounded as terminal jobs age past MAX_JOBS. + for (const job of state.jobs) { + if (job && job.jobId && !keptIds.has(job.jobId)) { + removeJobFile(workspaceRoot, job.jobId); + removeJobConfigFile(workspaceRoot, job.jobId); + removeJobLogFile(workspaceRoot, job.jobId); + } + } const config = state.config && typeof state.config === "object" ? state.config : {}; writeJsonAtomic(resolveStateFile(workspaceRoot), { version: STATE_VERSION, config, jobs }, { mode: PRIVATE_FILE_MODE }); return { version: STATE_VERSION, config, jobs }; @@ -337,3 +348,11 @@ export function removeJobConfigFile(workspaceRoot, jobId) { // ignore } } + +export function removeJobLogFile(workspaceRoot, jobId) { + try { + fs.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { + // ignore + } +} diff --git a/plugins/polycli/scripts/polycli-companion.bundle.mjs b/plugins/polycli/scripts/polycli-companion.bundle.mjs index a501ec0..f6d4f84 100755 --- a/plugins/polycli/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli/scripts/polycli-companion.bundle.mjs @@ -5079,6 +5079,14 @@ function loadState(workspaceRoot) { function saveState(workspaceRoot, state) { ensureStateDir(workspaceRoot); const jobs = pruneJobsForSave(state.jobs); + const keptIds = new Set(jobs.map((job) => job.jobId)); + for (const job of state.jobs) { + if (job && job.jobId && !keptIds.has(job.jobId)) { + removeJobFile(workspaceRoot, job.jobId); + removeJobConfigFile(workspaceRoot, job.jobId); + removeJobLogFile(workspaceRoot, job.jobId); + } + } const config = state.config && typeof state.config === "object" ? state.config : {}; writeJsonAtomic(resolveStateFile(workspaceRoot), { version: STATE_VERSION, config, jobs }, { mode: PRIVATE_FILE_MODE }); return { version: STATE_VERSION, config, jobs }; @@ -5179,6 +5187,12 @@ function readJobFile(jobFile) { return null; } } +function removeJobFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobFile(workspaceRoot, jobId)); + } catch { + } +} function writeJobConfigFile(workspaceRoot, jobId, payload) { ensureStateDir(workspaceRoot); writeJsonAtomic(resolveJobConfigFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); @@ -5197,6 +5211,12 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } +function removeJobLogFile(workspaceRoot, jobId) { + try { + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { + } +} // plugins/polycli/scripts/lib/run-ledger.mjs import { randomUUID as randomUUID2 } from "node:crypto"; diff --git a/plugins/polycli/scripts/tests/state.test.mjs b/plugins/polycli/scripts/tests/state.test.mjs index 5cebde9..f3e3dbb 100644 --- a/plugins/polycli/scripts/tests/state.test.mjs +++ b/plugins/polycli/scripts/tests/state.test.mjs @@ -13,6 +13,8 @@ import { resolveJobsDir, resolveStateFile, resolveJobFile, + resolveJobConfigFile, + resolveJobLogFile, resolveStateDir, resolveWorkspaceRoot, saveState, @@ -160,6 +162,37 @@ test("saveState preserves active jobs while pruning terminal history", () => { }); }); +test("saveState reclaims on-disk artifacts for terminal jobs pruned past MAX_JOBS", () => { + withPluginData(() => { + const workspaceRoot = "/tmp/polycli-prune-reclaim"; + const terminalJobs = Array.from({ length: 105 }, (_, index) => ({ + jobId: `terminal-${index}`, + status: "completed", + updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + })); + const activeJob = { jobId: "queued-keep", status: "queued", updatedAt: "2025-12-31T00:00:00.000Z" }; + + // terminal-0 is the oldest -> pruned past MAX_JOBS; terminal-104 is newest -> kept; the active + // job is always kept. Write all three artifact kinds for each so we can prove reclamation. + for (const jobId of ["terminal-0", "terminal-104", "queued-keep"]) { + writeJobFile(workspaceRoot, jobId, { job: { jobId, status: "completed" }, result: { ok: true, response: "x" } }); + writeJobConfigFile(workspaceRoot, jobId, { execution: { prompt: "p" } }); + fs.writeFileSync(resolveJobLogFile(workspaceRoot, jobId), "log line\n"); + } + + saveState(workspaceRoot, { jobs: [...terminalJobs, activeJob], config: {} }); + + // Dropped terminal job: all artifacts reclaimed. + assert.equal(fs.existsSync(resolveJobFile(workspaceRoot, "terminal-0")), false); + assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "terminal-0")), false); + assert.equal(fs.existsSync(resolveJobLogFile(workspaceRoot, "terminal-0")), false); + // Kept terminal job + active job: artifacts retained. + assert.equal(fs.existsSync(resolveJobFile(workspaceRoot, "terminal-104")), true); + assert.equal(fs.existsSync(resolveJobFile(workspaceRoot, "queued-keep")), true); + assert.equal(fs.existsSync(resolveJobLogFile(workspaceRoot, "queued-keep")), true); + }); +}); + test("config and last-used provider share the workspace state file", () => { withPluginData(() => { const workspaceRoot = "/tmp/polycli-config-state"; diff --git a/scripts/tests/opencode-host.test.mjs b/scripts/tests/opencode-host.test.mjs index 5f0739c..80360ac 100644 --- a/scripts/tests/opencode-host.test.mjs +++ b/scripts/tests/opencode-host.test.mjs @@ -1,7 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { isHardCompanionFailure } from "../../plugins/polycli-opencode/index.mjs"; +import { isHardCompanionFailure, runCompanion } from "../../plugins/polycli-opencode/index.mjs"; + +function fakeSpawn(status, stdout, stderr = "") { + return () => ({ status, stdout, stderr }); +} test("opencode host treats companion exit 2 as a soft signal, not a hard failure", () => { // Exit 2 is the companion's documented soft signal: `health` with no healthy provider and @@ -14,3 +18,22 @@ test("opencode host treats companion exit 2 as a soft signal, not a hard failure assert.equal(isHardCompanionFailure(4), true); assert.equal(isHardCompanionFailure(5), true); }); + +test("runCompanion returns the stdout envelope on a companion exit 2 (execution path)", () => { + const envelope = JSON.stringify({ waitTimedOut: true, jobs: [] }); + const stdout = runCompanion(["status", "--all", "--wait", "--json"], { spawn: fakeSpawn(2, envelope) }); + assert.equal(stdout, envelope); +}); + +test("runCompanion throws with the stdout attached on a hard exit 1 (execution path)", () => { + const envelope = JSON.stringify({ error: "boom", code: "unknown_provider" }); + assert.throws( + () => runCompanion(["timing", "--provider", "nope", "--json"], { spawn: fakeSpawn(1, envelope) }), + (error) => { + assert.equal(error.status, 1); + assert.equal(error.stdout, envelope); + assert.equal(JSON.parse(error.stdout).code, "unknown_provider"); + return true; + }, + ); +}); diff --git a/scripts/tests/validate-fixture-metadata.test.mjs b/scripts/tests/validate-fixture-metadata.test.mjs index 7323860..36c7b85 100644 --- a/scripts/tests/validate-fixture-metadata.test.mjs +++ b/scripts/tests/validate-fixture-metadata.test.mjs @@ -63,6 +63,42 @@ test("validateFixtureMetadata rejects missing required fields", () => { ); }); +test("validateFixtureMetadata rejects a provider that does not match the directory", () => { + const root = makeTempRoot(); + writeMeta(root, "qwen/stream-success.meta.json", { + provider: "claude", + name: "stream-success", + capturedAt: "2026-04-22T12:44:18.282Z", + version: "0.14.5", + argv: ["prompt"], + expected: { response: "HELLO_QWEN_FIXTURE" }, + }); + writeStream(root, "qwen/stream-success.stream.txt"); + + assert.throws( + () => validateFixtureMetadata({ fixtureRoot: root, requiredSuccessProviders: ["qwen"] }), + /provider must match the fixture directory \("qwen"\)/ + ); +}); + +test("validateFixtureMetadata rejects a name that does not match the file stem", () => { + const root = makeTempRoot(); + writeMeta(root, "qwen/stream-success.meta.json", { + provider: "qwen", + name: "wrong-success", + capturedAt: "2026-04-22T12:44:18.282Z", + version: "0.14.5", + argv: ["prompt"], + expected: { response: "HELLO_QWEN_FIXTURE" }, + }); + writeStream(root, "qwen/stream-success.stream.txt"); + + assert.throws( + () => validateFixtureMetadata({ fixtureRoot: root, requiredSuccessProviders: ["qwen"] }), + /name must match the file stem \("stream-success"\)/ + ); +}); + test("validateFixtureMetadata rejects sessionId values that are not strings", () => { const root = makeTempRoot(); writeMeta(root, "qwen/stream-success.meta.json", { diff --git a/scripts/validate-fixture-metadata.mjs b/scripts/validate-fixture-metadata.mjs index c090d57..956d98e 100644 --- a/scripts/validate-fixture-metadata.mjs +++ b/scripts/validate-fixture-metadata.mjs @@ -44,6 +44,12 @@ function assertNonEmptyString(value, label) { function validateMeta(relativePath, meta, { fixtureRoot }) { assertNonEmptyString(meta.provider, `${relativePath}: provider`); assertNonEmptyString(meta.name, `${relativePath}: name`); + // Path/meta consistency (docs/capture-fixtures.md): provider matches the fixture directory and + // name matches the file stem before .meta.json. + const expectedProvider = relativePath.split("/")[0]; + const expectedName = path.basename(relativePath, ".meta.json"); + assert.equal(meta.provider, expectedProvider, `${relativePath}: provider must match the fixture directory ("${expectedProvider}")`); + assert.equal(meta.name, expectedName, `${relativePath}: name must match the file stem ("${expectedName}")`); assertNonEmptyString(meta.capturedAt, `${relativePath}: capturedAt`); assert.doesNotThrow(() => new Date(meta.capturedAt).toISOString(), `${relativePath}: capturedAt must be an ISO timestamp`); assertNonEmptyString(meta.version, `${relativePath}: version`);