diff --git a/.gitignore b/.gitignore index d29b4d0c..ddaeb858 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ blackbox/ # pythinker-review .pythinker-review/ +# pythinker — local agent state (do not commit) +.pythinker-review-flow/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a341e704..52f919e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **`pythinker mcp add` no longer crashes on Windows and Linux native builds.** The PyInstaller specs were using `collect_data_files()` which silently omits the `fastmcp-*.dist-info/` sibling directory; fastmcp calls `importlib.metadata.version("fastmcp")` at import time, so every `mcp add` / `mcp list` invocation raised `PackageNotFoundError`. Switched all three specs (Windows installer, Linux installer, macOS/tarball) to `copy_metadata()` — the PyInstaller-standard hook for bundling dist-info. +- **Pythinker work directories are automatically gitignored on startup.** When the agent starts inside a git repository, `.pythinker/`, `.pythinker-review/`, and `.pythinker-review-flow/` are silently appended to the project's `.gitignore` if missing, preventing local agent state from making the working tree dirty. +- **Old sessions and plan files are swept on startup.** Archived session directories under `~/.pythinker/sessions/` and hero-name plan files under `~/.pythinker/plans/` older than `session_retention_days` (default 30) are removed non-interactively at startup. Set `session_retention_days = 0` to disable. - **Windows upgrade version display fix.** In-place upgrades no longer show a stale version number or re-trigger the update prompt. Inno Setup now wipes `_internal` before installing new files, preventing old `dist-info` directories from accumulating and causing `importlib.metadata` to report the previous version. ## 0.31.0 (2026-06-02) diff --git a/packages/linux-installer/pythinker.spec b/packages/linux-installer/pythinker.spec index de05b1f6..0a6b208c 100644 --- a/packages/linux-installer/pythinker.spec +++ b/packages/linux-installer/pythinker.spec @@ -3,7 +3,7 @@ # / tarball). Mode: --onedir — fpm wraps the directory into the package and # install-native.sh tar-gzips it for the curl-bash flow. -from PyInstaller.utils.hooks import collect_data_files, collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata block_cipher = None @@ -47,6 +47,15 @@ try: except Exception: pass +# fastmcp calls importlib.metadata.version("fastmcp") at module import time. +# collect_data_files() only collects files inside the package directory; the +# dist-info lives alongside it in site-packages. copy_metadata() is the +# PyInstaller-standard hook for making importlib.metadata work in frozen apps. +# Do NOT suppress errors here: a missing dist-info produces a broken binary, +# so let PackageNotFoundError surface and fail the build loudly. +for pkg in ("fastmcp", "mcp"): + datas += copy_metadata(pkg) + a = Analysis( ["entrypoint.py"], pathex=[], diff --git a/packages/pythinker-review/src/pythinker_review/store/findings_store.py b/packages/pythinker-review/src/pythinker_review/store/findings_store.py index 5c3a8d3b..9d95e8de 100644 --- a/packages/pythinker-review/src/pythinker_review/store/findings_store.py +++ b/packages/pythinker-review/src/pythinker_review/store/findings_store.py @@ -4,6 +4,7 @@ import json import os +import shutil from pathlib import Path from typing import TextIO @@ -80,6 +81,13 @@ def _update_index(self, meta: RunMeta) -> None: "findings_count": meta.findings_count, }, ) + # Collect run IDs that are about to fall off the index before truncating. + overflow_ids = [str(r["id"]) for r in runs[_INDEX_LIMIT:] if isinstance(r.get("id"), str)] tmp = idx_path.with_suffix(".json.tmp") tmp.write_text(json.dumps({"runs": runs[:_INDEX_LIMIT]}, indent=2), encoding="utf-8") os.replace(tmp, idx_path) + # Delete physical run directories for entries that overflowed the cap. + for run_id in overflow_ids: + run_dir = self._run_dir(run_id) + if run_dir.is_dir(): + shutil.rmtree(run_dir, ignore_errors=True) diff --git a/packages/windows-installer/pythinker.spec b/packages/windows-installer/pythinker.spec index 9e6a45c2..51241d21 100644 --- a/packages/windows-installer/pythinker.spec +++ b/packages/windows-installer/pythinker.spec @@ -2,7 +2,7 @@ # PyInstaller spec for the Pythinker Code Windows native build. # Mode: --onedir (faster startup, fewer AV false-positives than --onefile). -from PyInstaller.utils.hooks import collect_data_files, collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata block_cipher = None @@ -46,6 +46,15 @@ try: except Exception: pass +# fastmcp calls importlib.metadata.version("fastmcp") at module import time. +# collect_data_files() only collects files inside the package directory; the +# dist-info lives alongside it in site-packages. copy_metadata() is the +# PyInstaller-standard hook for making importlib.metadata work in frozen apps. +# Do NOT suppress errors here: a missing dist-info produces a broken binary, +# so let PackageNotFoundError surface and fail the build loudly. +for pkg in ("fastmcp", "mcp"): + datas += copy_metadata(pkg) + a = Analysis( ["entrypoint.py"], pathex=[], diff --git a/src/pythinker_code/acp/session.py b/src/pythinker_code/acp/session.py index d65bc8ec..ce209f6a 100644 --- a/src/pythinker_code/acp/session.py +++ b/src/pythinker_code/acp/session.py @@ -215,7 +215,7 @@ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse: case _: pass except LLMNotSet as e: - logger.exception("LLM not set:") + logger.warning("LLM not set — user has no provider configured") raise acp.RequestError.auth_required() from e except LLMNotSupported as e: logger.exception("LLM not supported:") diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index cfd137bf..6d28d348 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -803,8 +803,20 @@ async def _run(session_id: str | None, prefill_text: str | None = None) -> tuple render_scratchpad_section, scratch_file_exists, ) + from pythinker_code.session_cleanup import ( + sweep_old_plans, + sweep_old_sessions, + sweep_stale_work_dirs, + ) scratchpad_status = await ensure_git_excluded(work_dir) + + # Sweep accumulated state on startup (best-effort, non-blocking). + # Mirrors Claude Code's cleanupPeriodDays=30 model. + _retention = config.session_retention_days if isinstance(config, Config) else 30 + await asyncio.to_thread(sweep_old_sessions, _retention) + await asyncio.to_thread(sweep_old_plans, _retention) + await asyncio.to_thread(sweep_stale_work_dirs) scratch_exists_before_start = scratch_file_exists(work_dir) if scratchpad_status.available: session_source = "resume" if resumed else "startup" diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index 4c2d5add..e2c65cdf 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -497,6 +497,15 @@ class Config(BaseModel): "Enable anonymous telemetry to help improve pythinker-code. Set to false to opt out." ), ) + session_retention_days: int = Field( + default=30, + ge=0, + description=( + "Archived session directories in ~/.pythinker/sessions/ older than this many days " + "are removed on startup. Plan files older than this many days are also pruned. " + "0 disables cleanup." + ), + ) @model_validator(mode="after") def validate_model(self) -> Self: diff --git a/src/pythinker_code/scratchpad.py b/src/pythinker_code/scratchpad.py index 3c1d3176..3227f147 100644 --- a/src/pythinker_code/scratchpad.py +++ b/src/pythinker_code/scratchpad.py @@ -32,6 +32,11 @@ SESSION_SCRATCH_IGNORE_PATTERN = f"{SESSION_SCRATCH_DIR_REL_PATH}/*.md" _SESSION_SCRATCH_IGNORE_SAMPLE = f"{SESSION_SCRATCH_DIR_REL_PATH}/session.md" +# Patterns written to the project .gitignore when the agent starts in a git repo. +# These directories are local-only agent state and must never be committed. +_GITIGNORE_ENTRIES = (".pythinker/", ".pythinker-review/", ".pythinker-review-flow/") +_GITIGNORE_SECTION_HEADER = "# pythinker — local agent state (do not commit)" + StatusReason = Literal[ "available_non_git", "available_git_ignored", @@ -337,6 +342,9 @@ async def ensure_git_excluded( ignored=False, ) + # Ensure pythinker work dirs are gitignored in the project before any files are created. + await _append_gitignore_entries(work_dir) + for candidate in (SCRATCH_REL_PATH, _SESSION_SCRATCH_IGNORE_SAMPLE): tracked = await runner(["ls-files", "--error-unmatch", "--", candidate]) if not tracked.ok: @@ -429,6 +437,41 @@ def _exclude_lock(path: Path) -> Generator[None]: fh.close() +async def _append_gitignore_entries(work_dir: HostPath) -> None: + """Best-effort: append pythinker work-dir patterns to work_dir/.gitignore if missing. + + Called once at agent startup to prevent pythinker's local state directories + from making the project's git working tree dirty. Never raises. + """ + gitignore_path = Path(str(work_dir)) / ".gitignore" + try: + await with_retries(lambda: asyncio.to_thread(_write_gitignore_entries, gitignore_path)) + except Exception: + logger.debug("scratchpad .gitignore update failed") + + +def _write_gitignore_entries(gitignore_path: Path) -> None: + try: + with _exclude_lock(gitignore_path): + existing = gitignore_path.read_text(encoding="utf-8") if gitignore_path.exists() else "" + existing_lines = {line.strip() for line in existing.splitlines()} + missing = [e for e in _GITIGNORE_ENTRIES if e not in existing_lines] + if not missing: + return + prefix = "" if not existing or existing.endswith("\n") else "\n" + with gitignore_path.open("a", encoding="utf-8") as fh: + if _GITIGNORE_SECTION_HEADER not in existing_lines: + fh.write(f"{prefix}{_GITIGNORE_SECTION_HEADER}\n") + elif prefix: + fh.write(prefix) + for entry in missing: + fh.write(f"{entry}\n") + except OSError as exc: + if is_transient_oserror(exc): + raise TransientScratchpadError(str(exc)) from exc + raise + + @contextlib.contextmanager def _scratch_file_lock(fh: TextIO) -> Generator[None]: """Best-effort advisory lock on the scratch file itself (no extra git file).""" diff --git a/src/pythinker_code/session_cleanup.py b/src/pythinker_code/session_cleanup.py new file mode 100644 index 00000000..c2d2e8e8 --- /dev/null +++ b/src/pythinker_code/session_cleanup.py @@ -0,0 +1,260 @@ +"""Age-based cleanup for personal-scope session and plan state. + +Runs at agent startup to keep ~/.pythinker/ from growing unboundedly. +Mirrors the design used by Claude Code (cleanupPeriodDays=30): on startup, +directories/files older than the retention threshold are removed if they are +safe to discard (archived sessions, old plan files). + +Never raises — every error is logged at DEBUG level and silently skipped. +""" + +from __future__ import annotations + +import contextlib +import shutil +import time +from hashlib import md5 as _md5 +from pathlib import Path + +from pythinker_code.utils.logging import logger + +_SESSIONS_DIR_NAME = "sessions" +_PLANS_DIR_NAME = "plans" +_LOCAL_HOST = "local" + + +# --------------------------------------------------------------------------- +# Public sweep functions +# --------------------------------------------------------------------------- + + +def sweep_old_sessions(max_age_days: int, *, share_dir: Path | None = None) -> int: + """Remove archived session directories older than ``max_age_days`` days. + + Only sessions explicitly marked ``archived=True`` in their ``state.json`` are + eligible. Active or unarchived sessions are never touched regardless of age. + Orphan directories (no ``state.json``) are removed if their mtime is old enough. + + After removing a session directory the corresponding per-session scratchpad + file in the project's ``.pythinker/scratch/`` directory is also deleted + (best-effort; a missing project dir is silently skipped). + + Returns the number of directories removed. Returns 0 when disabled (``max_age_days <= 0``). + """ + if max_age_days <= 0: + return 0 + + from pythinker_code.share import get_share_dir + + root = share_dir or get_share_dir() + sessions_root = root / _SESSIONS_DIR_NAME + if not sessions_root.is_dir(): + return 0 + + cutoff = time.time() - max_age_days * 86_400.0 + bucket_to_path = _load_bucket_path_map(root) + removed = 0 + + try: + buckets = list(sessions_root.iterdir()) + except OSError: + return 0 + + for bucket in buckets: + if not bucket.is_dir(): + continue + work_dir_path = bucket_to_path.get(bucket.name) + try: + session_dirs = list(bucket.iterdir()) + except OSError: + continue + for session_dir in session_dirs: + if not session_dir.is_dir(): + continue + try: + if _maybe_remove_session(session_dir, cutoff, work_dir_path=work_dir_path): + removed += 1 + except Exception: + logger.debug("session_cleanup: skipping {d}", d=session_dir.name) + + # Prune the bucket itself if it is now empty. + try: + if not any(bucket.iterdir()): + bucket.rmdir() + except OSError: + pass + + if removed: + logger.debug("session_cleanup: removed {n} old archived session(s)", n=removed) + return removed + + +def sweep_old_plans(max_age_days: int, *, share_dir: Path | None = None) -> int: + """Remove plan markdown files older than ``max_age_days`` days. + + Plan files are hero-name-slugged markdown files written to ~/.pythinker/plans/ + for each planning session. They are ephemeral by nature and safe to prune once old. + + Returns the number of files removed. Returns 0 when disabled (``max_age_days <= 0``). + """ + if max_age_days <= 0: + return 0 + + from pythinker_code.share import get_share_dir + + plans_dir = (share_dir or get_share_dir()) / _PLANS_DIR_NAME + if not plans_dir.is_dir(): + return 0 + + cutoff = time.time() - max_age_days * 86_400.0 + removed = 0 + + try: + plan_files = list(plans_dir.glob("*.md")) + except OSError: + return 0 + + for plan_file in plan_files: + try: + if plan_file.stat().st_mtime < cutoff: + plan_file.unlink(missing_ok=True) + removed += 1 + except Exception: + logger.debug("session_cleanup: skipping plan {f}", f=plan_file.name) + + if removed: + logger.debug("session_cleanup: removed {n} old plan file(s)", n=removed) + return removed + + +def sweep_stale_work_dirs(*, share_dir: Path | None = None) -> int: + """Prune pythinker.json entries for paths that no longer exist and have no sessions. + + An entry is removed only when both conditions hold: + - The work directory path does not exist on disk. + - The corresponding sessions bucket directory is absent or empty. + + This prevents data loss for projects that have been moved or temporarily + unmounted while sessions still exist. + + Returns the number of entries pruned. + """ + from pythinker_code.metadata import WorkDirMeta, load_metadata, save_metadata + from pythinker_code.share import get_share_dir + + root = share_dir or get_share_dir() + sessions_root = root / _SESSIONS_DIR_NAME + + try: + metadata = load_metadata() + except Exception: + return 0 + + keep: list[WorkDirMeta] = [] + pruned = 0 + for wd in metadata.work_dirs: + if Path(wd.path).exists(): + keep.append(wd) + continue + try: + hash_ = _md5(wd.path.encode("utf-8"), usedforsecurity=False).hexdigest() + bucket_name = hash_ if wd.host == _LOCAL_HOST else f"{wd.host}_{hash_}" + bucket_dir = sessions_root / bucket_name + has_sessions = bucket_dir.is_dir() and any(bucket_dir.iterdir()) + except Exception: + has_sessions = True # err on the side of keeping + if has_sessions: + keep.append(wd) + else: + pruned += 1 + + if pruned: + metadata.work_dirs = keep + try: + save_metadata(metadata) + logger.debug("session_cleanup: pruned {n} stale work_dir(s) from registry", n=pruned) + except Exception: + logger.debug("session_cleanup: failed to save pruned metadata") + + return pruned + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _maybe_remove_session( + session_dir: Path, cutoff: float, *, work_dir_path: str | None = None +) -> bool: + """Return True if the session directory was removed, False otherwise.""" + from pythinker_code.session_state import load_session_state + + state_file = session_dir / "state.json" + + if not state_file.exists(): + # Orphan dir with no state — safe to remove if old enough. + try: + if session_dir.stat().st_mtime < cutoff: + shutil.rmtree(session_dir, ignore_errors=True) + return True + except OSError: + pass + return False + + state = load_session_state(session_dir) + + if not state.archived: + return False + + try: + reference: float = state.archived_at or state.wire_mtime or session_dir.stat().st_mtime + except OSError: + return False + + if reference >= cutoff: + return False + + shutil.rmtree(session_dir, ignore_errors=True) + if work_dir_path is not None: + _try_remove_scratchpad(work_dir_path, session_dir.name) + return True + + +def _try_remove_scratchpad(work_dir_path: str, session_uuid: str) -> None: + """Best-effort: delete the per-session scratchpad file for a removed session.""" + scratch_dir = Path(work_dir_path) / ".pythinker" / "scratch" + if not scratch_dir.is_dir(): + return + short_id = _session_short_id(session_uuid) + for f in scratch_dir.glob(f"{short_id}-*.md"): + with contextlib.suppress(OSError): + f.unlink(missing_ok=True) + + +def _session_short_id(session_uuid: str) -> str: + """Compute the 12-char slug used as the scratchpad filename prefix.""" + text = "".join(c if c.isalnum() else "-" for c in session_uuid.lower()) + while "--" in text: + text = text.replace("--", "-") + return text.strip("-")[:12].strip("-") or "session" + + +def _load_bucket_path_map(share_dir: Path) -> dict[str, str]: + """Return a mapping of sessions bucket directory name → work_dir path string.""" + from pythinker_code.metadata import load_metadata + + try: + metadata = load_metadata() + except Exception: + return {} + + result: dict[str, str] = {} + for wd in metadata.work_dirs: + try: + hash_ = _md5(wd.path.encode("utf-8"), usedforsecurity=False).hexdigest() + bucket = hash_ if wd.host == _LOCAL_HOST else f"{wd.host}_{hash_}" + result[bucket] = wd.path + except Exception: + pass + return result diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index 4c819385..5f18d6ec 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -129,6 +129,18 @@ def type_check(soul: PythinkerSoul): DEFAULT_MAX_FLOW_MOVES = 1000 +def _safe_cwd(fallback: str) -> str: + """Return the current working directory as a string. + + Falls back to *fallback* if the process CWD has been deleted (e.g. the + project directory was removed mid-session by a shell command). + """ + try: + return str(Path.cwd()) + except FileNotFoundError: + return str(fallback) + + def classify_llm_system(chat_provider: object | None) -> str: """Classify a chat provider into a stable gen_ai.system telemetry value.""" try: @@ -875,7 +887,7 @@ async def run( matcher_value=text_input_for_hook, input_data=events.user_prompt_submit( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), prompt=text_input_for_hook, ), ) @@ -917,7 +929,7 @@ async def run( "Stop", input_data=events.stop( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), stop_hook_active=False, ), ) @@ -1298,7 +1310,7 @@ async def _agent_loop(self) -> TurnOutcome: matcher_value=type(e).__name__, input_data=_hook_events.stop_failure( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), error_type=type(e).__name__, error_message=str(e), ), @@ -1361,7 +1373,7 @@ async def _append_notification(view: NotificationView) -> None: matcher_value=view.event.type, input_data=events.notification( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), sink="llm", notification_type=view.event.type, title=view.event.title, @@ -1706,7 +1718,7 @@ async def _compact_with_retry() -> CompactionResult: matcher_value=trigger_reason, input_data=events.pre_compact( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), trigger=trigger_reason, token_count=before_tokens, custom_instructions=custom_instruction, @@ -1763,7 +1775,7 @@ async def _compact_with_retry() -> CompactionResult: matcher_value=trigger_reason, input_data=events.post_compact( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), trigger=trigger_reason, estimated_token_count=estimated_token_count, compact_summary=summary_text, @@ -1774,7 +1786,7 @@ async def _compact_with_retry() -> CompactionResult: matcher_value="compact", input_data=events.session_start( session_id=self._runtime.session.id, - cwd=str(Path.cwd()), + cwd=_safe_cwd(str(self._runtime.session.work_dir)), source="compact", ), ) diff --git a/src/pythinker_code/tools/shell/__init__.py b/src/pythinker_code/tools/shell/__init__.py index 3d50cd0d..3bcefd68 100644 --- a/src/pythinker_code/tools/shell/__init__.py +++ b/src/pythinker_code/tools/shell/__init__.py @@ -281,12 +281,12 @@ async def _run_shell_command( timeout: int, ) -> int: async def _read_stream(stream: AsyncReadable, cb: Callable[[bytes], None]): - while True: - line = await stream.readline() - if line: - cb(line) - else: - break + # Use read() instead of readline() to avoid asyncio's 64 KB per-line + # limit (raises LimitOverrunError / ValueError depending on Python + # version). The callbacks only accumulate text, so chunk boundaries + # do not matter for correctness. + while chunk := await stream.read(65536): + cb(chunk) process = await pythinker_host.exec( *self._shell_args(command), env=get_noninteractive_env() diff --git a/src/pythinker_code/ui/print/__init__.py b/src/pythinker_code/ui/print/__init__.py index 25495e35..f5e62d1d 100644 --- a/src/pythinker_code/ui/print/__init__.py +++ b/src/pythinker_code/ui/print/__init__.py @@ -408,7 +408,7 @@ def _handler(): command = None except LLMNotSet as e: - logger.exception("LLM not set:") + logger.warning("LLM not set — user has no provider configured") print(str(e)) return ExitCode.FAILURE except LLMNotSupported as e: diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 4c83cfe6..00cc25f9 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -1275,7 +1275,7 @@ def _on_view_ready(view: Any) -> None: return True except LLMNotSet: _t = _get_tui_tokens() - logger.exception("LLM not set:") + logger.warning("LLM not set — user has no provider configured") console.print(f'[{_t.error}]LLM not set, send "/login" to login[/]') except LLMNotSupported as e: # actually unsupported input/mode should already be blocked by prompt session diff --git a/src/pythinker_code/utils/pyinstaller.py b/src/pythinker_code/utils/pyinstaller.py index 7cfd525d..f767b286 100644 --- a/src/pythinker_code/utils/pyinstaller.py +++ b/src/pythinker_code/utils/pyinstaller.py @@ -1,6 +1,6 @@ from __future__ import annotations -from PyInstaller.utils.hooks import collect_data_files, collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata from pythinker_code.cli._lazy_group import LazySubcommandGroup @@ -43,10 +43,12 @@ "dateparser", includes=["**/*.pkl"], ) - + collect_data_files( - "fastmcp", - includes=["../fastmcp-*.dist-info/*"], - ) + # fastmcp calls importlib.metadata.version("fastmcp") at module import time. + # copy_metadata() is the PyInstaller-standard hook for making + # importlib.metadata work in frozen apps; it bundles the dist-info sibling + # directory that collect_data_files() silently skips. + + copy_metadata("fastmcp") + + copy_metadata("mcp") + collect_data_files("trafilatura") # justext is trafilatura's fallback extractor. It loads its per-language # stoplists by os.listdir()-ing justext/stoplists/, so without the data diff --git a/src/pythinker_code/wire/__init__.py b/src/pythinker_code/wire/__init__.py index e4bb1f52..0a87c9e4 100644 --- a/src/pythinker_code/wire/__init__.py +++ b/src/pythinker_code/wire/__init__.py @@ -140,9 +140,12 @@ async def _consume_loop(self, queue: Queue[WireMessage]) -> None: while True: try: msg = await queue.get() - await self._record(msg) except QueueShutDown: break + try: + await self._record(msg) + except Exception: + logger.exception("Wire recorder failed to persist message:") async def _record(self, msg: WireMessage) -> None: await self._wire_file.append_message(msg) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 24c76f7a..cf7e9d03 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -82,6 +82,7 @@ def test_default_config_dump(): "merge_all_available_skills": True, "extra_skill_dirs": [], "telemetry": True, + "session_retention_days": 30, "skip_auto_prompt_injection": False, "tui": { "style": "card", diff --git a/tests/tools/test_shell_bash.py b/tests/tools/test_shell_bash.py index f4f9c20f..5464648c 100644 --- a/tests/tools/test_shell_bash.py +++ b/tests/tools/test_shell_bash.py @@ -194,6 +194,19 @@ async def test_output_truncation_on_failure(shell_tool: Shell): assert "Command failed with exit code:" in result.message +async def test_oversized_output_line(shell_tool: Shell): + """A single output line exceeding asyncio's 64 KB readline limit must not crash the tool.""" + # asyncio.StreamReader's default limit is 65536 bytes; emit a 70 KB line. + result = await shell_tool( + Params(command="python3 -c \"print('X' * 70000)\""), + ) + # The tool must return a result (not raise), and the oversized content must + # appear in the output rather than being silently dropped. + assert not result.is_error + assert isinstance(result.output, str) + assert "X" in result.output + + async def test_timeout_parameter_validation_bounds(shell_tool: Shell): """Test timeout parameter validation (bounds checking).""" # Test timeout < 1 (should fail validation) @@ -254,6 +267,11 @@ async def readline(self) -> bytes: await asyncio.Event().wait() raise AssertionError("unreachable") + async def read(self, n: int = -1) -> bytes: + started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + class FakeStdin: def close(self) -> None: pass diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 377858eb..74942219 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -40,42 +40,31 @@ def test_pyinstaller_datas(): "justext English stoplist missing from the PyInstaller datas" ) + # fastmcp and mcp call importlib.metadata.version() at module import time; + # copy_metadata() bundles their dist-info so importlib.metadata can resolve + # them in the frozen binary. The exact file set is version-dependent (like + # justext stoplists), so assert METADATA presence rather than pinning every + # file. copy_metadata() produces clean paths ({dist-info}/FILE) — no + # fastmcp/../ prefix that the old collect_data_files workaround created. + # copy_metadata() returns a single directory entry per package (the whole + # dist-info dir), so check that the dest dir name matches — not a file inside it. + for pkg in ("fastmcp", "mcp"): + pkg_dist = f"{pkg}-{version(pkg)}.dist-info" + assert any(d == pkg_dist for p, d in datas), ( + f"{pkg_dist} must be bundled so importlib.metadata works in the frozen app" + ) + + dist_info_dirs = {f"{pkg}-{version(pkg)}.dist-info" for pkg in ("fastmcp", "mcp")} datas = [ (p, d) for p, d in datas - if "web/static" not in d and "vis/static" not in d and d != "justext/stoplists" + if "web/static" not in d + and "vis/static" not in d + and d != "justext/stoplists" + and not any(d == di or d.startswith(di + "/") for di in dist_info_dirs) ] - fastmcp_dist = f"fastmcp-{version('fastmcp')}.dist-info" expected_datas = [ - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/INSTALLER", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/METADATA", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/RECORD", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/REQUESTED", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/WHEEL", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/entry_points.txt", - f"fastmcp/../{fastmcp_dist}", - ), - ( - f"{site_packages}/fastmcp/../{fastmcp_dist}/licenses/LICENSE", - f"fastmcp/../{fastmcp_dist}/licenses", - ), ( f"{site_packages}/trafilatura/data/tei_corpus.dtd", "trafilatura/data",